mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Rename to Shelfmark and IRC adjustments (#415)
This commit is contained in:
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user