Frontend update + Misc fixes (#735)

- Updated frontend CSS to Tailwind v4
- Reverted socket IO origin restriction
- Fixed search queries not persisting after auth redirect
- Move advanced search options to left UI selector
- Unlock IRC source to be used for audiobook content_type
- Tweaked security settings env var syncing to be prioritised
- Fix AA "all languages" query generation
- Added language-free AA query as second fallback in case of no results
- Testing moving SeleniumBase scratch files to /tmp via symlink
- Added enhanced logging for activity dismissals and other events
- Removed iFrame restrictions
This commit is contained in:
Alex
2026-03-11 18:16:34 +00:00
committed by GitHub
parent a2a5a22324
commit c59ea46540
99 changed files with 3222 additions and 2167 deletions
+1 -19
View File
@@ -19,25 +19,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="apple-touch-icon" href="logo.png" />
<title>Shelfmark</title>
<script>
// Apply theme immediately before first paint to prevent flash.
// CSS variables aren't available until the stylesheet loads, so set
// the background color directly on <html> to avoid a white flash.
(function() {
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
let theme = savedTheme;
if (savedTheme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.backgroundColor = theme === 'dark' ? '#121212' : '#f8f8f8';
// Add class to prevent transitions on initial load
document.documentElement.classList.add('preload');
})();
</script>
<script src="theme-init.js"></script>
</head>
<body>
<div id="root"></div>
+566 -925
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -12,19 +12,20 @@
"test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^24.10.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.5.3",
"vite": "^7.3.1"
}
+1 -4
View File
@@ -1,6 +1,3 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {},
}
+22
View File
@@ -0,0 +1,22 @@
// Apply theme immediately before first paint to prevent flash.
// This runs as a blocking script before the app bundle loads.
(function() {
var saved = localStorage.getItem('preferred-theme') || 'auto';
var theme = saved === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: saved;
var dark = theme === 'dark';
var bg = dark ? '#121212' : '#f8f8f8';
var fg = dark ? '#fff' : '#333';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
var s = document.createElement('style');
s.id = 'theme-init';
s.textContent = 'html,body,#root{background:' + bg + ';color:' + fg + '}';
document.head.appendChild(s);
document.documentElement.classList.add('preload');
})();
+46 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo, CSSProperties } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import {
Book,
Release,
@@ -24,6 +24,7 @@ import {
cancelDownload,
retryDownload,
getConfig,
getStatus,
getMetadataProviders,
getMetadataSearchConfig,
createRequest,
@@ -61,6 +62,7 @@ import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { formatActingAsUserName } from './utils/actingAsUser';
import { withBasePath } from './utils/basePath';
import { buildLoginRedirectPath, getReturnToFromSearch } from './utils/authRedirect';
import { getConfiguredMetadataProviderForContentType } from './utils/metadataProviders';
import { getEffectiveMetadataSort } from './utils/metadataSort';
import {
@@ -79,6 +81,7 @@ import {
import { bookFromRequestData } from './utils/requestFulfil';
import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents';
import { bookSupportsTargets } from './utils/bookTargetLoader';
import { wasDownloadQueuedAfterResponseError } from './utils/downloadRecovery';
import { getDynamicOptionGroup } from './components/shared/DynamicDropdown';
import { policyTrace } from './utils/policyTrace';
import { SearchModeProvider } from './contexts/SearchModeContext';
@@ -136,6 +139,9 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
return fallback;
};
const CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE =
'Download queued, but the proxy interrupted the response. Status will refresh shortly.';
type PendingOnBehalfDownload =
| {
type: 'book';
@@ -151,6 +157,7 @@ type PendingOnBehalfDownload =
};
function App() {
const location = useLocation();
const { toasts, showToast, removeToast } = useToast();
const { socket } = useSocket();
@@ -1130,8 +1137,10 @@ function App() {
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
const source = getBrowseSource(book);
const directContentType: ContentType = 'ebook';
const payload = buildReleaseDataFromDirectBook(book);
const requestStartedAtSeconds = Date.now() / 1000;
try {
await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId);
await downloadRelease(payload, onBehalfOfUserId);
await fetchStatus();
removeBookFromActiveList(book);
} catch (error) {
@@ -1154,6 +1163,17 @@ function App() {
await refreshRequestPolicy({ force: true });
return;
}
try {
const status = await getStatus();
if (wasDownloadQueuedAfterResponseError(status, payload.source_id, requestStartedAtSeconds)) {
await fetchStatus();
removeBookFromActiveList(book);
showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info');
return;
}
} catch (verificationError) {
console.warn('Failed to verify download after response error:', verificationError);
}
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
throw error;
}
@@ -1168,6 +1188,7 @@ function App() {
releaseContentType: ContentType,
onBehalfOfUserId?: number
): Promise<void> => {
const requestStartedAtSeconds = Date.now() / 1000;
try {
trackRelease(book.id, release.source_id);
await downloadRelease(
@@ -1220,6 +1241,17 @@ function App() {
await refreshRequestPolicy({ force: true });
return;
}
try {
const status = await getStatus();
if (wasDownloadQueuedAfterResponseError(status, release.source_id, requestStartedAtSeconds)) {
await fetchStatus();
removeBookFromActiveList(book);
showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info');
return;
}
} catch (verificationError) {
console.warn('Failed to verify release download after response error:', verificationError);
}
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
throw error;
}
@@ -2055,6 +2087,7 @@ function App() {
onLogout={handleLogoutWithCleanup}
onSearch={handleSearchDispatch}
onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined}
isAdvancedActive={showAdvanced}
isLoading={isSearching}
onShowToast={showToast}
onRemoveToast={removeToast}
@@ -2100,8 +2133,15 @@ function App() {
onMetadataProviderChange={handleMetadataProviderChange}
contentType={contentType}
isAdmin={requestRoleIsAdmin}
onClose={() => setShowAdvanced(false)}
/>
{!isInitialState && activeQueryTarget === 'manual' && (
<p className="text-xs opacity-50 px-4 sm:px-6 lg:px-8 pt-2 lg:ml-16">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
<main
className="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6"
style={
@@ -2359,8 +2399,10 @@ function App() {
}
const shouldRedirectFromLogin = !authRequired || isAuthenticated;
const postLoginPath = getReturnToFromSearch(location.search);
const loginRedirectPath = buildLoginRedirectPath(location);
const appElement = authRequired && !isAuthenticated ? (
<Navigate to="/login" replace />
<Navigate to={loginRedirectPath} replace />
) : (
mainAppContent
);
@@ -2371,7 +2413,7 @@ function App() {
path="/login"
element={
shouldRedirectFromLogin ? (
<Navigate to="/" replace />
<Navigate to={postLoginPath} replace />
) : (
<LoginPage
onLogin={handleLogin}
@@ -28,6 +28,7 @@ interface AdvancedFiltersProps {
onMetadataProviderChange?: (provider: string) => void;
contentType?: ContentType;
isAdmin?: boolean;
onClose?: () => void;
}
const SEARCH_MODE_OPTIONS = [
@@ -50,6 +51,7 @@ export const AdvancedFilters = ({
onMetadataProviderChange,
contentType = 'ebook',
isAdmin = false,
onClose,
}: AdvancedFiltersProps) => {
const { lang, content, formats } = filters;
@@ -91,10 +93,33 @@ export const AdvancedFilters = ({
const wrapperClassName = formClassName
? 'px-2'
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
: 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]';
const settingsForm = (
<div className={wrapperClassName}>
{onClose && (
<div className="flex justify-end mb-1">
<button
type="button"
onClick={onClose}
className="p-1 rounded-full hover-action transition-colors"
aria-label="Close filters"
title="Close filters"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
style={{ color: 'var(--text-muted)' }}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{isAdmin && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
@@ -169,7 +194,7 @@ export const AdvancedFilters = ({
return renderWrapper ? (
renderWrapper(settingsForm)
) : (
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full border-b-hairline pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8">{settingsForm}</div>
</div>
);
@@ -102,8 +102,8 @@ export const BookDownloadButton = ({
const baseClasses =
variant === 'icon'
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
const sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;
@@ -169,7 +169,7 @@ export const BookGetButton = ({
if (isIconVariant) {
return (
<button
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
@@ -182,7 +182,7 @@ export const BookGetButton = ({
return (
<button
className={`inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
className={`inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
@@ -30,7 +30,7 @@ const BookmarkIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
className={`${className} flex-shrink-0`}
className={`${className} shrink-0`}
>
<path
strokeLinecap="round"
@@ -203,7 +203,7 @@ export const BookTargetDropdown = ({
<button
type="button"
onClick={toggle}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-none`}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-hidden`}
>
<BookmarkIcon className="w-3 h-3" />
Hardcover Lists{count > 0 ? ` (${count})` : ''}
@@ -217,7 +217,7 @@ export const BookTargetDropdown = ({
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(); }}
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-none ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-hidden ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
aria-label="Hardcover Lists"
title={count > 0 ? `On ${count} Hardcover list${count > 1 ? 's' : ''}` : 'Hardcover Lists'}
>
@@ -65,7 +65,7 @@ export const ConfigSetupBanner = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
@@ -73,7 +73,7 @@ export const ConfigSetupBanner = ({
{/* Modal */}
<div
className={`relative w-full max-w-lg rounded-xl
border border-[var(--border-muted)] shadow-2xl
border-hairline border-(--border-muted) shadow-2xl
overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
@@ -82,13 +82,13 @@ export const ConfigSetupBanner = ({
aria-label="Settings Setup Information"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border-muted)]">
<div className="flex items-center justify-between px-5 py-4 border-b-hairline border-(--border-muted)">
<h2 className="text-lg font-semibold">
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
</h2>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
aria-label="Close"
>
<svg
@@ -113,8 +113,8 @@ export const ConfigSetupBanner = ({
</p>
{/* Code snippet */}
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
<div className="rounded-lg overflow-hidden border-hairline border-(--border-muted)">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b-hairline border-(--border-muted)"
style={{ background: 'var(--bg-soft)' }}>
docker-compose.yml
</div>
@@ -139,22 +139,22 @@ export const ConfigSetupBanner = ({
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-[var(--border-muted)] flex justify-end gap-3">
<div className="px-5 py-4 border-t-hairline border-(--border-muted) flex justify-end gap-3">
{showContinueButton ? (
<>
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border-hairline border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
<button
onClick={handleContinue}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
>
Continue to Settings
</button>
@@ -163,8 +163,8 @@ export const ConfigSetupBanner = ({
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
>
Got it
</button>
+7 -7
View File
@@ -135,7 +135,7 @@ export const DetailsModal = ({
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm bg-[var(--bg-soft)] sm:bg-[var(--bg)]';
const infoCardClass = 'rounded-2xl border-hairline border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)';
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
const infoValueClass = 'text-gray-900 dark:text-gray-100';
@@ -147,13 +147,13 @@ export const DetailsModal = ({
}}
>
<div
className={`details-container w-full max-w-4xl h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container w-full h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<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 h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--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">
@@ -198,7 +198,7 @@ export const DetailsModal = ({
</div>
) : (
<div
className="flex w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
className="flex w-full items-center justify-center rounded-xl border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
No cover
@@ -299,7 +299,7 @@ export const DetailsModal = ({
onSearchSeries(book.series_name!, book.series_id);
handleClose();
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors flex-shrink-0"
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
@@ -329,7 +329,7 @@ export const DetailsModal = ({
</div>
<footer
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
className="border-t-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+3 -4
View File
@@ -184,9 +184,8 @@ export const Dropdown = ({
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`}
className={`w-full px-3 py-2 text-sm border-hairline flex items-center justify-between gap-2 text-left focus:outline-hidden focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 ${triggerChrome !== 'minimal' ? 'dropdown-trigger' : ''} ${buttonClassName}`}
style={{
background: triggerChrome === 'minimal' ? 'transparent' : 'var(--bg-soft)',
color: 'var(--text)',
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
borderWidth: triggerChrome === 'minimal' ? 0 : undefined,
@@ -205,7 +204,7 @@ export const Dropdown = ({
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
className={`h-4 w-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -223,7 +222,7 @@ export const Dropdown = ({
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
} border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
} border-hairline z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
+1 -1
View File
@@ -132,7 +132,7 @@ export const DropdownList = ({
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
+1 -1
View File
@@ -39,7 +39,7 @@ export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) =>
{truncatedBuild && ` (${truncatedBuild})`}
</span>
{debug && (
<span className="text-xs px-1.5 py-0.5 rounded opacity-60" style={{ background: 'var(--border-muted)' }}>
<span className="text-xs px-1.5 py-0.5 rounded-sm opacity-60" style={{ background: 'var(--border-muted)' }}>
Debug
</span>
)}
+22 -16
View File
@@ -22,6 +22,7 @@ interface HeaderProps {
onSearchChange?: (value: string | number | boolean, label?: string) => void;
onSearch?: () => void;
onAdvancedToggle?: () => void;
isAdvancedActive?: boolean;
isLoading?: boolean;
onDownloadsClick?: () => void;
onSettingsClick?: () => void;
@@ -58,6 +59,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onSearchChange,
onSearch,
onAdvancedToggle,
isAdvancedActive = false,
isLoading = false,
onDownloadsClick,
onSettingsClick,
@@ -153,9 +155,11 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
const saved = localStorage.getItem('preferred-theme') || 'auto';
applyTheme(saved);
// Remove preload class after initial theme is applied to enable transitions
// Remove preload class and inline theme-init styles now that the
// external CSS is loaded and React has mounted.
requestAnimationFrame(() => {
document.documentElement.classList.remove('preload');
document.getElementById('theme-init')?.remove();
});
}, []);
@@ -163,7 +167,9 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (localStorage.getItem('preferred-theme') === 'auto') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
const effective = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', effective);
document.documentElement.style.colorScheme = effective;
}
};
mq.addEventListener('change', handler);
@@ -238,12 +244,11 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
}, [isDropdownOpen, isClosing]);
const applyTheme = (pref: string) => {
if (pref === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', pref);
}
const effective = pref === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: pref;
document.documentElement.setAttribute('data-theme', effective);
document.documentElement.style.colorScheme = effective;
};
const handleLogout = () => {
@@ -372,7 +377,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
<button
onClick={toggleDropdown}
className={`relative p-2 rounded-full hover-action transition-colors ${
isDropdownOpen ? 'bg-[var(--hover-action)]' : ''
isDropdownOpen ? 'bg-(--hover-action)' : ''
}`}
aria-label="User menu"
aria-expanded={isDropdownOpen}
@@ -394,7 +399,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
</svg>
{actingAsUser && (
<span
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border border-[var(--bg)]"
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border-hairline border-(--bg)"
title={`Downloading as ${formatActingAsUserName(actingAsUser)}`}
/>
)}
@@ -403,7 +408,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{/* Dropdown Menu */}
{(isDropdownOpen || isClosing) && (
<div
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border z-50 ${
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border-hairline z-50 ${
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
}`}
style={{
@@ -545,7 +550,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{/* User Footer */}
{authRequired && isAuthenticated && username && (
<div
className="border-t"
className="border-t-hairline"
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="px-4 py-3 flex items-center gap-2.5">
@@ -576,7 +581,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{isAdmin && onActingAsUserChange && (
<div
className="border-t px-4 py-3 space-y-2"
className="border-t-hairline px-4 py-3 space-y-2"
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="text-xs font-medium uppercase tracking-wide opacity-70">
@@ -620,7 +625,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
return (
<header
className="w-full sticky top-0 z-40 backdrop-blur-sm"
className="w-full sticky top-0 z-40 backdrop-blur-xs"
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 py-4">
@@ -635,7 +640,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
className="h-10 w-10 shrink-0 cursor-pointer lg:hidden"
/>
)}
@@ -650,7 +655,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
className="hidden lg:block h-12 w-12 shrink-0 cursor-pointer"
/>
)}
<SearchBar
@@ -661,6 +666,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onChange={handleSearchChange}
onSubmit={handleHeaderSearch}
onAdvancedToggle={onAdvancedToggle}
isAdvancedActive={isAdvancedActive}
isLoading={isLoading}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
+10 -8
View File
@@ -2,6 +2,7 @@ import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
import { buildOidcLoginUrl } from '../utils/authRedirect';
interface LoginFormProps {
onSubmit: (credentials: LoginCredentials) => void;
@@ -111,7 +112,7 @@ const PasswordLoginForm = ({
onChange={(event) => setUsername(event.target.value)}
onKeyDown={handleUsernameKeyDown}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="w-full px-4 py-2.5 rounded-lg border-hairline focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
@@ -140,7 +141,7 @@ const PasswordLoginForm = ({
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
className="w-full px-4 py-2.5 rounded-lg border-hairline focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
@@ -168,7 +169,7 @@ const PasswordLoginForm = ({
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
className="w-4 h-4 rounded-sm focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
@@ -229,6 +230,7 @@ export const LoginForm = ({
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
const [searchParams] = useSearchParams();
const oidcError = searchParams.get('oidc_error');
const oidcLoginUrl = buildOidcLoginUrl(searchParams.toString());
// Auto-expand password form if there's an error (likely from a password attempt)
useEffect(() => {
@@ -240,9 +242,9 @@ export const LoginForm = ({
// Auto-redirect to OIDC provider when enabled and no errors present
useEffect(() => {
if (oidcAutoRedirect && isOidc && !error && !oidcError) {
window.location.href = withBasePath('/api/auth/oidc/login');
window.location.href = oidcLoginUrl;
}
}, [oidcAutoRedirect, isOidc, error, oidcError]);
}, [oidcAutoRedirect, isOidc, error, oidcError, oidcLoginUrl]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -272,7 +274,7 @@ export const LoginForm = ({
{isOidc ? (
<>
<a
href={withBasePath('/api/auth/oidc/login')}
href={oidcLoginUrl}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white text-center transition-colors block bg-sky-700 hover:bg-sky-800"
>
{oidcButtonLabel || 'Sign in with OIDC'}
@@ -281,7 +283,7 @@ export const LoginForm = ({
{!hideLocalAuth && (
<>
<div className="flex items-center mt-5 mb-2">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
<button
type="button"
onClick={() => setShowPasswordLogin((prev) => !prev)}
@@ -289,7 +291,7 @@ export const LoginForm = ({
>
{showPasswordLogin ? 'Hide' : 'Use password'}
</button>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
</div>
{showPasswordLogin && (
@@ -88,25 +88,25 @@ export const OnBehalfConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-lg rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-lg rounded-xl border-hairline border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="text-lg font-semibold">
Download as {actingAsName}?
</h3>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close download confirmation"
disabled={isSubmitting}
>
@@ -120,18 +120,18 @@ export const OnBehalfConfirmationModal = ({
<p className="text-sm opacity-90">
This download will use {actingAsName}&apos;s output preferences and destination settings.
</p>
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3">
<div className="rounded-xl border-hairline border-(--border-muted) bg-(--bg-soft) px-4 py-3">
<p className="text-xs uppercase tracking-wide opacity-60">Title</p>
<p className="text-sm font-medium mt-1 break-words">{itemTitle}</p>
<p className="text-sm font-medium mt-1 wrap-break-word">{itemTitle}</p>
</div>
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--border-muted) px-6 py-4">
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
+12 -12
View File
@@ -331,7 +331,7 @@ export const OnboardingModal = ({
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div className="absolute inset-0 bg-black/50 backdrop-blur-xs" />
<div
className="relative rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
@@ -364,7 +364,7 @@ export const OnboardingModal = ({
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={handleClose} />
<div className="absolute inset-0 bg-black/50 backdrop-blur-xs" onClick={handleClose} />
<div
className="relative rounded-xl p-8 shadow-2xl max-w-md"
style={{ background: 'var(--bg)' }}
@@ -390,8 +390,8 @@ export const OnboardingModal = ({
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border-hairline border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
@@ -409,14 +409,14 @@ export const OnboardingModal = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
/>
{/* Modal */}
<div
className={`relative w-full max-w-xl rounded-xl
border border-[var(--border-muted)] shadow-2xl
border-hairline border-(--border-muted) shadow-2xl
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
@@ -424,7 +424,7 @@ export const OnboardingModal = ({
aria-label="Setup Wizard"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-muted)]">
<div className="flex items-center justify-between px-6 py-4 border-b-hairline border-(--border-muted)">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-sky-500/20 text-sky-500 text-sm font-medium">
{currentStepIndex + 1}
@@ -438,7 +438,7 @@ export const OnboardingModal = ({
</div>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
aria-label="Close"
>
<svg
@@ -455,7 +455,7 @@ export const OnboardingModal = ({
</div>
{/* Progress bar */}
<div className="h-1 bg-[var(--bg-soft)]">
<div className="h-1 bg-(--bg-soft)">
<div
className="h-full bg-sky-500 transition-all duration-300"
style={{ width: `${progress}%` }}
@@ -481,7 +481,7 @@ export const OnboardingModal = ({
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-[var(--border-muted)] flex items-center justify-between h-[68px]">
<div className="px-6 py-4 border-t-hairline border-(--border-muted) flex items-center justify-between h-[68px]">
<div>
<button
onClick={handleSkip}
@@ -499,8 +499,8 @@ export const OnboardingModal = ({
onClick={handleBack}
disabled={isSaving}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors
bg-(--bg-soft) border-hairline border-(--border-muted)
hover:bg-(--hover-surface) transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
Back
+10 -10
View File
@@ -245,7 +245,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
<div className="flex flex-col gap-1 max-w-xs">
{rows.map((row) => (
<div key={row.label} className="flex gap-2">
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{row.label}:</span>
<span className="text-gray-400 dark:text-gray-500 shrink-0">{row.label}:</span>
<span className="truncate">{row.value}</span>
</div>
))}
@@ -324,7 +324,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`}
className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
title={protocolLabel}
/>
{displayValue}
@@ -335,11 +335,11 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate gap-1.5`}>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`}
className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`}
title={protocolLabel}
/>
<span className="truncate">{displayValue}</span>
{peers && <span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{peers}</span>}
{peers && <span className="text-gray-400 dark:text-gray-500 shrink-0">{peers}</span>}
</div>
);
}
@@ -403,12 +403,12 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
// Icon sized to match visual height of format text badges
const icon = isAudiobook ? (
// Headphones icon for audiobook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
) : (
// Book icon for ebook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
);
@@ -433,7 +433,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
if (!primaryFormat) {
return (
<div className="flex items-center justify-start" title={isAudiobook ? 'Audiobook' : 'Book'}>
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-[3.25rem]`}>
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-13`}>
{icon}
</span>
</div>
@@ -444,7 +444,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
const formatBadge = (
<span className="inline-flex items-center gap-1">
<span
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-[3.25rem] text-center`}
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-13 text-center`}
>
{column.uppercase ? primaryFormat.toUpperCase() : primaryFormat}
</span>
@@ -492,7 +492,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`w-1.5 h-1.5 rounded-full shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
{displayValue}
@@ -506,7 +506,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate`}>
{isServerColumn && (
<span
className={`w-2 h-2 rounded-full mr-1.5 flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`w-2 h-2 rounded-full mr-1.5 shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
)}
+59 -59
View File
@@ -115,7 +115,7 @@ function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: num
<div key={index} className="relative w-4 h-4">
{/* Empty star (gray background) */}
<svg
className="absolute inset-0 w-4 h-4 text-gray-300 dark:text-gray-600"
className="absolute inset-0 w-4 h-4 text-zinc-300 dark:text-zinc-600"
fill="currentColor"
viewBox="0 0 20 20"
>
@@ -145,7 +145,7 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
if (!preview || imageError) {
return (
<div
className="w-7 h-10 sm:w-8 sm:h-12 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[7px] sm:text-[8px] font-medium text-gray-500 dark:text-gray-400 flex-shrink-0"
className="w-7 h-10 sm:w-8 sm:h-12 rounded-sm bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-[7px] sm:text-[8px] font-medium text-zinc-500 dark:text-zinc-400 shrink-0"
aria-label="No cover available"
>
No Cover
@@ -154,9 +154,9 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
}
return (
<div className="relative w-7 h-10 sm:w-8 sm:h-12 rounded overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70 flex-shrink-0">
<div className="relative w-7 h-10 sm:w-8 sm:h-12 rounded-sm overflow-hidden bg-zinc-100 dark:bg-zinc-800 border-hairline border-white/40 dark:border-zinc-700/70 shrink-0">
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="absolute inset-0 bg-linear-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
)}
<img
src={preview}
@@ -201,7 +201,7 @@ const LeadingCell = ({
return (
<div
className={`w-7 h-10 sm:w-8 sm:h-12 rounded-lg ${colorStyle.bg} flex items-center justify-center flex-shrink-0`}
className={`w-7 h-10 sm:w-8 sm:h-12 rounded-lg ${colorStyle.bg} flex items-center justify-center shrink-0`}
>
<span className={`text-[8px] sm:text-[9px] font-bold ${colorStyle.text} text-center leading-tight px-0.5`}>
{text}
@@ -286,7 +286,7 @@ const ReleaseRow = ({
)}
</p>
{author && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
<p className="text-xs text-zinc-500 dark:text-zinc-400 truncate">
{author}
</p>
)}
@@ -332,12 +332,12 @@ const ReleaseRow = ({
<span className="font-medium">{release.title}</span>
)}
{author && (
<span className="text-gray-500 dark:text-gray-400 font-normal"> — {author}</span>
<span className="text-zinc-500 dark:text-zinc-400 font-normal"> — {author}</span>
)}
</p>
{/* Plugin-provided info line (format, size, indexer, seeders, etc.) */}
{mobileColumns.length > 0 && (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-zinc-500 dark:text-zinc-400">
{(() => {
// Pre-filter columns that will render content to avoid orphan dots
const columnsWithContent = mobileColumns.filter((col) => {
@@ -365,7 +365,7 @@ const ReleaseRow = ({
});
return columnsWithContent.map((col, idx) => (
<span key={col.key} className="flex items-center gap-1.5">
{idx > 0 && <span className="text-gray-300 dark:text-gray-600">·</span>}
{idx > 0 && <span className="text-zinc-300 dark:text-zinc-600">·</span>}
<ReleaseCell column={col} release={release} compact onlineServers={onlineServers} />
</span>
));
@@ -390,7 +390,7 @@ const ReleaseRow = ({
function ShimmerBlock({ className }: { className: string }) {
return (
<div
className={`rounded bg-gray-200 dark:bg-gray-800 relative overflow-hidden ${className}`}
className={`rounded-sm bg-zinc-200 dark:bg-zinc-800 relative overflow-hidden ${className}`}
>
<div
className="absolute inset-0 dark:opacity-50"
@@ -411,7 +411,7 @@ function ReleaseSkeleton() {
const rows = 8;
return (
<div
className="divide-y divide-gray-200/60 dark:divide-gray-800/60 overflow-hidden"
className="divide-y divide-zinc-200/60 dark:divide-zinc-800/60 overflow-hidden"
style={{
maskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
@@ -453,7 +453,7 @@ function ReleaseSkeleton() {
</div>
{/* Action button skeleton */}
<ShimmerBlock className="w-8 h-8 !rounded-full" />
<ShimmerBlock className="w-8 h-8 rounded-full!" />
</div>
</div>
</div>
@@ -466,9 +466,9 @@ function ReleaseSkeleton() {
function EmptyState({ message }: { message: 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-gray-100 dark:bg-gray-800 mb-4">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 mb-4">
<svg
className="w-7 h-7 text-gray-400"
className="w-7 h-7 text-zinc-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -481,7 +481,7 @@ function EmptyState({ message }: { message: string }) {
/>
</svg>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400">{message}</p>
</div>
);
}
@@ -505,10 +505,10 @@ function ErrorState({ message }: { message: string }) {
/>
</svg>
</div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
<h4 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
Error Loading Releases
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">{message}</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto">{message}</p>
</div>
);
}
@@ -1292,18 +1292,18 @@ export const ReleaseModal = ({
}}
>
<div
className={`details-container w-full max-w-3xl h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container w-full h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<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">
<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-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
{/* Header */}
<header className="flex items-start gap-3 border-b border-[var(--border-muted)] px-5 py-4">
<header className="flex items-start gap-3 border-b-hairline border-(--border-muted) px-5 py-4">
{/* Animated thumbnail that appears when scrolling */}
{!isRequestMode && (
<div
className="flex-shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
className="shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
style={{
width: showHeaderThumb ? 46 : 0,
marginRight: showHeaderThumb ? 0 : -12,
@@ -1319,12 +1319,12 @@ export const ReleaseModal = ({
alt=""
width={46}
height={68}
className="rounded shadow-md object-cover object-top"
className="rounded-sm shadow-md object-cover object-top"
style={{ width: 46, height: 68, minWidth: 46 }}
/>
) : (
<div
className="rounded border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[7px] text-gray-500"
className="rounded-sm border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
style={{ width: 46, height: 68, minWidth: 46 }}
>
No cover
@@ -1334,23 +1334,23 @@ export const ReleaseModal = ({
</div>
)}
<div className="flex-1 space-y-1 min-w-0">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<p className="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Find Releases
</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug truncate">
{book.provider === 'manual' ? 'Manual Query' : (book.title || 'Untitled')}
</h3>
{!isRequestMode && (
<p className="text-sm text-gray-600 dark:text-gray-300 truncate">
<p className="text-sm text-zinc-600 dark:text-zinc-300 truncate">
{book.author || 'Unknown author'}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={handleClose}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
className="rounded-full p-2 text-zinc-500 transition-colors hover-action hover:text-zinc-900 dark:hover:text-zinc-100"
aria-label="Close"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
@@ -1364,34 +1364,34 @@ export const ReleaseModal = ({
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Book summary - scrolls with content */}
{!isRequestMode && (
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b-hairline border-(--border-muted)">
{book.preview ? (
<img
src={book.preview}
alt="Book cover"
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
className={`rounded-lg shadow-md object-cover object-top shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
/>
) : (
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
<div className={`rounded-lg border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[10px] text-zinc-500 shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
No cover
</div>
)}
<div className="flex-1 min-w-0 space-y-2">
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-zinc-600 dark:text-zinc-400">
{book.year && <span>{book.year}</span>}
{displayFields?.starField && (
<span className="flex items-center gap-1.5">
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
<span>{displayFields.starField.value}</span>
{displayFields.ratingsField && (
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
<span className="text-zinc-400 dark:text-zinc-500">({displayFields.ratingsField.value})</span>
)}
</span>
)}
{displayFields?.usersField && (
<span className="flex items-center gap-1">
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<svg className="h-3.5 w-3.5 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
{displayFields.usersField.value} readers
@@ -1404,7 +1404,7 @@ export const ReleaseModal = ({
{/* Series info */}
{book.series_name && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<span>
{book.series_position != null ? (
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
@@ -1432,7 +1432,7 @@ export const ReleaseModal = ({
{/* Description */}
{book.description && (
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
<div className="text-sm text-zinc-600 dark:text-zinc-400 relative">
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
{book.description}
{descriptionExpanded && descriptionOverflows && (
@@ -1452,7 +1452,7 @@ export const ReleaseModal = ({
<button
type="button"
onClick={() => setDescriptionExpanded(true)}
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-linear-to-r from-transparent via-(--bg) to-(--bg) sm:via-(--bg-soft) sm:to-(--bg-soft)"
>
more
</button>
@@ -1463,7 +1463,7 @@ export const ReleaseModal = ({
{/* Links row */}
<div className="flex flex-wrap items-center gap-3 text-xs">
{(book.isbn_13 || book.isbn_10) && (
<span className="text-gray-500 dark:text-gray-400">
<span className="text-zinc-500 dark:text-zinc-400">
ISBN: {book.isbn_13 || book.isbn_10}
</span>
)}
@@ -1509,13 +1509,13 @@ export const ReleaseModal = ({
)}
{/* Source tabs + filters - sticky within scroll container */}
<div className="sticky top-0 z-10 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
<div className="sticky top-0 z-10 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
{sourcesLoading ? (
<div className="flex gap-1 px-5 py-2">
<div className="h-10 w-32 animate-pulse bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-32 animate-pulse bg-zinc-200 dark:bg-zinc-700 rounded-sm" />
</div>
) : allTabs.length === 0 ? (
<div className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400">
<div className="px-5 py-3 text-sm text-zinc-500 dark:text-zinc-400">
{sourcesError || 'No release sources are available for this book.'}
</div>
) : (
@@ -1538,7 +1538,7 @@ export const ReleaseModal = ({
onClick={() => setActiveTab(tab.name)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${activeTab === tab.name
? 'text-emerald-600 dark:text-emerald-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200'
}`}
>
{tab.displayName}
@@ -1563,7 +1563,7 @@ export const ReleaseModal = ({
return next;
});
}}
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
className={`p-2.5 rounded-full transition-colors hover-surface text-zinc-500 dark:text-zinc-400 ${manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
aria-label="Manual search query"
title="Manual query"
@@ -1577,13 +1577,13 @@ export const ReleaseModal = ({
{(allSortOptions.length > 0 || availableFormats.length > 1) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
widthClassName="w-auto shrink-0"
panelClassName="w-48"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${isOpen ? 'bg-[var(--hover-surface)]' : ''
className={`relative p-2.5 rounded-full transition-colors hover-surface text-zinc-500 dark:text-zinc-400 ${isOpen ? 'bg-(--hover-surface)' : ''
}`}
aria-label="Sort releases"
>
@@ -1608,7 +1608,7 @@ export const ReleaseModal = ({
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>Best Match (Default)</span>
@@ -1633,7 +1633,7 @@ export const ReleaseModal = ({
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${isSelected
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>{opt.label}</span>
@@ -1654,7 +1654,7 @@ export const ReleaseModal = ({
{availableFormats.length > 1 && (
<>
{allSortOptions.length > 0 && (
<div className="mx-2 my-1 border-t border-gray-200 dark:border-gray-700" />
<div className="mx-2 my-1 border-t-hairline border-zinc-200 dark:border-zinc-700" />
)}
<button
type="button"
@@ -1662,7 +1662,7 @@ export const ReleaseModal = ({
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
currentSort?.key === FORMAT_SORT_KEY
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>
@@ -1692,7 +1692,7 @@ export const ReleaseModal = ({
className={`w-full pl-6 pr-3 py-1.5 text-left text-sm flex items-center justify-between hover-surface rounded ${
isSelected
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>{fmt.toUpperCase()}</span>
@@ -1722,7 +1722,7 @@ export const ReleaseModal = ({
(columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1)) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
widthClassName="w-auto shrink-0"
panelClassName="w-56"
noScrollLimit
renderTrigger={({ isOpen, toggle }) => {
@@ -1744,8 +1744,8 @@ export const ReleaseModal = ({
<button
type="button"
onClick={toggle}
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
className={`relative p-2.5 rounded-full transition-colors hover-surface text-zinc-500 dark:text-zinc-400 ${
isOpen ? 'bg-(--hover-surface)' : ''
}`}
aria-label="Filter releases"
>
@@ -1852,7 +1852,7 @@ export const ReleaseModal = ({
{/* Manual query panel (below source tabs) */}
{showManualQuery && (
<div className="px-5 py-3 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
<div className="px-5 py-3 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
<form
className="flex items-center gap-2"
onSubmit={async (e) => {
@@ -1907,7 +1907,7 @@ export const ReleaseModal = ({
value={manualQuery}
onChange={(e) => setManualQuery(e.target.value)}
placeholder="Type a custom search query (overrides all sources)"
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-[var(--text)]"
className="w-full px-3 py-2 text-sm rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-(--text)"
/>
<button
type="submit"
@@ -1920,7 +1920,7 @@ export const ReleaseModal = ({
{currentTabLoading ? 'Searching…' : 'Search'}
</button>
</form>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Manual query overrides ISBN/title/author/language expansion.
</p>
</div>
@@ -1959,7 +1959,7 @@ export const ReleaseModal = ({
<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"
className="px-3 py-1.5 text-sm text-zinc-500 dark:text-zinc-400 rounded-full hover-action transition-all duration-200"
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
@@ -1969,7 +1969,7 @@ export const ReleaseModal = ({
) : (
<>
{/* Key includes filter to force remount when filter changes */}
<div key={`releases-${formatFilter}-${languageFilter.join(',')}`} className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
<div key={`releases-${formatFilter}-${languageFilter.join(',')}`} className="divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
{filteredReleases.map((release, index) => (
<ReleaseRow
key={`${release.source}-${release.source_id}`}
@@ -2002,7 +2002,7 @@ export const ReleaseModal = ({
<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"
className="px-3 py-1.5 text-sm text-zinc-500 dark:text-zinc-400 rounded-full hover-action transition-all duration-200"
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
@@ -2019,7 +2019,7 @@ export const ReleaseModal = ({
{/* Sticky search status indicator - stays at bottom of visible scroll area */}
{searchStatus && searchStatus.source === activeTab && currentTabLoading && (
<div className="sticky bottom-0 z-10 flex items-center justify-center pointer-events-none pb-4 pt-2">
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-[var(--bg-soft)] border border-[var(--border-muted)] text-gray-500 dark:text-gray-400 text-sm shadow-lg pointer-events-auto">
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-(--bg-soft) border-hairline border-(--border-muted) text-zinc-500 dark:text-zinc-400 text-sm shadow-lg pointer-events-auto">
{searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
)}
@@ -134,25 +134,25 @@ export const RequestConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-xl rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-xl rounded-xl border-hairline border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="text-lg font-semibold">
Request Book
</h3>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close request confirmation"
disabled={isSubmitting}
>
@@ -163,9 +163,9 @@ export const RequestConfirmationModal = ({
</header>
<div className="space-y-4 px-6 py-5">
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-4">
<div className="rounded-xl border-hairline border-(--border-muted) bg-(--bg-soft) px-4 py-4">
<div className="flex gap-4">
<div className="w-16 h-24 flex-shrink-0 rounded-lg overflow-hidden border border-[var(--border-muted)] bg-[var(--bg)]">
<div className="w-16 h-24 shrink-0 rounded-lg overflow-hidden border-hairline border-(--border-muted) bg-(--bg)">
{preview.preview ? (
<img
src={preview.preview}
@@ -212,7 +212,7 @@ export const RequestConfirmationModal = ({
onChange={(event) => setNote(truncateRequestNote(event.target.value))}
maxLength={MAX_REQUEST_NOTE_LENGTH}
rows={4}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-sm resize-y min-h-[96px] focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-sm resize-y min-h-[96px] focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
placeholder="Add context for admins reviewing this request..."
disabled={isSubmitting}
/>
@@ -223,12 +223,12 @@ export const RequestConfirmationModal = ({
)}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--border-muted) px-6 py-4">
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -311,7 +311,7 @@ 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-[var(--hover-action)]' : ''
isOpen ? 'bg-(--hover-action)' : ''
} animate-pop-up`}
aria-haspopup="listbox"
aria-expanded={isOpen}
+83 -55
View File
@@ -26,6 +26,7 @@ interface SearchBarProps {
onSubmit: () => void;
isLoading?: boolean;
onAdvancedToggle?: () => void;
isAdvancedActive?: boolean;
placeholder?: string;
inputAriaLabel?: string;
className?: string;
@@ -33,8 +34,6 @@ interface SearchBarProps {
controlsClassName?: string;
clearButtonLabel?: string;
clearButtonTitle?: string;
advancedButtonLabel?: string;
advancedButtonTitle?: string;
searchButtonLabel?: string;
searchButtonTitle?: string;
autoComplete?: string;
@@ -100,6 +99,12 @@ const AudiobookIcon = () => (
</svg>
);
const CheckIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
);
const getDefaultPlaceholder = (
contentType: ContentType,
activeQueryTarget: QueryTargetOption | undefined,
@@ -147,6 +152,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onSubmit,
isLoading = false,
onAdvancedToggle,
isAdvancedActive = false,
placeholder,
inputAriaLabel = 'Search books',
className = '',
@@ -154,8 +160,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
controlsClassName = '',
clearButtonLabel = 'Clear search input',
clearButtonTitle = 'Clear search',
advancedButtonLabel = 'Search settings',
advancedButtonTitle = 'Search settings',
searchButtonLabel = 'Search books',
searchButtonTitle = 'Search',
autoComplete = 'off',
@@ -197,7 +201,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
? 'pl-3 rounded-r-full'
: 'pl-4 rounded-full';
const searchInputClass = [
'w-full min-w-0 py-3 border-0 outline-none search-input bg-transparent',
'w-full min-w-0 py-3 border-0 outline-hidden search-input bg-transparent',
inputPaddingClass,
].join(' ');
@@ -218,15 +222,21 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
};
}, []);
// Auto-open select dropdown only when transitioning into a select field
const prevFieldKeyRef = useRef<string | undefined>(undefined);
// Auto-open select dropdown only when transitioning into a select field.
// Initialise the ref to the current key so that mounting with an already-
// active select field (e.g. the Header SearchBar after first search)
// doesn't count as a field change. Using `if (fieldChanged)` instead of
// always calling setIsSelectOpen avoids StrictMode's second effect
// invocation resetting the state set by the first.
const prevFieldKeyRef = useRef<string | undefined>(activeQueryField?.key);
useEffect(() => {
const fieldKey = activeQueryField?.key;
const isSelect = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField';
const fieldChanged = fieldKey !== prevFieldKeyRef.current;
prevFieldKeyRef.current = fieldKey;
const fieldChanged = activeQueryField?.key !== prevFieldKeyRef.current;
prevFieldKeyRef.current = activeQueryField?.key;
setIsSelectOpen(fieldChanged && isSelect);
if (fieldChanged) {
setIsSelectOpen(isSelect);
}
setIsAutocompleteOpen(false);
setAutocompleteOptions([]);
}, [activeQueryField?.key, activeQueryField?.type]);
@@ -404,9 +414,9 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
Boolean(autocompleteEndpoint)
&& isAutocompleteOpen
&& textInputValue.trim().length >= autocompleteMinQueryLength;
const wrapperClasses = ['relative flex items-center rounded-full border', className].filter(Boolean).join(' ').trim();
const wrapperClasses = ['relative flex items-center rounded-full border-hairline', className].filter(Boolean).join(' ').trim();
const controlsClasses = [
'flex items-center gap-1 pr-2 flex-shrink-0',
'flex items-center gap-1 pr-2 shrink-0',
controlsClassName,
]
.filter(Boolean)
@@ -533,7 +543,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<span className="opacity-50 truncate">{effectivePlaceholder}</span>
)}
<svg
className={`w-3.5 h-3.5 opacity-40 flex-shrink-0 transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
className={`w-3.5 h-3.5 opacity-40 shrink-0 transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -553,7 +563,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50"
className="h-4 w-4 rounded-sm border-(--border-muted) text-emerald-500 focus:ring-emerald-500/50"
/>
<span className="truncate text-sm" style={{ color: 'var(--text)' }}>
{activeQueryField.label}
@@ -576,9 +586,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
>
{showQueryTargetSelector && (
<div
className="relative flex-shrink-0 flex self-stretch"
className="relative shrink-0 flex self-stretch"
ref={selectorRef}
onMouseEnter={() => {
onPointerEnter={(e) => {
if (e.pointerType !== 'mouse') return;
if (selectorHoverTimeout.current) {
clearTimeout(selectorHoverTimeout.current);
selectorHoverTimeout.current = null;
@@ -587,7 +598,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
setIsSelectOpen(false);
setIsAutocompleteOpen(false);
}}
onMouseLeave={() => {
onPointerLeave={(e) => {
if (e.pointerType !== 'mouse') return;
selectorHoverTimeout.current = setTimeout(() => {
setIsSelectorOpen(false);
selectorHoverTimeout.current = null;
@@ -628,7 +640,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{isSelectorOpen && (
<div
className="absolute left-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border shadow-2xl animate-fade-in-down"
className="absolute left-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border-hairline shadow-2xl animate-fade-in-down"
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
@@ -638,7 +650,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
>
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
{showContentTypeSelector && (
<div className="border-b pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className="border-b-hairline pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
Content
</div>
@@ -646,27 +658,27 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<button
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'ebook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
>
<BookIcon />
{contentType === 'ebook' ? <CheckIcon /> : <BookIcon />}
<span>Books</span>
</button>
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'audiobook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
>
<AudiobookIcon />
{contentType === 'audiobook' ? <CheckIcon /> : <AudiobookIcon />}
<span>Audiobooks</span>
</button>
</div>
@@ -687,19 +699,61 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onClick={() => handleQueryTargetSelect(target.key)}
title={target.description || target.label}
aria-label={target.label}
className={`min-w-0 rounded-xl border px-3 py-2.5 text-left text-sm font-medium transition-colors ${
className={`min-w-0 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors flex items-center gap-2 ${
isActive ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` : 'hover-surface'
}`}
style={isActive
? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
>
{isActive && <CheckIcon />}
<span className="block truncate">{target.label}</span>
</button>
);
})}
</div>
</div>
{onAdvancedToggle && (
<div className="border-t-hairline pt-3 mt-3" style={{ borderColor: 'var(--border-muted)' }}>
<button
type="button"
onClick={() => {
setIsSelectorOpen(false);
onAdvancedToggle();
}}
className={`w-full px-3 py-2.5 text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2 ${
isAdvancedActive
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
: 'hover-surface'
}`}
style={isAdvancedActive
? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text-muted)' }}
>
{isAdvancedActive ? (
<CheckIcon />
) : (
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
)}
Options & Filters
</button>
</div>
)}
</div>
</div>
)}
@@ -733,32 +787,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</svg>
</button>
)}
{onAdvancedToggle && (
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
aria-label={advancedButtonLabel}
title={advancedButtonTitle}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
style={{ color: 'var(--text)' }}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
)}
<button
ref={buttonRef}
type="button"
@@ -790,7 +818,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</svg>
)}
{isLoading && (
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent search-bar-spinner" />
<div className="spinner w-5 h-5 border-2 border-white border-t-transparent search-bar-spinner" />
)}
</button>
</div>
@@ -798,7 +826,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{selectDropdownOpen && (
<div
ref={selectPanelRef}
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
role="listbox"
aria-label={effectiveInputAriaLabel}
@@ -827,7 +855,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<svg className="w-4 h-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
@@ -841,7 +869,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{autocompleteDropdownOpen && (
<div
ref={autocompletePanelRef}
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
role="listbox"
aria-label={`${effectiveInputAriaLabel} suggestions`}
@@ -92,6 +92,7 @@ export const SearchSection = ({
onSubmit={onSearch}
isLoading={isLoading}
onAdvancedToggle={onAdvancedToggle}
isAdvancedActive={showAdvanced}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
@@ -100,6 +101,11 @@ export const SearchSection = ({
onQueryTargetChange={onQueryTargetChange}
activeQueryField={activeQueryField}
/>
{activeQueryTarget === 'manual' && (
<p className="text-xs opacity-50 px-2">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
<AdvancedFilters
visible={showAdvanced}
bookLanguages={bookLanguages}
@@ -115,6 +121,7 @@ export const SearchSection = ({
onMetadataProviderChange={onMetadataProviderChange}
contentType={contentType}
isAdmin={isAdmin}
onClose={onAdvancedToggle}
/>
</div>
</section>
@@ -25,7 +25,7 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-[1100] space-y-2">
<div id="toast-container" className="fixed bottom-4 right-4 z-1100 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
@@ -43,7 +43,7 @@ interface ActivityCardProps {
}
const BookFallback = () => (
<div className="w-12 h-[4.5rem] rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400">
<div className="w-12 h-18 rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400">
No Cover
</div>
);
@@ -233,7 +233,7 @@ const hasAttachedReleaseData = (record: RequestRecord): boolean => {
const DetailField = ({ label, value }: { label: string; value: string }) => (
<div className="py-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{label}</p>
<p className="text-xs font-medium break-words mt-0.5">{value}</p>
<p className="text-xs font-medium wrap-break-word mt-0.5">{value}</p>
</div>
);
@@ -507,7 +507,7 @@ export const ActivityCard = ({
const requestType = reviewRecord?.content_type === 'audiobook' ? 'Audiobook' : 'Book';
const titleAuthorLine = item.author ? `${item.title} — ${item.author}` : item.title;
const titleLineClassName = isDetailsExpanded
? 'text-sm leading-tight min-w-0 whitespace-normal break-words'
? 'text-sm leading-tight min-w-0 whitespace-normal wrap-break-word'
: 'text-sm truncate leading-tight min-w-0';
const canShowDownloadLink =
@@ -558,7 +558,7 @@ export const ActivityCard = ({
)}
<div className="flex gap-3 items-start">
{/* Artwork */}
<div className="w-12 h-[4.5rem] rounded flex-shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
<div className="w-12 h-18 rounded-sm shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
{item.preview ? (
<img
src={item.preview}
@@ -587,7 +587,7 @@ export const ActivityCard = ({
</p>
</Tooltip>
</div>
<div className="flex-shrink-0 inline-flex items-center gap-1 -my-1">
<div className="shrink-0 inline-flex items-center gap-1 -my-1">
{actions.map((action) => {
const config = actionUiConfig(action);
const icon =
@@ -746,7 +746,7 @@ export const ActivityCard = ({
type="button"
onClick={handleReviewManualApproval}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
className="px-2.5 py-1.5 rounded-md text-xs border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
{isReviewSubmitting ? 'Working...' : 'Manually Mark as Approved'}
</button>
@@ -756,7 +756,7 @@ export const ActivityCard = ({
type="button"
onClick={handleReviewBrowseAlternatives}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
className="px-2.5 py-1.5 rounded-md text-xs border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
Browse Alternatives
</button>
@@ -776,7 +776,7 @@ export const ActivityCard = ({
rows={3}
maxLength={MAX_ADMIN_NOTE_LENGTH}
placeholder="Optional note shown to the user"
className="w-full px-2.5 py-2 rounded-md border border-[var(--border-muted)] bg-[var(--bg)] text-xs resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
className="w-full px-2.5 py-2 rounded-md border-hairline border-(--border-muted) bg-(--bg) text-xs resize-y min-h-[72px] focus:outline-hidden focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
disabled={isRejectSubmitting}
/>
<div className="flex items-center justify-between">
@@ -786,7 +786,7 @@ export const ActivityCard = ({
type="button"
onClick={onRequestRejectClose}
disabled={isRejectSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
Cancel
</button>
@@ -657,7 +657,7 @@ export const ActivitySidebar = ({
<Dropdown
align="right"
widthClassName="w-auto"
panelClassName="min-w-[11rem]"
panelClassName="min-w-44"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
@@ -735,7 +735,7 @@ export const ActivitySidebar = ({
</div>
{activeTab !== 'history' && (
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
<div className="mt-2 border-b-hairline border-(--border-muted) -mx-4 px-4">
<div className="relative flex gap-1">
{/* Sliding indicator */}
<div
@@ -961,7 +961,7 @@ export const ActivitySidebar = ({
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
<div
className="p-3 border-t flex items-center justify-center"
className="p-3 border-t-hairline flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
@@ -979,7 +979,7 @@ export const ActivitySidebar = ({
{activeTab === 'history' && historyItems.length > 0 && (
<div
className="p-3 border-t flex items-center justify-center"
className="p-3 border-t-hairline flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
@@ -1011,7 +1011,7 @@ export const ActivitySidebar = ({
return (
<aside
className="hidden lg:flex fixed right-0 w-96 flex-col bg-[var(--bg-soft)] z-30 rounded-2xl shadow-lg overflow-hidden"
className="hidden lg:flex fixed right-0 w-96 flex-col bg-(--bg-soft) z-30 rounded-2xl shadow-lg overflow-hidden"
style={{
top: `${pinnedTopOffset}px`,
height: `calc(100dvh - ${pinnedTopOffset}px - 0.75rem)`,
@@ -1028,7 +1028,7 @@ export const ActivitySidebar = ({
return (
<>
<div
className={`fixed inset-0 bg-black/50 z-[45] transition-opacity duration-300 ${
className={`fixed inset-0 bg-black/50 z-45 transition-opacity duration-300 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
@@ -7,7 +7,7 @@ import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="w-full h-full bg-linear-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CardViewProps {
@@ -62,12 +62,12 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border-hairline border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
@@ -122,12 +122,12 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
className="w-8 h-8 rounded-full bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
@@ -177,7 +177,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
<div className="flex gap-1.5 sm:hidden">
<button
className="px-2 py-1.5 rounded border text-xs flex-1 flex items-center justify-center gap-1"
className="px-2 py-1.5 rounded-sm border-hairline text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
@@ -7,7 +7,7 @@ import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="w-full h-full bg-linear-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CompactViewProps {
@@ -51,7 +51,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
return (
<article
className="book-card !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform relative"
className="book-card flex! flex-row! w-full h-[180px]! transition-shadow duration-300 animate-pop-up will-change-transform relative"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -63,12 +63,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
<div className="relative w-[120px] h-full shrink-0">
<div className="absolute inset-0 overflow-hidden rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border-hairline border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
@@ -121,12 +121,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
className="w-8 h-8 rounded-full bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
@@ -177,7 +177,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{showDetailsButton ? (
<div className="flex gap-1.5">
<button
className="px-2 py-1.5 rounded border text-xs flex-shrink-0 flex items-center justify-center gap-1"
className="px-2 py-1.5 rounded-sm border-hairline text-xs shrink-0 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
@@ -25,7 +25,7 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
if (!preview || imageError) {
return (
<div
className="w-7 h-10 sm:w-10 sm:h-14 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
className="w-7 h-10 sm:w-10 sm:h-14 rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
aria-label="No cover available"
>
No Cover
@@ -34,9 +34,9 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
}
return (
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70">
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border-hairline border-white/40 dark:border-gray-700/70">
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="absolute inset-0 bg-linear-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
)}
<img
src={preview}
@@ -129,7 +129,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2 flex items-center gap-2" title={book.title || 'Untitled'}>
{showSeriesPosition && book.series_position != null && (
<span
className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded border border-emerald-700 flex-shrink-0"
className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded-sm border-hairline border-emerald-700 shrink-0"
style={{
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
@@ -247,18 +247,18 @@ export const SelfSettingsModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-3xl 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'}`}
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border-hairline border-(--border-muted) shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="sr-only">My Account</h3>
{editingUser ? (
<UserIdentityHeader
@@ -295,7 +295,7 @@ export const SelfSettingsModal = ({
<button
type="button"
onClick={() => { void loadEditContext(); }}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)] bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
>
Retry
</button>
@@ -350,7 +350,7 @@ export const SelfSettingsModal = ({
)}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--border-muted) px-6 py-4">
<UserEditActions
variant="modalFooter"
onSave={() => {
@@ -12,7 +12,7 @@ export const SettingsHeader = ({
onClose,
}: SettingsHeaderProps) => (
<header
className="flex items-center gap-3 px-5 py-4 border-b border-[var(--border-muted)] flex-shrink-0"
className="flex items-center gap-3 px-5 py-4 border-b-hairline border-(--border-muted) shrink-0"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
>
{showBack && (
@@ -319,7 +319,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl"
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="flex items-center gap-3">
@@ -356,7 +356,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl max-w-md"
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl max-w-md"
style={{ background: 'var(--bg)' }}
>
<div className="text-center space-y-4">
@@ -380,8 +380,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
@@ -441,7 +440,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
{/* Modal */}
<div
className={`relative w-full max-w-4xl h-[85vh] max-h-[750px] rounded-xl
border border-[var(--border-muted)] shadow-2xl
border-hairline border-(--border-muted) shadow-2xl
flex flex-col overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
@@ -213,13 +213,13 @@ export const SettingsSidebar = ({
<button
onClick={() => onSelectTab(item.tab.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.tab.icon)}</span>
<span className="flex-1">{item.tab.displayName}</span>
</button>
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
)}
</div>
);
@@ -232,7 +232,7 @@ export const SettingsSidebar = ({
<button
onClick={() => toggleGroup(item.group.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
@@ -240,18 +240,18 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="bg-[var(--bg-soft)]/50">
<div className="bg-(--bg-soft)/50">
{item.tabs.map((tab, index) => (
<div key={tab.name}>
<button
onClick={() => onSelectTab(tab.name)}
className="w-full flex items-center gap-4 pl-14 pr-5 py-3.5
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="flex-1 text-[15px]">{tab.displayName}</span>
</button>
{index < item.tabs.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
)}
</div>
))}
@@ -259,7 +259,7 @@ export const SettingsSidebar = ({
)}
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
)}
</div>
);
@@ -270,7 +270,7 @@ export const SettingsSidebar = ({
// Desktop: Sidebar navigation
return (
<nav className="w-60 border-r border-[var(--border-muted)] py-2 flex-shrink-0 overflow-y-auto">
<nav className="w-60 border-r-hairline border-(--border-muted) py-2 shrink-0 overflow-y-auto">
{sidebarItems.map((item) => {
if (item.type === 'section') {
return (
@@ -290,8 +290,8 @@ export const SettingsSidebar = ({
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors ${
selectedTab === item.tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span className="opacity-60">{getIcon(item.tab.icon)}</span>
@@ -309,8 +309,8 @@ export const SettingsSidebar = ({
<button
onClick={() => toggleGroup(item.group.name)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors hover:bg-[var(--hover-surface)]
${hasSelectedTab && !isExpanded ? 'bg-[var(--hover-action)]/50' : ''}`}
transition-colors hover:bg-(--hover-surface)
${hasSelectedTab && !isExpanded ? 'bg-(--hover-action)/50' : ''}`}
>
<span className="opacity-60">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
@@ -318,16 +318,16 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="ml-8 border-l border-[var(--border-muted)]">
<div className="ml-[22px] border-l-hairline border-(--border-muted) flex flex-col gap-0.5 pl-3">
{item.tabs.map((tab) => (
<button
key={tab.name}
onClick={() => onSelectTab(tab.name)}
className={`w-full flex items-center pl-4 pr-4 py-2 text-sm text-left
className={`w-full flex items-center pl-3 pr-4 py-2 text-sm text-left
transition-colors ${
selectedTab === tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span>{tab.displayName}</span>
@@ -2,9 +2,9 @@ import { CustomSettingsFieldRendererProps } from './types';
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
return (
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<div className="rounded-lg overflow-hidden border-hairline border-(--border-muted)">
<div
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b-hairline border-(--border-muted)"
style={{ background: 'var(--bg-soft)' }}
>
docker-compose.yml
@@ -31,7 +31,7 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
const styleClasses = {
default:
'bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)]',
'bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface)',
primary: 'bg-sky-600 text-white hover:bg-sky-700',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
@@ -5,7 +5,7 @@ interface HeadingFieldProps {
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-[var(--border-muted)]">
<div className="pb-1 not-first:pt-5 not-first:mt-1 not-first:border-t-hairline not-first:border-(--border-muted)">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">
@@ -163,7 +163,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
if (isDisabled) {
return (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{summaryFormatter()}
</div>
);
@@ -277,12 +277,12 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
onClick={() => toggleOption(opt.value)}
disabled={isDisabled}
className={`px-3 py-1.5 rounded-full text-sm font-medium
transition-colors border
transition-colors border-hairline
disabled:opacity-60 disabled:cursor-not-allowed
${
isSelected
? 'bg-sky-600 text-white border-sky-600'
: 'bg-transparent border-[var(--border-muted)] hover:bg-[var(--hover-surface)]'
: 'bg-transparent border-(--border-muted) hover:bg-(--hover-surface)'
}`}
>
{opt.label}
@@ -20,9 +20,8 @@ export const NumberField = ({ field, value, onChange, disabled }: NumberFieldPro
max={field.max}
step={field.step ?? 1}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -233,13 +233,12 @@ export const OrderableListField = ({
flex items-center gap-3 p-3 rounded-lg
transition-all duration-150
${isDragging ? 'opacity-50 cursor-grabbing' : isPinned ? 'cursor-default' : 'cursor-grab'}
border border-[var(--border-muted)]
${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-[var(--hover-surface)]' : ''}
border-hairline border-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
`}
>
{/* Reorder Controls - hidden for pinned items */}
{!isPinned ? (
<div className="flex flex-col flex-shrink-0 -my-1">
<div className="flex flex-col shrink-0 -my-1">
<button
type="button"
onClick={(e) => {
@@ -282,14 +281,14 @@ export const OrderableListField = ({
</button>
</div>
) : (
<div className="w-5 sm:w-4 flex-shrink-0" />
<div className="w-5 sm:w-4 shrink-0" />
)}
{/* Label and Description */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--text-muted)] mt-0.5">
<div className="text-xs text-(--text-muted) mt-0.5">
{item.description}
</div>
)}
@@ -308,7 +307,7 @@ export const OrderableListField = ({
</div>
{/* Toggle Switch */}
<div onClick={(e) => e.stopPropagation()} className="flex-shrink-0">
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<ToggleSwitch
checked={item.enabled && !item.isLocked}
onChange={() => toggleItem(index)}
@@ -21,9 +21,8 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
className="w-full px-3 py-2 pr-10 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 pr-10 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -32,7 +31,7 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
onClick={() => setShowPassword(!showPassword)}
disabled={isDisabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded
hover:bg-[var(--hover-action)] transition-colors
hover:bg-(--hover-action) transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
@@ -69,7 +69,7 @@ export const SelectField = ({ field, value, onChange, disabled, filterValue }: S
// When disabled, show a static display instead of the dropdown
const selectedOption = filteredOptions.find((opt) => opt.value === effectiveValue);
return (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{selectedOption?.label || 'Select...'}
</div>
);
@@ -88,7 +88,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
// Use fixed width for delete button column to ensure header/data alignment.
const gridTemplate = 'sm:[grid-template-columns:var(--table-cols)]';
const gridTemplate = 'sm:grid-cols-(--table-cols)';
const tableCols = useMemo(() => {
if (columns.length === 0) {
@@ -175,8 +175,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover-action transition-colors
bg-(--bg-soft) border-hairline border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
@@ -237,7 +236,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
<div key={col.key} className="flex flex-col gap-1 min-w-0">
{mobileLabel}
{isDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{options.find((o) => o.value === String(cellValue ?? ''))?.label || 'Select...'}
</div>
) : (
@@ -301,9 +300,8 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
placeholder={col.placeholder}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -333,7 +331,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
</button>
</div>
<div className="col-span-full border-t border-[var(--border-muted)] opacity-60" />
<div className="col-span-full border-t-hairline border-(--border-muted) opacity-60" />
</div>
))}
</div>
@@ -343,8 +341,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover-action transition-colors
bg-(--bg-soft) border-hairline border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
@@ -82,9 +82,8 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
return (
<div
className={`w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus-within:outline-none focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:border-sky-500
className={`w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
focus-within:outline-hidden focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:border-sky-500
transition-colors
${isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-text'}`}
onClick={() => {
@@ -92,16 +91,16 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
inputRef.current?.focus();
}}
>
<div className="flex flex-wrap gap-1 items-center min-h-[1.25rem]">
<div className="flex flex-wrap gap-1 items-center min-h-5">
{tags.map((tag, idx) => (
<span
key={`${tag}-${idx}`}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md
border border-[var(--border-muted)] bg-[var(--bg)]
border-hairline border-(--border-muted) bg-(--bg)
max-w-full"
title={tag}
>
<span className="truncate max-w-[22rem]">{tag}</span>
<span className="truncate max-w-88">{tag}</span>
{!isDisabled && !isRequired(tag) && (
<button
type="button"
@@ -109,7 +108,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
e.stopPropagation();
removeAt(idx);
}}
className="p-0.5 rounded hover:bg-[var(--hover-surface)]"
className="p-0.5 rounded-sm hover:bg-(--hover-surface)"
aria-label={`Remove ${tag}`}
>
<svg
@@ -146,7 +145,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
}}
onBlur={() => commitDraft()}
placeholder={tags.length === 0 ? field.placeholder : ''}
className="flex-1 min-w-[4rem] bg-transparent outline-none px-1 py-0"
className="flex-1 min-w-16 bg-transparent outline-hidden px-1 py-0"
/>
)}
@@ -19,9 +19,8 @@ export const TextField = ({ field, value, onChange, disabled }: TextFieldProps)
placeholder={field.placeholder}
maxLength={field.maxLength}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -78,7 +78,7 @@ interface FieldWrapperProps {
const DisabledBadge = ({ reason }: { reason?: string }) => (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-zinc-500/20 text-zinc-400 border border-zinc-500/30"
bg-zinc-500/20 text-zinc-400 border-hairline border-zinc-500/30"
title={reason || 'This setting is not available'}
>
<svg
@@ -103,7 +103,7 @@ const DisabledBadge = ({ reason }: { reason?: string }) => (
const RestartRequiredBadge = () => (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-amber-500/20 text-amber-600 dark:text-amber-400 border border-amber-500/30"
bg-amber-500/20 text-amber-600 dark:text-amber-400 border-hairline border-amber-500/30"
title="Changing this setting requires a container restart to take effect"
>
<svg
@@ -162,7 +162,7 @@ const UserOverriddenBadge = ({
<Tooltip content={content} position="top">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-sky-500/15 text-sky-500 dark:text-sky-400 border border-sky-500/30"
bg-sky-500/15 text-sky-500 dark:text-sky-400 border-hairline border-sky-500/30"
>
User overridden{count > 1 ? ` (${count})` : ''}
</span>
@@ -5,7 +5,7 @@ interface SettingsSaveBarProps {
export const SettingsSaveBar = ({ onSave, isSaving }: SettingsSaveBarProps) => (
<div
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
className="shrink-0 px-6 py-4 border-t-hairline border-(--border-muted) bg-(--bg) animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
@@ -142,23 +142,23 @@ export const RequestPolicyGrid = ({
type="button"
onClick={onClearOverrides}
disabled={clearOverridesDisabled}
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-[var(--border-muted)] bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 rounded-lg text-xs font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Clear all overrides
</button>
</div>
)}
<div className="rounded-lg border border-[var(--border-muted)]">
<div className="rounded-lg border-hairline border-(--border-muted)">
{/* Header */}
<div className="hidden sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2 bg-[var(--bg-soft)] text-xs font-medium opacity-60 border-b border-[var(--border-muted)] rounded-t-lg">
<div className="hidden sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2 bg-(--bg-soft) text-xs font-medium opacity-60 border-b-hairline border-(--border-muted) rounded-t-lg">
<span>Source</span>
<span>Ebook</span>
<span>Audiobook</span>
</div>
{/* Default row */}
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center bg-[var(--bg-soft)] border-b-2 border-[var(--border-muted)]">
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center bg-(--bg-soft) border-b-2 border-(--border-muted)">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">Default</p>
</div>
@@ -178,7 +178,7 @@ export const RequestPolicyGrid = ({
<div key={contentType} className="flex items-center gap-1.5">
{mobileLabel}
{isDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-sm opacity-60 cursor-not-allowed">
{REQUEST_POLICY_MODE_LABELS[mode]}
</div>
) : (
@@ -221,7 +221,7 @@ export const RequestPolicyGrid = ({
<div
key={sourceRow.source}
className={`grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center ${
index > 0 ? 'border-t border-[var(--border-muted)]' : ''
index > 0 ? 'border-t-hairline border-(--border-muted)' : ''
}`}
>
<div className="min-w-0">
@@ -295,7 +295,7 @@ export const RequestPolicyGrid = ({
}`}
>
{rulesDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
</div>
) : (
@@ -9,7 +9,7 @@ import { FieldWrapper } from '../shared';
import { CreateUserFormState } from './types';
const UserCardShell = ({ title, children }: { title: string; children: ReactNode }) => (
<div className="space-y-5 p-4 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)]">
<div className="space-y-5 p-4 rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg)">
<h3 className="text-sm font-medium">{title}</h3>
{children}
</div>
@@ -147,10 +147,10 @@ export const UserRoleControl = ({
onUserChange({ ...user, role: nextRole });
}}
widthClassName="w-28"
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
buttonClassName={`py-1! px-2.5! text-xs! font-medium! ${
user.role === 'admin'
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
: '!bg-zinc-500/10 !opacity-70'
? 'bg-sky-500/15! text-sky-600! dark:text-sky-400! border-sky-500/30!'
: 'bg-zinc-500/10! opacity-70!'
}`}
/>
);
@@ -251,7 +251,7 @@ export const UserEditActions = ({
type="button"
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border-hairline border-(--border-muted) shadow-sm hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -278,7 +278,7 @@ export const UserEditActions = ({
}
return (
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
<div className="flex flex-col gap-2 pt-3 border-t-hairline border-(--border-muted) sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<button
onClick={onSave}
@@ -290,8 +290,7 @@ export const UserEditActions = ({
<button
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -310,8 +309,8 @@ export const UserEditActions = ({
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted)
bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -320,7 +319,7 @@ export const UserEditActions = ({
<button
onClick={onDelete}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
border border-red-500/40 text-red-600 hover:bg-red-500/10"
border-hairline border-red-500/40 text-red-600 hover:bg-red-500/10"
>
Delete User
</button>
@@ -401,8 +400,7 @@ export const UserCreateCard = ({
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
>
Cancel
</button>
@@ -574,7 +572,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'before' && (
<>
{preferencesContent}
<div className="border-t border-[var(--border-muted)]" />
<div className="border-t-hairline border-(--border-muted)" />
</>
)}
@@ -598,7 +596,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'after' && (
<>
<div className="border-t border-[var(--border-muted)]" />
<div className="border-t-hairline border-(--border-muted)" />
{preferencesContent}
</>
)}
@@ -96,8 +96,7 @@ export const UserListView = ({
<p className="text-sm opacity-60">{loadError}</p>
<button
onClick={onRetryLoadUsers}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
>
Retry
</button>
@@ -118,7 +117,7 @@ export const UserListView = ({
return (
<div
key={user.id}
className={`rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] transition-colors ${active ? '' : 'opacity-60'}`}
className={`rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg-soft) transition-colors ${active ? '' : 'opacity-60'}`}
>
<div
role="button"
@@ -144,7 +143,7 @@ export const UserListView = ({
}
}
}}
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 cursor-pointer hover-surface rounded-t-lg ${isEditingRow ? 'border-b border-[var(--border-muted)]' : 'rounded-b-lg'}`}
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 cursor-pointer hover-surface rounded-t-lg ${isEditingRow ? 'border-b-hairline border-(--border-muted)' : 'rounded-b-lg'}`}
aria-expanded={isEditingRow}
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
>
@@ -185,7 +184,7 @@ export const UserListView = ({
</div>
{isEditingRow && (
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
<div className="p-4 space-y-5 bg-(--bg) rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<UserAccountCardContent
user={editingUser}
@@ -199,7 +199,7 @@ export const UserOverridesSections = ({
<div className="space-y-5">
{sectionNodes.map(({ id, node }, index) => (
<Fragment key={id}>
{index > 0 && <div className="border-t border-[var(--border-muted)]" />}
{index > 0 && <div className="border-t-hairline border-(--border-muted)" />}
{node}
</Fragment>
))}
@@ -38,8 +38,7 @@ export const UserOverridesView = ({
<div>
<button
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
>
<svg
className="w-4 h-4"
@@ -11,9 +11,9 @@ interface SearchFieldRendererProps {
}
const baseInputClass =
'w-full px-3 py-2 rounded-md border border-[var(--border-muted)] ' +
'bg-[var(--bg-soft)] text-sm ' +
'focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 ' +
'w-full px-3 py-2 rounded-md border-hairline border-(--border-muted) ' +
'bg-(--bg-soft) text-sm ' +
'focus:outline-hidden focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 ' +
'transition-colors';
export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: SearchFieldRendererProps) => {
@@ -93,7 +93,7 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50"
className="w-4 h-4 rounded-sm border-(--border-muted) text-emerald-500 focus:ring-emerald-500/50"
/>
<span className="text-sm">{field.label}</span>
</label>
@@ -26,13 +26,13 @@ export const ToggleSwitch = ({
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full
transition-colors duration-200 focus:outline-none focus:ring-2
transition-colors duration-200 focus:outline-hidden focus:ring-2
${ring} disabled:opacity-60 disabled:cursor-not-allowed
${checked ? active : 'bg-gray-300 dark:bg-gray-600'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white
shadow-sm transition-transform duration-200
shadow-xs transition-transform duration-200
${checked ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
@@ -194,7 +194,7 @@ export function Tooltip({
role="tooltip"
onMouseEnter={interactive ? handleTooltipMouseEnter : undefined}
onMouseLeave={interactive ? handleTooltipMouseLeave : undefined}
className={`fixed z-[9999] ${interactive ? 'select-text cursor-auto' : 'pointer-events-none'} ${tooltipSizeClass} ${transformClass} ${className}`}
className={`fixed z-9999 ${interactive ? 'select-text cursor-auto' : 'pointer-events-none'} ${tooltipSizeClass} ${transformClass} ${className}`}
style={{
top: coords.top,
left: coords.left,
@@ -0,0 +1,6 @@
export const getActivityErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
};
+4 -3
View File
@@ -17,6 +17,7 @@ import {
requestToActivityItem,
} from '../components/activity';
import { dedupeHistoryItems } from '../components/activity/activityHistory.js';
import { getActivityErrorMessage } from './useActivity.helpers.js';
const HISTORY_PAGE_SIZE = 50;
@@ -320,7 +321,7 @@ export const useActivity = ({
console.error('Activity dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast(errorMessage, 'error');
showToast(getActivityErrorMessage(error, errorMessage), 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
@@ -340,7 +341,7 @@ export const useActivity = ({
console.error('Request dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast('Failed to clear request', 'error');
showToast(getActivityErrorMessage(error, 'Failed to clear request'), 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
@@ -385,7 +386,7 @@ export const useActivity = ({
.catch((error) => {
console.error('Clear history failed:', error);
void refreshActivityHistory();
showToast('Failed to clear history', 'error');
showToast(getActivityErrorMessage(error, 'Failed to clear history'), 'error');
});
}, [refreshActivityHistory, refreshActivitySnapshot, resetActivityHistory, showToast]);
+6 -3
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { login, logout, checkAuth } from '../services/api';
import { useSocket } from '../contexts/SocketContext';
import { getReturnToFromSearch } from '../utils/authRedirect';
interface UseAuthOptions {
onLogoutSuccess?: () => void;
@@ -30,8 +31,10 @@ interface UseAuthReturn {
export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const { onLogoutSuccess, showToast } = options;
const location = useLocation();
const navigate = useNavigate();
const { socket } = useSocket();
const postLoginPath = getReturnToFromSearch(location.search);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
@@ -132,7 +135,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
applyAuthResponse(await checkAuth());
refreshSocketSession();
setLoginError(null);
navigate('/', { replace: true });
navigate(postLoginPath, { replace: true });
} else {
setLoginError(response.error || 'Login failed');
}
@@ -145,7 +148,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
} finally {
setIsLoggingIn(false);
}
}, [navigate, applyAuthResponse, refreshSocketSession]);
}, [navigate, applyAuthResponse, postLoginPath, refreshSocketSession]);
const handleLogout = useCallback(async () => {
try {
+55 -80
View File
@@ -1,9 +1,9 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
import {
SettingsResponse,
SettingsTab,
SettingsGroup,
SettingsField,
ActionResult,
UpdateResult,
} from '../types/settings';
@@ -14,67 +14,19 @@ import {
} from '../utils/themePreference';
import {
cloneSettingsValues,
extractSettingsValues,
getRestartRequiredFieldKeys,
getValueBearingFields,
mergeFetchedSettingsWithDirtyValues,
settingsTabMatchesSavedValues,
type SettingsValues,
} from '../utils/settingsValues';
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
interface FetchSettingsOptions {
silent?: boolean;
preserveDirtyValues?: boolean;
}
// Extract value from a field based on its type
function getFieldValue(field: SettingsField): unknown {
// These field types have no value property
if (
field.type === 'ActionButton'
|| field.type === 'HeadingField'
|| field.type === 'CustomComponentField'
) {
return undefined;
}
if (field.type === 'TableField') {
return (field as unknown as { value?: unknown }).value ?? [];
}
// All other fields have a value property
return field.value ?? '';
}
function getValueBearingFields(fields: SettingsField[]): ValueBearingField[] {
const seen = new Set<string>();
const valueFields: ValueBearingField[] = [];
const collect = (items: SettingsField[]) => {
items.forEach((field) => {
if (field.type === 'CustomComponentField') {
if (field.boundFields && field.boundFields.length > 0) {
collect(field.boundFields);
}
return;
}
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return;
}
if (seen.has(field.key)) {
return;
}
seen.add(field.key);
valueFields.push(field);
});
};
collect(fields);
return valueFields;
}
interface UseSettingsReturn {
tabs: SettingsTab[];
@@ -112,16 +64,10 @@ export function useSettings(): UseSettingsReturn {
originalValuesRef.current = originalValues;
}, [originalValues]);
const fetchSettings = useCallback(async (options: FetchSettingsOptions = {}) => {
const { silent = false, preserveDirtyValues = false } = options;
if (!silent) {
setIsLoading(true);
}
setError(null);
try {
const response = await getSettings();
const applySettingsResponse = useCallback(
(response: SettingsResponse, options: { preserveDirtyValues?: boolean } = {}) => {
const { preserveDirtyValues = false } = options;
// Inject theme field into the general tab at the beginning
const tabsWithTheme = response.tabs.map((tab) => {
if (tab.name === 'general') {
return {
@@ -131,22 +77,14 @@ export function useSettings(): UseSettingsReturn {
}
return tab;
});
setTabs(tabsWithTheme);
setGroups(response.groups || []);
// Initialize values from fetched data
const initialValues: SettingsValues = {};
tabsWithTheme.forEach((tab) => {
initialValues[tab.name] = {};
getValueBearingFields(tab.fields).forEach((field) => {
// Special handling for theme field - get from localStorage
if (field.key === '_THEME') {
initialValues[tab.name][field.key] = getStoredThemePreference();
} else {
initialValues[tab.name][field.key] = getFieldValue(field);
}
});
});
const initialValues = extractSettingsValues(tabsWithTheme);
if (initialValues.general && Object.prototype.hasOwnProperty.call(initialValues.general, '_THEME')) {
initialValues.general._THEME = getStoredThemePreference();
}
const nextValues = preserveDirtyValues
? mergeFetchedSettingsWithDirtyValues(initialValues, valuesRef.current, originalValuesRef.current)
@@ -155,19 +93,33 @@ export function useSettings(): UseSettingsReturn {
setValues(nextValues);
setOriginalValues(cloneSettingsValues(initialValues));
// Select first tab by default if none selected
if (tabsWithTheme.length > 0) {
setSelectedTab((current) => current ?? tabsWithTheme[0].name);
}
},
[]
);
const fetchSettings = useCallback(async (options: FetchSettingsOptions = {}) => {
const { silent = false, preserveDirtyValues = false } = options;
if (!silent) {
setIsLoading(true);
setError(null);
}
try {
const response = await getSettings();
applySettingsResponse(response, { preserveDirtyValues });
} catch (err) {
console.error('Failed to fetch settings:', err);
setError(err instanceof Error ? err.message : 'Failed to load settings');
if (!silent) {
setError(err instanceof Error ? err.message : 'Failed to load settings');
}
} finally {
if (!silent) {
setIsLoading(false);
}
}
}, []);
}, [applySettingsResponse]);
useEffect(() => {
fetchSettings();
@@ -223,13 +175,15 @@ export function useSettings(): UseSettingsReturn {
const saveTab = useCallback(
async (tabName: string): Promise<UpdateResult> => {
setIsSaving(true);
let valuesToSave: Record<string, unknown> = {};
let restartRequiredFor: string[] = [];
try {
const tabValues = values[tabName] || {};
const originalTabValues = originalValues[tabName] || {};
// Only send values that actually changed
const tab = tabs.find((t) => t.name === tabName);
const valuesToSave: Record<string, unknown> = {};
valuesToSave = {};
if (tab) {
for (const field of getValueBearingFields(tab.fields)) {
@@ -249,6 +203,8 @@ export function useSettings(): UseSettingsReturn {
valuesToSave[field.key] = value;
}
}
restartRequiredFor = getRestartRequiredFieldKeys(tab.fields, valuesToSave);
}
const result = await updateSettings(tabName, valuesToSave);
@@ -261,6 +217,25 @@ export function useSettings(): UseSettingsReturn {
return result;
} catch (err) {
if (Object.keys(valuesToSave).length > 0) {
try {
const response = await getSettings();
if (settingsTabMatchesSavedValues(tabName, response.tabs, valuesToSave)) {
setError(null);
applySettingsResponse(response);
return {
success: true,
message: 'Settings saved, but the proxy interrupted the response. Latest values were confirmed.',
updated: Object.keys(valuesToSave),
requiresRestart: restartRequiredFor.length > 0,
restartRequiredFor,
};
}
} catch (recoveryError) {
console.warn('Failed to verify settings save after response error:', recoveryError);
}
}
console.error('Failed to save settings tab:', tabName, err);
return {
success: false,
@@ -271,7 +246,7 @@ export function useSettings(): UseSettingsReturn {
setIsSaving(false);
}
},
[values, originalValues, tabs]
[applySettingsResponse, originalValues, tabs, values]
);
const executeAction = useCallback(
+1 -1
View File
@@ -22,7 +22,7 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel
>
<div className="w-full max-w-md">
<div
className="rounded-lg shadow-2xl p-6 border"
className="rounded-lg shadow-2xl p-6 border-hairline"
style={{
backgroundColor: 'var(--card-background)',
borderColor: 'var(--border-color)',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { StatusData } from '../types/index.js';
import { wasDownloadQueuedAfterResponseError } from '../utils/downloadRecovery.js';
describe('wasDownloadQueuedAfterResponseError', () => {
it('confirms a queued download immediately from active buckets', () => {
const status: StatusData = {
queued: {
'book-1': {
id: 'book-1',
title: 'Queued Book',
author: 'Author',
},
},
};
assert.equal(wasDownloadQueuedAfterResponseError(status, 'book-1', 1_000), true);
});
it('confirms a recent terminal item for the same request window', () => {
const status: StatusData = {
complete: {
'book-2': {
id: 'book-2',
title: 'Completed Book',
author: 'Author',
added_time: 1_005,
},
},
};
assert.equal(wasDownloadQueuedAfterResponseError(status, 'book-2', 1_006), true);
});
it('ignores stale terminal entries from older attempts', () => {
const status: StatusData = {
complete: {
'book-3': {
id: 'book-3',
title: 'Old Completed Book',
author: 'Author',
added_time: 900,
},
},
};
assert.equal(wasDownloadQueuedAfterResponseError(status, 'book-3', 1_000), false);
});
});
@@ -37,6 +37,7 @@ const tableFieldFixture: TableFieldConfig = {
{ value: 'ebook', label: 'Ebook', childOf: 'prowlarr' },
{ value: 'audiobook', label: 'Audiobook', childOf: 'prowlarr' },
{ value: 'ebook', label: 'Ebook', childOf: 'irc' },
{ value: 'audiobook', label: 'Audiobook', childOf: 'irc' },
],
},
{
@@ -70,7 +71,7 @@ describe('requestPolicyGridUtils', () => {
{
source: 'irc',
displayName: 'IRC',
supportedContentTypes: ['ebook'],
supportedContentTypes: ['ebook', 'audiobook'],
},
]);
});
@@ -97,7 +98,7 @@ describe('requestPolicyGridUtils', () => {
{ source: 'direct_download', content_type: 'ebook', mode: 'request_release' }, // same as inherited default -> kept (explicit intent)
{ source: 'prowlarr', content_type: 'ebook', mode: 'blocked' }, // same as inherited global rule -> kept (explicit intent)
{ source: 'prowlarr', content_type: 'audiobook', mode: 'request_release' }, // meaningful override -> kept
{ source: 'irc', content_type: 'audiobook', mode: 'blocked' }, // unsupported pair -> removed
{ source: 'direct_download', content_type: 'audiobook', mode: 'blocked' }, // unsupported pair -> removed
]);
const persisted = normalizeExplicitRulesForPersistence({
@@ -0,0 +1,18 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { getActivityErrorMessage } from '../hooks/useActivity.helpers.js';
describe('useActivity helpers', () => {
it('returns the backend error message when present', () => {
const error = new Error('User identity unavailable for activity workflow');
assert.equal(
getActivityErrorMessage(error, 'Failed to clear item'),
'User identity unavailable for activity workflow'
);
});
it('falls back to the provided message for non-error values', () => {
assert.equal(getActivityErrorMessage(null, 'Failed to clear item'), 'Failed to clear item');
});
});
@@ -1,6 +1,11 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { mergeFetchedSettingsWithDirtyValues } from '../utils/settingsValues.js';
import {
getRestartRequiredFieldKeys,
mergeFetchedSettingsWithDirtyValues,
settingsTabMatchesSavedValues,
} from '../utils/settingsValues.js';
import { SettingsTab } from '../types/settings.js';
describe('mergeFetchedSettingsWithDirtyValues', () => {
it('preserves unsaved dirty values while applying fresh fetched values', () => {
@@ -71,3 +76,63 @@ describe('mergeFetchedSettingsWithDirtyValues', () => {
);
});
});
describe('settings save verification helpers', () => {
const tabs: SettingsTab[] = [
{
name: 'general',
displayName: 'General',
order: 1,
fields: [
{
key: 'API_URL',
label: 'API URL',
type: 'TextField',
value: 'https://saved.example',
},
{
key: 'API_KEY',
label: 'API Key',
type: 'PasswordField',
value: '',
},
{
key: 'USE_SSL',
label: 'Use SSL',
type: 'CheckboxField',
value: true,
requiresRestart: true,
},
],
},
];
it('confirms a saved tab when backend values match the expected non-password changes', () => {
assert.equal(
settingsTabMatchesSavedValues('general', tabs, {
API_URL: 'https://saved.example',
API_KEY: 'secret',
}),
true
);
});
it('does not confirm a saved tab when a non-password field does not match', () => {
assert.equal(
settingsTabMatchesSavedValues('general', tabs, {
API_URL: 'https://different.example',
}),
false
);
});
it('collects restart-required keys for changed values', () => {
assert.deepEqual(
getRestartRequiredFieldKeys(tabs[0].fields, {
API_URL: 'https://saved.example',
USE_SSL: true,
}),
['USE_SSL']
);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { withBasePath } from './basePath';
type LocationLike = {
hash?: string;
pathname: string;
search?: string;
};
const PLACEHOLDER_ORIGIN = 'http://shelfmark.local';
const DEFAULT_RETURN_TO = '/';
const normalizeSearch = (search: string | undefined): string => {
if (!search) {
return '';
}
return search.startsWith('?') ? search : `?${search}`;
};
const normalizeHash = (hash: string | undefined): string => {
if (!hash) {
return '';
}
return hash.startsWith('#') ? hash : `#${hash}`;
};
export const sanitizeReturnTo = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
return null;
}
try {
const parsed = new URL(trimmed, PLACEHOLDER_ORIGIN);
if (parsed.origin !== PLACEHOLDER_ORIGIN) {
return null;
}
if (
parsed.pathname === '/login' ||
parsed.pathname.startsWith('/login/') ||
parsed.pathname === '/api' ||
parsed.pathname.startsWith('/api/')
) {
return null;
}
return `${parsed.pathname}${parsed.search}${parsed.hash}` || DEFAULT_RETURN_TO;
} catch {
return null;
}
};
export const buildCurrentReturnTo = ({ pathname, search, hash }: LocationLike): string => {
return sanitizeReturnTo(`${pathname}${normalizeSearch(search)}${normalizeHash(hash)}`) || DEFAULT_RETURN_TO;
};
export const getReturnToFromSearch = (search: string): string => {
const returnTo = new URLSearchParams(search).get('return_to');
return sanitizeReturnTo(returnTo) || DEFAULT_RETURN_TO;
};
export const buildLoginRedirectPath = (location: LocationLike): string => {
const returnTo = buildCurrentReturnTo(location);
if (returnTo === DEFAULT_RETURN_TO) {
return '/login';
}
const params = new URLSearchParams({ return_to: returnTo });
return `/login?${params.toString()}`;
};
export const buildOidcLoginUrl = (search: string): string => {
const returnTo = getReturnToFromSearch(search);
if (returnTo === DEFAULT_RETURN_TO) {
return withBasePath('/api/auth/oidc/login');
}
const params = new URLSearchParams({ return_to: returnTo });
return `${withBasePath('/api/auth/oidc/login')}?${params.toString()}`;
};
@@ -0,0 +1,33 @@
import { StatusData } from '../types';
const ACTIVE_STATUS_BUCKETS = ['queued', 'resolving', 'locating', 'downloading'] as const;
const TERMINAL_STATUS_BUCKETS = ['complete', 'error', 'cancelled'] as const;
const TERMINAL_CONFIRMATION_WINDOW_SECONDS = 2;
export function wasDownloadQueuedAfterResponseError(
status: StatusData,
taskId: string,
requestedAtSeconds: number
): boolean {
for (const bucket of ACTIVE_STATUS_BUCKETS) {
if (status[bucket]?.[taskId]) {
return true;
}
}
for (const bucket of TERMINAL_STATUS_BUCKETS) {
const task = status[bucket]?.[taskId];
if (!task) {
continue;
}
if (
typeof task.added_time === 'number'
&& task.added_time >= requestedAtSeconds - TERMINAL_CONFIRMATION_WINDOW_SECONDS
) {
return true;
}
}
return false;
}
+106
View File
@@ -1,5 +1,111 @@
import { SettingsField, SettingsTab } from '../types/settings';
export type SettingsValues = Record<string, Record<string, unknown>>;
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
export function getFieldValue(field: SettingsField): unknown {
if (
field.type === 'ActionButton'
|| field.type === 'HeadingField'
|| field.type === 'CustomComponentField'
) {
return undefined;
}
if (field.type === 'TableField') {
return (field as unknown as { value?: unknown }).value ?? [];
}
return field.value ?? '';
}
export function getValueBearingFields(fields: SettingsField[]): ValueBearingField[] {
const seen = new Set<string>();
const valueFields: ValueBearingField[] = [];
const collect = (items: SettingsField[]) => {
items.forEach((field) => {
if (field.type === 'CustomComponentField') {
if (field.boundFields && field.boundFields.length > 0) {
collect(field.boundFields);
}
return;
}
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return;
}
if (seen.has(field.key)) {
return;
}
seen.add(field.key);
valueFields.push(field);
});
};
collect(fields);
return valueFields;
}
export function extractSettingsValues(tabs: SettingsTab[]): SettingsValues {
const values: SettingsValues = {};
tabs.forEach((tab) => {
values[tab.name] = {};
getValueBearingFields(tab.fields).forEach((field) => {
values[tab.name][field.key] = getFieldValue(field);
});
});
return values;
}
export function getRestartRequiredFieldKeys(
fields: SettingsField[],
changedValues: Record<string, unknown>
): string[] {
return getValueBearingFields(fields)
.filter((field) => field.requiresRestart && Object.prototype.hasOwnProperty.call(changedValues, field.key))
.map((field) => field.key);
}
export function settingsTabMatchesSavedValues(
tabName: string,
tabs: SettingsTab[],
expectedValues: Record<string, unknown>
): boolean {
const tab = tabs.find((entry) => entry.name === tabName);
if (!tab) {
return false;
}
let verifiedFieldCount = 0;
for (const field of getValueBearingFields(tab.fields)) {
if (!Object.prototype.hasOwnProperty.call(expectedValues, field.key)) {
continue;
}
// Passwords are intentionally not returned by the backend, so they
// cannot be verified from a follow-up fetch.
if (field.type === 'PasswordField') {
continue;
}
verifiedFieldCount += 1;
if (JSON.stringify(getFieldValue(field)) !== JSON.stringify(expectedValues[field.key])) {
return false;
}
}
return verifiedFieldCount > 0;
}
export function cloneSettingsValues(values: SettingsValues): SettingsValues {
return JSON.parse(JSON.stringify(values)) as SettingsValues;
}
@@ -29,6 +29,7 @@ export function applyThemePreference(theme: string): void {
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
document.documentElement.style.colorScheme = effectiveTheme;
}
export function setThemePreference(theme: string): void {
-12
View File
@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {},
},
plugins: [],
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig(({ command }) => ({
base: command === 'build' ? './' : '/',
plugins: [react()],
plugins: [tailwindcss(), react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),