Compare commits
2 Commits
1dca1099cf
...
7cdc72b6e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cdc72b6e1 | |||
| 2b30ae47de |
@@ -2,7 +2,6 @@
|
|||||||
members = [
|
members = [
|
||||||
"apps/api",
|
"apps/api",
|
||||||
"apps/indexer",
|
"apps/indexer",
|
||||||
"apps/admin-ui",
|
|
||||||
"crates/core",
|
"crates/core",
|
||||||
"crates/parsers",
|
"crates/parsers",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ WORKDIR /app
|
|||||||
COPY Cargo.toml ./
|
COPY Cargo.toml ./
|
||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||||
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
|
||||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||||
COPY apps/api/src apps/api/src
|
COPY apps/api/src apps/api/src
|
||||||
COPY apps/indexer/src apps/indexer/src
|
COPY apps/indexer/src apps/indexer/src
|
||||||
COPY apps/admin-ui/src apps/admin-ui/src
|
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ export default async function BookDetailPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href="/books" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
|
<Link href="/books" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
← Back to books
|
← Back to books
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="bg-card rounded-xl shadow-card border border-line p-4 inline-block">
|
<div className="bg-card rounded-xl shadow-card border border-border p-4 inline-block">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(book.id)}
|
src={getBookCoverUrl(book.id)}
|
||||||
alt={`Cover of ${book.title}`}
|
alt={`Cover of ${book.title}`}
|
||||||
@@ -54,76 +54,76 @@ export default async function BookDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="bg-card rounded-xl shadow-soft border border-line p-6">
|
<div className="bg-card rounded-xl shadow-sm border border-border p-6">
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1>
|
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1>
|
||||||
|
|
||||||
{book.author && (
|
{book.author && (
|
||||||
<p className="text-lg text-muted mb-4">by {book.author}</p>
|
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.series && (
|
{book.series && (
|
||||||
<p className="text-sm text-muted mb-6">
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
{book.series}
|
{book.series}
|
||||||
{book.volume && <span className="ml-2 px-2 py-1 bg-primary-soft text-primary rounded text-xs">Volume {book.volume}</span>}
|
{book.volume && <span className="ml-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">Volume {book.volume}</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Format:</span>
|
<span className="text-sm text-muted-foreground">Format:</span>
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||||
book.kind === 'epub' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
|
book.kind === 'epub' ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{book.kind.toUpperCase()}
|
{book.kind.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.volume && (
|
{book.volume && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Volume:</span>
|
<span className="text-sm text-muted-foreground">Volume:</span>
|
||||||
<span className="text-sm text-foreground">{book.volume}</span>
|
<span className="text-sm text-foreground">{book.volume}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.language && (
|
{book.language && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Language:</span>
|
<span className="text-sm text-muted-foreground">Language:</span>
|
||||||
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
|
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.page_count && (
|
{book.page_count && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Pages:</span>
|
<span className="text-sm text-muted-foreground">Pages:</span>
|
||||||
<span className="text-sm text-foreground">{book.page_count}</span>
|
<span className="text-sm text-foreground">{book.page_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Library:</span>
|
<span className="text-sm text-muted-foreground">Library:</span>
|
||||||
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
|
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.series && (
|
{book.series && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Series:</span>
|
<span className="text-sm text-muted-foreground">Series:</span>
|
||||||
<span className="text-sm text-foreground">{book.series}</span>
|
<span className="text-sm text-foreground">{book.series}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.file_format && (
|
{book.file_format && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">File Format:</span>
|
<span className="text-sm text-muted-foreground">File Format:</span>
|
||||||
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
|
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.file_parse_status && (
|
{book.file_parse_status && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Parse Status:</span>
|
<span className="text-sm text-muted-foreground">Parse Status:</span>
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
<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 === 'success' ? 'bg-success/10 text-success' :
|
||||||
book.file_parse_status === 'failed' ? 'bg-error-soft text-error' : 'bg-muted/20 text-muted'
|
book.file_parse_status === 'failed' ? 'bg-destructive/10 text-error' : 'bg-muted/50 text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{book.file_parse_status}
|
{book.file_parse_status}
|
||||||
</span>
|
</span>
|
||||||
@@ -131,25 +131,25 @@ export default async function BookDetailPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{book.file_path && (
|
{book.file_path && (
|
||||||
<div className="flex flex-col py-2 border-b border-line">
|
<div className="flex flex-col py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted mb-1">File Path:</span>
|
<span className="text-sm text-muted-foreground mb-1">File Path:</span>
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
|
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col py-2 border-b border-line">
|
<div className="flex flex-col py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted mb-1">Book ID:</span>
|
<span className="text-sm text-muted-foreground mb-1">Book ID:</span>
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
|
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col py-2 border-b border-line">
|
<div className="flex flex-col py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted mb-1">Library ID:</span>
|
<span className="text-sm text-muted-foreground mb-1">Library ID:</span>
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
|
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.updated_at && (
|
{book.updated_at && (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-sm text-muted">Updated:</span>
|
<span className="text-sm text-muted-foreground">Updated:</span>
|
||||||
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +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, CursorPagination } from "../components/ui";
|
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -26,7 +26,6 @@ export default async function BooksPage({
|
|||||||
let totalHits: number | null = null;
|
let totalHits: number | null = null;
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
// Mode recherche
|
|
||||||
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
||||||
if (searchResponse) {
|
if (searchResponse) {
|
||||||
searchResults = searchResponse.hits.map(hit => ({
|
searchResults = searchResponse.hits.map(hit => ({
|
||||||
@@ -47,7 +46,6 @@ export default async function BooksPage({
|
|||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Mode liste avec pagination
|
|
||||||
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
|
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
@@ -55,7 +53,6 @@ export default async function BooksPage({
|
|||||||
}));
|
}));
|
||||||
books = booksPage.items;
|
books = booksPage.items;
|
||||||
nextCursor = booksPage.next_cursor;
|
nextCursor = booksPage.next_cursor;
|
||||||
// Note: L'API ne supporte pas encore prev_cursor, on gère ça côté UI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayBooks = (searchResults || books).map(book => ({
|
const displayBooks = (searchResults || books).map(book => ({
|
||||||
@@ -64,27 +61,34 @@ export default async function BooksPage({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const hasNextPage = !!nextCursor;
|
const hasNextPage = !!nextCursor;
|
||||||
const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié)
|
const hasPrevPage = !!cursor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
<div className="mb-6">
|
||||||
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
Books
|
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</h1>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
Books
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filtres et recherche */}
|
{/* Search Bar - Style compact et propre */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<form>
|
<CardContent className="pt-6">
|
||||||
<FormRow>
|
<form className="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
|
||||||
<FormField>
|
<FormField className="flex-1 w-full">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">Search</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
name="q"
|
name="q"
|
||||||
placeholder="Search books..."
|
placeholder="Search by title, author, series..."
|
||||||
defaultValue={searchQuery}
|
defaultValue={searchQuery}
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField>
|
<FormField className="w-full sm:w-48">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">Library</label>
|
||||||
<FormSelect name="library" defaultValue={libraryId || ""}>
|
<FormSelect name="library" defaultValue={libraryId || ""}>
|
||||||
<option value="">All libraries</option>
|
<option value="">All libraries</option>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
@@ -94,22 +98,40 @@ export default async function BooksPage({
|
|||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit">🔍 Search</Button>
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
{searchQuery && (
|
<Button type="submit" className="flex-1 sm:flex-none">
|
||||||
<Link
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
href="/books"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
className="px-4 py-2.5 border border-line text-muted font-medium rounded-lg hover:bg-muted/5 transition-colors"
|
</svg>
|
||||||
>
|
Search
|
||||||
✕ Clear
|
</Button>
|
||||||
</Link>
|
{searchQuery && (
|
||||||
)}
|
<Link
|
||||||
</FormRow>
|
href="/books"
|
||||||
</form>
|
className="
|
||||||
|
inline-flex items-center justify-center
|
||||||
|
h-10 px-4
|
||||||
|
border border-input
|
||||||
|
text-sm font-medium
|
||||||
|
text-muted-foreground
|
||||||
|
bg-background
|
||||||
|
rounded-md
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
transition-colors duration-200
|
||||||
|
flex-1 sm:flex-none
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Résultats de recherche */}
|
{/* Résultats */}
|
||||||
{searchQuery && totalHits !== null && (
|
{searchQuery && totalHits !== null && (
|
||||||
<p className="text-sm text-muted mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -119,7 +141,6 @@ export default async function BooksPage({
|
|||||||
<>
|
<>
|
||||||
<BooksGrid books={displayBooks} />
|
<BooksGrid books={displayBooks} />
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{!searchQuery && (
|
{!searchQuery && (
|
||||||
<CursorPagination
|
<CursorPagination
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
|
|||||||
@@ -13,22 +13,20 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
|||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-[2/3] overflow-hidden bg-gradient-to-br from-line/50 to-line">
|
<div className="relative aspect-[2/3] overflow-hidden bg-muted">
|
||||||
{/* Skeleton */}
|
{/* Skeleton */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-muted/10 animate-pulse transition-opacity duration-300 ${
|
className={`absolute inset-0 bg-muted/50 animate-pulse transition-opacity duration-300 ${
|
||||||
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/20 to-transparent shimmer" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<Image
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
fill
|
fill
|
||||||
className={`object-cover group-hover:scale-105 transition-all duration-300 ${
|
className={`object-cover group-hover:scale-105 transition-transform duration-300 ${
|
||||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
@@ -45,7 +43,7 @@ export function BookCard({ book }: BookCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/books/${book.id}`}
|
href={`/books/${book.id}`}
|
||||||
className="group block bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all duration-200 overflow-hidden"
|
className="group block bg-card rounded-xl border border-border/60 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-200 overflow-hidden"
|
||||||
>
|
>
|
||||||
<BookImage
|
<BookImage
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
@@ -62,11 +60,11 @@ export function BookCard({ book }: BookCardProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{book.author && (
|
{book.author && (
|
||||||
<p className="text-sm text-muted mb-1 truncate">{book.author}</p>
|
<p className="text-sm text-muted-foreground mb-1 truncate">{book.author}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.series && (
|
{book.series && (
|
||||||
<p className="text-xs text-muted/80 truncate mb-2">
|
<p className="text-xs text-muted-foreground/80 truncate mb-2">
|
||||||
{book.series}
|
{book.series}
|
||||||
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
|
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
|
||||||
</p>
|
</p>
|
||||||
@@ -76,14 +74,14 @@ export function BookCard({ book }: BookCardProps) {
|
|||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className={`
|
<span className={`
|
||||||
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
||||||
${book.kind === 'cbz' ? 'bg-success-soft text-success' : ''}
|
${book.kind === 'cbz' ? 'bg-success/10 text-success' : ''}
|
||||||
${book.kind === 'cbr' ? 'bg-warning-soft text-warning' : ''}
|
${book.kind === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
||||||
${book.kind === 'pdf' ? 'bg-error-soft text-error' : ''}
|
${book.kind === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
||||||
`}>
|
`}>
|
||||||
{book.kind}
|
{book.kind}
|
||||||
</span>
|
</span>
|
||||||
{book.language && (
|
{book.language && (
|
||||||
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary-soft text-primary">
|
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary/10 text-primary">
|
||||||
{book.language}
|
{book.language}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -114,12 +112,12 @@ interface EmptyStateProps {
|
|||||||
export function EmptyState({ message }: EmptyStateProps) {
|
export function EmptyState({ message }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<div className="w-16 h-16 mb-4 text-muted/30">
|
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted text-lg">{message}</p>
|
<p className="text-muted-foreground text-lg">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-error-soft text-error rounded-lg text-sm">
|
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
||||||
Error: {error}
|
Error: {error}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -78,7 +78,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
|
|
||||||
if (!progress) {
|
if (!progress) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-muted text-sm">
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
Loading progress...
|
Loading progress...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -89,7 +89,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
const total = progress.total_files ?? 0;
|
const total = progress.total_files ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-card rounded-lg border border-line">
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<StatusBadge status={progress.status} />
|
<StatusBadge status={progress.status} />
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
@@ -99,7 +99,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
|
|
||||||
<ProgressBar value={percent} showLabel size="lg" className="mb-3" />
|
<ProgressBar value={percent} showLabel size="lg" className="mb-3" />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
||||||
<span>{processed} / {total} files</span>
|
<span>{processed} / {total} files</span>
|
||||||
{progress.current_file && (
|
{progress.current_file && (
|
||||||
<span className="truncate max-w-md" title={progress.current_file}>
|
<span className="truncate max-w-md" title={progress.current_file}>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
|
<tr className={highlighted ? 'bg-primary/10' : 'hover:bg-muted/50'}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/jobs/${job.id}`}
|
href={`/jobs/${job.id}`}
|
||||||
@@ -115,10 +115,10 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-muted">
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
{duration}
|
{duration}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-muted">
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
{formatDate(job.created_at)}
|
{formatDate(job.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -143,7 +143,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
</tr>
|
</tr>
|
||||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
{showProgress && (job.status === "running" || job.status === "pending") && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-3 bg-muted/5">
|
<td colSpan={8} className="px-4 py-3 bg-muted/50">
|
||||||
<JobProgress
|
<JobProgress
|
||||||
jobId={job.id}
|
jobId={job.id}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { Badge } from "./ui/Badge";
|
||||||
|
import { ProgressBar } from "./ui/ProgressBar";
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +22,27 @@ interface Job {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const JobsIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||||
|
<path d="M6 8h12M6 12h12M6 16h8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SpinnerIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function JobsIndicator() {
|
export function JobsIndicator() {
|
||||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -67,13 +91,18 @@ export function JobsIndicator() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/jobs"
|
href="/jobs"
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted transition-all duration-200 hover:text-foreground hover:bg-primary-soft"
|
className="
|
||||||
|
flex items-center justify-center
|
||||||
|
w-9 h-9
|
||||||
|
rounded-md
|
||||||
|
text-muted-foreground
|
||||||
|
hover:text-foreground
|
||||||
|
hover:bg-accent
|
||||||
|
transition-colors duration-200
|
||||||
|
"
|
||||||
title="View all jobs"
|
title="View all jobs"
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<JobsIcon className="w-[18px] h-[18px]" />
|
||||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
|
||||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,56 +110,61 @@ export function JobsIndicator() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all duration-200 ${
|
className={`
|
||||||
runningJobs.length > 0
|
flex items-center gap-2
|
||||||
? 'bg-success-soft text-success'
|
px-3 py-2
|
||||||
: 'bg-warning-soft text-warning'
|
rounded-md
|
||||||
} ${isOpen ? 'ring-2 ring-primary' : ''}`}
|
font-medium text-sm
|
||||||
|
transition-all duration-200
|
||||||
|
${runningJobs.length > 0
|
||||||
|
? 'bg-success/10 text-success hover:bg-success/20'
|
||||||
|
: 'bg-warning/10 text-warning hover:bg-warning/20'
|
||||||
|
}
|
||||||
|
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
||||||
|
`}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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="w-4 h-4 animate-spin">
|
<div className="w-4 h-4 animate-spin">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<SpinnerIcon className="w-4 h-4" />
|
||||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<JobsIcon className="w-4 h-4" />
|
||||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
|
||||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Badge with count */}
|
{/* Badge with count */}
|
||||||
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold text-white bg-current rounded-full">
|
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold bg-current rounded-full">
|
||||||
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
|
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Chevron */}
|
{/* Chevron */}
|
||||||
<svg
|
<ChevronIcon
|
||||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Popin/Dropdown */}
|
{/* Popin/Dropdown with glassmorphism */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-96 bg-card rounded-xl shadow-card border border-line overflow-hidden z-50">
|
<div className="
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line bg-muted/5">
|
absolute right-0 top-full mt-2 w-96
|
||||||
|
bg-popover/95 backdrop-blur-md
|
||||||
|
rounded-xl
|
||||||
|
shadow-elevation-2
|
||||||
|
border border-border/60
|
||||||
|
overflow-hidden
|
||||||
|
z-50
|
||||||
|
animate-scale-in
|
||||||
|
">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60 bg-muted/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">📊</span>
|
<span className="text-xl">📊</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted-foreground">
|
||||||
{runningJobs.length > 0
|
{runningJobs.length > 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`
|
||||||
@@ -149,33 +183,29 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
{/* Overall progress bar if running */}
|
{/* Overall progress bar if running */}
|
||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
<div className="px-4 py-3 border-b border-line">
|
<div className="px-4 py-3 border-b border-border/60">
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
<span className="text-muted">Overall Progress</span>
|
<span className="text-muted-foreground">Overall Progress</span>
|
||||||
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-line rounded-full overflow-hidden">
|
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
||||||
<div
|
|
||||||
className="h-full bg-success rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${totalProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-80 overflow-y-auto">
|
{/* Job List */}
|
||||||
|
<div className="max-h-80 overflow-y-auto scrollbar-hide">
|
||||||
{activeJobs.length === 0 ? (
|
{activeJobs.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-muted">
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
<span className="text-4xl mb-2">✅</span>
|
<span className="text-4xl mb-2">✅</span>
|
||||||
<p>No active jobs</p>
|
<p>No active jobs</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-line">
|
<ul className="divide-y divide-border/60">
|
||||||
{activeJobs.map(job => (
|
{activeJobs.map(job => (
|
||||||
<li key={job.id}>
|
<li key={job.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/jobs/${job.id}`}
|
href={`/jobs/${job.id}`}
|
||||||
className="block px-4 py-3 hover:bg-muted/5 transition-colors"
|
className="block px-4 py-3 hover:bg-accent/50 transition-colors duration-200"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -186,37 +216,30 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<code className="text-xs px-1.5 py-0.5 bg-line/50 rounded font-mono">{job.id.slice(0, 8)}</code>
|
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
<Badge variant={job.type === 'rebuild' ? 'primary' : 'secondary'} className="text-[10px]">
|
||||||
job.type === 'rebuild' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
|
|
||||||
}`}>
|
|
||||||
{job.type}
|
{job.type}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{job.status === "running" && job.progress_percent !== null && (
|
{job.status === "running" && job.progress_percent !== null && (
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<div className="flex-1 h-1.5 bg-line rounded-full overflow-hidden">
|
<MiniProgressBar value={job.progress_percent} />
|
||||||
<div
|
<span className="text-xs font-medium text-muted-foreground">{job.progress_percent}%</span>
|
||||||
className="h-full bg-success rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${job.progress_percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium text-muted">{job.progress_percent}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<p className="text-xs text-muted mt-1.5 truncate" title={job.current_file}>
|
<p className="text-xs text-muted-foreground mt-1.5 truncate" title={job.current_file}>
|
||||||
📄 {job.current_file}
|
📄 {job.current_file}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.stats_json && (
|
{job.stats_json && (
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||||
<span>✓ {job.stats_json.indexed_files}</span>
|
<span>✓ {job.stats_json.indexed_files}</span>
|
||||||
{job.stats_json.errors > 0 && (
|
{job.stats_json.errors > 0 && (
|
||||||
<span className="text-error">⚠ {job.stats_json.errors}</span>
|
<span className="text-destructive">⚠ {job.stats_json.errors}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -230,11 +253,23 @@ export function JobsIndicator() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-4 py-2 border-t border-line bg-muted/5">
|
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
|
||||||
<p className="text-xs text-muted text-center">Auto-refreshing every 2s</p>
|
<p className="text-xs text-muted-foreground text-center">Auto-refreshing every 2s</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mini progress bar for dropdown
|
||||||
|
function MiniProgressBar({ value }: { value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-success rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${value}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { JobRow } from "./JobRow";
|
import { JobRow } from "./JobRow";
|
||||||
import { MiniProgressBar } from "./ui";
|
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,18 +44,15 @@ function formatDate(dateStr: string): string {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - date.getTime();
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
// Less than 1 hour: show relative
|
|
||||||
if (diff < 3600000) {
|
if (diff < 3600000) {
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
if (mins < 1) return "Just now";
|
if (mins < 1) return "Just now";
|
||||||
return `${mins}m ago`;
|
return `${mins}m ago`;
|
||||||
}
|
}
|
||||||
// Less than 24 hours: show hours
|
|
||||||
if (diff < 86400000) {
|
if (diff < 86400000) {
|
||||||
const hours = Math.floor(diff / 3600000);
|
const hours = Math.floor(diff / 3600000);
|
||||||
return `${hours}h ago`;
|
return `${hours}h ago`;
|
||||||
}
|
}
|
||||||
// Otherwise: show date
|
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,22 +101,22 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden">
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line bg-muted/5">
|
<tr className="border-b border-border/60 bg-muted/50">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">ID</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Library</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Files</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Duration</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line">
|
<tbody className="divide-y divide-border/60">
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<JobRow
|
<JobRow
|
||||||
key={job.id}
|
key={job.id}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
import { useState, useRef, useEffect, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import { Button } from "../components/ui";
|
||||||
import { Button, Badge } from "../components/ui";
|
|
||||||
|
|
||||||
interface LibraryActionsProps {
|
interface LibraryActionsProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
@@ -70,13 +69,16 @@ export function LibraryActions({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className={isOpen ? "bg-muted/10" : ""}
|
className={isOpen ? "bg-accent" : ""}
|
||||||
>
|
>
|
||||||
⚙️
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-card border border-line p-4 z-50">
|
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
|
||||||
<form action={handleSubmit}>
|
<form action={handleSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -86,7 +88,7 @@ export function LibraryActions({
|
|||||||
name="monitor_enabled"
|
name="monitor_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={monitorEnabled}
|
defaultChecked={monitorEnabled}
|
||||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
Auto Scan
|
Auto Scan
|
||||||
</label>
|
</label>
|
||||||
@@ -99,7 +101,7 @@ export function LibraryActions({
|
|||||||
name="watcher_enabled"
|
name="watcher_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={watcherEnabled}
|
defaultChecked={watcherEnabled}
|
||||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
File Watcher ⚡
|
File Watcher ⚡
|
||||||
</label>
|
</label>
|
||||||
@@ -110,7 +112,7 @@ export function LibraryActions({
|
|||||||
<select
|
<select
|
||||||
name="scan_mode"
|
name="scan_mode"
|
||||||
defaultValue={scanMode}
|
defaultValue={scanMode}
|
||||||
className="text-sm border border-line rounded-lg px-2 py-1 bg-background"
|
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||||
>
|
>
|
||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">Hourly</option>
|
||||||
|
|||||||
111
apps/backoffice/app/components/LibrarySubPageHeader.tsx
Normal file
111
apps/backoffice/app/components/LibrarySubPageHeader.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, Badge } from "./ui";
|
||||||
|
|
||||||
|
interface LibrarySubPageHeaderProps {
|
||||||
|
library: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
root_path: string;
|
||||||
|
book_count: number;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconColor?: string;
|
||||||
|
filterInfo?: {
|
||||||
|
label: string;
|
||||||
|
clearHref: string;
|
||||||
|
clearLabel: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibrarySubPageHeader({
|
||||||
|
library,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
iconColor = "text-primary",
|
||||||
|
filterInfo
|
||||||
|
}: LibrarySubPageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header avec breadcrumb intégré */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Link
|
||||||
|
href="/libraries"
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Libraries
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<span className={iconColor}>{icon}</span>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Bar - Version améliorée */}
|
||||||
|
<Card className="bg-muted/30 border-border/40">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
{/* Path */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground bg-background px-2 py-1 rounded border border-border/60">
|
||||||
|
{library.root_path}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<span className="hidden sm:block w-px h-4 bg-border" />
|
||||||
|
|
||||||
|
{/* Book count */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-foreground">
|
||||||
|
<span className="font-semibold">{library.book_count}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<span className="hidden sm:block w-px h-4 bg-border" />
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Badge
|
||||||
|
variant={library.enabled ? "success" : "muted"}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{library.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Filter Info (optionnel) */}
|
||||||
|
{filterInfo && (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{filterInfo.label}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={filterInfo.clearHref as `/libraries/${string}/books`}
|
||||||
|
className="text-sm text-primary hover:text-primary/80 font-medium"
|
||||||
|
>
|
||||||
|
{filterInfo.clearLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,14 +42,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
isPending
|
isPending
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'hover:border-primary'
|
: 'hover:border-primary'
|
||||||
} ${monitorEnabled ? 'bg-primary-soft border-primary text-primary' : 'bg-card border-line text-muted'}`}>
|
} ${monitorEnabled ? 'bg-primary/10 border-primary text-primary' : 'bg-card border-border text-muted-foreground'}`}>
|
||||||
<input
|
<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"
|
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span>Auto</span>
|
<span>Auto</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -58,14 +58,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
isPending
|
isPending
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'hover:border-primary'
|
: 'hover:border-primary'
|
||||||
} ${watcherEnabled ? 'bg-warning-soft border-warning text-warning' : 'bg-card border-line text-muted'}`}>
|
} ${watcherEnabled ? 'bg-warning/10 border-warning text-warning' : 'bg-card border-border text-muted-foreground'}`}>
|
||||||
<input
|
<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"
|
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
|
||||||
/>
|
/>
|
||||||
<span title="Real-time file watcher">⚡</span>
|
<span title="Real-time file watcher">⚡</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -74,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="px-3 py-1.5 text-sm rounded-lg border border-line bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">Hourly</option>
|
||||||
|
|||||||
@@ -1,61 +1,113 @@
|
|||||||
type BadgeVariant = "default" | "primary" | "success" | "warning" | "error" | "muted";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
type BadgeVariant =
|
||||||
|
| "default"
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "success"
|
||||||
|
| "warning"
|
||||||
|
| "error"
|
||||||
|
| "muted"
|
||||||
|
| "unread"
|
||||||
|
| "in-progress"
|
||||||
|
| "completed";
|
||||||
|
|
||||||
interface BadgeProps {
|
interface BadgeProps {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<BadgeVariant, string> = {
|
const variantStyles: Record<BadgeVariant, string> = {
|
||||||
default: "bg-muted/20 text-muted",
|
// shadcn/ui compatible
|
||||||
primary: "bg-primary-soft text-primary",
|
default: "bg-primary/90 text-primary-foreground border-transparent hover:bg-primary/80 backdrop-blur-md",
|
||||||
success: "bg-success-soft text-success",
|
secondary: "bg-secondary/80 text-secondary-foreground border-transparent hover:bg-secondary/60 backdrop-blur-md",
|
||||||
warning: "bg-warning-soft text-warning",
|
destructive: "bg-destructive/90 text-destructive-foreground border-transparent hover:bg-destructive/80 backdrop-blur-md",
|
||||||
error: "bg-error-soft text-error",
|
outline: "text-foreground border-border bg-background/50",
|
||||||
muted: "bg-muted/10 text-muted",
|
|
||||||
|
// Legacy + Additional variants
|
||||||
|
primary: "bg-primary/90 text-primary-foreground backdrop-blur-md",
|
||||||
|
success: "bg-success/90 text-success-foreground backdrop-blur-md",
|
||||||
|
warning: "bg-warning/90 text-white backdrop-blur-md",
|
||||||
|
error: "bg-destructive/90 text-destructive-foreground backdrop-blur-md",
|
||||||
|
muted: "bg-muted/60 text-muted-foreground backdrop-blur-md",
|
||||||
|
|
||||||
|
// Status badges from StripStream
|
||||||
|
unread: "badge-unread backdrop-blur-md",
|
||||||
|
"in-progress": "badge-in-progress backdrop-blur-md",
|
||||||
|
completed: "badge-completed backdrop-blur-md",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
|
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${variantStyles[variant]} ${className}`}>
|
<span className={`
|
||||||
|
inline-flex items-center
|
||||||
|
px-2.5 py-0.5
|
||||||
|
rounded-full
|
||||||
|
text-xs font-semibold
|
||||||
|
border
|
||||||
|
transition-colors duration-200
|
||||||
|
${variantStyles[variant]}
|
||||||
|
${className}
|
||||||
|
`}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusVariant = "running" | "success" | "failed" | "cancelled" | "pending";
|
// Status badge for jobs/tasks
|
||||||
|
const statusVariants: Record<string, BadgeVariant> = {
|
||||||
|
running: "in-progress",
|
||||||
|
success: "completed",
|
||||||
|
completed: "completed",
|
||||||
|
failed: "error",
|
||||||
|
cancelled: "muted",
|
||||||
|
pending: "warning",
|
||||||
|
unread: "unread",
|
||||||
|
};
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
status: string;
|
status: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusVariants: Record<StatusVariant, BadgeVariant> = {
|
|
||||||
running: "primary",
|
|
||||||
success: "success",
|
|
||||||
failed: "error",
|
|
||||||
cancelled: "muted",
|
|
||||||
pending: "warning",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||||
const variant = statusVariants[status as StatusVariant] || "default";
|
const variant = statusVariants[status.toLowerCase()] || "default";
|
||||||
return <Badge variant={variant} className={className}>{status}</Badge>;
|
return <Badge variant={variant} className={className}>{status}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobTypeVariant = "rebuild" | "full_rebuild";
|
// Job type badge
|
||||||
|
const jobTypeVariants: Record<string, BadgeVariant> = {
|
||||||
|
rebuild: "primary",
|
||||||
|
full_rebuild: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
interface JobTypeBadgeProps {
|
interface JobTypeBadgeProps {
|
||||||
type: string;
|
type: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobTypeVariants: Record<JobTypeVariant, BadgeVariant> = {
|
|
||||||
rebuild: "primary",
|
|
||||||
full_rebuild: "warning",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||||
const variant = jobTypeVariants[type as JobTypeVariant] || "default";
|
const variant = jobTypeVariants[type.toLowerCase()] || "default";
|
||||||
return <Badge variant={variant} className={className}>{type}</Badge>;
|
return <Badge variant={variant} className={className}>{type}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Progress badge (shows percentage)
|
||||||
|
interface ProgressBadgeProps {
|
||||||
|
progress: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBadge({ progress, className = "" }: ProgressBadgeProps) {
|
||||||
|
let variant: BadgeVariant = "unread";
|
||||||
|
if (progress === 100) variant = "completed";
|
||||||
|
else if (progress > 0) variant = "in-progress";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} className={className}>
|
||||||
|
{progress}%
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { ButtonHTMLAttributes, ReactNode } from "react";
|
import { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
type ButtonVariant = "primary" | "secondary" | "danger" | "warning" | "ghost";
|
type ButtonVariant =
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link"
|
||||||
|
| "primary"
|
||||||
|
| "danger"
|
||||||
|
| "warning";
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -9,22 +18,29 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
primary: "bg-primary text-white hover:bg-primary/90",
|
// shadcn/ui compatible variants
|
||||||
secondary: "border border-line text-muted hover:bg-muted/5",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
|
||||||
danger: "bg-error text-white hover:bg-error/90",
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||||
warning: "bg-warning text-white hover:bg-warning/90",
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
ghost: "text-muted hover:text-foreground hover:bg-muted/5",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/85 shadow-sm",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
|
||||||
|
// Legacy variants (mapped to new ones for compatibility)
|
||||||
|
primary: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
|
||||||
|
danger: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||||
|
warning: "bg-warning text-white hover:bg-warning/90 shadow-sm",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
sm: "h-8 px-3 text-xs",
|
sm: "h-9 px-3 text-xs rounded-md",
|
||||||
md: "h-10 px-4 text-sm",
|
md: "h-10 px-4 py-2 text-sm rounded-md",
|
||||||
lg: "h-12 px-6 text-base",
|
lg: "h-11 px-8 text-base rounded-md",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
children,
|
children,
|
||||||
variant = "primary",
|
variant = "default",
|
||||||
size = "md",
|
size = "md",
|
||||||
className = "",
|
className = "",
|
||||||
disabled,
|
disabled,
|
||||||
@@ -33,8 +49,12 @@ export function Button({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center justify-center font-medium rounded-lg transition-colors
|
inline-flex items-center justify-center
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
font-medium
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:pointer-events-none disabled:opacity-50
|
||||||
|
active:scale-[0.98]
|
||||||
${variantStyles[variant]}
|
${variantStyles[variant]}
|
||||||
${sizeStyles[size]}
|
${sizeStyles[size]}
|
||||||
${className}
|
${className}
|
||||||
@@ -46,3 +66,46 @@ export function Button({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Icon Button variant
|
||||||
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSizeStyles: Record<string, string> = {
|
||||||
|
sm: "h-8 w-8",
|
||||||
|
md: "h-9 w-9",
|
||||||
|
lg: "h-10 w-10",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
children,
|
||||||
|
size = "md",
|
||||||
|
variant = "ghost",
|
||||||
|
className = "",
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
title={title}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center justify-center
|
||||||
|
rounded-md
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:pointer-events-none disabled:opacity-50
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
active:scale-[0.96]
|
||||||
|
${iconSizeStyles[size]}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,25 +3,146 @@ import { ReactNode } from "react";
|
|||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
hover?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, className = "" }: CardProps) {
|
export function Card({ children, className = "", hover = true }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-card rounded-xl shadow-soft border border-line p-6 ${className}`}>
|
<div
|
||||||
|
className={`
|
||||||
|
bg-card text-card-foreground
|
||||||
|
rounded-lg border border-border/60
|
||||||
|
shadow-sm
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
${hover ? "hover:shadow-md hover:-translate-y-0.5" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardHeaderProps {
|
interface CardHeaderProps {
|
||||||
title: string;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeader({ title, className = "" }: CardHeaderProps) {
|
export function CardHeader({ children, className = "" }: CardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
|
<div className={`flex flex-col space-y-1.5 p-6 ${className}`}>
|
||||||
{title}
|
{children}
|
||||||
</h2>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardTitleProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ children, className = "" }: CardTitleProps) {
|
||||||
|
return (
|
||||||
|
<h3 className={`text-2xl font-semibold leading-none tracking-tight ${className}`}>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardDescriptionProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ children, className = "" }: CardDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-sm text-muted-foreground ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = "" }: CardContentProps) {
|
||||||
|
return (
|
||||||
|
<div className={`p-6 pt-0 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardFooterProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ children, className = "" }: CardFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center p-6 pt-0 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glass Card variant for special sections
|
||||||
|
interface GlassCardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassCard({ children, className = "" }: GlassCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
glass-card
|
||||||
|
rounded-xl
|
||||||
|
p-6
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
hover:shadow-elevation-2
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple card with header shortcut
|
||||||
|
interface SimpleCardProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
footer
|
||||||
|
}: SimpleCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
{(title || description) && (
|
||||||
|
<CardHeader>
|
||||||
|
{title && <CardTitle>{title}</CardTitle>}
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
{footer && (
|
||||||
|
<CardFooter>
|
||||||
|
{footer}
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,81 @@
|
|||||||
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
// Form Field Container
|
||||||
interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormField({ children, className = "" }: FormFieldProps) {
|
export function FormField({ children, className = "" }: FormFieldProps) {
|
||||||
return <div className={`flex-1 min-w-48 ${className}`}>{children}</div>;
|
return <div className={`flex flex-col space-y-1.5 ${className}`}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Label
|
||||||
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
|
export function FormLabel({ children, required, className = "", ...props }: FormLabelProps) {
|
||||||
return (
|
return (
|
||||||
<label className={`block text-sm font-medium text-foreground mb-1.5 ${className}`} {...props}>
|
<label
|
||||||
|
className={`text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
// Form Input
|
||||||
|
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function FormInput({ className = "", ...props }: FormInputProps) {
|
export function FormInput({ className = "", error, ...props }: FormInputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
className={`
|
||||||
|
flex h-10 w-full
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background px-3 py-2
|
||||||
|
text-sm
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
file:border-0 file:bg-transparent file:text-sm file:font-medium
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Select
|
||||||
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
|
export function FormSelect({ children, className = "", error, ...props }: FormSelectProps) {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
className={`
|
||||||
|
flex h-10 w-full
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background px-3 py-2
|
||||||
|
text-sm
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -47,11 +83,64 @@ export function FormSelect({ children, className = "", ...props }: FormSelectPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Row (horizontal layout)
|
||||||
interface FormRowProps {
|
interface FormRowProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormRow({ children, className = "" }: FormRowProps) {
|
export function FormRow({ children, className = "" }: FormRowProps) {
|
||||||
return <div className={`flex items-end gap-3 flex-wrap ${className}`}>{children}</div>;
|
return <div className={`flex flex-wrap items-end gap-4 ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Section
|
||||||
|
interface FormSectionProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSection({ title, description, children, className = "" }: FormSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{(title || description) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{title && <h3 className="text-lg font-medium text-foreground">{title}</h3>}
|
||||||
|
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Error Message
|
||||||
|
interface FormErrorProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormError({ children, className = "" }: FormErrorProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-xs text-destructive ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Description
|
||||||
|
interface FormDescriptionProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormDescription({ children, className = "" }: FormDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-xs text-muted-foreground ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,168 @@
|
|||||||
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
|
import { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes, ReactNode, forwardRef } from "react";
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
// Input Component
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Input({ label, className = "", ...props }: InputProps) {
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
return (
|
({ label, error, className = "", ...props }, ref) => {
|
||||||
<input
|
return (
|
||||||
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}`}
|
<div className="w-full">
|
||||||
{...props}
|
{label && (
|
||||||
/>
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
);
|
{label}
|
||||||
}
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
h-10 px-3 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
file:border-0 file:bg-transparent file:text-sm file:font-medium
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
// Select Component
|
||||||
|
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
error?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select({ label, children, className = "", ...props }: SelectProps) {
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
return (
|
({ label, error, children, className = "", ...props }, ref) => {
|
||||||
<select
|
return (
|
||||||
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}`}
|
<div className="w-full">
|
||||||
{...props}
|
{label && (
|
||||||
>
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
{children}
|
{label}
|
||||||
</select>
|
</label>
|
||||||
);
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
h-10 px-3 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
// Textarea Component
|
||||||
|
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ label, error, className = "", ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
min-h-[80px] px-3 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
resize-vertical
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
// Search Input with Icon
|
||||||
|
interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
({ icon, className = "", ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{icon && (
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
h-10 pl-10 pr-4 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
SearchInput.displayName = "SearchInput";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { IconButton } from "./Button";
|
||||||
|
|
||||||
interface CursorPaginationProps {
|
interface CursorPaginationProps {
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
@@ -44,14 +45,14 @@ export function CursorPagination({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||||
{/* Page size selector */}
|
{/* Page size selector */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-muted">Show</span>
|
<span className="text-sm text-muted-foreground">Show</span>
|
||||||
<select
|
<select
|
||||||
value={pageSize.toString()}
|
value={pageSize.toString()}
|
||||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
className="w-20 px-3 py-2 text-sm border border-line rounded-lg bg-background text-foreground"
|
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
|
||||||
>
|
>
|
||||||
{pageSizeOptions.map((size) => (
|
{pageSizeOptions.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -59,33 +60,38 @@ export function CursorPagination({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted">per page</span>
|
<span className="text-sm text-muted-foreground">per page</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Count info */}
|
{/* Count info */}
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing {currentCount} items
|
Showing {currentCount} items
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
{hasPrevPage && (
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="secondary"
|
size="sm"
|
||||||
size="sm"
|
onClick={goToFirst}
|
||||||
onClick={goToFirst}
|
disabled={!hasPrevPage}
|
||||||
>
|
>
|
||||||
← First
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
)}
|
</svg>
|
||||||
|
First
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={goToNext}
|
onClick={goToNext}
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
>
|
>
|
||||||
Next →
|
Next
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,14 +167,14 @@ export function OffsetPagination({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||||
{/* Page size selector */}
|
{/* Page size selector */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-muted">Show</span>
|
<span className="text-sm text-muted-foreground">Show</span>
|
||||||
<select
|
<select
|
||||||
value={pageSize.toString()}
|
value={pageSize.toString()}
|
||||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
className="w-20 px-3 py-2 text-sm border border-line rounded-lg bg-background text-foreground"
|
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
|
||||||
>
|
>
|
||||||
{pageSizeOptions.map((size) => (
|
{pageSizeOptions.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -176,34 +182,37 @@ export function OffsetPagination({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted">per page</span>
|
<span className="text-sm text-muted-foreground">per page</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page info */}
|
{/* Page info */}
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted-foreground">
|
||||||
{startItem}-{endItem} of {totalItems}
|
{startItem}-{endItem} of {totalItems}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page navigation */}
|
{/* Page navigation */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<IconButton
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToPage(currentPage - 1)}
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
|
title="Previous page"
|
||||||
>
|
>
|
||||||
←
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
{getPageNumbers().map((page, index) => (
|
{getPageNumbers().map((page, index) => (
|
||||||
<span key={index}>
|
<span key={index}>
|
||||||
{page === "..." ? (
|
{page === "..." ? (
|
||||||
<span className="px-3 py-2 text-sm text-muted">...</span>
|
<span className="px-3 py-2 text-sm text-muted-foreground">...</span>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant={currentPage === page ? "primary" : "ghost"}
|
variant={currentPage === page ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToPage(page as number)}
|
onClick={() => goToPage(page as number)}
|
||||||
|
className="min-w-[2.5rem]"
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -211,14 +220,16 @@ export function OffsetPagination({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
<IconButton
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
|
title="Next page"
|
||||||
>
|
>
|
||||||
→
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,13 +3,21 @@ interface ProgressBarProps {
|
|||||||
max?: number;
|
max?: number;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
|
variant?: "default" | "success" | "warning" | "error";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
sm: "h-1.5",
|
sm: "h-1.5",
|
||||||
md: "h-2",
|
md: "h-2",
|
||||||
lg: "h-8",
|
lg: "h-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: "bg-primary",
|
||||||
|
success: "bg-success",
|
||||||
|
warning: "bg-warning",
|
||||||
|
error: "bg-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProgressBar({
|
export function ProgressBar({
|
||||||
@@ -17,18 +25,19 @@ export function ProgressBar({
|
|||||||
max = 100,
|
max = 100,
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
size = "md",
|
size = "md",
|
||||||
|
variant = "default",
|
||||||
className = ""
|
className = ""
|
||||||
}: ProgressBarProps) {
|
}: ProgressBarProps) {
|
||||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${sizeStyles[size]} bg-line rounded-full overflow-hidden ${className}`}>
|
<div className={`relative ${sizeStyles[size]} bg-muted/50 rounded-full overflow-hidden ${className}`}>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 bg-success rounded-full transition-all duration-300"
|
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ease-out ${variantStyles[variant]}`}
|
||||||
style={{ width: `${percent}%` }}
|
style={{ width: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-foreground">
|
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-foreground">
|
||||||
{Math.round(percent)}%
|
{Math.round(percent)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -36,21 +45,112 @@ export function ProgressBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mini Progress Bar (for compact displays)
|
||||||
interface MiniProgressBarProps {
|
interface MiniProgressBarProps {
|
||||||
value: number;
|
value: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
variant?: "default" | "success" | "warning" | "error";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MiniProgressBar({ value, max = 100, className = "" }: MiniProgressBarProps) {
|
export function MiniProgressBar({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
variant = "default",
|
||||||
|
className = ""
|
||||||
|
}: MiniProgressBarProps) {
|
||||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex-1 h-1.5 bg-line rounded-full overflow-hidden ${className}`}>
|
<div className={`flex-1 h-1.5 bg-muted/50 rounded-full overflow-hidden ${className}`}>
|
||||||
<div
|
<div
|
||||||
className="h-full bg-success rounded-full transition-all duration-300"
|
className={`h-full rounded-full transition-all duration-500 ease-out ${variantStyles[variant]}`}
|
||||||
style={{ width: `${percent}%` }}
|
style={{ width: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Progress indicator with status colors based on percentage
|
||||||
|
interface SmartProgressBarProps {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmartProgressBar({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
size = "md",
|
||||||
|
className = ""
|
||||||
|
}: SmartProgressBarProps) {
|
||||||
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
|
||||||
|
// Determine variant based on percentage
|
||||||
|
let variant: "default" | "success" | "warning" | "error" = "default";
|
||||||
|
if (percent === 100) variant = "success";
|
||||||
|
else if (percent < 25) variant = "error";
|
||||||
|
else if (percent < 50) variant = "warning";
|
||||||
|
|
||||||
|
return <ProgressBar value={value} max={max} size={size} variant={variant} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circular Progress (for special use cases)
|
||||||
|
interface CircularProgressProps {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CircularProgress({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
size = 40,
|
||||||
|
strokeWidth = 4,
|
||||||
|
className = ""
|
||||||
|
}: CircularProgressProps) {
|
||||||
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
const offset = circumference - (percent / 100) * circumference;
|
||||||
|
|
||||||
|
// Determine color based on percentage
|
||||||
|
let color = "hsl(var(--color-primary))";
|
||||||
|
if (percent === 100) color = "hsl(var(--color-success))";
|
||||||
|
else if (percent < 25) color = "hsl(var(--color-destructive))";
|
||||||
|
else if (percent < 50) color = "hsl(var(--color-warning))";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-flex items-center justify-center ${className}`} style={{ width: size, height: size }}>
|
||||||
|
<svg className="transform -rotate-90" width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
className="text-muted-foreground"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
stroke={color}
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
className="transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-xs font-semibold text-foreground">
|
||||||
|
{Math.round(percent)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ interface StatBoxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<string, string> = {
|
const variantStyles: Record<string, string> = {
|
||||||
default: "bg-muted/5",
|
default: "bg-muted/50",
|
||||||
primary: "bg-primary-soft",
|
primary: "bg-primary/10",
|
||||||
success: "bg-success-soft",
|
success: "bg-success/10",
|
||||||
warning: "bg-warning-soft",
|
warning: "bg-warning/10",
|
||||||
error: "bg-error-soft",
|
error: "bg-destructive/10",
|
||||||
};
|
};
|
||||||
|
|
||||||
const valueVariantStyles: Record<string, string> = {
|
const valueVariantStyles: Record<string, string> = {
|
||||||
@@ -20,14 +20,14 @@ const valueVariantStyles: Record<string, string> = {
|
|||||||
primary: "text-primary",
|
primary: "text-primary",
|
||||||
success: "text-success",
|
success: "text-success",
|
||||||
warning: "text-warning",
|
warning: "text-warning",
|
||||||
error: "text-error",
|
error: "text-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`text-center p-4 rounded-lg ${variantStyles[variant]} ${className}`}>
|
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
|
||||||
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
||||||
<span className={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
|
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
export { Card, CardHeader } from "./Card";
|
export {
|
||||||
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
|
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
|
||||||
|
GlassCard, SimpleCard
|
||||||
|
} from "./Card";
|
||||||
|
export {
|
||||||
|
Badge, StatusBadge, JobTypeBadge, ProgressBadge
|
||||||
|
} from "./Badge";
|
||||||
export { StatBox } from "./StatBox";
|
export { StatBox } from "./StatBox";
|
||||||
export { ProgressBar, MiniProgressBar } from "./ProgressBar";
|
export {
|
||||||
export { Button } from "./Button";
|
ProgressBar, MiniProgressBar, SmartProgressBar, CircularProgress
|
||||||
export { Input, Select } from "./Input";
|
} from "./ProgressBar";
|
||||||
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
|
export { Button, IconButton } from "./Button";
|
||||||
|
export {
|
||||||
|
Input, Select, Textarea, SearchInput
|
||||||
|
} from "./Input";
|
||||||
|
export {
|
||||||
|
FormField, FormLabel, FormInput, FormSelect, FormRow,
|
||||||
|
FormSection, FormError, FormDescription
|
||||||
|
} from "./Form";
|
||||||
export { PageIcon, NavIcon } from "./Icon";
|
export { PageIcon, NavIcon } from "./Icon";
|
||||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Core Colors - Light Theme */
|
||||||
--color-background: hsl(36 33% 97%);
|
--color-background: hsl(36 33% 97%);
|
||||||
--color-foreground: hsl(222 33% 15%);
|
--color-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Card & Surfaces */
|
||||||
--color-card: hsl(0 0% 100%);
|
--color-card: hsl(0 0% 100%);
|
||||||
--color-line: hsl(32 18% 84%);
|
--color-card-foreground: hsl(222 33% 15%);
|
||||||
--color-line-strong: hsl(32 18% 76%);
|
--color-popover: hsl(0 0% 100%);
|
||||||
|
--color-popover-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Primary - Cyan/Teal */
|
||||||
--color-primary: hsl(198 78% 37%);
|
--color-primary: hsl(198 78% 37%);
|
||||||
|
--color-primary-foreground: hsl(210 40% 98%);
|
||||||
--color-primary-soft: hsl(198 52% 90%);
|
--color-primary-soft: hsl(198 52% 90%);
|
||||||
--color-muted: hsl(220 13% 40%);
|
|
||||||
|
/* Secondary - Warm Gray */
|
||||||
|
--color-secondary: hsl(36 30% 92%);
|
||||||
|
--color-secondary-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Muted */
|
||||||
|
--color-muted: hsl(36 24% 90%);
|
||||||
|
--color-muted-foreground: hsl(220 13% 40%);
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--color-accent: hsl(198 52% 90%);
|
||||||
|
--color-accent-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Destructive - Red */
|
||||||
|
--color-destructive: hsl(2 72% 48%);
|
||||||
|
--color-destructive-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-border: hsl(32 18% 84%);
|
||||||
|
--color-input: hsl(32 18% 84%);
|
||||||
|
--color-ring: hsl(198 78% 37%);
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
--color-success: hsl(142 60% 45%);
|
--color-success: hsl(142 60% 45%);
|
||||||
--color-success-soft: hsl(142 60% 90%);
|
--color-success-soft: hsl(142 60% 90%);
|
||||||
--color-warning: hsl(45 93% 47%);
|
--color-warning: hsl(45 93% 47%);
|
||||||
@@ -16,57 +45,159 @@
|
|||||||
--color-error: hsl(2 72% 48%);
|
--color-error: hsl(2 72% 48%);
|
||||||
--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;
|
/* Typography */
|
||||||
|
--font-sans: "Inter", "Avenir Next", "Segoe UI", "Noto Sans", system-ui, sans-serif;
|
||||||
|
--font-display: "Baskerville", "Times New Roman", serif;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
--shadow-soft: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
--shadow-soft: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
||||||
--shadow-card: 0 12px 30px -12px rgb(23 32 46 / 0.22);
|
--shadow-card: 0 12px 30px -12px rgb(23 32 46 / 0.22);
|
||||||
|
--shadow-elevation-1: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
||||||
|
--shadow-elevation-2: 0 8px 24px -8px rgb(23 32 46 / 0.18);
|
||||||
|
|
||||||
|
/* Animation Timing */
|
||||||
|
--duration-fast: 120ms;
|
||||||
|
--duration-base: 200ms;
|
||||||
|
--duration-slow: 320ms;
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark Theme */
|
||||||
.dark {
|
.dark {
|
||||||
--color-background: hsl(222 35% 10%);
|
--color-background: hsl(222 35% 10%);
|
||||||
--color-foreground: hsl(38 20% 92%);
|
--color-foreground: hsl(38 20% 92%);
|
||||||
--color-card: hsl(221 31% 13%);
|
--color-card: hsl(221 31% 13%);
|
||||||
--color-line: hsl(219 18% 25%);
|
--color-card-foreground: hsl(38 20% 92%);
|
||||||
--color-line-strong: hsl(219 18% 33%);
|
--color-popover: hsl(221 31% 13%);
|
||||||
|
--color-popover-foreground: hsl(38 20% 92%);
|
||||||
--color-primary: hsl(194 76% 62%);
|
--color-primary: hsl(194 76% 62%);
|
||||||
|
--color-primary-foreground: hsl(222 35% 10%);
|
||||||
--color-primary-soft: hsl(210 34% 24%);
|
--color-primary-soft: hsl(210 34% 24%);
|
||||||
--color-muted: hsl(218 17% 72%);
|
--color-secondary: hsl(221 22% 20%);
|
||||||
|
--color-secondary-foreground: hsl(38 20% 92%);
|
||||||
|
--color-muted: hsl(220 17% 24%);
|
||||||
|
--color-muted-foreground: hsl(218 17% 72%);
|
||||||
|
--color-accent: hsl(210 34% 24%);
|
||||||
|
--color-accent-foreground: hsl(38 20% 92%);
|
||||||
|
--color-destructive: hsl(2 80% 65%);
|
||||||
|
--color-destructive-foreground: hsl(210 40% 98%);
|
||||||
|
--color-border: hsl(219 18% 25%);
|
||||||
|
--color-input: hsl(219 18% 25%);
|
||||||
|
--color-ring: hsl(194 76% 62%);
|
||||||
--color-success: hsl(142 70% 55%);
|
--color-success: hsl(142 70% 55%);
|
||||||
--color-success-soft: hsl(142 30% 20%);
|
--color-success-soft: hsl(142 30% 20%);
|
||||||
--color-warning: hsl(45 90% 55%);
|
--color-warning: hsl(45 90% 55%);
|
||||||
--color-warning-soft: hsl(45 30% 20%);
|
--color-warning-soft: hsl(45 30% 20%);
|
||||||
--color-error: hsl(2 80% 65%);
|
--color-error: hsl(2 80% 65%);
|
||||||
--color-error-soft: hsl(2 30% 20%);
|
--color-error-soft: hsl(2 30% 20%);
|
||||||
|
--shadow-soft: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
||||||
|
--shadow-card: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
||||||
|
--shadow-elevation-1: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
||||||
|
--shadow-elevation-2: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base Styles */
|
||||||
* {
|
* {
|
||||||
border-color: var(--color-line);
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Fond décoratif avec dégradés marqués */
|
||||||
::-webkit-scrollbar {
|
body::before {
|
||||||
width: 8px;
|
content: '';
|
||||||
height: 8px;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 60% at 50% -10%, hsl(198 78% 37% / 0.35), transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 50% at 90% 100%, hsl(142 60% 45% / 0.25), transparent 50%),
|
||||||
|
radial-gradient(ellipse 70% 40% at 10% 90%, hsl(45 93% 47% / 0.2), transparent 50%),
|
||||||
|
radial-gradient(ellipse 60% 30% at 80% 50%, hsl(280 60% 50% / 0.12), transparent 50%);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
/* Dark mode - fond plus profond et marqué */
|
||||||
background: transparent;
|
.dark body::before {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 60% at 50% -10%, hsl(194 76% 62% / 0.3), transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 50% at 90% 100%, hsl(142 70% 55% / 0.22), transparent 50%),
|
||||||
|
radial-gradient(ellipse 70% 40% at 10% 90%, hsl(45 90% 55% / 0.18), transparent 50%),
|
||||||
|
radial-gradient(ellipse 60% 30% at 80% 50%, hsl(280 60% 65% / 0.1), transparent 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
/* Cercles décoratifs floutés plus visibles */
|
||||||
background: var(--color-line);
|
body::after {
|
||||||
border-radius: 4px;
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -2;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 500px at 5% 15%, hsl(198 78% 37% / 0.15) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 400px at 95% 85%, hsl(142 60% 45% / 0.12) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 350px at 25% 95%, hsl(45 93% 47% / 0.1) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 450px at 85% 5%, hsl(198 78% 37% / 0.12) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 300px at 50% 50%, hsl(280 60% 50% / 0.08) 0%, transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
.dark body::after {
|
||||||
background: var(--color-line-strong);
|
background:
|
||||||
|
radial-gradient(circle 500px at 5% 15%, hsl(194 76% 62% / 0.12) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 400px at 95% 85%, hsl(142 70% 55% / 0.1) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 350px at 25% 95%, hsl(45 90% 55% / 0.08) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 450px at 85% 5%, hsl(194 76% 62% / 0.1) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 300px at 50% 50%, hsl(280 60% 65% / 0.06) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Texture subtile de grain */
|
||||||
|
.bg-grain {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-grain::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
right: -50%;
|
||||||
|
bottom: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.06;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-grain::before {
|
||||||
|
opacity: 0.04;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection */
|
/* Selection */
|
||||||
@@ -75,40 +206,54 @@ body {
|
|||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus visible */
|
.dark ::selection {
|
||||||
|
background: hsl(194 76% 62% / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus Visible */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth scrolling */
|
/* Animations */
|
||||||
html {
|
@keyframes fade-in {
|
||||||
scroll-behavior: smooth;
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduced motion */
|
.animate-fade-in {
|
||||||
@media (prefers-reduced-motion: reduce) {
|
animation: fade-in 0.3s ease-in;
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom utilities - use directly in components */
|
/* Line clamp utilities */
|
||||||
.shadow-soft {
|
.line-clamp-1 {
|
||||||
box-shadow: var(--shadow-soft);
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-card {
|
.line-clamp-2 {
|
||||||
box-shadow: var(--shadow-card);
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode overrides */
|
.line-clamp-3 {
|
||||||
.dark {
|
display: -webkit-box;
|
||||||
--shadow-soft: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
-webkit-line-clamp: 3;
|
||||||
--shadow-card: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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";
|
import {
|
||||||
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
|
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||||
|
} from "../../components/ui";
|
||||||
|
|
||||||
interface JobDetailPageProps {
|
interface JobDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -85,8 +88,14 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href="/jobs" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
|
<Link
|
||||||
← Back to jobs
|
href="/jobs"
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to jobs
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,43 +103,47 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Overview Card */}
|
{/* Overview Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Overview" />
|
<CardHeader>
|
||||||
<div className="space-y-3">
|
<CardTitle>Overview</CardTitle>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
</CardHeader>
|
||||||
<span className="text-sm text-muted">ID</span>
|
<CardContent className="space-y-3">
|
||||||
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-sm text-foreground">{job.id}</code>
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
|
<span className="text-sm text-muted-foreground">ID</span>
|
||||||
|
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted">Type</span>
|
<span className="text-sm text-muted-foreground">Type</span>
|
||||||
<JobTypeBadge type={job.type} />
|
<JobTypeBadge type={job.type} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted">Status</span>
|
<span className="text-sm text-muted-foreground">Status</span>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-sm text-muted">Library</span>
|
<span className="text-sm text-muted-foreground">Library</span>
|
||||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Timeline Card */}
|
{/* Timeline Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Timeline" />
|
<CardHeader>
|
||||||
<div className="space-y-4">
|
<CardTitle>Timeline</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-sm font-medium text-foreground">Created</span>
|
<span className="text-sm font-medium text-foreground">Created</span>
|
||||||
<p className="text-sm text-muted">{new Date(job.created_at).toLocaleString()}</p>
|
<p className="text-sm text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-sm font-medium text-foreground">Started</span>
|
<span className="text-sm font-medium text-foreground">Started</span>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted-foreground">
|
||||||
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +152,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className={`w-2 h-2 rounded-full mt-2 ${job.finished_at ? 'bg-success' : job.started_at ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.finished_at ? 'bg-success' : job.started_at ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-sm font-medium text-foreground">Finished</span>
|
<span className="text-sm font-medium text-foreground">Finished</span>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted-foreground">
|
||||||
{job.finished_at
|
{job.finished_at
|
||||||
? new Date(job.finished_at).toLocaleString()
|
? new Date(job.finished_at).toLocaleString()
|
||||||
: job.started_at
|
: job.started_at
|
||||||
@@ -149,77 +162,93 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{job.started_at && (
|
||||||
{job.started_at && (
|
<div className="mt-4 inline-flex items-center px-3 py-1.5 bg-primary/10 text-primary rounded-lg text-sm font-medium">
|
||||||
<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>
|
)}
|
||||||
)}
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Progress Card */}
|
{/* Progress Card */}
|
||||||
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
|
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Progress" />
|
<CardHeader>
|
||||||
{job.total_files && job.total_files > 0 && (
|
<CardTitle>Progress</CardTitle>
|
||||||
<>
|
</CardHeader>
|
||||||
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
|
<CardContent>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{job.total_files && job.total_files > 0 && (
|
||||||
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
|
<>
|
||||||
<StatBox value={job.total_files} label="Total" />
|
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
|
||||||
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
|
||||||
|
<StatBox value={job.total_files} label="Total" />
|
||||||
|
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{job.current_file && (
|
||||||
|
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<span className="text-sm text-muted-foreground">Current file:</span>
|
||||||
|
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</CardContent>
|
||||||
{job.current_file && (
|
|
||||||
<div className="mt-4 p-3 bg-muted/5 rounded-lg">
|
|
||||||
<span className="text-sm text-muted">Current file:</span>
|
|
||||||
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Card */}
|
{/* Statistics Card */}
|
||||||
{job.stats_json && (
|
{job.stats_json && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Statistics" />
|
<CardHeader>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
<CardTitle>Statistics</CardTitle>
|
||||||
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
</CardHeader>
|
||||||
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
<CardContent>
|
||||||
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
||||||
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
||||||
</div>
|
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
||||||
{job.started_at && (
|
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
|
||||||
<div className="flex items-center justify-between py-2 border-t border-line">
|
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||||
<span className="text-sm text-muted">Speed:</span>
|
|
||||||
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{job.started_at && (
|
||||||
|
<div className="flex items-center justify-between py-2 border-t border-border/60">
|
||||||
|
<span className="text-sm text-muted-foreground">Speed:</span>
|
||||||
|
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Errors Card */}
|
{/* Errors Card */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader title={`Errors (${errors.length})`} />
|
<CardHeader>
|
||||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
<CardTitle>Errors ({errors.length})</CardTitle>
|
||||||
|
<CardDescription>Errors encountered during job execution</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
{errors.map((error) => (
|
{errors.map((error) => (
|
||||||
<div key={error.id} className="p-3 bg-error-soft rounded-lg">
|
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
|
||||||
<code className="block text-sm font-mono text-error mb-1">{error.file_path}</code>
|
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
|
||||||
<p className="text-sm text-error/80">{error.error_message}</p>
|
<p className="text-sm text-destructive/80">{error.error_message}</p>
|
||||||
<span className="text-xs text-muted">{new Date(error.created_at).toLocaleString()}</span>
|
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{job.error_opt && (
|
{job.error_opt && (
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader title="Error" />
|
<CardHeader>
|
||||||
<pre className="p-4 bg-error-soft rounded-lg text-sm text-error overflow-x-auto">{job.error_opt}</pre>
|
<CardTitle>Error</CardTitle>
|
||||||
|
<CardDescription>Job failed with error</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="p-4 bg-destructive/10 rounded-lg text-sm text-destructive overflow-x-auto border border-destructive/20">{job.error_opt}</pre>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +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";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -33,43 +33,63 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
<div className="mb-6">
|
||||||
<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>
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
Index Jobs
|
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</h1>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
Index Jobs
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<form action={triggerRebuild}>
|
<CardHeader>
|
||||||
<FormRow>
|
<CardTitle>Queue New Job</CardTitle>
|
||||||
<FormField>
|
<CardDescription>Select a library to rebuild or perform a full rebuild</CardDescription>
|
||||||
<FormSelect name="library_id" defaultValue="">
|
</CardHeader>
|
||||||
<option value="">All libraries</option>
|
<CardContent className="space-y-4">
|
||||||
{libraries.map((lib) => (
|
<form action={triggerRebuild}>
|
||||||
<option key={lib.id} value={lib.id}>
|
<FormRow>
|
||||||
{lib.name}
|
<FormField className="flex-1">
|
||||||
</option>
|
<FormSelect name="library_id" defaultValue="">
|
||||||
))}
|
<option value="">All libraries</option>
|
||||||
</FormSelect>
|
{libraries.map((lib) => (
|
||||||
</FormField>
|
<option key={lib.id} value={lib.id}>
|
||||||
<Button type="submit">🔄 Queue Rebuild</Button>
|
{lib.name}
|
||||||
</FormRow>
|
</option>
|
||||||
</form>
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Queue Rebuild
|
||||||
|
</Button>
|
||||||
|
</FormRow>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form action={triggerFullRebuild} className="mt-3">
|
<form action={triggerFullRebuild}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField>
|
<FormField className="flex-1">
|
||||||
<FormSelect name="library_id" defaultValue="">
|
<FormSelect name="library_id" defaultValue="">
|
||||||
<option value="">All libraries</option>
|
<option value="">All libraries</option>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<option key={lib.id} value={lib.id}>
|
<option key={lib.id} value={lib.id}>
|
||||||
{lib.name}
|
{lib.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit" variant="warning">🔁 Full Rebuild</Button>
|
<Button type="submit" variant="warning">
|
||||||
</FormRow>
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</form>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
Full Rebuild
|
||||||
|
</Button>
|
||||||
|
</FormRow>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<JobsList
|
<JobsList
|
||||||
|
|||||||
@@ -9,60 +9,73 @@ import { JobsIndicator } from "./components/JobsIndicator";
|
|||||||
import { NavIcon } from "./components/ui";
|
import { NavIcon } from "./components/ui";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Stripstream Backoffice",
|
title: "StripStream Backoffice",
|
||||||
description: "Backoffice administration for Stripstream Librarian"
|
description: "Backoffice administration for StripStream Librarian"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens";
|
||||||
|
label: string;
|
||||||
|
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens";
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: "dashboard" },
|
||||||
|
{ href: "/books", label: "Books", icon: "books" },
|
||||||
|
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
||||||
|
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
||||||
|
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased">
|
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{/* Navigation */}
|
{/* Header avec effet glassmorphism */}
|
||||||
<nav className="sticky top-0 z-50 w-full border-b border-line bg-card/80 backdrop-blur-md">
|
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="Stripstream"
|
alt="StripStream"
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-xl font-bold tracking-tight">StripStream</span>
|
<span className="text-xl font-bold tracking-tight text-foreground">
|
||||||
<span className="text-sm text-muted font-medium">backoffice</span>
|
StripStream
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground font-medium">
|
||||||
|
backoffice
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-2">
|
||||||
<div className="hidden md:flex items-center gap-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
<NavLink href="/">
|
{navItems.map((item) => (
|
||||||
<NavIcon name="dashboard" /> Dashboard
|
<NavLink key={item.href} href={item.href}>
|
||||||
</NavLink>
|
<NavIcon name={item.icon} />
|
||||||
<NavLink href="/books">
|
<span className="ml-2">{item.label}</span>
|
||||||
<NavIcon name="books" /> Books
|
</NavLink>
|
||||||
</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">
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||||
<JobsIndicator />
|
<JobsIndicator />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
</nav>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||||
@@ -75,11 +88,21 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigation Link Component
|
// Navigation Link Component
|
||||||
function NavLink({ href, children }: { href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens"; children: React.ReactNode }) {
|
function NavLink({ href, children }: { href: NavItem["href"]; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-foreground/80 hover:text-foreground hover:bg-primary-soft transition-colors"
|
className="
|
||||||
|
flex items-center
|
||||||
|
px-3 py-2
|
||||||
|
rounded-lg
|
||||||
|
text-sm font-medium
|
||||||
|
text-muted-foreground
|
||||||
|
hover:text-foreground
|
||||||
|
hover:bg-accent
|
||||||
|
transition-colors duration-200
|
||||||
|
active:scale-[0.98]
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
|
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
||||||
import { Card, Badge, Button, CursorPagination } from "../../../components/ui";
|
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||||
import Link from "next/link";
|
import { CursorPagination } from "../../../components/ui";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -42,38 +42,22 @@ export default async function LibraryBooksPage({
|
|||||||
const hasPrevPage = !!cursor;
|
const hasPrevPage = !!cursor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
<LibrarySubPageHeader
|
||||||
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors">← Back to libraries</Link>
|
library={library}
|
||||||
</div>
|
title={series ? `Books in "${seriesDisplayName}"` : "All Books"}
|
||||||
|
icon={
|
||||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 mb-6">
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<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>
|
<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" />
|
||||||
{library.name}
|
</svg>
|
||||||
</h1>
|
}
|
||||||
|
iconColor="text-success"
|
||||||
<Card className="mb-6">
|
filterInfo={series ? {
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
label: `Showing books from series "${seriesDisplayName}"`,
|
||||||
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
|
clearHref: `/libraries/${id}/books`,
|
||||||
<span className="text-muted">|</span>
|
clearLabel: "View all books"
|
||||||
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
|
} : undefined}
|
||||||
<span className="text-muted">|</span>
|
/>
|
||||||
<Badge variant={library.enabled ? "success" : "muted"}>
|
|
||||||
{library.enabled ? "Enabled" : "Disabled"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground">
|
|
||||||
{series ? `Books in "${seriesDisplayName}"` : "All Books"}
|
|
||||||
</h2>
|
|
||||||
{series && (
|
|
||||||
<Link href={`/libraries/${id}/books`} className="text-sm text-primary hover:text-primary/80">
|
|
||||||
View all
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{books.length > 0 ? (
|
{books.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -90,6 +74,6 @@ export default async function LibraryBooksPage({
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
|
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto } f
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Card, Badge } from "../../../components/ui";
|
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -23,29 +23,17 @@ export default async function LibrarySeriesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
<LibrarySubPageHeader
|
||||||
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors">← Back to libraries</Link>
|
library={library}
|
||||||
</div>
|
title={`Series (${series.length})`}
|
||||||
|
icon={
|
||||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 mb-6">
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
<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" />
|
||||||
{library.name}
|
</svg>
|
||||||
</h1>
|
}
|
||||||
|
iconColor="text-primary"
|
||||||
<Card className="mb-6">
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
||||||
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
|
|
||||||
<span className="text-muted">|</span>
|
|
||||||
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
|
|
||||||
<span className="text-muted">|</span>
|
|
||||||
<Badge variant={library.enabled ? "success" : "muted"}>
|
|
||||||
{library.enabled ? "Enabled" : "Disabled"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-6">Series ({series.length})</h2>
|
|
||||||
|
|
||||||
{series.length > 0 ? (
|
{series.length > 0 ? (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||||
@@ -55,8 +43,8 @@ export default async function LibrarySeriesPage({
|
|||||||
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
|
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden hover:shadow-card transition-shadow">
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
||||||
<div className="aspect-[2/3] relative bg-muted/10">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
alt={`Cover of ${s.name}`}
|
alt={`Cover of ${s.name}`}
|
||||||
@@ -69,7 +57,7 @@ export default async function LibrarySeriesPage({
|
|||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
{s.name === "unclassified" ? "Unclassified" : s.name}
|
{s.name === "unclassified" ? "Unclassified" : s.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,10 +66,10 @@ export default async function LibrarySeriesPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-muted">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<p>No series found in this library</p>
|
<p>No series found in this library</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { revalidatePath } from "next/cache";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
|
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
|
||||||
import { LibraryActions } from "../components/LibraryActions";
|
import { LibraryActions } from "../components/LibraryActions";
|
||||||
import { Card, CardHeader, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
import {
|
||||||
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
|
Button, Badge, FormField, FormInput, FormSelect, FormRow
|
||||||
|
} from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -73,105 +76,130 @@ export default async function LibrariesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
<div className="mb-6">
|
||||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
Libraries
|
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</h1>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Libraries
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Add Library Form */}
|
{/* Add Library Form */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader title="Add New Library" />
|
<CardHeader>
|
||||||
<form action={addLibrary}>
|
<CardTitle>Add New Library</CardTitle>
|
||||||
<FormRow>
|
<CardDescription>Create a new library from an existing folder</CardDescription>
|
||||||
<FormField>
|
</CardHeader>
|
||||||
<FormInput name="name" placeholder="Library name" required />
|
<CardContent>
|
||||||
</FormField>
|
<form action={addLibrary}>
|
||||||
<FormField>
|
<FormRow>
|
||||||
<FormSelect name="root_path" required defaultValue="">
|
<FormField className="flex-1 min-w-48">
|
||||||
<option value="" disabled>Select folder...</option>
|
<FormInput name="name" placeholder="Library name" required />
|
||||||
{folders.map((folder) => (
|
</FormField>
|
||||||
<option key={folder.path} value={folder.path}>
|
<FormField className="flex-1 min-w-48">
|
||||||
{folder.name}
|
<FormSelect name="root_path" required defaultValue="">
|
||||||
</option>
|
<option value="" disabled>Select folder...</option>
|
||||||
))}
|
{folders.map((folder) => (
|
||||||
</FormSelect>
|
<option key={folder.path} value={folder.path}>
|
||||||
</FormField>
|
{folder.name}
|
||||||
<Button type="submit">➕ Add Library</Button>
|
</option>
|
||||||
</FormRow>
|
))}
|
||||||
</form>
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit">Add Library</Button>
|
||||||
|
</FormRow>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Libraries Grid */}
|
{/* Libraries Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{libraries.map((lib) => {
|
{libraries.map((lib) => {
|
||||||
const seriesCount = seriesCountMap.get(lib.id) || 0;
|
const seriesCount = seriesCountMap.get(lib.id) || 0;
|
||||||
return (
|
return (
|
||||||
<Card key={lib.id} className="flex flex-col">
|
<Card key={lib.id} className="flex flex-col">
|
||||||
{/* Header with settings */}
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-foreground">{lib.name}</h3>
|
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||||
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
|
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
|
||||||
|
</div>
|
||||||
|
<LibraryActions
|
||||||
|
libraryId={lib.id}
|
||||||
|
monitorEnabled={lib.monitor_enabled}
|
||||||
|
scanMode={lib.scan_mode}
|
||||||
|
watcherEnabled={lib.watcher_enabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LibraryActions
|
</CardHeader>
|
||||||
libraryId={lib.id}
|
<CardContent className="flex-1 pt-0">
|
||||||
monitorEnabled={lib.monitor_enabled}
|
{/* Path */}
|
||||||
scanMode={lib.scan_mode}
|
<code className="text-xs font-mono text-muted-foreground mb-4 break-all block">{lib.root_path}</code>
|
||||||
watcherEnabled={lib.watcher_enabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Path */}
|
{/* Stats */}
|
||||||
<code className="text-xs font-mono text-muted mb-4 break-all">{lib.root_path}</code>
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${lib.id}/books`}
|
||||||
|
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Books</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${lib.id}/series`}
|
||||||
|
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Series</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Status */}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4 text-sm">
|
||||||
<Link href={`/libraries/${lib.id}/books`} className="text-center p-3 bg-muted/5 rounded-lg hover:bg-muted/10 transition-colors">
|
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
|
||||||
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
|
||||||
<span className="text-xs text-muted">Books</span>
|
|
||||||
</Link>
|
|
||||||
<Link href={`/libraries/${lib.id}/series`} className="text-center p-3 bg-muted/5 rounded-lg hover:bg-muted/10 transition-colors">
|
|
||||||
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
|
||||||
<span className="text-xs text-muted">Series</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="flex items-center gap-3 mb-4 text-sm">
|
|
||||||
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted'}`}>
|
|
||||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
|
|
||||||
</span>
|
|
||||||
{lib.watcher_enabled && (
|
|
||||||
<span className="text-warning" title="File watcher active">⚡</span>
|
|
||||||
)}
|
|
||||||
{lib.monitor_enabled && lib.next_scan_at && (
|
|
||||||
<span className="text-xs text-muted ml-auto">
|
|
||||||
Next: {formatNextScan(lib.next_scan_at)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{lib.watcher_enabled && (
|
||||||
</div>
|
<span className="text-warning" title="File watcher active">⚡</span>
|
||||||
|
)}
|
||||||
|
{lib.monitor_enabled && lib.next_scan_at && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto">
|
||||||
|
Next: {formatNextScan(lib.next_scan_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 mt-auto">
|
<div className="flex items-center gap-2">
|
||||||
<form className="flex-1">
|
<form className="flex-1">
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
<Button type="submit" variant="primary" size="sm" className="w-full" formAction={scanLibraryAction}>
|
<Button type="submit" variant="default" size="sm" className="w-full" formAction={scanLibraryAction}>
|
||||||
🔄 Index
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</form>
|
</svg>
|
||||||
<form className="flex-1">
|
Index
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
</Button>
|
||||||
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
|
</form>
|
||||||
🔁 Full
|
<form className="flex-1">
|
||||||
</Button>
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
</form>
|
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
|
||||||
<form>
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
<Button type="submit" variant="danger" size="sm" formAction={removeLibrary}>
|
</svg>
|
||||||
🗑
|
Full
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
<form>
|
||||||
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,76 +1,76 @@
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-4">
|
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">
|
||||||
Stripstream Backoffice
|
StripStream Backoffice
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted max-w-2xl mx-auto">
|
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||||
Manage libraries, indexing jobs, and API tokens from a modern admin interface.
|
Manage libraries, indexing jobs, and API tokens from a modern admin interface.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{/* Libraries Card */}
|
{/* Libraries Card */}
|
||||||
<a
|
<a
|
||||||
href="/libraries"
|
href="/libraries"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-primary-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary transition-colors">
|
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-primary group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-primary group-hover:text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Libraries</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Libraries</h2>
|
||||||
<p className="text-muted text-sm">Manage your comic libraries and folders</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Manage your comic libraries and folders</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Books Card */}
|
{/* Books Card */}
|
||||||
<a
|
<a
|
||||||
href="/books"
|
href="/books"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-success-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-success transition-colors">
|
<div className="w-12 h-12 bg-success/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-success transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-success group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Books</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Books</h2>
|
||||||
<p className="text-muted text-sm">Browse and search your comic collection</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Browse and search your comic collection</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Jobs Card */}
|
{/* Jobs Card */}
|
||||||
<a
|
<a
|
||||||
href="/jobs"
|
href="/jobs"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-warning-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-warning transition-colors">
|
<div className="w-12 h-12 bg-warning/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-warning transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-warning group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Jobs</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Jobs</h2>
|
||||||
<p className="text-muted text-sm">Monitor indexing jobs and progress</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Monitor indexing jobs and progress</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Tokens Card */}
|
{/* Tokens Card */}
|
||||||
<a
|
<a
|
||||||
href="/tokens"
|
href="/tokens"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-error-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-error transition-colors">
|
<div className="w-12 h-12 bg-destructive/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-destructive transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-error group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-destructive group-hover:text-destructive-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Tokens</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Tokens</h2>
|
||||||
<p className="text-muted text-sm">Manage API authentication tokens</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Manage API authentication tokens</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 p-6 bg-primary-soft rounded-xl border border-primary/20">
|
<div className="mt-12 p-6 bg-primary/5 backdrop-blur-sm rounded-xl border border-primary/20 hover:bg-primary/8 hover:border-primary/30 transition-all duration-300">
|
||||||
<h2 className="text-lg font-semibold mb-2 text-primary">Getting Started</h2>
|
<h2 className="text-lg font-semibold mb-2 text-primary">Getting Started</h2>
|
||||||
<p className="text-muted">
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
Start by creating a library from your comic folders, then trigger an index job to scan your collection.
|
Start by creating a library from your comic folders, then trigger an index job to scan your collection.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,26 +3,127 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
// Sun Icon
|
||||||
|
const SunIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="4" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Moon Icon
|
||||||
|
const MoonIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Monitor Icon (for system)
|
||||||
|
const MonitorIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 21h8m-4-4v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-9 w-9 rounded-md flex items-center justify-center text-muted-foreground"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<SunIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
||||||
const nextTheme = activeTheme === "dark" ? "light" : "dark";
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
if (theme === "system") {
|
||||||
|
setTheme(systemTheme === "dark" ? "light" : "dark");
|
||||||
|
} else {
|
||||||
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="theme-toggle"
|
onClick={toggleTheme}
|
||||||
onClick={() => setTheme(nextTheme)}
|
className="h-9 w-9 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200"
|
||||||
aria-label="Toggle color theme"
|
title={activeTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
disabled={!mounted}
|
aria-label={activeTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
>
|
>
|
||||||
{mounted ? (activeTheme === "dark" ? "Dark" : "Light") : "Theme"}
|
<div className="relative h-4 w-4">
|
||||||
|
<SunIcon
|
||||||
|
className={`absolute inset-0 h-4 w-4 transition-all duration-300 rotate-0 scale-100 ${
|
||||||
|
activeTheme === "dark" ? "rotate-90 scale-0 opacity-0" : "rotate-0 scale-100 opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<MoonIcon
|
||||||
|
className={`absolute inset-0 h-4 w-4 transition-all duration-300 -rotate-90 scale-0 ${
|
||||||
|
activeTheme === "dark" ? "rotate-0 scale-100 opacity-100" : "-rotate-90 scale-0 opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full theme selector with dropdown
|
||||||
|
export function ThemeSelector() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<SunIcon className="h-4 w-4" />
|
||||||
|
<span>Theme</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ value: "light", label: "Light", icon: SunIcon },
|
||||||
|
{ value: "dark", label: "Dark", icon: MoonIcon },
|
||||||
|
{ value: "system", label: "System", icon: MonitorIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border border-border bg-background p-1">
|
||||||
|
{themes.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm font-medium
|
||||||
|
transition-colors duration-200
|
||||||
|
${theme === value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +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";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -33,68 +33,90 @@ export default async function TokensPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
<div className="mb-6">
|
||||||
<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>
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
API Tokens
|
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</h1>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
API Tokens
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{params.created ? (
|
{params.created ? (
|
||||||
<Card className="mb-6">
|
<Card className="mb-6 border-success/50 bg-success/5">
|
||||||
<strong className="text-foreground block mb-2">Token created (copy it now, it won't be shown again):</strong>
|
<CardHeader>
|
||||||
<pre className="p-4 bg-muted/10 rounded-lg text-sm font-mono text-foreground overflow-x-auto">{params.created}</pre>
|
<CardTitle className="text-success">Token Created</CardTitle>
|
||||||
|
<CardDescription>Copy it now, it won't be shown again</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<form action={createTokenAction}>
|
<CardHeader>
|
||||||
<FormRow>
|
<CardTitle>Create New Token</CardTitle>
|
||||||
<FormField>
|
<CardDescription>Generate a new API token with the desired scope</CardDescription>
|
||||||
<FormInput name="name" placeholder="token name" required />
|
</CardHeader>
|
||||||
</FormField>
|
<CardContent>
|
||||||
<FormField>
|
<form action={createTokenAction}>
|
||||||
<FormSelect name="scope" defaultValue="read">
|
<FormRow>
|
||||||
<option value="read">read</option>
|
<FormField className="flex-1 min-w-48">
|
||||||
<option value="admin">admin</option>
|
<FormInput name="name" placeholder="Token name" required />
|
||||||
</FormSelect>
|
</FormField>
|
||||||
</FormField>
|
<FormField className="w-32">
|
||||||
<Button type="submit">➕ Create Token</Button>
|
<FormSelect name="scope" defaultValue="read">
|
||||||
</FormRow>
|
<option value="read">Read</option>
|
||||||
</form>
|
<option value="admin">Admin</option>
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit">Create Token</Button>
|
||||||
|
</FormRow>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line bg-muted/5">
|
<tr className="border-b border-border/60 bg-muted/50">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Name</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Name</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Scope</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Scope</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Prefix</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Prefix</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Revoked</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line">
|
<tbody className="divide-y divide-border/60">
|
||||||
{tokens.map((token) => (
|
{tokens.map((token) => (
|
||||||
<tr key={token.id} className="hover:bg-muted/5">
|
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
|
||||||
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
||||||
<td className="px-4 py-3 text-sm text-foreground">{token.scope}</td>
|
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-foreground">{token.prefix}</code>
|
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
|
||||||
|
{token.scope}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
{token.revoked_at ? (
|
{token.revoked_at ? (
|
||||||
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-error-soft text-error">yes</span>
|
<Badge variant="error">Revoked</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-success-soft text-success">no</span>
|
<Badge variant="success">Active</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{!token.revoked_at && (
|
{!token.revoked_at && (
|
||||||
<form action={revokeTokenAction}>
|
<form action={revokeTokenAction}>
|
||||||
<input type="hidden" name="id" value={token.id} />
|
<input type="hidden" name="id" value={token.id} />
|
||||||
<Button type="submit" variant="danger" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
🚫 Revoke
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
2
apps/backoffice/next-env.d.ts
vendored
2
apps/backoffice/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
677
apps/backoffice/package-lock.json
generated
677
apps/backoffice/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/node": "22.13.14",
|
"@types/node": "22.13.14",
|
||||||
"@types/react": "19.0.12",
|
"@types/react": "19.0.12",
|
||||||
"@types/react-dom": "19.0.5",
|
"@types/react-dom": "19.0.5",
|
||||||
@@ -23,6 +24,19 @@
|
|||||||
"typescript": "5.8.2"
|
"typescript": "5.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
@@ -547,6 +561,56 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||||
@@ -702,6 +766,289 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"enhanced-resolve": "^5.19.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.31.1",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.8.1",
|
||||||
|
"@emnapi/runtime": "^1.8.1",
|
||||||
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/postcss": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
|
"@tailwindcss/node": "4.2.1",
|
||||||
|
"@tailwindcss/oxide": "4.2.1",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.14",
|
"version": "22.13.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
|
||||||
@@ -852,8 +1199,8 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -865,6 +1212,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||||
|
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -889,6 +1250,306 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.31.1",
|
||||||
|
"lightningcss-darwin-arm64": "1.31.1",
|
||||||
|
"lightningcss-darwin-x64": "1.31.1",
|
||||||
|
"lightningcss-freebsd-x64": "1.31.1",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.31.1",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.31.1",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.31.1",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.31.1",
|
||||||
|
"lightningcss-linux-x64-musl": "1.31.1",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.31.1",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.31.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1171,6 +1832,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/node": "22.13.14",
|
"@types/node": "22.13.14",
|
||||||
"@types/react": "19.0.12",
|
"@types/react": "19.0.12",
|
||||||
"@types/react-dom": "19.0.5",
|
"@types/react-dom": "19.0.5",
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ WORKDIR /app
|
|||||||
COPY Cargo.toml ./
|
COPY Cargo.toml ./
|
||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||||
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
|
||||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||||
COPY apps/api/src apps/api/src
|
COPY apps/api/src apps/api/src
|
||||||
COPY apps/indexer/src apps/indexer/src
|
COPY apps/indexer/src apps/indexer/src
|
||||||
COPY apps/admin-ui/src apps/admin-ui/src
|
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user