mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 11:51:12 +01:00
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:
+1
-19
@@ -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>
|
||||
|
||||
Generated
+566
-925
File diff suppressed because it is too large
Load Diff
@@ -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,6 +1,3 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {},
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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}'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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)',
|
||||
|
||||
+647
-587
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']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user