Rename to Shelfmark and IRC adjustments (#415)

This commit is contained in:
Alex
2026-01-11 19:38:38 +00:00
committed by GitHub
parent 29a8d856a6
commit afeae46821
134 changed files with 2009 additions and 1367 deletions
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Calibre Web Book Downloader - Modern UI" />
<meta name="description" content="Shelfmark - Book search and download" />
<!-- Theme color with media queries for light/dark mode -->
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
@@ -12,12 +12,12 @@
<!-- iOS PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Book Downloader" />
<meta name="apple-mobile-web-app-title" content="Shelfmark" />
<!-- App Icons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/logo.png" />
<title>Book Downloader</title>
<title>Shelfmark</title>
<script>
// Apply theme immediately before first paint to prevent flash
(function() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 115 KiB

+3 -2
View File
@@ -608,8 +608,8 @@ function App() {
/>
{isInitialState && !featureNoticeDismissed && (
<div className="absolute bottom-4 left-0 right-0 px-4 text-center text-sm text-gray-500 dark:text-gray-400">
<span>New: Torrent, Usenet and IRC downloads, Audiobook support and more. Configure in Settings</span>
<div className="absolute bottom-4 left-0 right-0 px-4 text-center text-sm opacity-40">
<span>We've renamed to Shelfmark. New: Torrent, Usenet, IRC and Audiobook support.</span>
<button
onClick={handleDismissFeatureNotice}
className="ml-2 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline"
@@ -701,6 +701,7 @@ function App() {
setSettingsOpen(true);
}}
/>
</SearchModeProvider>
);
Binary file not shown.
@@ -137,7 +137,7 @@ export const AdvancedFilters = ({
placeholder="ISBN"
autoComplete="off"
enterKeyHint="search"
className="w-full px-3 py-2 rounded-md border"
className="w-full px-3 py-2 text-sm rounded-lg border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
@@ -160,7 +160,7 @@ export const AdvancedFilters = ({
placeholder="Author"
autoComplete="off"
enterKeyHint="search"
className="w-full px-3 py-2 rounded-md border"
className="w-full px-3 py-2 text-sm rounded-lg border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
@@ -183,7 +183,7 @@ export const AdvancedFilters = ({
placeholder="Title"
autoComplete="off"
enterKeyHint="search"
className="w-full px-3 py-2 rounded-md border"
className="w-full px-3 py-2 text-sm rounded-lg border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
@@ -109,7 +109,7 @@ export const ConfigSetupBanner = ({
<p className="text-sm opacity-80">
{showContinueButton
? 'To save settings, add a config volume to your Docker Compose file:'
: 'CWA Book Downloader now has a settings page! To enable it, add a config volume to your Docker Compose file:'}
: 'Shelfmark now has a settings page! To enable it, add a config volume to your Docker Compose file:'}
</p>
{/* Code snippet */}
@@ -124,7 +124,7 @@ export const ConfigSetupBanner = ({
>
<code>
<span className="opacity-60">services:</span>{'\n'}
<span className="opacity-60">{' '}cwa-book-downloader:</span>{'\n'}
<span className="opacity-60">{' '}shelfmark:</span>{'\n'}
{' '}volumes:{'\n'}
{' '}- <span className="text-blue-400">/path/to/config</span>:<span className="text-green-400">/config</span>
</code>
+16 -14
View File
@@ -114,26 +114,25 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm';
const infoCardStyle = { background: 'var(--bg)' };
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm bg-[var(--bg-soft)] sm:bg-[var(--bg)]';
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
const infoValueClass = 'text-gray-900 dark:text-gray-100';
return (
<div
className="modal-overlay active px-4 py-6 sm:px-6"
className="modal-overlay active sm:px-6 sm:py-6"
onClick={e => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div
className={`details-container w-full max-w-4xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container w-full max-w-4xl h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] text-[var(--text)] shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] px-5 py-4">
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] text-[var(--text)] shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4">
<div className="flex-1 space-y-1">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Book</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug">
@@ -188,7 +187,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
<div className="flex flex-1 flex-col gap-4 sm:gap-5 lg:min-h-0">
{book.description && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<div className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>Description</p>
<p className={`${infoValueClass} whitespace-pre-line`}>{book.description}</p>
</div>
@@ -197,7 +196,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
{/* Metadata grid - adapts columns based on mode and available data */}
<div className={`grid grid-cols-2 gap-3 lg:gap-4 ${isMetadata ? 'lg:grid-cols-2' : 'lg:grid-cols-4'}`}>
{metadata.map(item => (
<div key={item.label} className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<div key={item.label} className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{item.label}</p>
<p className={infoValueClass}>{item.value}</p>
</div>
@@ -205,7 +204,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
{/* Rating box - Universal mode only */}
{ratingField && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<div className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{ratingField.label}</p>
<p className={`${infoValueClass} flex items-center gap-1.5`}>
<svg className="h-4 w-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
@@ -218,7 +217,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
{/* Readers box - Universal mode only */}
{readersField && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<div className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{readersField.label}</p>
<p className={`${infoValueClass} flex items-center gap-1.5`}>
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
@@ -254,7 +253,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
{/* ISBN - Universal mode only */}
{isMetadata && (book.isbn_13 || book.isbn_10) && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<div className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>ISBN</p>
<p className={infoValueClass}>{book.isbn_13 || book.isbn_10}</p>
</div>
@@ -262,7 +261,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
{/* Series info - Universal mode only */}
{isMetadata && book.series_name && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<div className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>Series</p>
<div className="flex items-center justify-between gap-2">
<p className={infoValueClass}>
@@ -293,7 +292,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
{/* Extended info (publisher, etc.) - Direct Download mode only */}
{!isMetadata && extendedInfoEntries.length > 0 && (
<div className={`${infoCardClass} space-y-3`} style={infoCardStyle}>
<div className={`${infoCardClass} space-y-3`}>
<ul className="space-y-3 list-none">
{extendedInfoEntries.map(([key, value]) => (
<li key={key} className="space-y-1">
@@ -308,7 +307,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
</div>
</div>
<footer className="border-t border-[var(--border-muted)] bg-[var(--bg-soft)] px-5 py-4">
<footer
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<div className="flex items-center justify-between gap-4">
{/* Source link - shown for both Universal and Direct Download modes */}
{book.source_url && (
+85 -45
View File
@@ -1,5 +1,19 @@
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
// Find the closest scrollable ancestor element
function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null {
let current = element?.parentElement;
while (current) {
const style = getComputedStyle(current);
const overflowY = style.overflowY;
if (overflowY === 'auto' || overflowY === 'scroll') {
return current;
}
current = current.parentElement;
}
return null;
}
// Simple throttle function to limit how often a function can be called
function throttle<T extends (...args: unknown[]) => void>(fn: T, delay: number): T {
let lastCall = 0;
@@ -93,8 +107,18 @@ export const Dropdown = ({
const rect = containerRef.current.getBoundingClientRect();
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
// Check if we're inside a scrollable container and use its bounds
const scrollableAncestor = getScrollableAncestor(containerRef.current);
const containerBottom = scrollableAncestor
? scrollableAncestor.getBoundingClientRect().bottom
: window.innerHeight;
const containerTop = scrollableAncestor
? scrollableAncestor.getBoundingClientRect().top
: 0;
const spaceBelow = containerBottom - rect.bottom - 8;
const spaceAbove = rect.top - containerTop - 8;
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
setPanelDirection(shouldOpenUp ? 'up' : 'down');
@@ -117,57 +141,73 @@ export const Dropdown = ({
}, [isOpen, updatePanelDirection]);
return (
<div className={`relative ${widthClassName}`} ref={containerRef}>
<div className={widthClassName} ref={containerRef}>
{label && (
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5" onClick={toggleOpen}>
{label}
</label>
)}
{renderTrigger ? (
renderTrigger({ isOpen, toggle: toggleOpen })
) : (
<button
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 text-sm rounded-lg border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
>
<span className="truncate">
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
<div className="relative">
{renderTrigger ? (
renderTrigger({ isOpen, toggle: toggleOpen })
) : (
<button
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
borderRadius: isOpen
? panelDirection === 'down'
? '0.5rem 0.5rem 0 0'
: '0 0 0.5rem 0.5rem'
: '0.5rem',
}}
>
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
)}
<span className="truncate">
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
)}
{isOpen && (
<div
ref={panelRef}
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
panelDirection === 'down' ? 'mt-2' : 'bottom-full mb-2'
} rounded-lg border shadow-lg z-20 ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
}}
>
<div className={noScrollLimit ? '' : 'max-h-64 overflow-auto'}>
{children({ close })}
{isOpen && (
<div
ref={panelRef}
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
} border shadow-lg z-20 ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
borderRadius: renderTrigger
? '0.5rem'
: panelDirection === 'down'
? '0 0 0.5rem 0.5rem'
: '0.5rem 0.5rem 0 0',
marginTop: !renderTrigger && panelDirection === 'down' ? '-1px' : undefined,
marginBottom: !renderTrigger && panelDirection === 'up' ? '-1px' : undefined,
}}
>
<div className={noScrollLimit ? '' : 'max-h-64 overflow-auto'}>
{children({ close })}
</div>
</div>
</div>
)}
)}
</div>
</div>
);
};
+31 -32
View File
@@ -6,44 +6,43 @@ interface FooterProps {
export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) => {
// Determine version display - show "dev" if no version is set
const versionDisplay = releaseVersion && releaseVersion !== 'N/A'
? releaseVersion
const versionDisplay = releaseVersion && releaseVersion !== 'N/A'
? releaseVersion
: 'dev';
// Truncate long build versions (e.g., git hashes) to 7 chars
const truncatedBuild = buildVersion && buildVersion !== 'N/A'
? buildVersion.length > 7 ? buildVersion.slice(0, 7) : buildVersion
: null;
return (
<footer
className="mt-8 border-t py-6"
className="mt-8 py-4"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(1.5rem + env(safe-area-inset-bottom))',
paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))',
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 text-left">
<p className="text-sm opacity-80">Calibre Web Book Downloader</p>
<p className="text-xs opacity-60 mt-1">
Version: {versionDisplay}
{buildVersion && buildVersion !== 'N/A' && ` (${buildVersion})`}
{debug && ' • Debug Mode'}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader"
target="_blank"
rel="noopener noreferrer"
className="opacity-80 hover:opacity-100"
aria-label="GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className="w-6 h-6"
>
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
</svg>
</a>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-baseline justify-center gap-2">
<a
href="https://github.com/calibrain/shelfmark"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium opacity-70 hover:opacity-100 transition-opacity"
>
Shelfmark
</a>
<span
className="text-xs opacity-40 font-normal"
title={buildVersion && buildVersion !== 'N/A' ? `Build: ${buildVersion}` : undefined}
>
{versionDisplay}
{truncatedBuild && ` (${truncatedBuild})`}
</span>
{debug && (
<span className="text-xs px-1.5 py-0.5 rounded opacity-60" style={{ background: 'var(--border-muted)' }}>
Debug
</span>
)}
</div>
</footer>
);
+6 -8
View File
@@ -225,7 +225,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
<button
onClick={toggleDropdown}
className={`relative p-2 rounded-full hover-action transition-colors ${
isDropdownOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
isDropdownOpen ? 'bg-[var(--hover-action)]' : ''
}`}
aria-label="User menu"
aria-expanded={isDropdownOpen}
@@ -261,7 +261,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
<div className="py-1">
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader/issues"
href="https://github.com/calibrain/shelfmark/issues"
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-slate-700 dark:text-slate-200"
@@ -408,15 +408,13 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
return (
<header
className="w-full sticky top-0 z-40 backdrop-blur-sm header-with-fade"
className="w-full sticky top-0 z-40 backdrop-blur-sm"
style={{ background: 'var(--bg)', paddingTop: 'env(safe-area-inset-top)' }}
>
<div className={`max-w-full mx-auto px-4 sm:px-6 lg:px-8 transition-all duration-500 ${
showSearch ? 'h-auto py-4' : 'h-24'
}`}>
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* When search is active: stack on mobile, side-by-side on desktop */}
{showSearch && (
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3 animate-pop-up">
{/* Logo + Icon buttons - appear first on mobile (above search), last on desktop (right side) */}
<div className="flex items-center justify-between w-full lg:w-auto lg:justify-end lg:order-2">
{/* Logo - visible on mobile only, aligned left */}
@@ -461,7 +459,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{/* When search is NOT active: show icon buttons only on the right */}
{!showSearch && (
<div className="flex items-center justify-end h-full">
<div className="flex items-center justify-end min-h-[48px]">
<IconButtons />
</div>
)}
+32 -67
View File
@@ -342,7 +342,7 @@ const ReleaseRow = ({
return (
<div
className="pl-5 pr-2 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-slide-up will-change-transform"
className="pl-5 pr-2 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform"
style={{
animationDelay: `${index * 30}ms`,
animationFillMode: 'both',
@@ -545,37 +545,6 @@ function EmptyState({ message }: { message: string }) {
);
}
// Configure source CTA
function ConfigureSourceCTA({ sourceName }: { sourceName: string }) {
return (
<div className="text-center py-12 px-4">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-amber-100 dark:bg-amber-900/30 mb-4">
<svg
className="w-7 h-7 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
{sourceName} Not Configured
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">
Configure {sourceName} in Settings to enable searching this source.
</p>
</div>
);
}
// Error state component
function ErrorState({ message }: { message: string }) {
return (
@@ -940,31 +909,30 @@ export const ReleaseModal = ({
}, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType]);
// Build list of tabs to show
// All sources come from backend with their enabled status
// Filter by supported content types, then order: 1) Default source, 2) Other enabled sources, 3) Disabled sources
// Only show enabled sources that support the current content type
// Order: default source first, then other enabled sources
const allTabs = useMemo(() => {
type TabInfo = { name: string; displayName: string; enabled: boolean };
const enabledTabs: TabInfo[] = [];
const disabledTabs: TabInfo[] = [];
// Filter sources by content type and separate by enabled status
// Filter to only enabled sources that support this content type
availableSources.forEach((src) => {
// Skip disabled sources entirely - they won't appear as tabs
if (!src.enabled) {
return;
}
// Check if source supports the current content type
const supportedTypes = src.supported_content_types || ['ebook', 'audiobook'];
if (!supportedTypes.includes(contentType)) {
return; // Skip sources that don't support this content type
return;
}
const tab = { name: src.name, displayName: src.display_name, enabled: src.enabled };
if (src.enabled) {
enabledTabs.push(tab);
} else {
disabledTabs.push(tab);
}
enabledTabs.push({ name: src.name, displayName: src.display_name, enabled: true });
});
// Sort enabled tabs so default source appears first
// Sort so default source appears first
if (defaultReleaseSource) {
enabledTabs.sort((a, b) => {
if (a.name === defaultReleaseSource) return -1;
@@ -973,8 +941,7 @@ export const ReleaseModal = ({
});
}
// Combine: enabled sources first (with default first), then disabled
return [...enabledTabs, ...disabledTabs];
return enabledTabs;
}, [availableSources, defaultReleaseSource, contentType]);
// Update tab indicator position when active tab changes
@@ -1203,7 +1170,6 @@ export const ReleaseModal = ({
const currentTabLoading = loadingBySource[activeTab] ?? false;
const currentTabError = errorBySource[activeTab] ?? null;
const currentTabEnabled = allTabs.find((t) => t.name === activeTab)?.enabled ?? false;
const isInitialLoading = currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError);
return (
@@ -1637,10 +1603,6 @@ export const ReleaseModal = ({
<div className="min-h-[200px]">
{sourcesLoading ? (
<ReleaseSkeleton />
) : !currentTabEnabled ? (
<ConfigureSourceCTA
sourceName={allTabs.find((t) => t.name === activeTab)?.displayName || activeTab}
/>
) : isInitialLoading && filteredReleases.length === 0 ? (
<ReleaseSkeleton />
) : currentTabError ? (
@@ -1654,19 +1616,21 @@ export const ReleaseModal = ({
: 'No releases found for this book.'
}
/>
{/* Expand search button - also shown in empty state */}
{!expandedBySource[activeTab] &&
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type &&
!['title_author', 'expanded'].includes(
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
) && (
{/* Action button - plugin-defined or default expand search */}
{(columnConfig.action_button || (
!expandedBySource[activeTab] &&
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type &&
!['title_author', 'expanded'].includes(
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
)
)) && (
<div className="py-3 text-center">
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
>
Expand search
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
</div>
)}
@@ -1689,15 +1653,16 @@ export const ReleaseModal = ({
/>
))}
</div>
{/* Expand search button */}
{!expandedBySource[activeTab] &&
!currentTabLoading &&
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type &&
!['title_author', 'expanded'].includes(
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
) && (
{/* Action button - plugin-defined or default expand search */}
{!currentTabLoading && (columnConfig.action_button || (
!expandedBySource[activeTab] &&
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type &&
!['title_author', 'expanded'].includes(
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
)
)) && (
<div
className="py-3 text-center animate-slide-up will-change-transform"
className="py-3 text-center animate-pop-up will-change-transform"
style={{
animationDelay: `${filteredReleases.length * 30}ms`,
animationFillMode: 'both',
@@ -1708,7 +1673,7 @@ export const ReleaseModal = ({
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
>
Expand search
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
</div>
)}
@@ -284,8 +284,8 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
type="button"
onClick={toggle}
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover-action ${
isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
} animate-fade-in-up`}
isOpen ? 'bg-[var(--hover-action)]' : ''
} animate-pop-up`}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label="Change sort order"
@@ -63,9 +63,9 @@ export const SearchSection = ({
return (
<section
id="search-section"
className={`transition-all duration-500 ease-in-out ${
isInitialState
? 'search-initial-state mb-6'
className={`${
isInitialState
? 'search-initial-state mb-6'
: 'mb-3 sm:mb-4'
} ${showAdvanced ? 'search-advanced-visible' : ''}`}
>
@@ -46,7 +46,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
return (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-slide-up will-change-transform"
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -47,7 +47,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
return (
<article
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-slide-up will-change-transform"
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -100,7 +100,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
return (
<div
key={book.id}
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-slide-up will-change-transform"
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform"
style={{
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
@@ -310,7 +310,7 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
{/* Modal */}
<div
className={`relative w-full max-w-4xl h-[85vh] rounded-xl
className={`relative w-full max-w-4xl h-[85vh] max-h-[750px] rounded-xl
border border-[var(--border-muted)] shadow-2xl
flex flex-col overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
@@ -257,7 +257,7 @@ export const SettingsSidebar = ({
// Desktop: Sidebar navigation
return (
<nav className="w-56 border-r border-[var(--border-muted)] py-2 flex-shrink-0 overflow-y-auto">
<nav className="w-60 border-r border-[var(--border-muted)] py-2 flex-shrink-0 overflow-y-auto">
{sidebarItems.map((item) => {
if (item.type === 'section') {
return (
+83 -32
View File
@@ -3,6 +3,15 @@
@tailwind components;
@tailwind utilities;
/* Fonts */
@font-face {
font-family: 'Inter';
src: url('./assets/fonts/Inter-Variable.ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
font-style: normal;
}
/* Custom utilities */
@layer utilities {
.scrollbar-hide {
@@ -49,6 +58,8 @@ input, select, textarea, button,
--hover-surface: rgba(15, 23, 42, 0.05);
--hover-action: rgba(15, 23, 42, 0.08);
--hover-row: rgba(15, 23, 42, 0.07);
--scrollbar-thumb: rgba(15, 23, 42, 0.15);
--scrollbar-thumb-hover: rgba(15, 23, 42, 0.25);
}
[data-theme="dark"] {
@@ -62,6 +73,39 @@ input, select, textarea, button,
--hover-surface: rgba(255, 255, 255, 0.05);
--hover-action: rgba(255, 255, 255, 0.12);
--hover-row: rgba(255, 255, 255, 0.08);
--scrollbar-thumb: rgba(255, 255, 255, 0.15);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.25);
}
/* Global scrollbar styling - thin, subtle, theme-aware */
* {
/* Firefox */
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
/* WebKit browsers (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Hide scrollbar corner (where horizontal and vertical meet) */
::-webkit-scrollbar-corner {
background: transparent;
}
* {
@@ -87,7 +131,7 @@ html {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
@@ -152,10 +196,10 @@ footer {
}
}
@keyframes fade-in-up {
@keyframes pop-up {
from {
opacity: 0;
transform: translate3d(0, 10px, 0);
transform: translate3d(0, 16px, 0);
}
to {
opacity: 1;
@@ -189,24 +233,36 @@ footer {
animation: slide-up 0.5s ease-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-fade-in-up {
animation: fade-in-up 0.4s ease-out;
.animate-pop-up {
animation: pop-up 0.35s ease-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-fade-in-down {
animation: fade-in-down 0.2s ease-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-fade-out-up {
animation: fade-out-up 0.15s ease-in;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
/* Button spinner styles */
@@ -264,6 +320,10 @@ footer {
max-width: 64rem;
max-height: 90vh;
pointer-events: auto;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
/* Responsive Design */
@@ -440,7 +500,8 @@ button:disabled {
/* Responsive to different window sizes */
.search-initial-state {
flex: 1;
justify-content: center; /* Only add vertical centering in initial state */
justify-content: flex-start;
padding-top: 20vh; /* Position in upper portion of screen */
}
/* Mobile-friendly book card layout */
@@ -549,28 +610,6 @@ button:disabled {
}
}
/* Sticky Header with Gradient Fade Effect */
.header-with-fade {
overflow: visible;
}
.header-with-fade::after {
content: '';
position: absolute;
bottom: -20px;
left: 0;
right: 0;
height: 20px;
/* Use background-color instead of gradient with CSS variables for smooth transitions */
background: var(--bg);
opacity: 1;
pointer-events: none;
/* Create fade effect using mask instead of gradient */
mask-image: linear-gradient(to bottom, black 0%, black 20%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 20%, transparent 100%);
transition: background-color 0.2s ease, opacity 0.2s ease;
}
/* Skeleton Loader Animation */
@keyframes shimmer {
0% {
@@ -590,22 +629,22 @@ button:disabled {
@keyframes settings-modal-enter {
from {
opacity: 0;
transform: scale(0.95);
transform: scale3d(0.95, 0.95, 1);
}
to {
opacity: 1;
transform: scale(1);
transform: scale3d(1, 1, 1);
}
}
@keyframes settings-modal-exit {
from {
opacity: 1;
transform: scale(1);
transform: scale3d(1, 1, 1);
}
to {
opacity: 0;
transform: scale(0.95);
transform: scale3d(0.95, 0.95, 1);
}
}
@@ -621,16 +660,28 @@ button:disabled {
.settings-modal-enter {
animation: settings-modal-enter 0.2s ease-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.settings-modal-exit {
animation: settings-modal-exit 0.15s ease-in;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
will-change: opacity;
}
.animate-fade-out {
animation: fade-out 0.15s ease-in;
will-change: opacity;
}
+6
View File
@@ -231,6 +231,11 @@ export interface LeadingCellConfig {
uppercase?: boolean; // Force uppercase for badge text
}
export interface SourceActionButton {
label: string; // Button text (e.g., "Refresh search")
action: string; // Action type: "expand" triggers expand_search
}
export interface ReleaseColumnConfig {
columns: ColumnSchema[];
grid_template: string; // CSS grid-template-columns for dynamic section
@@ -238,6 +243,7 @@ export interface ReleaseColumnConfig {
online_servers?: string[]; // For IRC: list of currently online server nicks
cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min)
supported_filters?: string[]; // Which filters this source supports: ["format", "language"]
action_button?: SourceActionButton; // Custom action button (replaces default expand search)
}
// A downloadable release from any source