Frontend tooling + Linter pass + React rule enforcement (#886)

- Added `oxlint`, `oxfmt`, `vitest`, `knip`
- Configured oxlint rules including react best practices (e.g. for
effect usage)
- Full linting pass on frontend code
- Full react rules pass on effect usage
- Full reformatting using oxfmt
- Full dead code cleanup using knip
This commit is contained in:
Alex
2026-04-13 10:56:09 +01:00
committed by GitHub
parent 87d5f127d6
commit a7694eb8ad
201 changed files with 16226 additions and 10520 deletions
+9 -1
View File
@@ -112,7 +112,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
node-version: 24
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
@@ -120,6 +120,14 @@ jobs:
working-directory: src/frontend
run: npm ci
- name: Lint
working-directory: src/frontend
run: npm run lint
- name: Check formatting
working-directory: src/frontend
run: npm run format:check
- name: Typecheck
working-directory: src/frontend
run: npm run typecheck
+1
View File
@@ -74,6 +74,7 @@ pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
src/frontend/coverage/
htmlcov/
.tox/
.nox/
+9
View File
@@ -11,3 +11,12 @@ repos:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: oxfmt
name: oxfmt
entry: npx --prefix src/frontend oxfmt --config src/frontend/.oxfmtrc.json
language: system
types_or: [javascript, jsx, ts, tsx, css, json]
files: ^src/frontend/
+24 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks python-test-lint python-test-lint-fix python-test-format python-test-format-check python-test-typecheck python-test-checks python-coverage prek-install
.PHONY: help install install-python-dev dev build preview typecheck frontend-lint frontend-format frontend-format-check frontend-checks frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks python-test-lint python-test-lint-fix python-test-format python-test-format-check python-test-typecheck python-test-checks python-coverage prek-install
# Frontend directory
FRONTEND_DIR := src/frontend
@@ -17,6 +17,10 @@ help:
@echo " build-serve - Build and serve via Flask (test prod build without Docker)"
@echo " preview - Preview production build"
@echo " typecheck - Run TypeScript type checking"
@echo " frontend-lint - Run Oxlint against frontend code"
@echo " frontend-format - Format frontend code with Oxfmt"
@echo " frontend-format-check - Check frontend formatting with Oxfmt"
@echo " frontend-checks - Run all frontend static analysis checks"
@echo " frontend-test - Run frontend unit tests"
@echo " install-python-dev - Sync Python runtime + dev tooling with uv"
@echo " python-lint - Run Ruff against Python backend code"
@@ -52,6 +56,8 @@ install:
install-python-dev:
@echo "Syncing Python runtime and dev tooling with uv..."
uv sync --locked --extra browser
@echo "Installing prek git hooks..."
uv run prek install
# Start development server
dev:
@@ -137,6 +143,23 @@ prek-install:
@echo "Installing prek git hooks..."
uv run prek install
# Frontend linting
frontend-lint:
@echo "Running Oxlint..."
cd $(FRONTEND_DIR) && npm run lint
# Frontend formatting
frontend-format:
@echo "Formatting frontend code with Oxfmt..."
cd $(FRONTEND_DIR) && npm run format
frontend-format-check:
@echo "Checking frontend formatting with Oxfmt..."
cd $(FRONTEND_DIR) && npm run format:check
# All frontend static analysis
frontend-checks: frontend-lint frontend-format-check typecheck
# Run frontend unit tests
frontend-test:
@echo "Running frontend unit tests..."
+15
View File
@@ -20,12 +20,14 @@ src/
## Frontend Development
### Prerequisites
- Node.js (v16 or higher)
- npm or yarn
### Quick Start
From the project root:
```bash
# Install dependencies
make install
@@ -44,6 +46,7 @@ make typecheck
```
Alternatively, from `src/frontend`:
```bash
npm install
npm run dev
@@ -51,12 +54,14 @@ npm run build
```
### Technology Stack
- **Framework**: React 18 with TypeScript
- **Build Tool**: Vite 5
- **Styling**: TailwindCSS 3
- **Communication**: WebSocket for real-time updates
### Key Features
- **Search Interface**: Real-time book search with filtering
- **Download Queue**: Live status updates via WebSocket
- **Details Modal**: Rich book information display
@@ -65,22 +70,30 @@ npm run build
## Development Tips
### Hot Module Replacement (HMR)
The development server supports HMR for instant feedback during development.
### API Integration
The frontend communicates with the Flask backend via:
- REST API endpoints (`/api/*`)
- WebSocket connection (`ws://localhost:8084/ws`)
### Building for Production
The production build is optimized and minified:
```bash
make build
```
Output is generated in `src/frontend/dist/`
### Type Safety
Run TypeScript checks without building:
```bash
make typecheck
```
@@ -88,11 +101,13 @@ make typecheck
## Debugging
### Development Server Issues
- Ensure port 5173 is available
- Check that the backend is running on port 8084
- Verify WebSocket connection in browser console
### Build Issues
- Clear `node_modules` and reinstall: `make clean && make install`
- Check Node.js version compatibility
- Verify TypeScript configuration
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 2,
"useTabs": false,
"sortImports": true,
"sortTailwindcss": true,
"ignorePatterns": ["dist/**"]
}
+97
View File
@@ -0,0 +1,97 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "unicorn", "oxc", "jsx-a11y", "import"],
"options": {
"typeAware": true
},
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "error"
},
"rules": {
"react/react-in-jsx-scope": "off",
"no-console": "off",
"unicorn/no-null": "off",
"unicorn/filename-case": "off",
"unicorn/prefer-top-level-await": "off",
"import/no-default-export": "error",
"import/no-cycle": ["error", { "maxDepth": 5 }],
"unicorn/no-abusive-eslint-disable": "error",
"no-new-func": "error",
"unicorn/custom-error-definition": "error",
"typescript/no-explicit-any": "error",
"typescript/no-unsafe-argument": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-invalid-void-type": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-require-imports": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-misused-promises": "error",
"typescript/no-non-null-assertion": "error",
"typescript/only-throw-error": "error",
"react/no-danger": "error",
"react/no-clone-element": "error",
"react/no-react-children": "error",
"react/button-has-type": "error",
"no-var": "error",
"no-alert": "error",
"no-script-url": "error",
"no-template-curly-in-string": "error",
"eqeqeq": ["error", "always", { "null": "ignore" }],
"no-return-assign": "error",
"import/no-mutable-exports": "error",
"import/no-commonjs": "error",
"typescript/consistent-type-imports": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/no-object-as-default-parameter": "error",
"unicorn/no-nested-ternary": "error",
"unicorn/no-unreadable-iife": "error",
"no-empty-function": "error",
"no-useless-return": "error",
"import/no-duplicates": "error",
"react/jsx-no-useless-fragment": "error",
"react/self-closing-comp": "error",
"typescript/switch-exhaustiveness-check": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react",
"importNames": ["useEffect"],
"message": "Direct useEffect is not allowed. Derive state during render, use event handlers, reset with key, or use useMountEffect / a named sync hook for external system sync. See hooks/useMountEffect.ts and the React docs: https://react.dev/learn/you-might-not-need-an-effect"
}
]
}
]
},
"overrides": [
{
"files": [
"src/hooks/useMountEffect.ts",
"src/hooks/useBodyScrollLock.ts",
"src/hooks/useEscapeKey.ts",
"src/hooks/useDismiss.ts",
"src/hooks/app/useStatusChangeNotifications.ts",
"src/hooks/useRealtimeStatus.ts",
"src/hooks/useActivity.ts"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["src/tests/**"],
"rules": {
"typescript/no-explicit-any": "off"
}
}
],
"ignorePatterns": ["dist/**", "node_modules/**"]
}
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
// Keep Knip strict about missing entry/config discovery so false positives
// are caught at the config level instead of papered over with ignores.
"treatConfigHintsAsErrors": true,
// This script is intentionally loaded from index.html as a classic script,
// so we need to declare it as an entry point manually.
"entry": ["public/theme-init.js"]
}
+2340 -54
View File
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -1,15 +1,20 @@
{
"name": "cwad-frontend",
"private": true,
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/*.node.test.js",
"test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json"
"lint": "oxlint src/",
"lint:warn": "oxlint --deny warnings src/",
"knip": "knip",
"knip:prod": "knip --production",
"format": "oxfmt src/",
"format:check": "oxfmt --check src/",
"test:unit": "vitest run"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
@@ -19,14 +24,17 @@
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"postcss": "^8.5.9",
"knip": "^6.4.0",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.8"
"vite": "^8.0.8",
"vitest": "^4.1.4"
}
}
-3
View File
@@ -1,3 +0,0 @@
export default {
plugins: {},
}
+7 -4
View File
@@ -1,10 +1,13 @@
// Apply theme immediately before first paint to prevent flash.
// This runs as a blocking script before the app bundle loads.
(function() {
(function () {
var saved = localStorage.getItem('preferred-theme') || 'auto';
var theme = saved === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: saved;
var theme =
saved === 'auto'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: saved;
var dark = theme === 'dark';
var bg = dark ? '#121212' : '#f8f8f8';
+1367 -1267
View File
File diff suppressed because it is too large Load Diff
+53 -25
View File
@@ -1,5 +1,7 @@
import { ReactNode } from 'react';
import {
import type { ReactNode } from 'react';
import { CONTENT_OPTIONS } from '../data/filterOptions';
import type {
AdvancedFilterState,
ContentType,
Language,
@@ -7,11 +9,21 @@ import {
SearchMode,
} from '../types';
import { normalizeLanguageSelection } from '../utils/languageFilters';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { DropdownList } from './DropdownList';
import { CONTENT_OPTIONS } from '../data/filterOptions';
import { LanguageMultiSelect } from './LanguageMultiSelect';
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const;
const FORMAT_TYPES = [
'pdf',
'epub',
'mobi',
'azw3',
'fb2',
'djvu',
'cbz',
'cbr',
'zip',
'rar',
] as const;
interface AdvancedFiltersProps {
visible: boolean;
@@ -33,8 +45,17 @@ interface AdvancedFiltersProps {
}
const SEARCH_MODE_OPTIONS = [
{ value: 'direct', label: 'Direct', description: 'Search web sources for books and download directly. Works out of the box.' },
{ value: 'universal', label: 'Universal', description: 'Metadata-based search with downloads from all sources. Book and Audiobook support.' },
{
value: 'direct',
label: 'Direct',
description: 'Search web sources for books and download directly. Works out of the box.',
},
{
value: 'universal',
label: 'Universal',
description:
'Metadata-based search with downloads from all sources. Book and Audiobook support.',
},
];
export const AdvancedFilters = ({
@@ -63,16 +84,21 @@ export const AdvancedFilters = ({
};
const handleContentChange = (next: string[] | string) => {
const value = Array.isArray(next) ? next[0] ?? '' : next;
const value = Array.isArray(next) ? (next[0] ?? '') : next;
onFiltersChange({ content: value });
};
const handleFormatsChange = (next: string[] | string) => {
const nextFormats = Array.isArray(next) ? next : next ? [next] : [];
let nextFormats: string[] = [];
if (Array.isArray(next)) {
nextFormats = next;
} else if (next) {
nextFormats = [next];
}
onFiltersChange({ formats: nextFormats });
};
const formatOptions = FORMAT_TYPES.map(format => ({
const formatOptions = FORMAT_TYPES.map((format) => ({
value: format,
label: format.toUpperCase(),
}));
@@ -91,25 +117,30 @@ export const AdvancedFilters = ({
};
});
let metadataProviderLabel = 'Book Metadata Provider';
if (combinedMode) {
metadataProviderLabel = 'Combined Metadata Provider';
} else if (contentType === 'audiobook') {
metadataProviderLabel = 'Audiobook Metadata Provider';
}
if (!visible) return null;
const wrapperClassName = formClassName
? 'px-2'
: 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]';
const wrapperClassName = formClassName ? 'px-2' : 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]';
const settingsForm = (
<div className={wrapperClassName}>
{onClose && (
<div className="flex justify-end mb-1">
<div className="mb-1 flex justify-end">
<button
type="button"
onClick={onClose}
className="p-1 rounded-full hover-action transition-colors"
className="hover-action rounded-full p-1 transition-colors"
aria-label="Close filters"
title="Close filters"
>
<svg
className="w-4 h-4"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
@@ -123,13 +154,13 @@ export const AdvancedFilters = ({
</div>
)}
{isAdmin && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<DropdownList
label="Search Mode"
options={SEARCH_MODE_OPTIONS}
value={searchMode}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
const next = Array.isArray(value) ? (value[0] ?? 'direct') : value;
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
}}
placeholder="Choose a mode"
@@ -138,11 +169,11 @@ export const AdvancedFilters = ({
{searchMode === 'universal' && (
<DropdownList
label={combinedMode ? 'Combined Metadata Provider' : contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
label={metadataProviderLabel}
options={providerOptions}
value={activeMetadataProvider ?? ''}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? '' : value;
const next = Array.isArray(value) ? (value[0] ?? '') : value;
onMetadataProviderChange?.(next);
}}
placeholder="Choose a provider"
@@ -156,10 +187,7 @@ export const AdvancedFilters = ({
<div className="space-y-4">
<form
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
}
className={formClassName ?? 'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'}
>
<LanguageMultiSelect
options={bookLanguages}
@@ -194,7 +222,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="mb-4 w-full border-b pt-6 pb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8">{settingsForm}</div>
</div>
);
@@ -1,6 +1,7 @@
import { CSSProperties } from 'react';
import { Book, ButtonStateInfo } from '../types';
import type { CSSProperties } from 'react';
import { useSearchMode } from '../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../types';
import { BookDownloadButton } from './BookDownloadButton';
import { BookGetButton } from './BookGetButton';
@@ -1,5 +1,7 @@
import { useEffect, useState, CSSProperties } from 'react';
import { ButtonStateInfo } from '../types';
import type { CSSProperties } from 'react';
import { useState } from 'react';
import type { ButtonStateInfo } from '../types';
import { CircularProgress } from './shared';
type ButtonSize = 'sm' | 'md';
@@ -54,48 +56,48 @@ export const BookDownloadButton = ({
ariaLabel,
}: BookDownloadButtonProps) => {
const [isQueuing, setIsQueuing] = useState(false);
useEffect(() => {
if (isQueuing && buttonState.state !== 'download') {
setIsQueuing(false);
}
}, [buttonState.state, isQueuing]);
const isQueuingVisible = isQueuing && buttonState.state === 'download';
const isCompleted = buttonState.state === 'complete';
const hasError = buttonState.state === 'error';
const isBlocked = buttonState.state === 'blocked';
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted || isBlocked;
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(
buttonState.state,
);
const isDisabled =
buttonState.state !== 'download' || isQueuingVisible || isCompleted || isBlocked;
const displayText = isQueuingVisible ? 'Queuing...' : buttonState.text;
const showCircularProgress =
buttonState.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isQueuingVisible;
const isRequestAction = buttonState.state === 'download' && buttonState.text === 'Request';
const iconVariantActionIconPath = isRequestAction
? 'M12 4.5v15m7.5-7.5h-15'
: 'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3';
const primaryActionIconPath = isRequestAction ? 'M12 4.5v15m7.5-7.5h-15' : 'M12 4v12m0 0l-4-4m4 4 4-4M6 20h12';
const primaryActionIconPath = isRequestAction
? 'M12 4.5v15m7.5-7.5h-15'
: 'M12 4v12m0 0l-4-4m4 4 4-4M6 20h12';
let primaryStateClasses = 'bg-sky-700 hover:bg-sky-800';
if (isCompleted) {
primaryStateClasses = 'bg-green-600 cursor-not-allowed';
} else if (hasError) {
primaryStateClasses = 'bg-red-600 cursor-not-allowed opacity-75';
} else if (isBlocked) {
primaryStateClasses = 'bg-gray-500 cursor-not-allowed opacity-70';
} else if (isInProgress) {
primaryStateClasses = 'bg-gray-500 cursor-not-allowed opacity-75';
}
const primaryStateClasses =
isCompleted
? 'bg-green-600 cursor-not-allowed'
: hasError
? 'bg-red-600 cursor-not-allowed opacity-75'
: isBlocked
? 'bg-gray-500 cursor-not-allowed opacity-70'
: isInProgress
? 'bg-gray-500 cursor-not-allowed opacity-75'
: 'bg-sky-700 hover:bg-sky-800';
const iconStateClasses =
isCompleted
? 'bg-green-600 text-white cursor-not-allowed'
: hasError
? 'bg-red-600 text-white cursor-not-allowed opacity-75'
: isBlocked
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-70'
: isInProgress
? 'bg-gray-500 text-white cursor-not-allowed opacity-75'
: 'text-gray-600 dark:text-gray-200 hover-action';
let iconStateClasses = 'text-gray-600 dark:text-gray-200 hover-action';
if (isCompleted) {
iconStateClasses = 'bg-green-600 text-white cursor-not-allowed';
} else if (hasError) {
iconStateClasses = 'bg-red-600 text-white cursor-not-allowed opacity-75';
} else if (isBlocked) {
iconStateClasses = 'text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-70';
} else if (isInProgress) {
iconStateClasses = 'bg-gray-500 text-white cursor-not-allowed opacity-75';
}
const stateClasses = variant === 'icon' ? iconStateClasses : primaryStateClasses;
const widthClasses = variant === 'primary' && fullWidth ? 'w-full' : '';
@@ -113,7 +115,7 @@ export const BookDownloadButton = ({
setIsQueuing(true);
try {
await onDownload();
} catch (error) {
} catch {
setIsQueuing(false);
return;
}
@@ -125,17 +127,42 @@ export const BookDownloadButton = ({
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
<svg
className={`${iconSizes.mobile} sm:hidden`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M5 13l4 4L19 7"
/>
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
<svg
className={`${iconSizes.desktop} hidden sm:block`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M5 13l4 4L19 7"
/>
</svg>
</>
);
}
return (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
className={primaryIconSizes[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
@@ -145,18 +172,48 @@ export const BookDownloadButton = ({
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
<svg
className={`${iconSizes.mobile} sm:hidden`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
<svg
className={`${iconSizes.desktop} hidden sm:block`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</>
);
}
return (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className={primaryIconSizes[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
}
@@ -165,18 +222,48 @@ export const BookDownloadButton = ({
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
<svg
className={`${iconSizes.mobile} sm:hidden`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z"
/>
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
<svg
className={`${iconSizes.desktop} hidden sm:block`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z"
/>
</svg>
</>
);
}
return (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
<svg
className={primaryIconSizes[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z"
/>
</svg>
);
}
@@ -192,21 +279,47 @@ export const BookDownloadButton = ({
if (showSpinner) {
if (variant === 'icon' && iconSizes) {
return (
<div className={`${iconSizes.mobile} border-2 border-current border-t-transparent rounded-full animate-spin`} />
<div
className={`${iconSizes.mobile} animate-spin rounded-full border-2 border-current border-t-transparent`}
/>
);
}
const spinnerClass = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4';
return <div className={`${spinnerClass} border-2 border-current border-t-transparent rounded-full animate-spin`} />;
return (
<div
className={`${spinnerClass} animate-spin rounded-full border-2 border-current border-t-transparent`}
/>
);
}
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
<svg
className={`${iconSizes.mobile} sm:hidden`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d={iconVariantActionIconPath}
/>
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
<svg
className={`${iconSizes.desktop} hidden sm:block`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d={iconVariantActionIconPath}
/>
</svg>
</>
);
@@ -217,18 +330,34 @@ export const BookDownloadButton = ({
return (
<button
type="button"
className={`${baseClasses} ${sizeClass} ${stateClasses} ${widthClasses} ${className}`.trim()}
onClick={handleDownload}
onClick={() => void handleDownload()}
disabled={isDisabled || isInProgress}
data-action="download"
style={style}
aria-label={ariaLabel ?? displayText}
>
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={primaryActionIconPath} />
</svg>
)}
{variant === 'primary' &&
showIcon &&
!isCompleted &&
!hasError &&
!showCircularProgress &&
!showSpinner && (
<svg
className={primaryIconSizes[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={primaryActionIconPath}
/>
</svg>
)}
{variant === 'primary' && <span className="download-button-text">{displayText}</span>}
{variant === 'icon' && <span className="sr-only">{ariaLabel ?? displayText}</span>}
+41 -23
View File
@@ -1,5 +1,6 @@
import { CSSProperties } from 'react';
import { Book, ButtonStateInfo } from '../types';
import type { CSSProperties } from 'react';
import type { Book, ButtonStateInfo } from '../types';
import { CircularProgress } from './shared';
type ButtonSize = 'sm' | 'md';
@@ -57,8 +58,10 @@ export const BookGetButton = ({
const isCompleted = buttonState?.state === 'complete';
const hasError = buttonState?.state === 'error';
const isBlocked = buttonState?.state === 'blocked';
const isInProgress = buttonState && ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined;
const isInProgress =
buttonState && ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state);
const showCircularProgress =
buttonState?.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isLoading;
// Disable button while loading metadata
@@ -67,14 +70,10 @@ export const BookGetButton = ({
// Determine button styling based on state
const getButtonClasses = () => {
if (isCompleted) {
return isIconVariant
? 'bg-green-600 text-white'
: 'bg-green-600 hover:bg-green-700';
return isIconVariant ? 'bg-green-600 text-white' : 'bg-green-600 hover:bg-green-700';
}
if (hasError) {
return isIconVariant
? 'bg-red-600 text-white opacity-75'
: 'bg-red-600 hover:bg-red-700';
return isIconVariant ? 'bg-red-600 text-white opacity-75' : 'bg-red-600 hover:bg-red-700';
}
if (isBlocked) {
return isIconVariant
@@ -83,15 +82,11 @@ export const BookGetButton = ({
}
if (isLoading) {
// Show loading state (fetching metadata)
return isIconVariant
? 'text-gray-400 dark:text-gray-500'
: 'bg-emerald-600/70';
return isIconVariant ? 'text-gray-400 dark:text-gray-500' : 'bg-emerald-600/70';
}
if (isInProgress) {
// Show progress state but keep it clickable
return isIconVariant
? 'bg-sky-600 text-white'
: 'bg-sky-600 hover:bg-sky-700';
return isIconVariant ? 'bg-sky-600 text-white' : 'bg-sky-600 hover:bg-sky-700';
}
// Default state - icon variant has no background
return isIconVariant
@@ -131,7 +126,12 @@ export const BookGetButton = ({
if (hasError) {
return (
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
}
@@ -139,27 +139,43 @@ export const BookGetButton = ({
if (isBlocked) {
return (
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16.5 10.5V7.875a4.125 4.125 0 1 0-8.25 0V10.5m-.75 0h9a2.25 2.25 0 0 1 2.25 2.25v6A2.25 2.25 0 0 1 16.5 21h-9a2.25 2.25 0 0 1-2.25-2.25v-6a2.25 2.25 0 0 1 2.25-2.25Z"
/>
</svg>
);
}
if (showCircularProgress) {
const progressSize = isIconVariant ? (size === 'sm' ? 16 : 20) : (size === 'sm' ? 12 : 16);
let progressSize: number;
if (isIconVariant) {
progressSize = size === 'sm' ? 16 : 20;
} else {
progressSize = size === 'sm' ? 12 : 16;
}
return <CircularProgress progress={buttonState?.progress} size={progressSize} />;
}
if (showSpinner) {
return (
<div
className={`${iconSize} border-2 border-current border-t-transparent rounded-full animate-spin`}
className={`${iconSize} animate-spin rounded-full border-2 border-current border-t-transparent`}
/>
);
}
// Default "+" icon for Get action
return (
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg
className={iconSize}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
@@ -169,7 +185,8 @@ export const BookGetButton = ({
if (isIconVariant) {
return (
<button
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()}
type="button"
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:outline-hidden ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
@@ -182,7 +199,8 @@ export const BookGetButton = ({
return (
<button
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()}
type="button"
className={`inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:outline-hidden ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DropdownList, type DropdownListOption } from './DropdownList';
import {
setBookTargetState,
type BookTargetOption,
} from '../services/api';
import { loadBookTargets } from '../utils/bookTargetLoader';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useMountEffect } from '../hooks/useMountEffect';
import { setBookTargetState, type BookTargetOption } from '../services/api';
import { emitBookTargetChange, onBookTargetChange } from '../utils/bookTargetEvents';
import { loadBookTargets } from '../utils/bookTargetLoader';
import { DropdownList, type DropdownListOption } from './DropdownList';
interface BookTargetDropdownProps {
provider: string;
@@ -78,13 +78,38 @@ export const BookTargetDropdown = ({
align = 'auto',
className,
onOpenChange,
}: BookTargetDropdownProps) => {
return (
<BookTargetDropdownSession
key={`${provider}:${bookId}`}
provider={provider}
bookId={bookId}
onShowToast={onShowToast}
widthClassName={widthClassName}
variant={variant}
align={align}
className={className}
onOpenChange={onOpenChange}
/>
);
};
const BookTargetDropdownSession = ({
provider,
bookId,
onShowToast,
widthClassName = 'w-full sm:w-56',
variant = 'default',
align = 'auto',
className,
onOpenChange,
}: BookTargetDropdownProps) => {
const [options, setOptions] = useState<BookTargetOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [pendingTargets, setPendingTargets] = useState<Set<string>>(new Set());
const [pendingTargets, setPendingTargets] = useState(new Set<string>());
useEffect(() => {
useMountEffect(() => {
let isMounted = true;
const run = async () => {
@@ -113,15 +138,15 @@ export const BookTargetDropdown = ({
return () => {
isMounted = false;
};
}, [provider, bookId]);
});
// Sync from changes made by other BookTargetDropdown instances for the same book
useEffect(() => {
useMountEffect(() => {
return onBookTargetChange((event) => {
if (event.provider !== provider || event.bookId !== bookId) return;
setOptions((prev) => updateOptionChecked(prev, event.target, event.selected));
});
}, [provider, bookId]);
});
const selectedValues = useMemo(
() => options.filter((option) => option.checked).map((option) => option.value),
@@ -150,102 +175,113 @@ export const BookTargetDropdown = ({
}));
}, [isLoading, loadError, options, pendingTargets]);
const handleChange = useCallback((nextValue: string[] | string) => {
if (!Array.isArray(nextValue)) {
return;
}
const handleChange = useCallback(
(nextValue: string[] | string) => {
if (!Array.isArray(nextValue)) {
return;
}
const nextSelected = new Set(nextValue);
const currentSelected = new Set(selectedValues);
const toggledTarget =
nextValue.find((value) => !currentSelected.has(value))
?? selectedValues.find((value) => !nextSelected.has(value));
const nextSelected = new Set(nextValue);
const currentSelected = new Set(selectedValues);
const toggledTarget =
nextValue.find((value) => !currentSelected.has(value)) ??
selectedValues.find((value) => !nextSelected.has(value));
if (!toggledTarget || pendingTargets.has(toggledTarget)) {
return;
}
if (!toggledTarget || pendingTargets.has(toggledTarget)) {
return;
}
const selected = nextSelected.has(toggledTarget);
const toggledOption = options.find((option) => option.value === toggledTarget);
if (!toggledOption) {
return;
}
const selected = nextSelected.has(toggledTarget);
const toggledOption = options.find((option) => option.value === toggledTarget);
if (!toggledOption) {
return;
}
setPendingTargets((prev) => new Set(prev).add(toggledTarget));
setOptions((prev) => updateOptionChecked(prev, toggledTarget, selected));
setPendingTargets((prev) => new Set(prev).add(toggledTarget));
setOptions((prev) => updateOptionChecked(prev, toggledTarget, selected));
void (async () => {
try {
const result = await setBookTargetState(provider, bookId, toggledTarget, selected);
setOptions((prev) => updateOptionChecked(prev, toggledTarget, result.selected));
void (async () => {
try {
const result = await setBookTargetState(provider, bookId, toggledTarget, selected);
setOptions((prev) => updateOptionChecked(prev, toggledTarget, result.selected));
if (result.changed) {
emitBookTargetChange({
provider,
bookId,
target: toggledTarget,
selected: result.selected,
});
// When a status was implicitly deselected, sync other instances
if (result.deselectedTarget) {
setOptions((prev) => updateOptionChecked(prev, result.deselectedTarget!, false));
if (result.changed) {
emitBookTargetChange({
provider,
bookId,
target: result.deselectedTarget,
selected: false,
target: toggledTarget,
selected: result.selected,
});
// When a status was implicitly deselected, sync other instances
const deselectedTarget = result.deselectedTarget;
if (deselectedTarget) {
setOptions((prev) => updateOptionChecked(prev, deselectedTarget, false));
emitBookTargetChange({
provider,
bookId,
target: deselectedTarget,
selected: false,
});
}
const label = stripCountSuffix(toggledOption.label);
onShowToast?.(`${result.selected ? 'Added to' : 'Removed from'} ${label}`, 'success');
}
const label = stripCountSuffix(toggledOption.label);
onShowToast?.(
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
'success',
);
} catch (error) {
setOptions((prev) => updateOptionChecked(prev, toggledTarget, !selected));
const message =
error instanceof Error ? error.message : 'Failed to update Hardcover list';
onShowToast?.(message, 'error');
} finally {
setPendingTargets((prev) => {
const nextPending = new Set(prev);
nextPending.delete(toggledTarget);
return nextPending;
});
}
} catch (error) {
setOptions((prev) => updateOptionChecked(prev, toggledTarget, !selected));
const message = error instanceof Error ? error.message : 'Failed to update Hardcover list';
onShowToast?.(message, 'error');
} finally {
setPendingTargets((prev) => {
const nextPending = new Set(prev);
nextPending.delete(toggledTarget);
return nextPending;
});
}
})();
}, [bookId, onShowToast, options, pendingTargets, provider, selectedValues]);
})();
},
[bookId, onShowToast, options, pendingTargets, provider, selectedValues],
);
const customTrigger = variant === 'pill'
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
return (
<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-hidden`}
>
<BookmarkIcon className="w-3 h-3" />
Hardcover Lists{count > 0 ? ` (${count})` : ''}
</button>
);
let customTrigger: ((props: { isOpen: boolean; toggle: () => void }) => ReactNode) | undefined;
if (variant === 'pill') {
customTrigger = ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
return (
<button
type="button"
onClick={toggle}
className={`inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-600 transition-colors hover:bg-emerald-100 focus:outline-hidden dark:bg-emerald-900/20 dark:text-emerald-400 dark:hover:bg-emerald-900/40`}
>
<BookmarkIcon className="h-3 w-3" />
Hardcover Lists{count > 0 ? ` (${count})` : ''}
</button>
);
};
} else if (variant === 'icon') {
customTrigger = ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
let title = 'Hardcover Lists';
if (count > 0) {
title = `On ${count} Hardcover list${count > 1 ? 's' : ''}`;
}
: variant === 'icon'
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(); }}
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'}
>
<BookmarkIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${count > 0 ? 'fill-current' : ''}`} />
</button>
);
}
: undefined;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggle();
}}
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-hidden ${className ?? 'hover-action p-1.5 text-gray-600 sm:p-2 dark:text-gray-200'}`}
aria-label="Hardcover Lists"
title={title}
>
<BookmarkIcon className={`h-4 w-4 sm:h-5 sm:w-5 ${count > 0 ? 'fill-current' : ''}`} />
</button>
);
};
}
return (
<DropdownList
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useState } from 'react';
const STORAGE_KEY = 'cwa-config-banner-dismissed';
@@ -19,20 +19,23 @@ export const ConfigSetupBanner = ({
onContinue,
settingsEnabled,
}: ConfigSetupBannerProps) => {
const [autoShowVisible, setAutoShowVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
// Auto-show mode: check localStorage on mount
useEffect(() => {
if (settingsEnabled !== undefined) {
const dismissed = localStorage.getItem(STORAGE_KEY);
setAutoShowVisible(!settingsEnabled && dismissed !== 'true');
const [dismissed, setDismissed] = useState(() => {
if (typeof window === 'undefined') {
return false;
}
}, [settingsEnabled]);
try {
return window.localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
});
// Determine if we should show based on controlled or auto-show mode
const isControlledMode = controlledOpen !== undefined;
const isVisible = isControlledMode ? controlledOpen : autoShowVisible;
const isAutoShowVisible = settingsEnabled !== undefined ? !settingsEnabled && !dismissed : false;
const isVisible = isControlledMode ? controlledOpen : isAutoShowVisible;
const handleClose = useCallback(() => {
setIsClosing(true);
@@ -41,12 +44,15 @@ export const ConfigSetupBanner = ({
if (isControlledMode) {
onClose?.();
} else {
// Auto-show mode: save to localStorage
localStorage.setItem(STORAGE_KEY, 'true');
setAutoShowVisible(false);
try {
window.localStorage.setItem(STORAGE_KEY, 'true');
} catch {
// Best effort only.
}
setDismissed(true);
}
}, 150);
}, [isControlledMode, onClose]);
}, [isControlledMode, onClose, setDismissed]);
const handleContinue = useCallback(() => {
setIsClosing(true);
@@ -64,31 +70,30 @@ export const ConfigSetupBanner = ({
return (
<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-xs transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
aria-label="Close settings setup dialog"
/>
{/* Modal */}
<div
className={`relative w-full max-w-lg rounded-xl
border border-(--border-muted) shadow-2xl
overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative z-10 w-full max-w-lg overflow-hidden rounded-xl border border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-label="Settings Setup Information"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-(--border-muted)">
<div className="flex items-center justify-between border-b border-(--border-muted) px-5 py-4">
<h2 className="text-lg font-semibold">
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
</h2>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
className="rounded-lg p-1.5 transition-colors hover:bg-(--hover-surface)"
aria-label="Close"
>
<svg
@@ -97,7 +102,7 @@ export const ConfigSetupBanner = ({
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
className="h-5 w-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
@@ -105,7 +110,7 @@ export const ConfigSetupBanner = ({
</div>
{/* Content */}
<div className="px-5 py-4 space-y-4">
<div className="space-y-4 px-5 py-4">
<p className="text-sm opacity-80">
{showContinueButton
? 'To save settings, add a config volume to your Docker Compose file:'
@@ -113,20 +118,25 @@ export const ConfigSetupBanner = ({
</p>
{/* Code snippet */}
<div className="rounded-lg overflow-hidden border border-(--border-muted)">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-(--border-muted)"
style={{ background: 'var(--bg-soft)' }}>
<div className="overflow-hidden rounded-lg border border-(--border-muted)">
<div
className="border-b border-(--border-muted) px-3 py-1.5 text-xs font-medium opacity-60"
style={{ background: 'var(--bg-soft)' }}
>
docker-compose.yml
</div>
<pre
className="px-3 py-3 text-sm overflow-x-auto"
className="overflow-x-auto px-3 py-3 text-sm"
style={{ background: 'var(--bg-soft)' }}
>
<code>
<span className="opacity-60">services:</span>{'\n'}
<span className="opacity-60">{' '}shelfmark:</span>{'\n'}
<span className="opacity-60">services:</span>
{'\n'}
<span className="opacity-60">{' '}shelfmark:</span>
{'\n'}
{' '}volumes:{'\n'}
{' '}- <span className="text-blue-400">/path/to/config</span>:<span className="text-green-400">/config</span>
{' '}- <span className="text-blue-400">/path/to/config</span>:
<span className="text-green-400">/config</span>
</code>
</pre>
</div>
@@ -139,32 +149,29 @@ export const ConfigSetupBanner = ({
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-(--border-muted) flex justify-end gap-3">
<div className="flex justify-end gap-3 border-t border-(--border-muted) px-5 py-4">
{showContinueButton ? (
<>
<button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface)"
>
Close
</button>
<button
type="button"
onClick={handleContinue}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
className="rounded-lg bg-(--primary-color) px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-(--primary-dark)"
>
Continue to Settings
</button>
</>
) : (
<button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
className="rounded-lg bg-(--primary-color) px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-(--primary-dark)"
>
Got it
</button>
+144 -92
View File
@@ -1,21 +1,41 @@
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useMountEffect } from '../hooks/useMountEffect';
import type { Book, ButtonStateInfo } from '../types';
import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { isUserCancelledError } from '../utils/errors';
import { BookTargetDropdown } from './BookTargetDropdown';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
interface DetailsModalProps {
book: Book | null;
onClose: () => void;
onDownload: (book: Book) => Promise<void>;
onFindDownloads?: (book: Book) => void; // For Universal mode
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
onFindDownloads?: (book: Book) => void; // For Universal mode
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
buttonState: ButtonStateInfo;
showReleaseSourceLinks?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
interface DetailsModalAutoCloseProps {
clearQueuing: () => void;
closeModal: () => void;
}
const DetailsModalAutoClose = ({ clearQueuing, closeModal }: DetailsModalAutoCloseProps) => {
useMountEffect(() => {
clearQueuing();
const timer = window.setTimeout(closeModal, 500);
return () => window.clearTimeout(timer);
});
return null;
};
export const DetailsModal = ({
book,
onClose,
@@ -37,37 +57,8 @@ export const DetailsModal = ({
}, 150);
}, [onClose]);
// Clear queuing state and close modal once button state changes from download
useEffect(() => {
if (isQueuing && buttonState.state !== 'download') {
setIsQueuing(false);
// Close modal after status has updated
const timer = setTimeout(handleClose, 500);
return () => clearTimeout(timer);
}
}, [buttonState.state, isQueuing, handleClose]);
// Handle ESC key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [handleClose]);
useEffect(() => {
if (book) {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}
}, [book]);
useBodyScrollLock(Boolean(book));
useEscapeKey(Boolean(book), handleClose);
const hasBookTargets = Boolean(book && isMetadataBook(book) && bookSupportsTargets(book));
@@ -98,7 +89,15 @@ export const DetailsModal = ({
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
? 'Find Downloads'
: buttonState.text;
const downloadButtonClassName = (() => {
if (buttonState.state === 'blocked') {
return 'bg-gray-500';
}
return isMetadata ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-sky-700 hover:bg-sky-800';
})();
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
const bookProvider = book.provider;
const bookProviderId = book.provider_id;
// Build metadata grid based on mode
// Universal mode: Year, Genres (no language, no publisher - often blank from providers)
@@ -116,18 +115,22 @@ export const DetailsModal = ({
{ label: 'Language', value: book.language || '-' },
{ label: 'Format', value: book.format || '-' },
{ label: 'Size', value: book.size || '-' },
...(downloadCount ? [{ label: 'Downloads', value: Number(downloadCount).toLocaleString() }] : []),
...(downloadCount
? [{ label: 'Downloads', value: Number(downloadCount).toLocaleString() }]
: []),
];
// Extract rating and readers from display_fields for dedicated boxes (Universal mode)
const ratingField = isMetadata && book.display_fields?.find(f => f.icon === 'star');
const readersField = isMetadata && book.display_fields?.find(f => f.icon === 'users');
const ratingField = isMetadata && book.display_fields?.find((f) => f.icon === 'star');
const readersField = isMetadata && book.display_fields?.find((f) => f.icon === 'users');
// Other display fields (pages, editions, etc.) shown inline
const otherDisplayFields = isMetadata && book.display_fields?.filter(f => f.icon !== 'star' && f.icon !== 'users');
const otherDisplayFields =
isMetadata && book.display_fields?.filter((f) => f.icon !== 'star' && f.icon !== 'users');
// Use provider display name from backend, fall back to capitalized provider name
const providerDisplay = book.provider_display_name
|| (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : '');
const providerDisplay =
book.provider_display_name ||
(book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : '');
const isSquareCover = book.cover_aspect === 'square';
const artworkMaxHeight = 'calc(90vh - 220px)';
const artworkMaxWidth = isSquareCover
@@ -141,28 +144,36 @@ export const DetailsModal = ({
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)';
const infoCardClass =
'rounded-2xl border 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';
const modal = (
<div
className="modal-overlay active sm:px-6 sm:py-6"
onClick={e => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div className="modal-overlay active sm:px-6 sm:py-6">
{isQueuing && buttonState.state !== 'download' && (
<DetailsModalAutoClose clearQueuing={() => setIsQueuing(false)} closeModal={handleClose} />
)}
<button
type="button"
className="absolute inset-0 border-0 bg-transparent p-0"
onClick={handleClose}
tabIndex={-1}
aria-label="Close details"
/>
<div
className={`details-container w-full h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container relative z-10 h-full w-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-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4">
<div className="flex h-full flex-col overflow-hidden rounded-none border-0 border-(--border-muted) bg-(--bg) text-(--text) shadow-none sm:h-[90vh] sm:max-h-[90vh] sm:rounded-2xl sm:border sm:bg-(--bg-soft) sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-(--border-muted) bg-(--bg) px-5 py-4 sm:bg-(--bg-soft)">
<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">
<p className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400">
Book
</p>
<h3 id={titleId} className="text-lg leading-snug font-semibold">
{book.title || 'Untitled'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
@@ -172,7 +183,7 @@ export const DetailsModal = ({
<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="hover-action rounded-full p-2 text-gray-500 transition-colors hover:text-gray-900 dark:hover:text-gray-100"
aria-label="Close details"
>
<svg
@@ -187,8 +198,8 @@ export const DetailsModal = ({
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-stretch lg:gap-8 lg:min-h-0">
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6">
<div className="flex flex-col gap-6 lg:min-h-0 lg:flex-row lg:items-stretch lg:gap-8">
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
{book.preview ? (
<div
@@ -221,8 +232,10 @@ export const DetailsModal = ({
)}
{/* Metadata grid - adapts columns based on mode and available data */}
<div className={`grid grid-cols-2 gap-3 lg:gap-4 ${isMetadata ? 'lg:grid-cols-2' : 'lg:grid-cols-4'}`}>
{metadata.map(item => (
<div
className={`grid grid-cols-2 gap-3 lg:gap-4 ${isMetadata ? 'lg:grid-cols-2' : 'lg:grid-cols-4'}`}
>
{metadata.map((item) => (
<div key={item.label} className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{item.label}</p>
<p className={infoValueClass}>{item.value}</p>
@@ -234,7 +247,11 @@ export const DetailsModal = ({
<div className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{ratingField.label}</p>
<p className={`${infoValueClass} flex items-center gap-1.5`}>
<svg className="h-4 w-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<svg
className="h-4 w-4 text-amber-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{ratingField.value}
@@ -247,8 +264,18 @@ export const DetailsModal = ({
<div className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{readersField.label}</p>
<p className={`${infoValueClass} flex items-center gap-1.5`}>
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<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
className="h-4 w-4 text-gray-500"
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>
{readersField.value}
</p>
@@ -256,12 +283,13 @@ export const DetailsModal = ({
)}
{/* Other display fields (length, narrator, format, etc.) - Universal mode only */}
{otherDisplayFields && otherDisplayFields.map(field => (
<div key={field.label} className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{field.label}</p>
<p className={infoValueClass}>{field.value}</p>
</div>
))}
{otherDisplayFields &&
otherDisplayFields.map((field) => (
<div key={field.label} className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{field.label}</p>
<p className={infoValueClass}>{field.value}</p>
</div>
))}
</div>
{/* ISBN - Universal mode only */}
@@ -279,7 +307,14 @@ export const DetailsModal = ({
<div className="flex items-center justify-between gap-2">
<p className={infoValueClass}>
{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}</>
<>
#
{Number.isInteger(book.series_position)
? book.series_position
: book.series_position}
{book.series_count ? ` of ${book.series_count}` : ''} in{' '}
{book.series_name}
</>
) : (
book.series_name
)}
@@ -288,13 +323,27 @@ export const DetailsModal = ({
<button
type="button"
onClick={() => {
onSearchSeries(book.series_name!, book.series_id);
const seriesName = book.series_name;
if (!seriesName) {
return;
}
onSearchSeries(seriesName, 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 shrink-0"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-600 transition-colors hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-400 dark:hover:bg-emerald-900/40"
>
<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" />
<svg
className="h-3 w-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"
/>
</svg>
View series
</button>
@@ -306,11 +355,13 @@ export const DetailsModal = ({
{/* Extended info (publisher, etc.) - Direct Download mode only */}
{!isMetadata && extendedInfoEntries.length > 0 && (
<div className={`${infoCardClass} space-y-3`}>
<ul className="space-y-3 list-none">
<ul className="list-none space-y-3">
{extendedInfoEntries.map(([key, value]) => (
<li key={key} className="space-y-1">
<p className={infoLabelClass}>{key}</p>
<p className={infoValueClass}>{Array.isArray(value) ? value.join(', ') : value}</p>
<p className={infoValueClass}>
{Array.isArray(value) ? value.join(', ') : value}
</p>
</li>
))}
</ul>
@@ -320,9 +371,7 @@ export const DetailsModal = ({
</div>
</div>
<footer
className="border-t border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4"
>
<footer className="border-t border-(--border-muted) bg-(--bg) px-5 py-4 sm:bg-(--bg-soft)">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Source link - shown for both Universal and Direct Download modes */}
{showBookSourceLink && (
@@ -332,7 +381,7 @@ export const DetailsModal = ({
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
View on {isMetadata ? providerDisplay : "Source"}
View on {isMetadata ? providerDisplay : 'Source'}
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -344,26 +393,29 @@ export const DetailsModal = ({
</a>
)}
<div className="flex w-full flex-col gap-3 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
{hasBookTargets && book.provider_id && (
{hasBookTargets && bookProvider && bookProviderId && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id}
provider={bookProvider}
bookId={bookProviderId}
onShowToast={onShowToast}
widthClassName="w-full sm:w-56"
/>
)}
{/* Action button - mirrors search result action state/flow */}
<button
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
className={`rounded-lg px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isMetadata
? buttonState.state === 'blocked'
? 'bg-gray-500'
: 'bg-emerald-600 hover:bg-emerald-700'
: buttonState.state === 'blocked'
? 'bg-gray-500'
: 'bg-sky-700 hover:bg-sky-800'
type="button"
onClick={() => {
if (isMetadata) {
onFindDownloads?.(book);
return;
}
void handleDownload();
}}
disabled={
isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'
}
className={`rounded-lg px-5 py-2 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
downloadButtonClassName
}`}
>
{isMetadata ? metadataActionText : buttonState.text}
+48 -55
View File
@@ -1,4 +1,7 @@
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useDismiss } from '../hooks/useDismiss';
// Find the closest scrollable ancestor element
function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null {
@@ -15,11 +18,14 @@ function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null
}
// Simple throttle function to limit how often a function can be called
function throttle<T extends (...args: unknown[]) => void>(fn: T, delay: number): T {
function throttle<Args extends unknown[]>(
fn: (...args: Args) => void,
delay: number,
): (...args: Args) => void {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
return (...args: Args) => {
const now = Date.now();
const timeSinceLastCall = now - lastCall;
@@ -34,7 +40,7 @@ function throttle<T extends (...args: unknown[]) => void>(fn: T, delay: number):
fn(...args);
}, delay - timeSinceLastCall);
}
}) as T;
};
}
interface DropdownProps {
@@ -71,45 +77,43 @@ export const Dropdown = ({
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
const [resolvedAlign, setResolvedAlign] = useState<'left' | 'right'>(align === 'right' ? 'right' : 'left');
const [resolvedAlign, setResolvedAlign] = useState<'left' | 'right'>(
align === 'right' ? 'right' : 'left',
);
let triggerBorderRadius = '0.5rem';
if (triggerChrome === 'minimal') {
triggerBorderRadius = '0';
} else if (isOpen) {
triggerBorderRadius = panelDirection === 'down' ? '0.5rem 0.5rem 0 0' : '0 0 0.5rem 0.5rem';
}
let panelOffsetClassName = '';
if (panelDirection === 'down') {
panelOffsetClassName = renderTrigger ? 'mt-2' : '';
} else {
panelOffsetClassName = renderTrigger ? 'bottom-full mb-2' : 'bottom-full';
}
let panelBorderRadius = '0.5rem';
if (!renderTrigger) {
panelBorderRadius = panelDirection === 'down' ? '0 0 0.5rem 0.5rem' : '0.5rem 0.5rem 0 0';
}
const toggleOpen = () => {
if (disabled) return;
setIsOpen(prev => {
setIsOpen((prev) => {
const next = !prev;
onOpenChange?.(next);
return next;
});
};
const close = () => {
const close = useCallback(() => {
setIsOpen(false);
onOpenChange?.(false);
};
}, [onOpenChange]);
useEffect(() => {
if (!isOpen) return;
const handleClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
close();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
useDismiss(isOpen, [containerRef], close);
// Memoize the panel direction calculation
const updatePanelDirection = useCallback(() => {
@@ -125,9 +129,7 @@ export const Dropdown = ({
const containerBottom = scrollableAncestor
? scrollableAncestor.getBoundingClientRect().bottom
: window.innerHeight;
const containerTop = scrollableAncestor
? scrollableAncestor.getBoundingClientRect().top
: 0;
const containerTop = scrollableAncestor ? scrollableAncestor.getBoundingClientRect().top : 0;
const spaceBelow = containerBottom - rect.bottom - 8;
const spaceAbove = rect.top - containerTop - 8;
@@ -154,7 +156,9 @@ export const Dropdown = ({
}, [align]);
useLayoutEffect(() => {
if (!isOpen) return;
if (!isOpen) {
return undefined;
}
// Throttle scroll/resize handlers to reduce layout thrashing
const throttledUpdate = throttle(updatePanelDirection, 100);
@@ -172,7 +176,10 @@ export const Dropdown = ({
return (
<div className={widthClassName} ref={containerRef}>
{label && (
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5" onClick={toggleOpen}>
<label
className="mb-1.5 block text-xs font-medium text-gray-500 dark:text-gray-400"
onClick={toggleOpen}
>
{label}
</label>
)}
@@ -184,20 +191,12 @@ 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-hidden focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 ${triggerChrome !== 'minimal' ? 'dropdown-trigger' : ''} ${buttonClassName}`}
className={`flex w-full items-center justify-between gap-2 border px-3 py-2 text-left text-sm focus:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-hidden ${triggerChrome !== 'minimal' ? 'dropdown-trigger' : ''} ${buttonClassName}`}
style={{
color: 'var(--text)',
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
borderWidth: triggerChrome === 'minimal' ? 0 : undefined,
borderRadius: isOpen
? triggerChrome === 'minimal'
? '0'
: panelDirection === 'down'
? '0.5rem 0.5rem 0 0'
: '0 0 0.5rem 0.5rem'
: triggerChrome === 'minimal'
? '0'
: '0.5rem',
borderRadius: triggerBorderRadius,
}}
>
<span className="min-w-0 flex-1 truncate">
@@ -219,18 +218,12 @@ export const Dropdown = ({
<div
ref={panelRef}
className={`absolute ${resolvedAlign === 'right' ? 'right-0' : 'left-0'} ${
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
} border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
panelOffsetClassName
} z-20 border ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
borderRadius: renderTrigger
? '0.5rem'
: panelDirection === 'down'
? '0 0 0.5rem 0.5rem'
: '0.5rem 0.5rem 0 0',
borderRadius: panelBorderRadius,
marginTop: !renderTrigger && panelDirection === 'down' ? '-1px' : undefined,
marginBottom: !renderTrigger && panelDirection === 'up' ? '-1px' : undefined,
}}
+15 -11
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import type { ReactNode } from 'react';
import { Dropdown } from './Dropdown';
export interface DropdownListOption {
@@ -48,7 +49,7 @@ export const DropdownList = ({
onOpenChange,
}: DropdownListProps) => {
const selectedValues = normalizeValue(value, multiple);
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value));
const checkboxEnabled = showCheckboxes ?? multiple;
const stayOpenOnSelect = keepOpenOnSelect ?? multiple;
@@ -60,7 +61,7 @@ export const DropdownList = ({
if (selectedOptions.length === 0) {
// For single select with empty string value, find and show the empty value option label
if (!multiple) {
const emptyOption = options.find(opt => opt.value === '');
const emptyOption = options.find((opt) => opt.value === '');
if (emptyOption) {
return emptyOption.label;
}
@@ -76,7 +77,7 @@ export const DropdownList = ({
return selectedOptions[0].label;
}
const [first, second, ...rest] = selectedOptions.map(opt => opt.label);
const [first, second, ...rest] = selectedOptions.map((opt) => opt.label);
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
return `${first}, ${second ?? ''}${suffix}`.trim();
};
@@ -86,7 +87,7 @@ export const DropdownList = ({
if (multiple) {
const next = selectedValues.includes(option.value)
? selectedValues.filter(v => v !== option.value)
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
onChange(next);
if (!stayOpenOnSelect) {
@@ -120,20 +121,20 @@ export const DropdownList = ({
let lastGroup: string | undefined;
return (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => {
{options.map((option) => {
const showGroupHeader = option.group != null && option.group !== lastGroup;
if (option.group != null) lastGroup = option.group;
return (
<div key={option.value}>
{showGroupHeader && (
<div className="px-3 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
<div className="px-3 pt-2 pb-1 text-xs font-medium tracking-wide uppercase opacity-60 select-none">
{option.group}
</div>
)}
<button
type="button"
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
className={`hover-surface flex w-full items-center gap-2 px-3 py-2 text-left text-sm ${
option.disabled ? 'cursor-not-allowed opacity-50' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
@@ -143,7 +144,7 @@ export const DropdownList = ({
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
className="pointer-events-none h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500"
/>
)}
{option.icon}
@@ -164,7 +165,10 @@ export const DropdownList = ({
);
};
const normalizeValue = (value: string[] | string | null | undefined, multiple: boolean): string[] => {
const normalizeValue = (
value: string[] | string | null | undefined,
multiple: boolean,
): string[] => {
if (multiple) {
if (Array.isArray(value)) {
return value;
+12 -10
View File
@@ -6,14 +6,13 @@ interface FooterProps {
export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) => {
// Determine version display - show "dev" if no version is set
const versionDisplay = releaseVersion && releaseVersion !== 'N/A'
? releaseVersion
: 'dev';
const versionDisplay = releaseVersion && releaseVersion !== 'N/A' ? releaseVersion : 'dev';
// Truncate long build versions (e.g., git hashes) to 7 chars
const truncatedBuild = buildVersion && buildVersion !== 'N/A'
? buildVersion.length > 7 ? buildVersion.slice(0, 7) : buildVersion
: null;
let truncatedBuild: string | null = null;
if (buildVersion && buildVersion !== 'N/A') {
truncatedBuild = buildVersion.length > 7 ? buildVersion.slice(0, 7) : buildVersion;
}
return (
<footer
@@ -22,24 +21,27 @@ export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) =>
paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))',
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-baseline justify-center gap-2">
<div className="mx-auto flex max-w-7xl items-baseline justify-center gap-2 px-4 sm:px-6 lg:px-8">
<a
href="https://github.com/calibrain/shelfmark"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium opacity-70 hover:opacity-100 transition-opacity"
className="text-sm font-medium opacity-70 transition-opacity hover:opacity-100"
>
Shelfmark
</a>
<span
className="text-xs opacity-40 font-normal"
className="text-xs font-normal opacity-40"
title={buildVersion && buildVersion !== 'N/A' ? `Build: ${buildVersion}` : undefined}
>
{versionDisplay}
{truncatedBuild && ` (${truncatedBuild})`}
</span>
{debug && (
<span className="text-xs px-1.5 py-0.5 rounded-sm opacity-60" style={{ background: 'var(--border-muted)' }}>
<span
className="rounded-sm px-1.5 py-0.5 text-xs opacity-60"
style={{ background: 'var(--border-muted)' }}
>
Debug
</span>
)}
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,11 @@
import { Language } from '../types';
import type { Language } from '../types';
import {
LANGUAGE_OPTION_ALL,
LANGUAGE_OPTION_DEFAULT,
normalizeLanguageSelection,
} from '../utils/languageFilters';
import { DropdownList, DropdownListOption } from './DropdownList';
import type { DropdownListOption } from './DropdownList';
import { DropdownList } from './DropdownList';
interface LanguageMultiSelectProps {
options: Language[];
@@ -26,11 +27,14 @@ export const LanguageMultiSelect = ({
const defaultCodeSet = new Set(defaultLanguageCodes);
// Get default languages with their full info
const defaultLanguages = options.filter(lang => defaultCodeSet.has(lang.code));
const nonDefaultLanguages = options.filter(lang => !defaultCodeSet.has(lang.code));
const defaultLanguages = options.filter((lang) => defaultCodeSet.has(lang.code));
const nonDefaultLanguages = options.filter((lang) => !defaultCodeSet.has(lang.code));
// All selectable values (individual language codes, not LANGUAGE_OPTION_DEFAULT)
const selectableValues = [...defaultLanguageCodes, ...nonDefaultLanguages.map(lang => lang.code)];
const selectableValues = [
...defaultLanguageCodes,
...nonDefaultLanguages.map((lang) => lang.code),
];
// Build option list: All, then defaults (marked), then others
const optionList: DropdownListOption[] = [
@@ -39,26 +43,26 @@ export const LanguageMultiSelect = ({
label: 'All languages',
},
// Each default language as a separate option
...defaultLanguages.map(lang => ({
...defaultLanguages.map((lang) => ({
value: lang.code,
label: `${lang.language} (default)`,
})),
// Non-default languages
...nonDefaultLanguages.map(lang => ({
...nonDefaultLanguages.map((lang) => ({
value: lang.code,
label: lang.language,
})),
];
// Expand LANGUAGE_OPTION_DEFAULT to individual default codes for comparison
const expandedValue = value.flatMap(v =>
v === LANGUAGE_OPTION_DEFAULT ? defaultLanguageCodes : [v]
const expandedValue = value.flatMap((v) =>
v === LANGUAGE_OPTION_DEFAULT ? defaultLanguageCodes : [v],
);
const includesAllSelection = value.includes(LANGUAGE_OPTION_ALL);
const effectiveValue = includesAllSelection ? selectableValues : expandedValue;
const selectedSet = new Set(effectiveValue);
const isAllSelected = selectableValues.every(code => selectedSet.has(code));
const isAllSelected = selectableValues.every((code) => selectedSet.has(code));
const displayedValue = isAllSelected ? [LANGUAGE_OPTION_ALL, ...effectiveValue] : effectiveValue;
const summaryFormatter = (_selected: DropdownListOption[], fallback: string) => {
@@ -69,7 +73,7 @@ export const LanguageMultiSelect = ({
const labels: string[] = [];
// Check each language
options.forEach(lang => {
options.forEach((lang) => {
if (selectedSet.has(lang.code)) {
labels.push(lang.language);
}
@@ -95,7 +99,7 @@ export const LanguageMultiSelect = ({
const toggledAllOff =
isAllSelected && !includesAll && nextArray.length === effectiveValue.length;
let resolved = nextArray.filter(code => code !== LANGUAGE_OPTION_ALL);
let resolved = nextArray.filter((code) => code !== LANGUAGE_OPTION_ALL);
if (toggledAllOn) {
resolved = [LANGUAGE_OPTION_ALL];
+53 -42
View File
@@ -1,8 +1,11 @@
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import type { FormEvent, KeyboardEvent } from 'react';
import { useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
import { useMountEffect } from '../hooks/useMountEffect';
import type { LoginCredentials } from '../types';
import { buildOidcLoginUrl } from '../utils/authRedirect';
import { withBasePath } from '../utils/basePath';
interface LoginFormProps {
onSubmit: (credentials: LoginCredentials) => void;
@@ -22,18 +25,14 @@ const EyeIcon = () => (
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
className="h-5 w-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
@@ -44,7 +43,7 @@ const EyeSlashIcon = () => (
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
className="h-5 w-5"
>
<path
strokeLinecap="round"
@@ -57,11 +56,11 @@ const EyeSlashIcon = () => (
const PasswordLoginForm = ({
onSubmit,
isLoading,
autoFocus,
shouldFocusOnMount,
}: {
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
autoFocus: boolean;
shouldFocusOnMount: boolean;
}) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -70,11 +69,11 @@ const PasswordLoginForm = ({
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) {
useMountEffect(() => {
if (shouldFocusOnMount) {
usernameRef.current?.focus();
}
}, [autoFocus]);
});
const handleUsernameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
@@ -94,7 +93,7 @@ const PasswordLoginForm = ({
onSubmit={onSubmit}
>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium mb-2">
<label htmlFor="username" className="mb-2 block text-sm font-medium">
Username
</label>
<input
@@ -112,7 +111,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-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="w-full rounded-lg border px-4 py-2.5 transition-colors focus:ring-2 focus:ring-sky-500 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
@@ -123,7 +122,7 @@ const PasswordLoginForm = ({
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium mb-2">
<label htmlFor="password" className="mb-2 block text-sm font-medium">
Password
</label>
<div className="relative">
@@ -141,7 +140,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-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
className="w-full rounded-lg border px-4 py-2.5 pr-10 transition-colors focus:ring-2 focus:ring-sky-500 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
@@ -153,7 +152,7 @@ const PasswordLoginForm = ({
type="button"
onClick={() => setShowPassword((current) => !current)}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="hover-action absolute top-1/2 right-2 -translate-y-1/2 transform rounded-full p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
@@ -169,7 +168,7 @@ const PasswordLoginForm = ({
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded-sm focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
className="h-4 w-4 rounded-sm accent-sky-900 focus:ring-2 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
@@ -181,13 +180,13 @@ const PasswordLoginForm = ({
type="submit"
name="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
className="w-full rounded-lg bg-sky-700 px-4 py-2.5 font-medium text-white transition-colors hover:bg-sky-800 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-sky-700"
aria-label="Sign in"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
className="mr-3 -ml-1 h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -199,12 +198,12 @@ const PasswordLoginForm = ({
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
/>
</svg>
Signing in...
</span>
@@ -228,29 +227,35 @@ export const LoginForm = ({
}: LoginFormProps) => {
const isOidc = authMode === 'oidc';
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
const [lastAutoExpandPasswordLoginKey, setLastAutoExpandPasswordLoginKey] = useState('');
const [searchParams] = useSearchParams();
const oidcError = searchParams.get('oidc_error');
const oidcLoginUrl = buildOidcLoginUrl(searchParams.toString());
const autoExpandPasswordLoginKey = isOidc && error ? error : '';
// Auto-expand password form if there's an error (likely from a password attempt)
useEffect(() => {
if (error && isOidc) {
// React recommends adjusting state during render in rare cases like this,
// rather than mirroring props in an effect. This preserves the previous
// auto-open-on-new-error behavior while still letting the user hide the form.
if (autoExpandPasswordLoginKey !== lastAutoExpandPasswordLoginKey) {
setLastAutoExpandPasswordLoginKey(autoExpandPasswordLoginKey);
if (autoExpandPasswordLoginKey) {
setShowPasswordLogin(true);
}
}, [error, isOidc]);
}
// Auto-redirect to OIDC provider when enabled and no errors present
useEffect(() => {
useMountEffect(() => {
if (oidcAutoRedirect && isOidc && !error && !oidcError) {
window.location.href = oidcLoginUrl;
}
}, [oidcAutoRedirect, isOidc, error, oidcError, oidcLoginUrl]);
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const usernameValue = (formData.get('username') as string)?.trim() || '';
const passwordValue = (formData.get('password') as string) || '';
const usernameEntry = formData.get('username');
const passwordEntry = formData.get('password');
const usernameValue = typeof usernameEntry === 'string' ? usernameEntry.trim() : '';
const passwordValue = typeof passwordEntry === 'string' ? passwordEntry : '';
if (usernameValue && passwordValue && !isLoading) {
onSubmit({
@@ -266,28 +271,26 @@ export const LoginForm = ({
return (
<div>
{displayError && (
<div className="mb-4 p-3 rounded-lg text-sm bg-red-600 text-white">
{displayError}
</div>
<div className="mb-4 rounded-lg bg-red-600 p-3 text-sm text-white">{displayError}</div>
)}
{isOidc ? (
<>
<a
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"
className="block w-full rounded-lg bg-sky-700 px-4 py-2.5 text-center font-medium text-white transition-colors hover:bg-sky-800"
>
{oidcButtonLabel || 'Sign in with OIDC'}
</a>
{!hideLocalAuth && (
<>
<div className="flex items-center mt-5 mb-2">
<div className="mt-5 mb-2 flex items-center">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<button
type="button"
onClick={() => setShowPasswordLogin((prev) => !prev)}
className="px-3 text-sm opacity-60 hover:opacity-100 transition-opacity"
className="px-3 text-sm opacity-60 transition-opacity hover:opacity-100"
>
{showPasswordLogin ? 'Hide' : 'Use password'}
</button>
@@ -296,14 +299,22 @@ export const LoginForm = ({
{showPasswordLogin && (
<div className="pt-2">
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={true} />
<PasswordLoginForm
onSubmit={handleSubmit}
isLoading={isLoading}
shouldFocusOnMount={true}
/>
</div>
)}
</>
)}
</>
) : (
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={autoFocus} />
<PasswordLoginForm
onSubmit={handleSubmit}
isLoading={isLoading}
shouldFocusOnMount={autoFocus}
/>
)}
</div>
);
@@ -0,0 +1,40 @@
import { useMountEffect } from '@/hooks/useMountEffect';
import { getMetadataSearchConfig } from '@/services/api';
import type { ContentType, MetadataSearchConfig } from '@/types';
interface MetadataConfigSessionProps {
contentType: ContentType;
metadataProvider: string | null;
onResolved: (config: MetadataSearchConfig | null) => void;
}
export const MetadataConfigSession = ({
contentType,
metadataProvider,
onResolved,
}: MetadataConfigSessionProps) => {
useMountEffect(() => {
let cancelled = false;
void getMetadataSearchConfig(contentType, metadataProvider ?? undefined)
.then((nextConfig) => {
if (cancelled) {
return;
}
onResolved(nextConfig);
})
.catch((error) => {
console.error('Failed to load metadata search config:', error);
if (cancelled) {
return;
}
onResolved(null);
});
return () => {
cancelled = true;
};
});
return null;
};
@@ -1,4 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useEscapeKey } from '../hooks/useEscapeKey';
interface OnBehalfConfirmationModalProps {
isOpen: boolean;
@@ -29,39 +32,8 @@ export const OnBehalfConfirmationModal = ({
}, 150);
}, [isSubmitting, onClose]);
useEffect(() => {
if (!isOpen) {
return;
}
setIsSubmitting(false);
setIsClosing(false);
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', onEscape);
return () => {
document.removeEventListener('keydown', onEscape);
};
}, [isOpen, handleClose]);
useBodyScrollLock(isOpen);
useEscapeKey(isOpen, handleClose);
if (!isOpen && !isClosing) return null;
if (!isOpen) return null;
@@ -87,9 +59,12 @@ export const OnBehalfConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
tabIndex={-1}
aria-label="Close download confirmation"
/>
<div
@@ -106,11 +81,17 @@ export const OnBehalfConfirmationModal = ({
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg p-1.5 transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close download confirmation"
disabled={isSubmitting}
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -118,11 +99,12 @@ export const OnBehalfConfirmationModal = ({
<div className="space-y-3 px-6 py-5">
<p className="text-sm opacity-90">
This download will use {actingAsName}&apos;s output preferences and destination settings.
This download will use {actingAsName}&apos;s output preferences and destination
settings.
</p>
<div className="rounded-xl border 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 wrap-break-word">{itemTitle}</p>
<p className="text-xs tracking-wide uppercase opacity-60">Title</p>
<p className="mt-1 text-sm font-medium wrap-break-word">{itemTitle}</p>
</div>
</div>
@@ -131,15 +113,17 @@ export const OnBehalfConfirmationModal = ({
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={submit}
onClick={() => {
void submit();
}}
disabled={confirmDisabled}
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center gap-2"
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? 'Queuing...' : 'Confirm'}
</button>
+132 -136
View File
@@ -1,28 +1,21 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useMountEffect } from '../hooks/useMountEffect';
import type { OnboardingStep } from '../services/api';
import {
getOnboarding,
saveOnboarding,
skipOnboarding,
executeSettingsAction,
OnboardingStep,
} from '../services/api';
import {
SettingsField,
TextFieldConfig,
PasswordFieldConfig,
CheckboxFieldConfig,
SelectFieldConfig,
MultiSelectFieldConfig,
TagListFieldConfig,
HeadingFieldConfig,
ActionButtonConfig,
ActionResult,
ShowWhenCondition,
} from '../types/settings';
import { FieldWrapper } from './settings/shared';
import type { SettingsField, ActionResult, ShowWhenCondition } from '../types/settings';
import { toBooleanValue, toStringArray, toStringValue } from '../utils/objectHelpers';
import {
TextField,
PasswordField,
NumberField,
CheckboxField,
SelectField,
MultiSelectField,
@@ -30,6 +23,7 @@ import {
HeadingField,
ActionButton,
} from './settings/fields';
import { FieldWrapper } from './settings/shared';
interface OnboardingModalProps {
isOpen: boolean;
@@ -40,7 +34,7 @@ interface OnboardingModalProps {
function evaluateShowWhenCondition(
showWhen: ShowWhenCondition,
values: Record<string, unknown>
values: Record<string, unknown>,
): boolean {
const currentValue = values[showWhen.field];
@@ -52,15 +46,12 @@ function evaluateShowWhenCondition(
}
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
? typeof currentValue === 'string' && showWhen.value.includes(currentValue)
: currentValue === showWhen.value;
}
// Check if a field should be visible based on showWhen condition
function isFieldVisible(
field: SettingsField,
values: Record<string, unknown>
): boolean {
function isFieldVisible(field: SettingsField, values: Record<string, unknown>): boolean {
if ('hiddenInUi' in field && field.hiddenInUi) {
return false;
}
@@ -76,10 +67,7 @@ function isFieldVisible(
}
// Check if a step should be visible based on its showWhen conditions (all must be true)
function isStepVisible(
step: OnboardingStep,
values: Record<string, unknown>
): boolean {
function isStepVisible(step: OnboardingStep, values: Record<string, unknown>): boolean {
if (!step.showWhen || step.showWhen.length === 0) return true;
// All conditions must be true (AND logic)
@@ -95,14 +83,14 @@ const renderField = (
value: unknown,
onChange: (value: unknown) => void,
onAction: () => Promise<ActionResult>,
isDisabled: boolean
isDisabled: boolean,
) => {
switch (field.type) {
case 'TextField':
return (
<TextField
field={field as TextFieldConfig}
value={(value as string) ?? ''}
field={field}
value={toStringValue(value) ?? ''}
onChange={onChange}
disabled={isDisabled}
/>
@@ -110,8 +98,17 @@ const renderField = (
case 'PasswordField':
return (
<PasswordField
field={field as PasswordFieldConfig}
value={(value as string) ?? ''}
field={field}
value={toStringValue(value) ?? ''}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'NumberField':
return (
<NumberField
field={field}
value={typeof value === 'number' ? value : field.value}
onChange={onChange}
disabled={isDisabled}
/>
@@ -119,8 +116,8 @@ const renderField = (
case 'CheckboxField':
return (
<CheckboxField
field={field as CheckboxFieldConfig}
value={(value as boolean) ?? false}
field={field}
value={toBooleanValue(value) ?? false}
onChange={onChange}
disabled={isDisabled}
/>
@@ -128,8 +125,8 @@ const renderField = (
case 'SelectField':
return (
<SelectField
field={field as SelectFieldConfig}
value={(value as string) ?? ''}
field={field}
value={toStringValue(value) ?? ''}
onChange={onChange}
disabled={isDisabled}
/>
@@ -137,8 +134,8 @@ const renderField = (
case 'MultiSelectField':
return (
<MultiSelectField
field={field as MultiSelectFieldConfig}
value={(value as string[]) ?? []}
field={field}
value={toStringArray(value) ?? []}
onChange={onChange}
disabled={isDisabled}
/>
@@ -146,22 +143,20 @@ const renderField = (
case 'TagListField':
return (
<TagListField
field={field as TagListFieldConfig}
value={(value as string[]) ?? []}
field={field}
value={toStringArray(value) ?? []}
onChange={(v) => onChange(v)}
disabled={isDisabled}
/>
);
case 'ActionButton':
return (
<ActionButton
field={field as ActionButtonConfig}
onAction={onAction}
disabled={isDisabled}
/>
);
return <ActionButton field={field} onAction={onAction} disabled={isDisabled} />;
case 'HeadingField':
return <HeadingField field={field as HeadingFieldConfig} />;
return <HeadingField field={field} />;
case 'OrderableListField':
case 'TableField':
case 'CustomComponentField':
return <div>Unsupported onboarding field type: {field.type}</div>;
default:
return <div>Unknown field type</div>;
}
@@ -173,18 +168,55 @@ export const OnboardingModal = ({
onComplete,
onShowToast,
}: OnboardingModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
setSessionVersion((current) => current + 1);
}, 150);
}, [onClose]);
useBodyScrollLock(isOpen);
useEscapeKey(isOpen, handleClose);
if (!isOpen && !isClosing) return null;
return (
<OnboardingModalSession
key={sessionVersion}
isClosing={isClosing}
handleClose={handleClose}
onComplete={onComplete}
onShowToast={onShowToast}
/>
);
};
interface OnboardingModalSessionProps {
isClosing: boolean;
handleClose: () => void;
onComplete: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const OnboardingModalSession = ({
isClosing,
handleClose,
onComplete,
onShowToast,
}: OnboardingModalSessionProps) => {
const [steps, setSteps] = useState<OnboardingStep[]>([]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch onboarding config on mount
useEffect(() => {
if (!isOpen) return;
useMountEffect(() => {
const fetchOnboarding = async () => {
try {
setIsLoading(true);
@@ -200,8 +232,8 @@ export const OnboardingModal = ({
}
};
fetchOnboarding();
}, [isOpen]);
void fetchOnboarding();
});
// Get visible steps based on current values
const visibleSteps = useMemo(() => {
@@ -236,15 +268,6 @@ export const OnboardingModal = ({
}
}, [currentStepIndex]);
// Handle close with animation
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsClosing(false);
onClose();
}, 150);
}, [onClose]);
// Handle skip
const handleSkip = useCallback(async () => {
try {
@@ -297,47 +320,17 @@ export const OnboardingModal = ({
};
}
},
[currentStep, values]
[currentStep, values],
);
// Handle ESC key
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleClose]);
// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}
}, [isOpen]);
if (!isOpen && !isClosing) return null;
// Loading state
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-xs" />
<div
className="relative rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="relative rounded-xl p-8 shadow-2xl" style={{ background: 'var(--bg)' }}>
<div className="flex items-center gap-3">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -364,15 +357,21 @@ 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-xs" onClick={handleClose} />
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-xs"
onClick={handleClose}
tabIndex={-1}
aria-label="Close setup wizard"
/>
<div
className="relative rounded-xl p-8 shadow-2xl max-w-md"
className="relative max-w-md rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="text-center space-y-4">
<div className="space-y-4 text-center">
<div className="text-red-500">
<svg
className="w-12 h-12 mx-auto"
className="mx-auto h-12 w-12"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -388,10 +387,9 @@ export const OnboardingModal = ({
</div>
<p className="text-sm">{error}</p>
<button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface)"
>
Close
</button>
@@ -409,24 +407,21 @@ 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-xs 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'}`}
/>
{/* Modal */}
<div
className={`relative w-full max-w-xl rounded-xl
border border-(--border-muted) shadow-2xl
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-xl rounded-xl border border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-label="Setup Wizard"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-(--border-muted)">
<div className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
<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">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-500/20 text-sm font-medium text-sky-500">
{currentStepIndex + 1}
</div>
<div>
@@ -437,8 +432,9 @@ export const OnboardingModal = ({
</div>
</div>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
className="rounded-lg p-1.5 transition-colors hover:bg-(--hover-surface)"
aria-label="Close"
>
<svg
@@ -447,7 +443,7 @@ export const OnboardingModal = ({
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
className="h-5 w-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
@@ -463,7 +459,7 @@ export const OnboardingModal = ({
</div>
{/* Content */}
<div className="px-6 py-5 space-y-5 min-h-[280px]">
<div className="min-h-[280px] space-y-5 px-6 py-5">
{visibleFields.map((field) => {
const isDisabled = 'fromEnv' in field ? (field.fromEnv ?? false) : false;
return (
@@ -473,7 +469,7 @@ export const OnboardingModal = ({
values[field.key],
(v) => handleChange(field.key, v),
() => handleAction(field.key),
isDisabled
isDisabled,
)}
</FieldWrapper>
);
@@ -481,13 +477,15 @@ export const OnboardingModal = ({
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-(--border-muted) flex items-center justify-between h-[68px]">
<div className="flex h-[68px] items-center justify-between border-t border-(--border-muted) px-6 py-4">
<div>
<button
onClick={handleSkip}
type="button"
onClick={() => {
void handleSkip();
}}
disabled={isSaving || !isFirstStep}
className={`px-4 py-2 rounded-lg text-sm font-medium
${isFirstStep ? 'opacity-60 hover:opacity-100 transition-opacity' : 'invisible'}`}
className={`rounded-lg px-4 py-2 text-sm font-medium ${isFirstStep ? 'opacity-60 transition-opacity hover:opacity-100' : 'invisible'}`}
>
Skip setup
</button>
@@ -496,12 +494,10 @@ export const OnboardingModal = ({
<div className="flex gap-3">
{!isFirstStep && (
<button
type="button"
onClick={handleBack}
disabled={isSaving}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-50"
>
Back
</button>
@@ -509,17 +505,16 @@ export const OnboardingModal = ({
{isLastStep ? (
<button
onClick={handleFinish}
type="button"
onClick={() => {
void handleFinish();
}}
disabled={isSaving}
className="px-5 py-2 rounded-lg text-sm font-medium
bg-sky-600 text-white
hover:bg-sky-700 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
flex items-center gap-2"
className="flex items-center gap-2 rounded-lg bg-sky-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -543,13 +538,10 @@ export const OnboardingModal = ({
</button>
) : (
<button
type="button"
onClick={handleNext}
disabled={isSaving}
className="px-5 py-2 rounded-lg text-sm font-medium
bg-sky-600 text-white
hover:bg-sky-700 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
flex items-center gap-1"
className="flex items-center gap-1 rounded-lg bg-sky-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
<svg
@@ -558,9 +550,13 @@ export const OnboardingModal = ({
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4"
className="h-4 w-4"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
)}
+196 -119
View File
@@ -1,27 +1,86 @@
import { ColumnSchema, Release } from '../types';
import type { ColumnSchema, Release } from '../types';
import { getColorStyleFromHint, getProtocolDotColor, getFormatColor } from '../utils/colorMaps';
import { getNestedValue } from '../utils/objectHelpers';
import {
getNestedValue,
isRecord,
toComparableText,
toNumberValue,
toStringArray,
toStringValue,
} from '../utils/objectHelpers';
import { Tooltip } from './shared/Tooltip';
interface ReleaseCellProps {
column: ColumnSchema;
release: Release;
compact?: boolean; // When true, renders badges as plain text (for mobile info lines)
onlineServers?: string[]; // For IRC: list of online server nicks to show status indicator
compact?: boolean; // When true, renders badges as plain text (for mobile info lines)
onlineServers?: string[]; // For IRC: list of online server nicks to show status indicator
}
const normalizeFlagLabel = (tag: string): string => {
const normalized = tag.trim().toLowerCase();
if (!normalized) return tag;
switch (normalized) {
case 'freeleech':
case 'free leech':
case 'fl':
return 'FL';
case 'double upload':
case 'doubleupload':
case 'du':
return 'DU';
case 'vip':
return 'VIP';
case 'internal':
case 'int':
return 'INT';
default:
return tag;
}
};
const formatRelativeTime = (dateString: string): string | null => {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return null;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return '1 day';
return `${diffDays} days`;
} catch {
return null;
}
};
const getDuplicateAwareKey = (counts: Map<string, number>, keyBase: string): string => {
const nextCount = (counts.get(keyBase) ?? 0) + 1;
counts.set(keyBase, nextCount);
return nextCount === 1 ? keyBase : `${keyBase}-${nextCount}`;
};
/**
* Generic cell renderer for release list columns.
* Renders different column types (text, badge, size, number, seeders) based on schema.
* When compact=true, badges render as plain text for use in mobile info lines.
*/
export const ReleaseCell = ({ column, release, compact = false, onlineServers }: ReleaseCellProps) => {
const rawValue = getNestedValue(release as unknown as Record<string, unknown>, column.key);
const value = rawValue !== undefined && rawValue !== null
? String(rawValue)
: column.fallback;
export const ReleaseCell = ({
column,
release,
compact = false,
onlineServers,
}: ReleaseCellProps) => {
const rawValue = getNestedValue(release, column.key);
const cellValue =
rawValue !== undefined && rawValue !== null ? toComparableText(rawValue) : column.fallback;
const displayValue = column.uppercase ? value.toUpperCase() : value;
const displayValue = column.uppercase ? cellValue.toUpperCase() : cellValue;
// Alignment classes
const alignClass = {
@@ -37,12 +96,12 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
if (compact) {
return <span>{displayValue}</span>;
}
const colorStyle = getColorStyleFromHint(value, column.color_hint);
const colorStyle = getColorStyleFromHint(cellValue, column.color_hint);
// Build rich tooltip content for formats
let tooltipContent: React.ReactNode = null;
if (column.key === 'extra.formats_display') {
const formats = (release.extra as Record<string, unknown> | undefined)?.formats;
const formats = release.extra?.formats;
if (Array.isArray(formats) && formats.length > 1) {
tooltipContent = (
<div className="flex flex-wrap gap-1.5">
@@ -51,7 +110,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<span
key={String(fmt)}
className={`${fmtColor.bg} ${fmtColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
className={`${fmtColor.bg} ${fmtColor.text} rounded-lg px-1.5 py-0.5 text-[10px] font-semibold tracking-wide sm:px-2 sm:text-[11px]`}
>
{String(fmt).toUpperCase()}
</span>
@@ -64,7 +123,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
const badge = (
<span
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
className={`${colorStyle.bg} ${colorStyle.text} rounded-lg px-1.5 py-0.5 text-[10px] font-semibold tracking-wide sm:px-2 sm:text-[11px]`}
>
{displayValue}
</span>
@@ -72,7 +131,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<div className={`flex items-center ${alignClass}`}>
{value !== column.fallback ? (
{cellValue !== column.fallback ? (
tooltipContent ? (
<Tooltip content={tooltipContent} position="top">
{badge}
@@ -81,41 +140,25 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
badge
)
) : (
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
<span className="text-[10px] text-gray-500 sm:text-xs dark:text-gray-400">
{column.fallback}
</span>
)}
</div>
);
}
case 'tags': {
const tags = Array.isArray(rawValue)
? rawValue.map((tag) => String(tag)).filter((tag) => tag.trim())
: rawValue !== undefined && rawValue !== null && String(rawValue).trim()
? [String(rawValue)]
: [];
const isFlags = column.color_hint?.type === 'map' && column.color_hint.value === 'flags';
const normalizeFlagLabel = (tag: string): string => {
const normalized = tag.trim().toLowerCase();
if (!normalized) return tag;
switch (normalized) {
case 'freeleech':
case 'free leech':
case 'fl':
return 'FL';
case 'double upload':
case 'doubleupload':
case 'du':
return 'DU';
case 'vip':
return 'VIP';
case 'internal':
case 'int':
return 'INT';
default:
return tag;
let tags: string[] = [];
if (Array.isArray(rawValue)) {
tags = rawValue.map((tag) => toComparableText(tag)).filter((tag) => tag.trim());
} else if (rawValue !== undefined && rawValue !== null) {
const comparableValue = toComparableText(rawValue).trim();
if (comparableValue) {
tags = [comparableValue];
}
};
}
const isFlags = column.color_hint?.type === 'map' && column.color_hint.value === 'flags';
if (compact) {
if (tags.length === 0) {
@@ -134,22 +177,27 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
<span className="text-[10px] text-gray-500 sm:text-xs dark:text-gray-400">
{column.fallback}
</span>
</div>
);
}
const tagKeyCounts = new Map<string, number>();
return (
<div className={`flex flex-wrap items-center gap-1.5 ${alignClass}`}>
{tags.map((tag, idx) => {
{tags.map((tag) => {
const normalized = isFlags ? normalizeFlagLabel(tag) : tag;
const displayTag = column.uppercase ? normalized.toUpperCase() : normalized;
const colorStyle = getColorStyleFromHint(tag, column.color_hint);
const tagKey = getDuplicateAwareKey(tagKeyCounts, `${column.key}|${displayTag}`);
return (
<span
key={`${tag}-${idx}`}
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide whitespace-nowrap`}
key={tagKey}
className={`${colorStyle.bg} ${colorStyle.text} rounded-lg px-1.5 py-0.5 text-[10px] font-semibold tracking-wide whitespace-nowrap sm:px-2 sm:text-[11px]`}
>
{displayTag}
</span>
@@ -161,27 +209,11 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
case 'size': {
// Build tooltip from extra metadata (torznab attrs, publish date, etc.)
const extra = release.extra as Record<string, unknown> | undefined;
const torznabAttrs = extra?.torznab_attrs as Record<string, string> | undefined;
const publishDate = extra?.publish_date as string | undefined;
const postedDate = extra?.posted_date as string | undefined;
const bitrate = extra?.bitrate as string | undefined;
// Helper to format age in days
const formatRelativeTime = (dateStr: string): string | null => {
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return '1 day';
return `${diffDays} days`;
} catch {
return null;
}
};
const extra = release.extra;
const torznabAttrs = isRecord(extra?.torznab_attrs) ? extra.torznab_attrs : undefined;
const publishDate = toStringValue(extra?.publish_date);
const postedDate = toStringValue(extra?.posted_date);
const bitrate = toStringValue(extra?.bitrate);
const rows: Array<{ label: string; value: string }> = [];
@@ -194,9 +226,9 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
}
if (postedDate) {
const value = postedDate.trim();
const relativeTime = formatRelativeTime(value);
rows.push({ label: 'Posted', value: relativeTime ?? value });
const postedDateValue = postedDate.trim();
const relativeTime = formatRelativeTime(postedDateValue);
rows.push({ label: 'Posted', value: relativeTime ?? postedDateValue });
}
if (bitrate) {
@@ -222,7 +254,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
];
for (const attr of displayAttrs) {
const val = torznabAttrs[attr.key];
const val = toStringValue(torznabAttrs[attr.key]);
if (val && val.trim()) {
rows.push({ label: attr.label, value: val.trim() });
}
@@ -230,8 +262,8 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
}
// Add files and grabs from extra
const files = extra?.files as number | undefined;
const grabs = extra?.grabs as number | undefined;
const files = toNumberValue(extra?.files);
const grabs = toNumberValue(extra?.grabs);
if (files !== undefined && files !== null) {
rows.push({ label: 'Files', value: String(files) });
}
@@ -242,10 +274,10 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
let sizeTooltipContent: React.ReactNode = null;
if (rows.length > 0) {
sizeTooltipContent = (
<div className="flex flex-col gap-1 max-w-xs">
<div className="flex max-w-xs flex-col gap-1">
{rows.map((row) => (
<div key={row.label} className="flex gap-2">
<span className="text-gray-400 dark:text-gray-500 shrink-0">{row.label}:</span>
<span className="shrink-0 text-gray-400 dark:text-gray-500">{row.label}:</span>
<span className="truncate">{row.value}</span>
</div>
))}
@@ -276,7 +308,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
// Peers display: "S/L" string with badge colored by seeder count
// Color logic: 0 = red, 1-10 = yellow, 10+ = blue
const seeders = release.seeders;
const peersValue = value || column.fallback;
const peersValue = cellValue || column.fallback;
const isFallback = seeders == null || peersValue === column.fallback;
// If no data, show plain text like badge type does
@@ -286,7 +318,9 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
<span className="text-[10px] text-gray-500 sm:text-xs dark:text-gray-400">
{column.fallback}
</span>
</div>
);
}
@@ -302,11 +336,17 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
}
if (compact) {
return <span className={`font-medium ${badgeColors.split(' ').slice(1).join(' ')}`}>{peersValue}</span>;
return (
<span className={`font-medium ${badgeColors.split(' ').slice(1).join(' ')}`}>
{peersValue}
</span>
);
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className={`${badgeColors} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}>
<span
className={`${badgeColors} rounded-lg px-1.5 py-0.5 text-[10px] font-semibold tracking-wide sm:px-2 sm:text-[11px]`}
>
{peersValue}
</span>
</div>
@@ -317,14 +357,19 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
// Indexer name with colored dot indicating protocol (torrent/usenet) and peers count
const protocol = release.protocol as string | undefined;
const dotColor = getProtocolDotColor(protocol);
const protocolLabel = protocol === 'torrent' ? 'Torrent' : protocol === 'nzb' ? 'Usenet' : protocol || 'Unknown';
let protocolLabel = protocol || 'Unknown';
if (protocol === 'torrent') {
protocolLabel = 'Torrent';
} else if (protocol === 'nzb') {
protocolLabel = 'Usenet';
}
const peers = release.peers;
if (compact) {
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
className={`h-1.5 w-1.5 shrink-0 rounded-full ${dotColor}`}
title={protocolLabel}
/>
{displayValue}
@@ -333,33 +378,34 @@ 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 shrink-0 ${dotColor}`}
title={protocolLabel}
/>
<div
className={`flex items-center ${alignClass} gap-1.5 truncate text-xs text-gray-600 dark:text-gray-300`}
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} title={protocolLabel} />
<span className="truncate">{displayValue}</span>
{peers && <span className="text-gray-400 dark:text-gray-500 shrink-0">{peers}</span>}
{peers && <span className="shrink-0 text-gray-400 dark:text-gray-500">{peers}</span>}
</div>
);
}
case 'flag_icon': {
// Colored badge showing FL, VIP, or both
if (!value || value === column.fallback) {
if (!cellValue || cellValue === column.fallback) {
if (compact) return null;
return <div className={`flex items-center ${alignClass}`} />;
}
const flagColor = getColorStyleFromHint(value, { type: 'map', value: 'flags' });
const flagColor = getColorStyleFromHint(cellValue, { type: 'map', value: 'flags' });
if (compact) {
return <span className={`${flagColor.text} font-medium`}>{value}</span>;
return <span className={`${flagColor.text} font-medium`}>{cellValue}</span>;
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className={`${flagColor.bg} ${flagColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide whitespace-nowrap`}>
{value}
<span
className={`${flagColor.bg} ${flagColor.text} rounded-lg px-1.5 py-0.5 text-[10px] font-semibold tracking-wide whitespace-nowrap sm:px-2 sm:text-[11px]`}
>
{cellValue}
</span>
</div>
);
@@ -368,9 +414,9 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
case 'format_content_type': {
// Content type icon + format badge combined
// Shows primary format as badge with colored dots for additional formats
const contentType = release.content_type as string | undefined;
const contentType = release.content_type;
const isAudiobook = contentType === 'audiobook';
const formats = (release.extra as Record<string, unknown> | undefined)?.formats as string[] | undefined;
const formats = toStringArray(release.extra?.formats);
const primaryFormat = formats?.[0] || null;
const additionalFormats = formats?.slice(1) || [];
@@ -386,13 +432,13 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
tooltipContent = (
<div className="flex flex-wrap gap-1.5">
{formats.map((fmt) => {
const fmtColor = getFormatColor(String(fmt));
const fmtColor = getFormatColor(fmt);
return (
<span
key={String(fmt)}
className={`${fmtColor.bg} ${fmtColor.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}
key={fmt}
className={`${fmtColor.bg} ${fmtColor.text} rounded-lg px-1.5 py-0.5 text-[10px] font-semibold tracking-wide sm:px-2 sm:text-[11px]`}
>
{String(fmt).toUpperCase()}
{fmt.toUpperCase()}
</span>
);
})}
@@ -403,24 +449,52 @@ 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 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
className="h-4 w-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 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
className="h-4 w-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>
);
if (compact) {
if (!primaryFormat) {
return <span className="inline-flex items-center text-gray-500" title={isAudiobook ? 'Audiobook' : 'Book'}>{icon}</span>;
return (
<span
className="inline-flex items-center text-gray-500"
title={isAudiobook ? 'Audiobook' : 'Book'}
>
{icon}
</span>
);
}
// Simple text tooltip for compact mode
const compactTooltip = formats && formats.length > 1
? formats.map((fmt) => String(fmt).toUpperCase()).join(', ')
: undefined;
const compactTooltip =
formats && formats.length > 1
? formats.map((fmt) => fmt.toUpperCase()).join(', ')
: undefined;
return (
<span className={column.uppercase ? 'uppercase' : ''} title={compactTooltip}>
{primaryFormat.toUpperCase()}
@@ -432,8 +506,13 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
// No format - just show icon with same width as format badges
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-13`}>
<div
className="flex items-center justify-start"
title={isAudiobook ? 'Audiobook' : 'Book'}
>
<span
className={`${colorStyle.bg} ${colorStyle.text} inline-flex w-13 items-center justify-center rounded-lg py-0.5 text-[10px] font-semibold sm:text-[11px]`}
>
{icon}
</span>
</div>
@@ -444,14 +523,12 @@ 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-13 text-center`}
className={`${colorStyle.bg} ${colorStyle.text} w-13 rounded-lg py-0.5 text-center text-[10px] font-semibold tracking-wide whitespace-nowrap sm:text-[11px]`}
>
{column.uppercase ? primaryFormat.toUpperCase() : primaryFormat}
</span>
{additionalFormats.length > 0 && (
<span
className="bg-gray-500/20 text-gray-700 dark:text-gray-300 text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide"
>
<span className="rounded-lg bg-gray-500/20 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-gray-700 sm:px-2 sm:text-[11px] dark:text-gray-300">
+{additionalFormats.length}
</span>
)}
@@ -485,14 +562,14 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
default: {
// Check if this is a server column with online status data
const isServerColumn = column.key === 'extra.server' && onlineServers !== undefined;
const isOnline = isServerColumn && onlineServers?.includes(value);
const isOnline = isServerColumn && onlineServers?.includes(cellValue);
if (compact) {
if (isServerColumn) {
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`h-1.5 w-1.5 shrink-0 rounded-full ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
{displayValue}
@@ -503,10 +580,12 @@ 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`}>
<div
className={`flex items-center ${alignClass} truncate text-xs text-gray-600 dark:text-gray-300`}
>
{isServerColumn && (
<span
className={`w-2 h-2 rounded-full mr-1.5 shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`mr-1.5 h-2 w-2 shrink-0 rounded-full ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
)}
@@ -516,5 +595,3 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
}
}
};
export default ReleaseCell;
File diff suppressed because it is too large Load Diff
@@ -1,31 +1,99 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CreateRequestPayload } from '../types';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useMountEffect } from '../hooks/useMountEffect';
import { getMetadataBookInfo } from '../services/api';
import { isSourceBackedRequestPayload } from '../utils/requestPayload';
import type { CreateRequestPayload } from '../types';
import type { RequestConfirmationPreview } from '../utils/requestConfirmation';
import {
applyRequestNoteToPayload,
buildRequestConfirmationPreview,
enrichPreviewFromBook,
MAX_REQUEST_NOTE_LENGTH,
RequestConfirmationPreview,
truncateRequestNote,
} from '../utils/requestConfirmation';
import { isSourceBackedRequestPayload } from '../utils/requestPayload';
interface RequestConfirmationModalProps {
payload: CreateRequestPayload | null;
extraPayloads?: CreateRequestPayload[];
allowNotes: boolean;
onConfirm: (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]) => Promise<boolean>;
onConfirm: (
payload: CreateRequestPayload,
extraPayloads?: CreateRequestPayload[],
) => Promise<boolean>;
onClose: () => void;
}
export const RequestConfirmationModal = ({
interface RequestConfirmationModalSessionProps {
payload: CreateRequestPayload;
extraPayloads?: CreateRequestPayload[];
allowNotes: boolean;
onConfirm: (
payload: CreateRequestPayload,
extraPayloads?: CreateRequestPayload[],
) => Promise<boolean>;
onClose: () => void;
}
const getRequestConfirmationPreviewSignature = (
preview: RequestConfirmationPreview,
payload: CreateRequestPayload,
): string => {
return [
payload.context.request_level,
payload.context.content_type,
payload.context.source,
preview.title,
preview.author,
preview.year,
preview.seriesLine,
preview.releaseLine,
].join('|');
};
const getRequestConfirmationSessionKey = (payload: CreateRequestPayload): string => {
return [
payload.context.source,
payload.context.content_type,
payload.context.request_level,
JSON.stringify(payload.book_data),
JSON.stringify(payload.release_data ?? null),
String(payload.on_behalf_of_user_id ?? ''),
].join('|');
};
export function RequestConfirmationModal({
payload,
extraPayloads = [],
allowNotes,
onConfirm,
onClose,
}: RequestConfirmationModalProps) => {
}: RequestConfirmationModalProps) {
if (!payload) {
return null;
}
return (
<RequestConfirmationModalSession
key={getRequestConfirmationSessionKey(payload)}
payload={payload}
extraPayloads={extraPayloads}
allowNotes={allowNotes}
onConfirm={onConfirm}
onClose={onClose}
/>
);
}
function RequestConfirmationModalSession({
payload,
extraPayloads = [],
allowNotes,
onConfirm,
onClose,
}: RequestConfirmationModalSessionProps) {
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@@ -35,63 +103,53 @@ export const RequestConfirmationModal = ({
return;
}
setIsClosing(true);
setTimeout(() => {
window.setTimeout(() => {
onClose();
setIsClosing(false);
}, 150);
}, [isSubmitting, onClose]);
useEffect(() => {
if (payload) {
setNote('');
setIsSubmitting(false);
setIsClosing(false);
}
}, [payload]);
useEffect(() => {
if (!payload) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [payload]);
useEffect(() => {
if (!payload) return;
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', onEscape);
return () => document.removeEventListener('keydown', onEscape);
}, [payload, handleClose]);
useBodyScrollLock(Boolean(payload));
useEscapeKey(Boolean(payload), handleClose);
const basePreview = useMemo(() => {
return payload ? buildRequestConfirmationPreview(payload) : null;
}, [payload]);
const extraPreviews = useMemo(() => {
return extraPayloads.map(buildRequestConfirmationPreview);
const extraPreviewEntries = useMemo(() => {
const signatureCounts = new Map<string, number>();
return extraPayloads.map((extraPayload) => {
const preview = buildRequestConfirmationPreview(extraPayload);
const signature = getRequestConfirmationPreviewSignature(preview, extraPayload);
const nextCount = (signatureCounts.get(signature) ?? 0) + 1;
signatureCounts.set(signature, nextCount);
return {
key: nextCount === 1 ? signature : `${signature}|${nextCount}`,
payload: extraPayload,
preview,
};
});
}, [extraPayloads]);
const [enriched, setEnriched] = useState<RequestConfirmationPreview | null>(null);
const enrichRef = useRef(0);
useEffect(() => {
setEnriched(null);
if (!payload) return;
useMountEffect(() => {
const bookData = payload.book_data || {};
const currentBasePreview = basePreview;
if (!currentBasePreview) {
return;
}
const provider = bookData.provider;
const providerId = bookData.provider_id;
// Only fetch for metadata providers, and skip if series info is already present
if (
typeof provider !== 'string' || !provider ||
typeof providerId !== 'string' || !providerId ||
typeof provider !== 'string' ||
!provider ||
typeof providerId !== 'string' ||
!providerId ||
isSourceBackedRequestPayload(payload) ||
bookData.series_name
) {
@@ -103,18 +161,16 @@ export const RequestConfirmationModal = ({
.then((book) => {
if (id !== enrichRef.current) return;
if (book.series_name) {
setEnriched((prev) => enrichPreviewFromBook(prev ?? basePreview!, book));
setEnriched((prev) => enrichPreviewFromBook(prev ?? currentBasePreview, book));
}
})
.catch(() => {
// Enrichment is best-effort; ignore failures
});
}, [payload, basePreview]);
});
const preview = enriched ?? basePreview;
if (!payload && !isClosing) return null;
if (!payload) return null;
if (!preview) return null;
const titleId = 'request-confirmation-modal-title';
@@ -128,7 +184,10 @@ export const RequestConfirmationModal = ({
setIsSubmitting(true);
try {
const nextPayload = applyRequestNoteToPayload(payload, note, allowNotes);
const success = await onConfirm(nextPayload, extraPayloads.length > 0 ? extraPayloads : undefined);
const success = await onConfirm(
nextPayload,
extraPayloads.length > 0 ? extraPayloads : undefined,
);
if (!success) {
setIsSubmitting(false);
}
@@ -139,9 +198,12 @@ export const RequestConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
tabIndex={-1}
aria-label="Close request confirmation"
/>
<div
@@ -158,11 +220,17 @@ export const RequestConfirmationModal = ({
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg p-1.5 transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close request confirmation"
disabled={isSubmitting}
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -171,27 +239,25 @@ export const RequestConfirmationModal = ({
<div className="space-y-4 px-6 py-5">
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-4">
<div className="flex gap-4">
<div className="w-16 h-24 shrink-0 rounded-lg overflow-hidden border border-(--border-muted) bg-(--bg)">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-lg border border-(--border-muted) bg-(--bg)">
{preview.preview ? (
<img
src={preview.preview}
alt={`${preview.title} cover`}
className="w-full h-full object-cover object-top"
className="h-full w-full object-cover object-top"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-[10px] opacity-60">
<div className="flex h-full w-full items-center justify-center text-[10px] opacity-60">
No cover
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-snug">{preview.title}</p>
<p className="text-sm opacity-80 mt-1">{preview.author}</p>
<p className="text-sm leading-snug font-semibold">{preview.title}</p>
<p className="mt-1 text-sm opacity-80">{preview.author}</p>
{(preview.year || preview.seriesLine) && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1.5">
{preview.year && (
<span className="text-xs opacity-60">{preview.year}</span>
)}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
{preview.year && <span className="text-xs opacity-60">{preview.year}</span>}
{preview.year && preview.seriesLine && (
<span className="text-xs opacity-40">·</span>
)}
@@ -201,22 +267,31 @@ export const RequestConfirmationModal = ({
</div>
)}
{/* Release lines — show all (primary + extras) with content type labels when combined */}
{(preview.releaseLine || extraPreviews.length > 0) && (
{(preview.releaseLine || extraPreviewEntries.length > 0) && (
<div className="mt-1.5 space-y-0.5">
{preview.releaseLine && (
<p className="text-xs opacity-60">
{extraPreviews.length > 0 && (
<span className="font-medium opacity-80">{payload.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
{extraPreviewEntries.length > 0 && (
<span className="font-medium opacity-80">
{payload.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}
</span>
)}
{preview.releaseLine}
</p>
)}
{extraPreviews.map((ep, i) => ep.releaseLine && (
<p key={i} className="text-xs opacity-60">
<span className="font-medium opacity-80">{extraPayloads[i]?.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
{ep.releaseLine}
</p>
))}
{extraPreviewEntries.map(
({ key, payload: extraPayload, preview: extraPreview }) =>
extraPreview.releaseLine && (
<p key={key} className="text-xs opacity-60">
<span className="font-medium opacity-80">
{extraPayload.context.content_type === 'ebook'
? 'Book: '
: 'Audiobook: '}
</span>
{extraPreview.releaseLine}
</p>
),
)}
</div>
)}
</div>
@@ -234,11 +309,11 @@ 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-(--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"
className="min-h-[96px] w-full resize-y rounded-lg border border-(--border-muted) bg-(--bg) px-3 py-2 text-sm focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden"
placeholder="Add context for admins reviewing this request..."
disabled={isSubmitting}
/>
<p className="text-xs opacity-60 text-right">
<p className="text-right text-xs opacity-60">
{note.length}/{MAX_REQUEST_NOTE_LENGTH}
</p>
</div>
@@ -250,21 +325,35 @@ export const RequestConfirmationModal = ({
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={submit}
onClick={() => {
void submit();
}}
disabled={confirmDisabled}
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center gap-2"
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Requesting...
</>
@@ -276,4 +365,4 @@ export const RequestConfirmationModal = ({
</div>
</div>
);
};
}
+111 -86
View File
@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo, SortOption } from '../types';
import { useState } from 'react';
import { useSearchMode } from '../contexts/SearchModeContext';
import { SORT_OPTIONS } from '../data/filterOptions';
import { useMediaQuery } from '../hooks/useMediaQuery';
import type { Book, ButtonStateInfo, SortOption } from '../types';
import { Dropdown } from './Dropdown';
import { CardView } from './resultsViews/CardView';
import { CompactView } from './resultsViews/CompactView';
import { ListView } from './resultsViews/ListView';
import { Dropdown } from './Dropdown';
import { SORT_OPTIONS } from '../data/filterOptions';
// Grid layout classes by view mode
const GRID_CLASSES = {
@@ -55,51 +57,51 @@ export const ResultsSection = ({
resultsSourceUrl,
}: ResultsSectionProps) => {
const { searchMode } = useSearchMode();
const activeViewClasses =
searchMode === 'universal'
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'bg-sky-700 text-white hover:bg-sky-800';
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
const saved = localStorage.getItem('bookViewMode');
return saved === 'card' || saved === 'compact' || saved === 'list' ? saved : 'compact';
if (typeof window === 'undefined') {
return 'compact';
}
try {
const saved = window.localStorage.getItem('bookViewMode');
return saved === 'card' || saved === 'compact' || saved === 'list' ? saved : 'compact';
} catch {
return 'compact';
}
});
const isDesktop = useMediaQuery('(min-width: 640px)');
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
localStorage.setItem('bookViewMode', viewMode);
}, [viewMode]);
// Track whether we're in desktop layout (sm breakpoint and above)
// Debounced to avoid excessive state updates during resize
useEffect(() => {
let timeoutId: number;
const checkDesktop = () => {
clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
setIsDesktop(window.innerWidth >= 640); // sm breakpoint
}, 100);
};
// Initial check without debounce
setIsDesktop(window.innerWidth >= 640);
window.addEventListener('resize', checkDesktop);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', checkDesktop);
};
}, []);
const updateViewMode = (nextViewMode: 'card' | 'compact' | 'list') => {
setViewMode(nextViewMode);
try {
window.localStorage.setItem('bookViewMode', nextViewMode);
} catch {
// Best effort only.
}
};
if (!visible) return null;
return (
<section id="results-section" className="mb-4 sm:mb-8 w-full">
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
{showSortControl ? (
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
) : resultsSourceUrl ? (
<section id="results-section" className="mb-4 w-full sm:mb-8">
<div className="relative z-10 mb-2 flex items-center justify-between sm:mb-3">
{showSortControl && (
<SortControl
value={sortValue}
onChange={onSortChange}
metadataSortOptions={metadataSortOptions}
/>
)}
{!showSortControl && resultsSourceUrl && (
<a
href={resultsSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 animate-pop-up"
className="animate-pop-up inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
View list on Hardcover
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -111,18 +113,17 @@ export const ResultsSection = ({
/>
</svg>
</a>
) : null}
)}
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="flex items-center gap-2 ml-auto">
<div className="ml-auto flex items-center gap-2">
{isDesktop && (
<button
onClick={() => setViewMode('card')}
className={`p-2 rounded-full transition-all duration-200 ${
type="button"
onClick={() => updateViewMode('card')}
className={`rounded-full p-2 transition-all duration-200 ${
viewMode === 'card'
? searchMode === 'universal'
? 'text-white bg-emerald-600 hover:bg-emerald-700'
: 'text-white bg-sky-700 hover:bg-sky-800'
? activeViewClasses
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="Card view"
@@ -130,7 +131,7 @@ export const ResultsSection = ({
aria-pressed={viewMode === 'card'}
>
<svg
className="w-5 h-5"
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -145,12 +146,11 @@ export const ResultsSection = ({
</button>
)}
<button
onClick={() => setViewMode('compact')}
className={`p-2 rounded-full transition-all duration-200 ${
type="button"
onClick={() => updateViewMode('compact')}
className={`rounded-full p-2 transition-all duration-200 ${
viewMode === 'compact'
? searchMode === 'universal'
? 'text-white bg-emerald-600 hover:bg-emerald-700'
: 'text-white bg-sky-700 hover:bg-sky-800'
? activeViewClasses
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="Compact view"
@@ -158,7 +158,7 @@ export const ResultsSection = ({
aria-pressed={viewMode === 'compact'}
>
<svg
className="w-5 h-5"
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -171,12 +171,11 @@ export const ResultsSection = ({
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-full transition-all duration-200 ${
type="button"
onClick={() => updateViewMode('list')}
className={`rounded-full p-2 transition-all duration-200 ${
viewMode === 'list'
? searchMode === 'universal'
? 'text-white bg-emerald-600 hover:bg-emerald-700'
: 'text-white bg-sky-700 hover:bg-sky-800'
? activeViewClasses
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="List view"
@@ -184,7 +183,7 @@ export const ResultsSection = ({
aria-pressed={viewMode === 'list'}
>
<svg
className="w-5 h-5"
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -200,7 +199,16 @@ export const ResultsSection = ({
</div>
</div>
{viewMode === 'list' ? (
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} onShowToast={onShowToast} />
<ListView
books={books}
onDetails={onDetails}
onDownload={onDownload}
onGetReleases={onGetReleases}
getButtonState={getButtonState}
getUniversalButtonState={getUniversalButtonState}
showSeriesPosition={sortValue === 'series_order'}
onShowToast={onShowToast}
/>
) : (
<div
id="results-grid"
@@ -210,9 +218,10 @@ export const ResultsSection = ({
const shouldUseCardLayout = isDesktop && viewMode === 'card';
const animationDelay = index * 50;
// Use appropriate button state function based on search mode
const buttonState = searchMode === 'universal'
? getUniversalButtonState(book.id)
: getButtonState(book.id);
const buttonState =
searchMode === 'universal'
? getUniversalButtonState(book.id)
: getButtonState(book.id);
return shouldUseCardLayout ? (
<CardView
@@ -243,27 +252,38 @@ export const ResultsSection = ({
})}
</div>
)}
{books.length === 0 && (
<div className="mt-4 text-sm opacity-80">No results found.</div>
)}
{books.length === 0 && <div className="mt-4 text-sm opacity-80">No results found.</div>}
{/* Load More button (universal mode pagination) */}
{searchMode === 'universal' && hasMore && onLoadMore && (
<div className="mt-12 flex flex-col items-center gap-2">
<button
type="button"
onClick={onLoadMore}
disabled={isLoadingMore}
className={`px-3 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
className={`rounded-full px-3 py-2 text-sm font-medium transition-all duration-200 ${
isLoadingMore
? 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-wait'
: 'bg-emerald-600 hover:bg-emerald-700 text-white'
? 'cursor-wait bg-gray-300 text-gray-500 dark:bg-gray-600 dark:text-gray-400'
: 'bg-emerald-600 text-white hover:bg-emerald-700'
}`}
>
{isLoadingMore ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</span>
@@ -297,10 +317,14 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
const { searchMode } = useSearchMode();
// Use different sort options based on search mode
// For universal mode, use dynamic options from API (with fallback)
const sortOptions = searchMode === 'universal'
? (metadataSortOptions && metadataSortOptions.length > 0 ? metadataSortOptions : DEFAULT_UNIVERSAL_SORT_OPTIONS)
: SORT_OPTIONS;
const selected = sortOptions.find(option => option.value === value) ?? sortOptions[0];
let sortOptions = SORT_OPTIONS;
if (searchMode === 'universal') {
sortOptions =
metadataSortOptions && metadataSortOptions.length > 0
? metadataSortOptions
: DEFAULT_UNIVERSAL_SORT_OPTIONS;
}
const selected = sortOptions.find((option) => option.value === value) ?? sortOptions[0];
return (
<Dropdown
@@ -310,7 +334,7 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
<button
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 ${
className={`hover-action relative flex items-center gap-2 rounded-full px-3 py-2 text-gray-900 transition-all duration-200 dark:text-gray-100 ${
isOpen ? 'bg-(--hover-action)' : ''
} animate-pop-up`}
aria-haspopup="listbox"
@@ -323,7 +347,7 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5 sm:w-6 sm:h-6"
className="h-5 w-5 sm:h-6 sm:w-6"
>
<path
strokeLinecap="round"
@@ -337,19 +361,20 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
>
{({ close }) => (
<div role="listbox" aria-label="Sort results">
{sortOptions.map(option => {
{sortOptions.map((option) => {
const isSelected = option.value === selected.value;
let selectedClassName = '';
if (isSelected) {
selectedClassName =
searchMode === 'universal'
? 'font-medium text-emerald-600 dark:text-emerald-400'
: 'font-medium text-sky-600 dark:text-sky-300';
}
return (
<button
type="button"
key={option.value || 'default'}
className={`w-full px-3 py-2 text-left text-base flex items-center justify-between gap-2 hover-surface ${
isSelected
? searchMode === 'universal'
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-sky-600 dark:text-sky-300 font-medium'
: ''
}`}
className={`hover-surface flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-base ${selectedClassName}`}
onClick={() => {
onChange(option.value);
close();
@@ -365,7 +390,7 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
className="h-4 w-4"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
import { startTransition } from 'react';
import { useMountEffect } from '@/hooks/useMountEffect';
import type { DynamicFieldOption } from '@/services/api';
import { fetchFieldOptions } from '@/services/api';
const autocompleteOptionsCache = new Map<string, DynamicFieldOption[]>();
const AUTOCOMPLETE_CACHE_MAX = 100;
const cacheAutocompleteOptions = (cacheKey: string, options: DynamicFieldOption[]): void => {
if (autocompleteOptionsCache.size >= AUTOCOMPLETE_CACHE_MAX) {
const oldest = autocompleteOptionsCache.keys().next().value;
if (oldest !== undefined) {
autocompleteOptionsCache.delete(oldest);
}
}
autocompleteOptionsCache.set(cacheKey, options);
};
interface SearchBarAutocompleteSessionProps {
autocompleteEndpoint: string;
query: string;
minQueryLength: number;
onLoading: (requestKey: string) => void;
onResolved: (requestKey: string, options: DynamicFieldOption[]) => void;
}
export const SearchBarAutocompleteSession = ({
autocompleteEndpoint,
query,
minQueryLength,
onLoading,
onResolved,
}: SearchBarAutocompleteSessionProps) => {
useMountEffect(() => {
const normalizedQuery = query.trim();
if (normalizedQuery.length < minQueryLength) {
return undefined;
}
const requestKey = `${autocompleteEndpoint}::${normalizedQuery.toLowerCase()}`;
const cached = autocompleteOptionsCache.get(requestKey);
if (cached) {
onResolved(requestKey, cached);
return undefined;
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
if (cancelled) {
return;
}
onLoading(requestKey);
void fetchFieldOptions(autocompleteEndpoint, normalizedQuery)
.then((loaded) => {
if (cancelled) {
return;
}
cacheAutocompleteOptions(requestKey, loaded);
startTransition(() => {
onResolved(requestKey, loaded);
});
})
.catch(() => {
if (cancelled) {
return;
}
startTransition(() => {
onResolved(requestKey, []);
});
});
}, 260);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
});
return null;
};
@@ -0,0 +1,45 @@
import type { SortOption } from '@/types';
import { loadDynamicFieldOptions } from '@/utils/dynamicFieldOptions';
import { useMountEffect } from '../hooks/useMountEffect';
interface SearchBarDynamicOptionsSessionProps {
dynamicEndpoint: string;
onResolved: (options: SortOption[]) => void;
}
export const SearchBarDynamicOptionsSession = ({
dynamicEndpoint,
onResolved,
}: SearchBarDynamicOptionsSessionProps) => {
useMountEffect(() => {
let cancelled = false;
void loadDynamicFieldOptions(dynamicEndpoint)
.then((loaded) => {
if (cancelled) {
return;
}
onResolved(
loaded.map((option) => ({
value: option.value,
label: option.label,
group: option.group,
})),
);
})
.catch(() => {
if (cancelled) {
return;
}
onResolved([]);
});
return () => {
cancelled = true;
};
});
return null;
};
+16 -13
View File
@@ -1,4 +1,4 @@
import {
import type {
AdvancedFilterState,
Language,
MetadataSearchField,
@@ -75,20 +75,22 @@ export const SearchSection = ({
<section
id="search-section"
className={`${
isInitialState
? 'search-initial-state mb-6'
: 'mb-3 sm:mb-4'
isInitialState ? 'search-initial-state mb-6' : 'mb-3 sm:mb-4'
} ${showAdvanced ? 'search-advanced-visible' : ''}`}
>
<div className={`flex items-center justify-center gap-3 transition-all duration-300 ${
isInitialState ? 'opacity-100 mb-6 sm:mb-8' : 'opacity-0 h-0 mb-0 overflow-hidden'
}`}>
<div
className={`flex items-center justify-center gap-3 transition-all duration-300 ${
isInitialState ? 'mb-6 opacity-100 sm:mb-8' : 'mb-0 h-0 overflow-hidden opacity-0'
}`}
>
<img src={logoUrl} alt="Logo" className="h-8 w-8" />
<h1 className="text-2xl font-semibold">Book Search & Download</h1>
</div>
<div className={`flex flex-col gap-3 search-wrapper transition-all duration-500 ${
isInitialState ? '' : 'hidden'
}`}>
<div
className={`search-wrapper flex flex-col gap-3 transition-all duration-500 ${
isInitialState ? '' : 'hidden'
}`}
>
<SearchBar
value={queryValue}
valueLabel={queryValueLabel}
@@ -108,8 +110,9 @@ export const SearchSection = ({
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 className="px-2 text-xs opacity-50">
Manual search queries release sources directly. Some sources may return limited
metadata, which can affect file naming templates.
</p>
)}
<AdvancedFilters
@@ -119,7 +122,7 @@ export const SearchSection = ({
filters={advancedFilters}
onFiltersChange={onAdvancedFiltersChange}
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
renderWrapper={form => form}
renderWrapper={(form) => form}
searchMode={searchMode}
onSearchModeChange={onSearchModeChange}
metadataProviders={metadataProviders}
+4 -19
View File
@@ -1,23 +1,10 @@
import { useEffect, useState } from 'react';
import { Toast } from '../types';
import type { Toast } from '../types';
interface ToastContainerProps {
toasts: Toast[];
}
export const ToastContainer = ({ toasts }: ToastContainerProps) => {
const [visibleToasts, setVisibleToasts] = useState<Set<string>>(new Set());
useEffect(() => {
toasts.forEach(toast => {
if (!visibleToasts.has(toast.id)) {
setTimeout(() => {
setVisibleToasts(prev => new Set([...prev, toast.id]));
}, 10);
}
});
}, [toasts]);
const toastTypeClasses: Record<Toast['type'], string> = {
success: 'bg-green-600 text-white',
error: 'bg-red-600 text-white',
@@ -25,13 +12,11 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-1100 space-y-2">
{toasts.map(toast => (
<div id="toast-container" className="fixed right-4 bottom-4 z-1100 space-y-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`toast-notification px-4 py-3 rounded-md shadow-lg text-sm font-medium transition-all duration-300 ${
toastTypeClasses[toast.type]
} ${visibleToasts.has(toast.id) ? 'toast-visible' : ''}`}
className={`toast-notification toast-visible animate-pop-up rounded-md px-4 py-3 text-sm font-medium shadow-lg transition-all duration-300 ${toastTypeClasses[toast.type]}`}
>
{toast.message}
</div>
@@ -0,0 +1,146 @@
import type { Dispatch, SetStateAction } from 'react';
import { useMountEffect } from '@/hooks/useMountEffect';
import type { AppConfig, AdvancedFilterState, ContentType, SearchMode, SortOption } from '@/types';
import { buildSearchQuery } from '@/utils/buildSearchQuery';
import { getEffectiveMetadataSort } from '@/utils/metadataSort';
import type { ParsedUrlSearch } from '@/utils/parseUrlSearchParams';
const ADVANCED_FILTER_VISIBILITY_KEYS = ['content', 'lang', 'formats'] as const;
interface UrlSearchBootstrapMountProps {
parsedParams: ParsedUrlSearch;
config: AppConfig;
contentType: ContentType;
advancedFilters: AdvancedFilterState;
resolvedMetadataDefaultSort: string;
resolvedMetadataSortOptions: SortOption[];
setContentType: (value: ContentType) => void;
setSearchInput: (value: string) => void;
setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>;
setShowAdvanced: (value: boolean) => void;
setActiveQueryTarget: (value: string) => void;
runSearchWithPolicyRefresh: (opts: {
query: string;
contentTypeOverride?: ContentType;
searchModeOverride?: SearchMode;
}) => void;
onComplete: () => void;
}
export const UrlSearchBootstrapMount = ({
parsedParams,
config,
contentType,
advancedFilters,
resolvedMetadataDefaultSort,
resolvedMetadataSortOptions,
setContentType,
setSearchInput,
setAdvancedFilters,
setShowAdvanced,
setActiveQueryTarget,
runSearchWithPolicyRefresh,
onComplete,
}: UrlSearchBootstrapMountProps) => {
useMountEffect(() => {
onComplete();
const parsedSearchMode = config.search_mode || 'direct';
const urlContentTypeOverride =
parsedSearchMode === 'universal' ? parsedParams.contentType : undefined;
if (urlContentTypeOverride && urlContentTypeOverride !== contentType) {
setContentType(urlContentTypeOverride);
}
if (!parsedParams.hasSearchParams) {
return;
}
const bookLanguages = config.book_languages || [];
const defaultLanguageCodes =
config.default_language && config.default_language.length > 0
? config.default_language
: [bookLanguages[0]?.code || 'en'];
if (parsedParams.searchInput) {
setSearchInput(parsedParams.searchInput);
}
let nextQueryTarget = 'general';
if (parsedSearchMode === 'direct') {
if (parsedParams.advancedFilters.isbn) {
nextQueryTarget = 'isbn';
} else if (parsedParams.advancedFilters.author) {
nextQueryTarget = 'author';
} else if (parsedParams.advancedFilters.title) {
nextQueryTarget = 'title';
}
}
setActiveQueryTarget(nextQueryTarget);
const resolvedUrlMetadataSort =
parsedSearchMode === 'universal'
? getEffectiveMetadataSort({
currentSort:
typeof parsedParams.advancedFilters.sort === 'string'
? parsedParams.advancedFilters.sort
: '',
defaultSort: resolvedMetadataDefaultSort,
sortOptions: resolvedMetadataSortOptions,
})
: parsedParams.advancedFilters.sort;
if (Object.keys(parsedParams.advancedFilters).length > 0) {
setAdvancedFilters((prev) => ({
...prev,
...parsedParams.advancedFilters,
...(parsedSearchMode === 'universal' && resolvedUrlMetadataSort
? { sort: resolvedUrlMetadataSort }
: {}),
}));
const hasAdvancedValues = ADVANCED_FILTER_VISIBILITY_KEYS.some((key) => {
const value = parsedParams.advancedFilters[key];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
});
if (hasAdvancedValues) {
setShowAdvanced(true);
}
}
const mergedFilters: AdvancedFilterState = {
...advancedFilters,
...parsedParams.advancedFilters,
...(parsedSearchMode === 'universal' && resolvedUrlMetadataSort
? { sort: resolvedUrlMetadataSort }
: {}),
};
const query = buildSearchQuery({
searchInput:
parsedSearchMode === 'direct' && nextQueryTarget !== 'general'
? ''
: parsedParams.searchInput,
showAdvanced: true,
advancedFilters: {
...mergedFilters,
isbn: nextQueryTarget === 'isbn' ? parsedParams.advancedFilters.isbn || '' : '',
author: nextQueryTarget === 'author' ? parsedParams.advancedFilters.author || '' : '',
title: nextQueryTarget === 'title' ? parsedParams.advancedFilters.title || '' : '',
},
bookLanguages,
defaultLanguage: defaultLanguageCodes,
searchMode: parsedSearchMode,
});
runSearchWithPolicyRefresh({
query,
contentTypeOverride: urlContentTypeOverride,
searchModeOverride: parsedSearchMode,
});
});
return null;
};
@@ -1,14 +1,13 @@
import { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { RequestRecord } from '../../types';
import type { ReactNode } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { RequestRecord } from '../../types';
import { withBasePath } from '../../utils/basePath';
import { Tooltip } from '../shared/Tooltip';
import { ActivityItem } from './activityTypes';
import { ActivityCardAction, buildActivityCardModel } from './activityCardModel';
import {
STATUS_BADGE_STYLES,
STATUS_TOOLTIP_CLASSES,
getProgressConfig,
} from './activityStyles';
import type { ActivityCardAction } from './activityCardModel';
import { buildActivityCardModel } from './activityCardModel';
import { STATUS_BADGE_STYLES, STATUS_TOOLTIP_CLASSES, getProgressConfig } from './activityStyles';
import type { ActivityItem } from './activityTypes';
interface RequestApproveOptions {
browseOnly?: boolean;
@@ -18,7 +17,7 @@ interface RequestApproveOptions {
type RequestApproveHandler = (
requestId: number,
record: RequestRecord,
options?: RequestApproveOptions
options?: RequestApproveOptions,
) => Promise<void> | void;
interface ActivityCardProps {
@@ -43,7 +42,7 @@ interface ActivityCardProps {
}
const BookFallback = () => (
<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">
<div className="flex h-18 w-12 items-center justify-center rounded-sm bg-gray-200 text-[8px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">
No Cover
</div>
);
@@ -63,7 +62,7 @@ const IconButton = ({
type="button"
onClick={onClick}
aria-label={title}
className={`h-7 w-7 rounded-full inline-flex items-center justify-center transition-colors ${className}`}
className={`inline-flex h-7 w-7 items-center justify-center rounded-full transition-colors ${className}`}
>
{children}
</button>
@@ -88,7 +87,7 @@ const actionKey = (action: ActivityCardAction): string => {
};
const actionUiConfig = (
action: ActivityCardAction
action: ActivityCardAction,
): { title: string; className: string; icon: 'cross' | 'check' | 'stop' | 'retry' } => {
switch (action.kind) {
case 'download-remove':
@@ -118,7 +117,8 @@ const actionUiConfig = (
case 'request-approve':
return {
title: 'Approve',
className: 'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30',
className:
'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30',
icon: 'check',
};
case 'request-reject':
@@ -151,38 +151,59 @@ const actionUiConfig = (
const ActionIcon = ({ icon }: { icon: 'cross' | 'check' | 'stop' | 'retry' }) => {
if (icon === 'stop') {
return (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
);
}
if (icon === 'check') {
return (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" aria-hidden="true">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m5 13 4 4L19 7" />
</svg>
);
}
if (icon === 'retry') {
return (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M18.363 5.634A8.997 9.002 29.494 0 0 7.5 4.206 8.997 9.002 29.494 0 0 3.306 14.33 8.997 9.002 29.494 0 0 11.996 21a8.997 9.002 29.494 0 0 8.694-6.673m-2.327-8.693L20.87 8.14m.017-4.994v5.015m0 0h-5.013" />
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.363 5.634A8.997 9.002 29.494 0 0 7.5 4.206 8.997 9.002 29.494 0 0 3.306 14.33 8.997 9.002 29.494 0 0 11.996 21a8.997 9.002 29.494 0 0 8.694-6.673m-2.327-8.693L20.87 8.14m.017-4.994v5.015m0 0h-5.013"
/>
</svg>
);
}
return (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" aria-hidden="true">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
};
const asRecord = (value: unknown): Record<string, unknown> => {
if (value && typeof value === 'object') {
return value as Record<string, unknown>;
}
return {};
};
const asRecord = (value: Record<string, unknown> | null | undefined): Record<string, unknown> =>
value ?? {};
const toOptionalText = (value: unknown): string | undefined => {
if (typeof value === 'string' && value.trim()) {
@@ -232,11 +253,224 @@ 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 wrap-break-word mt-0.5">{value}</p>
<p className="text-[10px] tracking-wide uppercase opacity-60">{label}</p>
<p className="mt-0.5 text-xs font-medium wrap-break-word">{value}</p>
</div>
);
interface ReviewInlinePanelProps {
reviewRecord: RequestRecord;
showSourceField: boolean;
requestedAt: string;
requestType: string;
sourceLabel: string;
hasAttachedRelease: boolean;
missingAttachedReleaseMessage: string;
approveLabel: string;
canMarkAsApprovedWithoutRelease: boolean;
canBrowseAlternatives: boolean;
fileTitle: string;
fileSize: string;
fileFormat: string;
requiresBrowseBeforeApprove: boolean;
reviewApproveHandler: RequestApproveHandler;
}
const ReviewInlinePanel = ({
reviewRecord,
showSourceField,
requestedAt,
requestType,
sourceLabel,
hasAttachedRelease,
missingAttachedReleaseMessage,
approveLabel,
canMarkAsApprovedWithoutRelease,
canBrowseAlternatives,
fileTitle,
fileSize,
fileFormat,
requiresBrowseBeforeApprove,
reviewApproveHandler,
}: ReviewInlinePanelProps) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleReviewApprove = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
if (requiresBrowseBeforeApprove) {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true });
return;
}
await reviewApproveHandler(reviewRecord.id, reviewRecord);
} finally {
setIsSubmitting(false);
}
};
const handleReviewBrowseAlternatives = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true });
} finally {
setIsSubmitting(false);
}
};
const handleReviewManualApproval = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { manualApproval: true });
} finally {
setIsSubmitting(false);
}
};
return (
<div className="animate-fade-in -mx-4 mt-2 space-y-3 px-4 pb-2">
<div
className={`grid grid-cols-1 ${showSourceField ? 'sm:grid-cols-3' : 'sm:grid-cols-2'} gap-x-3 gap-y-1`}
>
<DetailField label="Requested" value={requestedAt} />
<DetailField label="Type" value={requestType} />
{showSourceField && <DetailField label="Source" value={sourceLabel} />}
</div>
{hasAttachedRelease ? (
<div className="space-y-2">
<p className="text-[11px] font-medium tracking-wide uppercase opacity-70">
Attached File
</p>
<div className="grid grid-cols-1 gap-x-3 gap-y-1">
<DetailField label="Title" value={fileTitle} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<DetailField label="Size" value={fileSize} />
<DetailField label="Format" value={fileFormat.toUpperCase()} />
</div>
</div>
) : (
<p className="text-xs opacity-70">{missingAttachedReleaseMessage}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleReviewApprove()}
disabled={isSubmitting}
className="rounded-md bg-green-600 px-2.5 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-60"
>
{isSubmitting ? 'Working...' : approveLabel}
</button>
{canMarkAsApprovedWithoutRelease && (
<button
type="button"
onClick={() => void handleReviewManualApproval()}
disabled={isSubmitting}
className="rounded-md border border-(--border-muted) px-2.5 py-1.5 text-xs transition-colors hover:bg-(--hover-surface) disabled:opacity-50"
>
{isSubmitting ? 'Working...' : 'Manually Mark as Approved'}
</button>
)}
{canBrowseAlternatives && hasAttachedRelease && (
<button
type="button"
onClick={() => void handleReviewBrowseAlternatives()}
disabled={isSubmitting}
className="rounded-md border border-(--border-muted) px-2.5 py-1.5 text-xs transition-colors hover:bg-(--hover-surface) disabled:opacity-50"
>
Browse Alternatives
</button>
)}
</div>
</div>
);
};
interface RejectInlinePanelProps {
requestId: number;
itemTitle: string;
onRequestRejectClose?: () => void;
onRequestRejectConfirm: (requestId: number, adminNote?: string) => Promise<void> | void;
}
const RejectInlinePanel = ({
requestId,
itemTitle,
onRequestRejectClose,
onRequestRejectConfirm,
}: RejectInlinePanelProps) => {
const [rejectNote, setRejectNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleInlineRejectConfirm = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
const trimmed = rejectNote.trim();
await onRequestRejectConfirm(requestId, trimmed || undefined);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="animate-fade-in -mx-4 mt-2 space-y-3 px-4 pb-2">
<p className="text-xs font-medium">
Reject request for <span className="opacity-80">{itemTitle || 'Untitled request'}</span>
</p>
<textarea
value={rejectNote}
onChange={(event) => setRejectNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
rows={3}
maxLength={MAX_ADMIN_NOTE_LENGTH}
placeholder="Optional note shown to the user"
className="min-h-[72px] w-full resize-y rounded-md border border-(--border-muted) bg-(--bg) px-2.5 py-2 text-xs focus:border-red-500 focus:ring-2 focus:ring-red-500/30 focus:outline-hidden"
disabled={isSubmitting}
/>
<div className="flex items-center justify-between">
<span className="text-[11px] opacity-60">
{rejectNote.length}/{MAX_ADMIN_NOTE_LENGTH}
</span>
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={onRequestRejectClose}
disabled={isSubmitting}
className="rounded-md px-2.5 py-1.5 text-xs transition-colors hover:bg-(--hover-surface) disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={() => void handleInlineRejectConfirm()}
disabled={isSubmitting}
className="rounded-md bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-60"
>
{isSubmitting ? 'Rejecting...' : 'Reject'}
</button>
</div>
</div>
</div>
);
};
const MAX_ADMIN_NOTE_LENGTH = 1000;
export const ActivityCard = ({
@@ -265,9 +499,6 @@ export const ActivityCard = ({
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
const [titleOverflow, setTitleOverflow] = useState(false);
const [isReviewSubmitting, setIsReviewSubmitting] = useState(false);
const [rejectNote, setRejectNote] = useState('');
const [isRejectSubmitting, setIsRejectSubmitting] = useState(false);
useLayoutEffect(() => {
const measureBadgeOverflow = () => {
@@ -275,9 +506,7 @@ export const ActivityCard = ({
model.badges.forEach((badge, index) => {
const badgeId = `${badge.key}-${index}`;
const element = badgeRefs.current[badgeId];
nextOverflow[badgeId] = Boolean(
element && element.scrollWidth - element.clientWidth > 1
);
nextOverflow[badgeId] = Boolean(element && element.scrollWidth - element.clientWidth > 1);
});
setBadgeOverflow((current) => {
@@ -315,9 +544,7 @@ export const ActivityCard = ({
useLayoutEffect(() => {
const measureTitleOverflow = () => {
const element = titleLineRef.current;
const nextOverflow = Boolean(
element && element.scrollWidth - element.clientWidth > 1
);
const nextOverflow = Boolean(element && element.scrollWidth - element.clientWidth > 1);
setTitleOverflow((current) => (current === nextOverflow ? current : nextOverflow));
};
@@ -340,22 +567,6 @@ export const ActivityCard = ({
const reviewApproveHandler = onRequestReviewApprove || onRequestApprove;
const isDetailsExpanded = isRequestDetailsOpen || isRequestRejectOpen;
useEffect(() => {
if (!isRequestDetailsOpen) {
setIsReviewSubmitting(false);
return;
}
}, [isRequestDetailsOpen, reviewRecord?.id, reviewRecord?.updated_at]);
useEffect(() => {
if (!isRequestRejectOpen) {
setRejectNote('');
setIsRejectSubmitting(false);
return;
}
setRejectNote('');
}, [isRequestRejectOpen, reviewRecord?.id, reviewRecord?.updated_at]);
const runAction = (action: ActivityCardAction) => {
switch (action.kind) {
case 'download-remove':
@@ -379,10 +590,10 @@ export const ActivityCard = ({
}
break;
}
onRequestApprove?.(action.requestId, action.record);
void onRequestApprove?.(action.requestId, action.record);
break;
case 'request-reject':
onRequestReject?.(action.requestId);
void onRequestReject?.(action.requestId);
break;
case 'request-cancel':
onRequestCancel?.(action.requestId);
@@ -430,85 +641,42 @@ export const ActivityCard = ({
'Unknown';
const fileSize = toOptionalText(releaseData.size) || 'Unknown';
const sourceLabel = toSourceLabel(
releaseData.source_display_name || releaseData.source || reviewRecord?.source_hint
releaseData.source_display_name || releaseData.source || reviewRecord?.source_hint,
);
const hasAttachedRelease =
reviewRecord?.request_level === 'release' && Object.keys(releaseData).length > 0;
const requiresBrowseBeforeApprove =
reviewRecord?.request_level === 'book' || !hasAttachedRelease;
const requiresBrowseBeforeApprove = reviewRecord?.request_level === 'book' || !hasAttachedRelease;
const showSourceField = reviewRecord?.request_level === 'release';
const isRetryAfterFailure = Boolean(toOptionalText(reviewRecord?.last_failure_reason));
const approveLabel =
requiresBrowseBeforeApprove
? isRetryAfterFailure
? 'Browse Releases To Retry'
: 'Browse Releases To Approve'
: 'Approve Attached File';
let approveLabel = 'Approve Attached File';
if (requiresBrowseBeforeApprove) {
approveLabel = isRetryAfterFailure ? 'Browse Releases To Retry' : 'Browse Releases To Approve';
}
const canMarkAsApprovedWithoutRelease = requiresBrowseBeforeApprove && !hasAttachedRelease;
const provider = toOptionalText(bookData.provider)?.toLowerCase();
const providerId = toOptionalText(bookData.provider_id);
const canBrowseAlternatives = Boolean(provider && providerId);
const handleReviewApprove = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
return;
}
setIsReviewSubmitting(true);
try {
if (requiresBrowseBeforeApprove) {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true });
return;
}
await reviewApproveHandler(reviewRecord.id, reviewRecord);
} finally {
setIsReviewSubmitting(false);
}
};
const handleReviewBrowseAlternatives = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
return;
}
setIsReviewSubmitting(true);
try {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true });
} finally {
setIsReviewSubmitting(false);
}
};
const handleReviewManualApproval = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
return;
}
setIsReviewSubmitting(true);
try {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { manualApproval: true });
} finally {
setIsReviewSubmitting(false);
}
};
const canShowInlineReview = Boolean(isRequestDetailsOpen && reviewRecord && reviewApproveHandler);
const rejectConfirmHandler = onRequestRejectConfirm || onRequestReject;
const canShowInlineReject = Boolean(
isRequestRejectOpen &&
item.requestId &&
rejectConfirmHandler
);
const requestedAt = reviewRecord ? formatDateTime(reviewRecord.created_at) : '';
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 wrap-break-word'
: 'text-sm truncate leading-tight min-w-0';
let missingAttachedReleaseMessage =
'No attached release data is available. Choose a release before approval.';
if (reviewRecord?.request_level === 'book') {
missingAttachedReleaseMessage = isRetryAfterFailure
? 'Previous download failed. Choose a release before re-approving.'
: 'This is a book-level request without an attached file. Choose a release before approval.';
} else if (isRetryAfterFailure) {
missingAttachedReleaseMessage =
'Previous download failed and the attached release was cleared. Choose a release before re-approving.';
}
const canShowDownloadLink =
item.kind === 'download' &&
@@ -530,40 +698,22 @@ export const ActivityCard = ({
item.title
);
const handleInlineRejectConfirm = async () => {
if (!item.requestId || !rejectConfirmHandler || isRejectSubmitting) {
return;
}
setIsRejectSubmitting(true);
try {
const trimmed = rejectNote.trim();
await rejectConfirmHandler(item.requestId, trimmed || undefined);
} finally {
setIsRejectSubmitting(false);
}
};
return (
<div
className={`px-4 py-2 -mx-4 cursor-default ${
isSelected ? 'relative' : 'hover-row'
}`}
>
<div className={`-mx-4 cursor-default px-4 py-2 ${isSelected ? 'relative' : 'hover-row'}`}>
{isSelected && (
<span
aria-hidden="true"
className="absolute left-0 top-2 bottom-2 w-1 bg-gray-400/80 dark:bg-gray-500/80"
className="absolute top-2 bottom-2 left-0 w-1 bg-gray-400/80 dark:bg-gray-500/80"
/>
)}
<div className="flex gap-3 items-start">
<div className="flex items-start gap-3">
{/* Artwork */}
<div className="w-12 h-18 rounded-sm shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
<div className="h-18 w-12 shrink-0 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-700">
{item.preview ? (
<img
src={item.preview}
alt={`${item.title} cover`}
className="w-full h-full object-cover object-top"
className="h-full w-full object-cover object-top"
/>
) : (
<BookFallback />
@@ -571,9 +721,9 @@ export const ActivityCard = ({
</div>
{/* Content */}
<div className="flex-1 min-w-0 py-0.5">
<div className="min-w-0 flex-1 py-0.5">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<Tooltip
content={!isDetailsExpanded && titleOverflow ? titleAuthorLine : undefined}
delay={0}
@@ -583,21 +733,17 @@ export const ActivityCard = ({
>
<p ref={titleLineRef} className={titleLineClassName}>
<span className="font-semibold">{titleNode}</span>
{item.author && <span className="opacity-60 text-xs"> — {item.author}</span>}
{item.author && <span className="text-xs opacity-60"> — {item.author}</span>}
</p>
</Tooltip>
</div>
<div className="shrink-0 inline-flex items-center gap-1 -my-1">
<div className="-my-1 inline-flex shrink-0 items-center gap-1">
{actions.map((action) => {
const config = actionUiConfig(action);
const icon =
action.kind === 'request-approve' && isRetryAfterFailure
? 'retry'
: config.icon;
action.kind === 'request-approve' && isRetryAfterFailure ? 'retry' : config.icon;
const actionTitle =
action.kind === 'request-approve' && isRetryAfterFailure
? 'Retry'
: config.title;
action.kind === 'request-approve' && isRetryAfterFailure ? 'Retry' : config.title;
return (
<Tooltip
key={actionKey(action)}
@@ -618,11 +764,11 @@ export const ActivityCard = ({
{showRequestDetailsToggle && onRequestDetailsToggle && (
<IconButton
title={isDetailsExpanded ? 'Hide details' : 'Show details'}
className="text-gray-500 hover-action"
className="hover-action text-gray-500"
onClick={onRequestDetailsToggle}
>
<svg
className={`w-4 h-4 transition-transform ${isDetailsExpanded ? 'rotate-180' : ''}`}
className={`h-4 w-4 transition-transform ${isDetailsExpanded ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -636,17 +782,17 @@ export const ActivityCard = ({
</div>
</div>
<p className="text-[11px] leading-tight opacity-60 truncate mt-0.5" title={item.metaLine}>
<p className="mt-0.5 truncate text-[11px] leading-tight opacity-60" title={item.metaLine}>
{item.metaLine}
</p>
{noteLine && (
<p className="text-[11px] opacity-60 italic truncate mt-0.5" title={noteLine}>
<p className="mt-0.5 truncate text-[11px] italic opacity-60" title={noteLine}>
{noteLine}
</p>
)}
<div className="mt-1.5 flex items-center gap-2 min-w-0">
<div className="mt-1.5 flex min-w-0 items-center gap-2">
{model.badges.map((badge, index) => {
const badgeId = `${badge.key}-${index}`;
const badgeStyle = STATUS_BADGE_STYLES[badge.visualStatus];
@@ -674,11 +820,11 @@ export const ActivityCard = ({
delete badgeRefs.current[badgeId];
}
}}
className={`relative px-2 py-0.5 rounded-md text-[11px] font-medium truncate ${badgeStyle.bg} ${badgeStyle.text} ${badge.isActiveDownload ? 'flex-1 min-w-0' : 'inline-block max-w-full'}`}
className={`relative truncate rounded-md px-2 py-0.5 text-[11px] font-medium ${badgeStyle.bg} ${badgeStyle.text} ${badge.isActiveDownload ? 'min-w-0 flex-1' : 'inline-block max-w-full'}`}
>
{progressConfig && badgeStyle.fillColor && (
<span
className="absolute inset-y-0 left-0 rounded-md overflow-hidden transition-[width] duration-300"
className="absolute inset-y-0 left-0 overflow-hidden rounded-md transition-[width] duration-300"
style={{ width: `${progressConfig.percent}%` }}
>
<span
@@ -686,9 +832,10 @@ export const ActivityCard = ({
style={{ backgroundColor: badgeStyle.fillColor }}
/>
<span
className="absolute inset-0 rounded-md opacity-30 activity-wave"
className="activity-wave absolute inset-0 rounded-md opacity-30"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.55) 50%, transparent 100%)',
background:
'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.55) 50%, transparent 100%)',
backgroundSize: '200% 100%',
}}
/>
@@ -701,110 +848,38 @@ export const ActivityCard = ({
})}
</div>
{canShowInlineReview && (
<div className="-mx-4 mt-2 px-4 pb-2 space-y-3 animate-fade-in">
<div className={`grid grid-cols-1 ${showSourceField ? 'sm:grid-cols-3' : 'sm:grid-cols-2'} gap-x-3 gap-y-1`}>
<DetailField label="Requested" value={requestedAt} />
<DetailField label="Type" value={requestType} />
{showSourceField && <DetailField label="Source" value={sourceLabel} />}
</div>
{isRequestDetailsOpen && reviewRecord && reviewApproveHandler ? (
<ReviewInlinePanel
key={`review-${reviewRecord.id}-${reviewRecord.updated_at ?? 'unknown'}`}
reviewRecord={reviewRecord}
showSourceField={showSourceField}
requestedAt={requestedAt}
requestType={requestType}
sourceLabel={sourceLabel}
hasAttachedRelease={hasAttachedRelease}
missingAttachedReleaseMessage={missingAttachedReleaseMessage}
approveLabel={approveLabel}
canMarkAsApprovedWithoutRelease={canMarkAsApprovedWithoutRelease}
canBrowseAlternatives={canBrowseAlternatives}
fileTitle={fileTitle}
fileSize={fileSize}
fileFormat={fileFormat}
requiresBrowseBeforeApprove={requiresBrowseBeforeApprove}
reviewApproveHandler={reviewApproveHandler}
/>
) : null}
{hasAttachedRelease ? (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide opacity-70">Attached File</p>
<div className="grid grid-cols-1 gap-x-3 gap-y-1">
<DetailField label="Title" value={fileTitle} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<DetailField label="Size" value={fileSize} />
<DetailField label="Format" value={String(fileFormat).toUpperCase()} />
</div>
</div>
) : (
<p className="text-xs opacity-70">
{reviewRecord?.request_level === 'book'
? isRetryAfterFailure
? 'Previous download failed. Choose a release before re-approving.'
: 'This is a book-level request without an attached file. Choose a release before approval.'
: isRetryAfterFailure
? 'Previous download failed and the attached release was cleared. Choose a release before re-approving.'
: 'No attached release data is available. Choose a release before approval.'}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleReviewApprove}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-white bg-green-600 hover:bg-green-700 transition-colors disabled:opacity-60"
>
{isReviewSubmitting ? 'Working...' : approveLabel}
</button>
{canMarkAsApprovedWithoutRelease && (
<button
type="button"
onClick={handleReviewManualApproval}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
{isReviewSubmitting ? 'Working...' : 'Manually Mark as Approved'}
</button>
)}
{canBrowseAlternatives && hasAttachedRelease && (
<button
type="button"
onClick={handleReviewBrowseAlternatives}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
Browse Alternatives
</button>
)}
</div>
</div>
)}
{canShowInlineReject && (
<div className="-mx-4 mt-2 px-4 pb-2 space-y-3 animate-fade-in">
<p className="text-xs font-medium">
Reject request for <span className="opacity-80">{item.title || 'Untitled request'}</span>
</p>
<textarea
value={rejectNote}
onChange={(event) => setRejectNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
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-(--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">
<span className="text-[11px] opacity-60">{rejectNote.length}/{MAX_ADMIN_NOTE_LENGTH}</span>
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={onRequestRejectClose}
disabled={isRejectSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleInlineRejectConfirm}
disabled={isRejectSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60"
>
{isRejectSubmitting ? 'Rejecting...' : 'Reject'}
</button>
</div>
</div>
</div>
)}
{isRequestRejectOpen && typeof item.requestId === 'number' && rejectConfirmHandler ? (
<RejectInlinePanel
key={`reject-${item.requestId}-${reviewRecord?.id ?? 'none'}-${reviewRecord?.updated_at ?? 'unknown'}`}
requestId={item.requestId}
itemTitle={item.title}
onRequestRejectClose={onRequestRejectClose}
onRequestRejectConfirm={rejectConfirmHandler}
/>
) : null}
</div>
</div>
</div>
);
};
@@ -1,40 +0,0 @@
import { ActivityVisualStatus } from './activityTypes';
import { getProgressConfig, isActiveDownloadStatus } from './activityStyles';
interface ActivityProgressBarProps {
status: ActivityVisualStatus;
progress?: number;
animated?: boolean;
}
export const ActivityProgressBar = ({
status,
progress,
animated,
}: ActivityProgressBarProps) => {
if (!isActiveDownloadStatus(status)) {
return null;
}
const config = getProgressConfig(status, progress);
return (
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
<div
className={`h-full ${config.color} transition-all duration-300 relative overflow-hidden`}
style={{ width: `${Math.min(100, Math.max(0, config.percent))}%` }}
>
{(animated ?? config.animated) && config.percent < 100 && (
<span
className="absolute inset-0 opacity-30 activity-wave"
style={{
background:
'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.55) 50%, transparent 100%)',
backgroundSize: '200% 100%',
}}
/>
)}
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { RequestRecord } from '../../types';
import type { RequestRecord } from '../../types';
import { isActiveDownloadStatus } from './activityStyles.js';
import { ActivityItem, ActivityVisualStatus } from './activityTypes';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
export type ActivityCardAction =
| {
@@ -18,7 +18,7 @@ export type ActivityCardAction =
requestId: number;
};
export interface ActivityCardBadge {
interface ActivityCardBadge {
key: 'download' | 'request' | 'status';
text: string;
visualStatus: ActivityVisualStatus;
@@ -26,7 +26,7 @@ export interface ActivityCardBadge {
progress?: number;
}
export interface ActivityCardModel {
interface ActivityCardModel {
badges: ActivityCardBadge[];
noteLine?: string;
actions: ActivityCardAction[];
@@ -65,12 +65,13 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
: item.visualStatus;
const failureReason = item.requestRecord?.last_failure_reason?.trim() || null;
const hasFailureReason = requestVisualStatus === 'pending' && Boolean(failureReason);
const hasInFlightLinkedDownload = (
const hasInFlightLinkedDownload =
item.kind === 'download' &&
requestVisualStatus === 'fulfilled' &&
isActiveDownloadStatus(item.visualStatus)
);
let visualStatus: ActivityVisualStatus = hasInFlightLinkedDownload ? 'resolving' : requestVisualStatus;
isActiveDownloadStatus(item.visualStatus);
let visualStatus: ActivityVisualStatus = hasInFlightLinkedDownload
? 'resolving'
: requestVisualStatus;
if (hasFailureReason) {
visualStatus = 'error';
}
@@ -78,8 +79,8 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
let text = item.statusLabel;
if (hasInFlightLinkedDownload) {
text = 'Approved';
} else if (hasFailureReason) {
text = failureReason as string;
} else if (hasFailureReason && failureReason) {
text = failureReason;
} else if (requestVisualStatus === 'pending') {
text = getPendingRequestText(item, isAdmin);
} else if (requestVisualStatus === 'fulfilled') {
@@ -138,12 +139,10 @@ const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
}
if (
item.adminNote &&
(
requestStatus === 'rejected' ||
(requestStatus === 'rejected' ||
requestStatus === 'fulfilled' ||
item.visualStatus === 'rejected' ||
item.visualStatus === 'fulfilled'
)
item.visualStatus === 'fulfilled')
) {
return `"${item.adminNote}"`;
}
@@ -227,10 +226,7 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
return [];
};
export const buildActivityCardModel = (
item: ActivityItem,
isAdmin: boolean
): ActivityCardModel => {
export const buildActivityCardModel = (item: ActivityItem, isAdmin: boolean): ActivityCardModel => {
return {
badges: buildBadges(item, isAdmin),
noteLine: buildRequestNoteLine(item),
@@ -1,6 +1,6 @@
import { Book, RequestRecord, StatusData } from '../../types';
import { ActivityItem, ActivityVisualStatus } from './activityTypes';
import type { Book, RequestRecord, StatusData } from '../../types';
import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
export type DownloadStatusKey = Extract<
keyof StatusData,
@@ -66,12 +66,16 @@ const statusKeyToVisualStatus = (statusKey: DownloadStatusKey): ActivityVisualSt
return 'cancelled';
};
const getDownloadProgress = (status: ActivityVisualStatus, bookProgress: unknown): number | undefined => {
const getDownloadProgress = (
status: ActivityVisualStatus,
bookProgress: unknown,
): number | undefined => {
if (status === 'queued') return 5;
if (status === 'resolving') return 15;
if (status === 'locating') return 90;
if (status === 'downloading') {
const progress = typeof bookProgress === 'number' ? Math.max(0, Math.min(100, bookProgress)) : 0;
const progress =
typeof bookProgress === 'number' ? Math.max(0, Math.min(100, bookProgress)) : 0;
return Math.max(0, Math.min(100, 20 + progress * 0.8));
}
return undefined;
@@ -79,13 +83,10 @@ const getDownloadProgress = (status: ActivityVisualStatus, bookProgress: unknown
export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey): ActivityItem => {
const visualStatus = statusKeyToVisualStatus(statusKey);
const requestId = (
typeof book.request_id === 'number' &&
Number.isFinite(book.request_id) &&
book.request_id > 0
)
? Math.trunc(book.request_id)
: undefined;
const requestId =
typeof book.request_id === 'number' && Number.isFinite(book.request_id) && book.request_id > 0
? Math.trunc(book.request_id)
: undefined;
const metaLine = joinMetaParts([
toOptionalText(book.format)?.toUpperCase(),
toOptionalText(book.size),
@@ -118,12 +119,9 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
};
};
const parseRecordData = (value: unknown): Record<string, unknown> => {
if (value && typeof value === 'object') {
return value as Record<string, unknown>;
}
return {};
};
const parseRecordData = (
value: Record<string, unknown> | null | undefined,
): Record<string, unknown> => value ?? {};
const requestStatusToVisualStatus = (status: RequestRecord['status']): ActivityVisualStatus => {
if (status === 'pending') return 'pending';
@@ -136,7 +134,7 @@ const buildRequestMetaLine = (
record: RequestRecord,
bookData: Record<string, unknown>,
releaseData: Record<string, unknown>,
viewerRole: 'user' | 'admin'
viewerRole: 'user' | 'admin',
): string => {
if (record.request_level === 'book') {
const contentType = toOptionalText(record.content_type || bookData.content_type)?.toLowerCase();
@@ -156,7 +154,7 @@ const buildRequestMetaLine = (
export const requestToActivityItem = (
record: RequestRecord,
viewerRole: 'user' | 'admin'
viewerRole: 'user' | 'admin',
): ActivityItem => {
const visualStatus = requestStatusToVisualStatus(record.status);
const bookData = parseRecordData(record.book_data);
@@ -1,4 +1,4 @@
import { ActivityVisualStatus } from './activityTypes';
import type { ActivityVisualStatus } from './activityTypes';
export const STATUS_ACCENT_CLASSES: Record<ActivityVisualStatus, string> = {
queued: 'border-l-amber-500',
@@ -26,7 +26,7 @@ export const STATUS_LABELS: Record<ActivityVisualStatus, string> = {
cancelled: 'Cancelled',
};
export interface StatusBadgeStyle {
interface StatusBadgeStyle {
bg: string;
text: string;
waveColor?: string;
@@ -85,26 +85,39 @@ export const STATUS_BADGE_STYLES: Record<ActivityVisualStatus, StatusBadgeStyle>
};
export const STATUS_TOOLTIP_CLASSES: Record<ActivityVisualStatus, string> = {
queued: 'bg-amber-50 text-amber-800 border border-amber-300/50 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-700/50',
pending: 'bg-amber-50 text-amber-800 border border-amber-300/50 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-700/50',
resolving: 'bg-indigo-50 text-indigo-800 border border-indigo-300/50 dark:bg-indigo-950 dark:text-indigo-200 dark:border-indigo-700/50',
locating: 'bg-teal-50 text-teal-800 border border-teal-300/50 dark:bg-teal-950 dark:text-teal-200 dark:border-teal-700/50',
downloading: 'bg-sky-50 text-sky-800 border border-sky-300/50 dark:bg-sky-950 dark:text-sky-200 dark:border-sky-700/50',
complete: 'bg-green-50 text-green-800 border border-green-300/50 dark:bg-green-950 dark:text-green-200 dark:border-green-700/50',
fulfilled: 'bg-green-50 text-green-800 border border-green-300/50 dark:bg-green-950 dark:text-green-200 dark:border-green-700/50',
error: 'bg-red-50 text-red-800 border border-red-300/50 dark:bg-red-950 dark:text-red-200 dark:border-red-700/50',
rejected: 'bg-red-50 text-red-800 border border-red-300/50 dark:bg-red-950 dark:text-red-200 dark:border-red-700/50',
cancelled: 'bg-gray-50 text-gray-700 border border-gray-300/50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600/50',
queued:
'bg-amber-50 text-amber-800 border border-amber-300/50 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-700/50',
pending:
'bg-amber-50 text-amber-800 border border-amber-300/50 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-700/50',
resolving:
'bg-indigo-50 text-indigo-800 border border-indigo-300/50 dark:bg-indigo-950 dark:text-indigo-200 dark:border-indigo-700/50',
locating:
'bg-teal-50 text-teal-800 border border-teal-300/50 dark:bg-teal-950 dark:text-teal-200 dark:border-teal-700/50',
downloading:
'bg-sky-50 text-sky-800 border border-sky-300/50 dark:bg-sky-950 dark:text-sky-200 dark:border-sky-700/50',
complete:
'bg-green-50 text-green-800 border border-green-300/50 dark:bg-green-950 dark:text-green-200 dark:border-green-700/50',
fulfilled:
'bg-green-50 text-green-800 border border-green-300/50 dark:bg-green-950 dark:text-green-200 dark:border-green-700/50',
error:
'bg-red-50 text-red-800 border border-red-300/50 dark:bg-red-950 dark:text-red-200 dark:border-red-700/50',
rejected:
'bg-red-50 text-red-800 border border-red-300/50 dark:bg-red-950 dark:text-red-200 dark:border-red-700/50',
cancelled:
'bg-gray-50 text-gray-700 border border-gray-300/50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600/50',
};
export const isActiveDownloadStatus = (status: ActivityVisualStatus): boolean =>
status === 'queued' || status === 'resolving' || status === 'locating' || status === 'downloading';
status === 'queued' ||
status === 'resolving' ||
status === 'locating' ||
status === 'downloading';
const clampPercent = (value: number): number => Math.max(0, Math.min(100, value));
export const getProgressConfig = (
status: ActivityVisualStatus,
progress?: number
progress?: number,
): { percent: number; color: string; animated: boolean } => {
switch (status) {
case 'queued':
@@ -1,4 +1,4 @@
import { RequestRecord } from '../../types';
import type { RequestRecord } from '../../types';
export type ActivityKind = 'download' | 'request';
+1 -11
View File
@@ -1,14 +1,4 @@
export { ActivitySidebar } from './ActivitySidebar';
export { ActivityCard } from './ActivityCard';
export { ActivityProgressBar } from './ActivityProgressBar';
export { downloadToActivityItem, requestToActivityItem } from './activityMappers';
export {
STATUS_ACCENT_CLASSES,
STATUS_BADGE_STYLES,
STATUS_LABELS,
getProgressConfig,
isActiveDownloadStatus,
} from './activityStyles';
export type { ActivityItem, ActivityKind, ActivityVisualStatus } from './activityTypes';
export { ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY } from './ActivitySidebar';
export type { ActivityItem } from './activityTypes';
export type { ActivityDismissTarget } from './ActivitySidebar';
-11
View File
@@ -1,11 +0,0 @@
export { Header } from './Header';
export { SearchSection } from './SearchSection';
export { SearchBar } from './SearchBar';
export { ResultsSection } from './ResultsSection';
export { DetailsModal } from './DetailsModal';
export { ActivitySidebar } from './activity';
export { ToastContainer } from './ToastContainer';
export { Footer } from './Footer';
export { CardView } from './resultsViews/CardView';
export { CompactView } from './resultsViews/CompactView';
export { ListView } from './resultsViews/ListView';
@@ -1,13 +1,14 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<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" />
<div className="h-full w-full animate-pulse 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" />
);
interface CardViewProps {
@@ -21,7 +22,16 @@ interface CardViewProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false, onShowToast }: CardViewProps) => {
export const CardView = ({
book,
onDetails,
onDownload,
onGetReleases,
buttonState,
animationDelay = 0,
showSeriesPosition = false,
onShowToast,
}: CardViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
@@ -29,6 +39,14 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const targetProvider = book.provider;
const targetBookId = book.provider_id;
let zIndex: number | undefined;
if (dropdownOpen) {
zIndex = 20;
} else if (isHovered) {
zIndex = 10;
}
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
@@ -39,10 +57,10 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
}
};
const handleGetReleases = async (book: Book) => {
const handleGetReleases = async (selectedBook: Book) => {
setIsLoadingReleases(true);
try {
await onGetReleases(book);
await onGetReleases(selectedBook);
} finally {
setIsLoadingReleases(false);
}
@@ -50,24 +68,27 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
return (
<article
className="book-card flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform relative"
className="book-card space-between animate-pop-up relative flex h-full w-full flex-col transition-shadow duration-300 will-change-transform max-sm:h-[180px] max-sm:flex-row sm:max-w-[292px] sm:flex-col"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
zIndex,
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:shrink-0" style={{ aspectRatio: book.cover_aspect === 'square' ? '1/1' : '2/3' }}>
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
<div
className="relative w-full max-sm:h-full max-sm:w-[120px] max-sm:shrink-0 sm:w-full"
style={{ aspectRatio: book.cover_aspect === 'square' ? '1/1' : '2/3' }}
>
<div className="absolute inset-0 overflow-hidden max-sm:rounded-l-[.75rem] sm:rounded-t-[.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 rounded-md border border-emerald-700 bg-emerald-600 px-2 py-1 text-xs font-bold text-white"
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)',
@@ -86,7 +107,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
className="h-full w-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
@@ -98,68 +119,84 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
<div
className="flex h-full w-full items-center justify-center text-sm opacity-50"
style={{ background: 'var(--border-muted)' }}
>
No Cover
</div>
)}
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
className="pointer-events-none absolute inset-0 bg-white transition-opacity duration-300"
style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }}
/>
</div>
<div
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 max-sm:hidden transition-all duration-300"
className="absolute right-2 bottom-2 z-10 flex flex-col gap-1.5 transition-all duration-300 max-sm:hidden"
style={{
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
}}
>
{bookSupportsTargets(book) && (
{bookSupportsTargets(book) && targetProvider && targetBookId && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
provider={targetProvider}
bookId={targetBookId}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
className="h-8 w-8 bg-white/90 shadow-lg backdrop-blur-xs hover:scale-110 dark:bg-neutral-800/90"
onOpenChange={setDropdownOpen}
/>
)}
<button
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"
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 shadow-lg backdrop-blur-xs transition-all duration-300 hover:scale-110 dark:bg-neutral-800/90"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
void handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</button>
</div>
</div>
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
<div className="space-y-1 max-sm:space-y-0.5 max-sm:min-w-0">
<h3 className="font-semibold leading-tight line-clamp-2 text-base max-sm:line-clamp-3 max-sm:min-w-0" title={book.title || 'Untitled'}>
<div className="flex flex-col gap-3 p-4 max-sm:min-w-0 max-sm:flex-1 max-sm:justify-between max-sm:gap-2 max-sm:p-3 max-sm:py-2 sm:flex sm:flex-1 sm:flex-col sm:justify-end">
<div className="space-y-1 max-sm:min-w-0 max-sm:space-y-0.5">
<h3
className="line-clamp-2 text-base leading-tight font-semibold max-sm:line-clamp-3 max-sm:min-w-0"
title={book.title || 'Untitled'}
>
{book.title || 'Untitled'}
</h3>
<p className="text-sm max-sm:text-xs opacity-80 truncate max-sm:min-w-0">{book.author || 'Unknown author'}</p>
<p className="truncate text-sm opacity-80 max-sm:min-w-0 max-sm:text-xs">
{book.author || 'Unknown author'}
</p>
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<div className="flex flex-wrap gap-2 text-xs opacity-70 max-sm:gap-1 max-sm:text-[10px]">
<span>{book.year || '-'}</span>
<span>•</span>
<DisplayFieldBadges fields={book.display_fields.filter(f => f.icon !== 'editions')} />
<DisplayFieldBadges
fields={book.display_fields.filter((f) => f.icon !== 'editions')}
/>
</div>
) : (
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<div className="flex flex-wrap gap-2 text-xs opacity-70 max-sm:gap-1 max-sm:text-[10px]">
<span>{book.year || '-'}</span>
<span>•</span>
<span>{book.language || '-'}</span>
@@ -177,21 +214,26 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
<div className="flex gap-1.5 sm:hidden">
<button
className="px-2 py-1.5 rounded-sm border text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
type="button"
className="flex flex-1 items-center justify-center gap-1 rounded-sm border px-2 py-1.5 text-xs"
onClick={() => {
void handleDetails(book.id);
}}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">{isLoadingDetails ? 'Loading' : 'Details'}</span>
<div
className={`details-spinner w-3 h-3 border-2 border-current border-t-transparent rounded-full ${isLoadingDetails ? '' : 'hidden'}`}
className={`details-spinner h-3 w-3 rounded-full border-2 border-current border-t-transparent ${isLoadingDetails ? '' : 'hidden'}`}
/>
</button>
<BookActionButton
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
onGetReleases={(selectedBook) => {
void handleGetReleases(selectedBook);
}}
isLoadingReleases={isLoadingReleases}
size="sm"
className="flex-1"
@@ -203,9 +245,11 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
onGetReleases={(selectedBook) => {
void handleGetReleases(selectedBook);
}}
isLoadingReleases={isLoadingReleases}
className="hidden sm:flex rounded-none"
className="hidden rounded-none sm:flex"
fullWidth
style={{
borderBottomLeftRadius: '.75rem',
@@ -1,13 +1,14 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges, DisplayFieldIcon } from '../shared';
const SkeletonLoader = () => (
<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" />
<div className="h-full w-full animate-pulse 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" />
);
interface CompactViewProps {
@@ -22,7 +23,17 @@ interface CompactViewProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false, onShowToast }: CompactViewProps) => {
export const CompactView = ({
book,
onDetails,
onDownload,
onGetReleases,
buttonState,
showDetailsButton = false,
animationDelay = 0,
showSeriesPosition = false,
onShowToast,
}: CompactViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
@@ -30,6 +41,15 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const targetProvider = book.provider;
const targetBookId = book.provider_id;
const microphoneField = book.display_fields?.find((field) => field.icon === 'microphone');
let zIndex: number | undefined;
if (dropdownOpen) {
zIndex = 20;
} else if (isHovered) {
zIndex = 10;
}
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
@@ -40,10 +60,10 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
}
};
const handleGetReleases = async (book: Book) => {
const handleGetReleases = async (selectedBook: Book) => {
setIsLoadingReleases(true);
try {
await onGetReleases(book);
await onGetReleases(selectedBook);
} finally {
setIsLoadingReleases(false);
}
@@ -51,24 +71,24 @@ 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 animate-pop-up relative flex! h-[180px]! w-full flex-row! transition-shadow duration-300 will-change-transform"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
zIndex,
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full shrink-0">
<div className="relative h-full w-[120px] 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 rounded-md border border-emerald-700 bg-emerald-600 px-2 py-1 text-xs font-bold text-white"
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)',
@@ -87,7 +107,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
className="h-full w-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
@@ -99,46 +119,58 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
<div
className="flex h-full w-full items-center justify-center text-sm opacity-50"
style={{ background: 'var(--border-muted)' }}
>
No Cover
</div>
)}
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }} />
<div
className="pointer-events-none absolute inset-0 bg-white transition-opacity duration-300"
style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }}
/>
</div>
{!showDetailsButton && (
<div
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 transition-all duration-300"
className="absolute right-2 bottom-2 z-10 flex flex-col gap-1.5 transition-all duration-300"
style={{
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
}}
>
{bookSupportsTargets(book) && (
{bookSupportsTargets(book) && targetProvider && targetBookId && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
provider={targetProvider}
bookId={targetBookId}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
className="h-8 w-8 bg-white/90 shadow-lg backdrop-blur-xs hover:scale-110 dark:bg-neutral-800/90"
onOpenChange={setDropdownOpen}
/>
)}
<button
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"
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 shadow-lg backdrop-blur-xs transition-all duration-300 hover:scale-110 dark:bg-neutral-800/90"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
void handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</button>
@@ -146,12 +178,15 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
)}
</div>
<div className="p-3 py-2 flex flex-col flex-1 min-w-0">
<div className="space-y-0.5 min-w-0">
<h3 className="font-semibold leading-tight line-clamp-3 text-base min-w-0" title={book.title || 'Untitled'}>
<div className="flex min-w-0 flex-1 flex-col p-3 py-2">
<div className="min-w-0 space-y-0.5">
<h3
className="line-clamp-3 min-w-0 text-base leading-tight font-semibold"
title={book.title || 'Untitled'}
>
{book.title || 'Untitled'}
</h3>
<p className="text-xs opacity-80 truncate min-w-0">{book.author || 'Unknown author'}</p>
<p className="min-w-0 truncate text-xs opacity-80">{book.author || 'Unknown author'}</p>
<div className="text-xs opacity-70">
<span>{book.year || '-'}</span>
</div>
@@ -160,16 +195,21 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
<div className="mt-auto flex flex-col gap-2">
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
<>
<DisplayFieldBadges fields={book.display_fields.filter(f => f.icon !== 'editions' && f.icon !== 'microphone')} className="text-xs opacity-70" />
{book.display_fields.find(f => f.icon === 'microphone') && (
<DisplayFieldBadges
fields={book.display_fields.filter(
(f) => f.icon !== 'editions' && f.icon !== 'microphone',
)}
className="text-xs opacity-70"
/>
{microphoneField && (
<div className="flex items-center gap-0.5 text-xs opacity-70">
<DisplayFieldIcon icon="microphone" />
<span>{book.display_fields.find(f => f.icon === 'microphone')!.value}</span>
<span>{microphoneField.value}</span>
</div>
)}
</>
) : (
<div className="text-xs opacity-70 flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1 text-xs opacity-70">
<span>{book.language || '-'}</span>
<span>•</span>
<span>{book.format || '-'}</span>
@@ -185,19 +225,28 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{showDetailsButton ? (
<div className="flex gap-1.5">
<button
className="px-2 py-1.5 rounded-sm border text-xs shrink-0 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
type="button"
className="flex shrink-0 items-center justify-center gap-1 rounded-sm border px-2 py-1.5 text-xs"
onClick={() => {
void handleDetails(book.id);
}}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">{isLoadingDetails ? 'Loading' : 'Details'}</span>
{isLoadingDetails && <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />}
<span className="details-button-text">
{isLoadingDetails ? 'Loading' : 'Details'}
</span>
{isLoadingDetails && (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
</button>
<BookActionButton
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
onGetReleases={(selectedBook) => {
void handleGetReleases(selectedBook);
}}
isLoadingReleases={isLoadingReleases}
size="sm"
className="flex-1"
@@ -208,7 +257,9 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
onGetReleases={(selectedBook) => {
void handleGetReleases(selectedBook);
}}
isLoadingReleases={isLoadingReleases}
size="sm"
fullWidth
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo, DisplayField } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
interface ListViewProps {
books: Book[];
@@ -18,7 +19,30 @@ interface ListViewProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const ListViewThumbnail = ({ preview, title, coverAspect }: { preview?: string; title?: string; coverAspect?: string }) => {
const getKeyedDisplayFields = (fields: DisplayField[]) => {
const signatureCounts = new Map<string, number>();
return fields.map((field) => {
const signature = [field.icon ?? 'none', field.label, field.value].join('|');
const nextCount = (signatureCounts.get(signature) ?? 0) + 1;
signatureCounts.set(signature, nextCount);
return {
field,
key: nextCount === 1 ? signature : `${signature}|${nextCount}`,
};
});
};
const ListViewThumbnail = ({
preview,
title,
coverAspect,
}: {
preview?: string;
title?: string;
coverAspect?: string;
}) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const isSquare = coverAspect === 'square';
@@ -27,7 +51,7 @@ const ListViewThumbnail = ({ preview, title, coverAspect }: { preview?: string;
if (!preview || imageError) {
return (
<div
className={`${sizeClass} 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`}
className={`${sizeClass} flex items-center justify-center rounded-sm bg-gray-200 text-[8px] font-medium text-gray-500 sm:text-[9px] dark:bg-gray-700 dark:text-gray-300`}
aria-label="No cover available"
>
No Cover
@@ -36,14 +60,16 @@ const ListViewThumbnail = ({ preview, title, coverAspect }: { preview?: string;
}
return (
<div className={`relative ${sizeClass} rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70`}>
<div
className={`relative ${sizeClass} overflow-hidden rounded-sm border border-white/40 bg-gray-100 dark:border-gray-700/70 dark:bg-gray-800`}
>
{!imageLoaded && (
<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" />
<div className="absolute inset-0 animate-pulse 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" />
)}
<img
src={preview}
alt={title || 'Book cover'}
className={`w-full h-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
className={`h-full w-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
@@ -53,7 +79,16 @@ const ListViewThumbnail = ({ preview, title, coverAspect }: { preview?: string;
);
};
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false, onShowToast }: ListViewProps) => {
export const ListView = ({
books,
onDetails,
onDownload,
onGetReleases,
getButtonState,
getUniversalButtonState,
showSeriesPosition = false,
onShowToast,
}: ListViewProps) => {
const { searchMode } = useSearchMode();
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
const [releasesLoadingId, setReleasesLoadingId] = useState<string | null>(null);
@@ -91,13 +126,25 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
role="region"
aria-label="List view of books"
>
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60 w-full">
<div className="w-full divide-y divide-gray-200/60 dark:divide-gray-800/60">
{books.map((book, index) => {
// Use appropriate button state function based on search mode
const buttonState = searchMode === 'universal'
? getUniversalButtonState(book.id)
: getButtonState(book.id);
const buttonState =
searchMode === 'universal' ? getUniversalButtonState(book.id) : getButtonState(book.id);
const isLoadingDetails = detailsLoadingId === book.id;
const ratingField = book.display_fields?.find((field) => field.icon === 'star');
const lengthField = book.display_fields?.find(
(field) => field.icon === 'clock' || field.icon === 'book',
);
const narratorField = book.display_fields?.find((field) => field.icon === 'microphone');
const targetProvider = book.provider;
const targetBookId = book.provider_id;
const mobileDisplayFields =
searchMode === 'universal' && book.display_fields
? getKeyedDisplayFields(
book.display_fields.filter((field) => field.icon !== 'editions').slice(0, 2),
)
: [];
// Compute color styles for direct mode badges
const languageColor = getLanguageColor(book.language);
@@ -106,7 +153,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
return (
<div
key={book.id}
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform relative"
className="hover-row animate-pop-up relative w-full px-1.5 py-1.5 transition-colors duration-200 will-change-transform sm:px-2 sm:py-2"
style={{
zIndex: openDropdownBookId === book.id ? 30 : undefined,
animationDelay: `${index * 50}ms`,
@@ -116,22 +163,31 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
>
{/* Mobile and Desktop: Single row layout */}
{/* Universal mode uses separate columns for each display field, direct mode uses language/format/size */}
<div className={`grid items-center gap-2 sm:gap-y-1 sm:gap-x-0.5 w-full ${
searchMode === 'universal'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(90px,0.5fr)_minmax(90px,0.5fr)_minmax(120px,0.7fr)_auto]'
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto]'
}`}>
<div
className={`grid w-full items-center gap-2 sm:gap-x-0.5 sm:gap-y-1 ${
searchMode === 'universal'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(90px,0.5fr)_minmax(90px,0.5fr)_minmax(120px,0.7fr)_auto]'
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto]'
}`}
>
{/* Thumbnail */}
<div className="flex items-center pl-1 sm:pl-3">
<ListViewThumbnail preview={book.preview} title={book.title} coverAspect={book.cover_aspect} />
<ListViewThumbnail
preview={book.preview}
title={book.title}
coverAspect={book.cover_aspect}
/>
</div>
{/* Title and Author */}
<div className="min-w-0 flex flex-col justify-center sm:pl-3">
<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'}>
<div className="flex min-w-0 flex-col justify-center sm:pl-3">
<h3
className="line-clamp-1 flex items-center gap-2 text-xs leading-tight font-semibold min-[400px]:text-sm sm:line-clamp-2 sm:text-base"
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-sm border border-emerald-700 shrink-0"
className="mr-1.5 inline-flex shrink-0 rounded-sm border border-emerald-700 bg-emerald-600 px-1.5 py-0.5 text-[10px] font-bold text-white sm:text-xs"
style={{
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
@@ -142,17 +198,17 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
)}
<span className="truncate">{book.title || 'Untitled'}</span>
</h3>
<p className="text-[10px] min-[400px]:text-xs sm:text-sm text-gray-600 dark:text-gray-300 truncate">
<p className="truncate text-[10px] text-gray-600 min-[400px]:text-xs sm:text-sm dark:text-gray-300">
{book.author || 'Unknown author'}
{book.year && <span className="sm:hidden"> • {book.year}</span>}
</p>
</div>
{/* Mobile universal mode info */}
<div className="flex sm:hidden flex-col items-end text-[10px] opacity-70 leading-tight">
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
book.display_fields.filter(f => f.icon !== 'editions').slice(0, 2).map((field, idx) => (
<span key={idx} className="flex items-center gap-0.5" title={field.label}>
<div className="flex flex-col items-end text-[10px] leading-tight opacity-70 sm:hidden">
{mobileDisplayFields.length > 0 ? (
mobileDisplayFields.map(({ field, key }) => (
<span key={key} className="flex items-center gap-0.5" title={field.label}>
<DisplayFieldIcon icon={field.icon} />
<span>{field.value}</span>
</span>
@@ -166,7 +222,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
</div>
{/* Year - Desktop only */}
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
<div className="hidden justify-center text-xs text-gray-700 sm:flex dark:text-gray-200">
{book.year || '-'}
</div>
@@ -174,25 +230,25 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{searchMode === 'universal' && (
<>
{/* Rating column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'star') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'star')!} />
<div className="hidden justify-start sm:flex">
{ratingField ? (
<DisplayFieldBadge field={ratingField} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
{/* Length column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'clock' || f.icon === 'book') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'clock' || f.icon === 'book')!} />
<div className="hidden justify-start sm:flex">
{lengthField ? (
<DisplayFieldBadge field={lengthField} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
{/* Narrator column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'microphone') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'microphone')!} />
<div className="hidden justify-start sm:flex">
{narratorField ? (
<DisplayFieldBadge field={narratorField} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
@@ -202,9 +258,9 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Direct mode: Language Badge - Desktop only */}
{searchMode !== 'universal' && (
<div className="hidden sm:flex justify-center">
<div className="hidden justify-center sm:flex">
<span
className={`${languageColor.bg} ${languageColor.text} text-[11px] font-semibold px-2 py-0.5 rounded-lg uppercase tracking-wide`}
className={`${languageColor.bg} ${languageColor.text} rounded-lg px-2 py-0.5 text-[11px] font-semibold tracking-wide uppercase`}
title={book.language || 'Unknown'}
>
{book.language || '-'}
@@ -214,9 +270,9 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Direct mode: Format Badge - Desktop only */}
{searchMode !== 'universal' && (
<div className="hidden sm:flex justify-center">
<div className="hidden justify-center sm:flex">
<span
className={`${formatColor.bg} ${formatColor.text} text-[11px] font-semibold px-2 py-0.5 rounded-lg uppercase tracking-wide`}
className={`${formatColor.bg} ${formatColor.text} rounded-lg px-2 py-0.5 text-[11px] font-semibold tracking-wide uppercase`}
title={book.format || 'Unknown'}
>
{book.format || '-'}
@@ -226,33 +282,46 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Direct mode: Size - Desktop only */}
{searchMode !== 'universal' && (
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
<div className="hidden justify-center text-xs text-gray-700 sm:flex dark:text-gray-200">
{book.size || '-'}
</div>
)}
{/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1 sm:pr-3">
{bookSupportsTargets(book) && (
{bookSupportsTargets(book) && targetProvider && targetBookId && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
provider={targetProvider}
bookId={targetBookId}
onShowToast={onShowToast}
variant="icon"
onOpenChange={(isOpen) => setOpenDropdownBookId(isOpen ? book.id : null)}
/>
)}
<button
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover-action transition-all duration-200"
onClick={() => handleDetails(book.id)}
type="button"
className="hover-action flex items-center justify-center rounded-full p-1.5 text-gray-600 transition-all duration-200 sm:p-2 dark:text-gray-200"
onClick={() => {
void handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label={`View details for ${book.title || 'this book'}`}
>
{isLoadingDetails ? (
<div className="w-4 h-4 sm:w-5 sm:h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent sm:h-5 sm:w-5" />
) : (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
<svg
className="h-4 w-4 sm:h-5 sm:w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"
/>
</svg>
)}
</button>
@@ -260,7 +329,9 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
onGetReleases={(selectedBook) => {
void handleGetReleases(selectedBook);
}}
isLoadingReleases={releasesLoadingId === book.id}
variant="icon"
size="md"
@@ -1,23 +1,29 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useBodyScrollLock } from '../../hooks/useBodyScrollLock';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useMountEffect } from '../../hooks/useMountEffect';
import type { AdminUser, DeliveryPreferencesResponse } from '../../services/api';
import {
AdminUser,
DeliveryPreferencesResponse,
getSelfUserEditContext,
testSelfNotificationPreferences,
updateSelfUser,
} from '../../services/api';
import {
getStoredThemePreference,
setThemePreference,
THEME_FIELD,
} from '../../utils/themePreference';
import { SelectField } from './fields';
import { FieldWrapper } from './shared';
import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard';
import {
DEFAULT_SELF_USER_OVERRIDE_SECTIONS,
normalizeUserOverrideSections,
UserOverridesSections,
type UserOverrideSectionId,
} from './users';
import { PerUserSettings } from './users/types';
import type { PerUserSettings } from './users/types';
import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard';
import { useUserOverridesState } from './users/useUserOverridesState';
import { getStoredThemePreference, setThemePreference, THEME_FIELD } from '../../utils/themePreference';
interface SelfSettingsModalProps {
isOpen: boolean;
@@ -55,27 +61,73 @@ export const SelfSettingsModal = ({
onSettingsSaved,
}: SelfSettingsModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const handleRequestClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
setSessionVersion((current) => current + 1);
}, 150);
}, [onClose]);
useBodyScrollLock(isOpen);
if (!isOpen && !isClosing) {
return null;
}
return (
<SelfSettingsModalSession
key={sessionVersion}
isOpen={isOpen}
isClosing={isClosing}
onRequestClose={handleRequestClose}
onShowToast={onShowToast}
onSettingsSaved={onSettingsSaved}
/>
);
};
interface SelfSettingsModalSessionProps {
isOpen: boolean;
isClosing: boolean;
onRequestClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSettingsSaved?: () => void;
}
const SelfSettingsModalSession = ({
isOpen,
isClosing,
onRequestClose,
onShowToast,
onSettingsSaved,
}: SelfSettingsModalSessionProps) => {
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [originalUser, setOriginalUser] = useState<AdminUser | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [searchPreferences, setSearchPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [notificationPreferences, setNotificationPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [visibleSections, setVisibleSections] = useState<UserOverrideSectionId[]>(
DEFAULT_SELF_USER_OVERRIDE_SECTIONS
const [deliveryPreferences, setDeliveryPreferences] =
useState<DeliveryPreferencesResponse | null>(null);
const [searchPreferences, setSearchPreferences] = useState<DeliveryPreferencesResponse | null>(
null,
);
const [notificationPreferences, setNotificationPreferences] =
useState<DeliveryPreferencesResponse | null>(null);
const [visibleSections, setVisibleSections] = useState(DEFAULT_SELF_USER_OVERRIDE_SECTIONS);
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [themeValue, setThemeValue] = useState<string>(getStoredThemePreference());
const [themeValue, setThemeValue] = useState(getStoredThemePreference());
const preferenceGroups = useMemo(
() => [deliveryPreferences, searchPreferences, notificationPreferences],
[deliveryPreferences, searchPreferences, notificationPreferences]
[deliveryPreferences, searchPreferences, notificationPreferences],
);
const {
userSettings,
@@ -98,7 +150,7 @@ export const SelfSettingsModal = ({
setSearchPreferences(context.searchPreferences || null);
setNotificationPreferences(context.notificationPreferences || null);
setVisibleSections(
normalizeUserOverrideSections(context.visibleUserSettingsSections, 'self')
normalizeUserOverrideSections(context.visibleUserSettingsSections, 'self'),
);
applyUserOverridesContext({
settings: (context.user.settings || {}) as PerUserSettings,
@@ -113,56 +165,24 @@ export const SelfSettingsModal = ({
}
}, [applyUserOverridesContext]);
useEffect(() => {
if (!isOpen) {
return;
}
setIsClosing(false);
useMountEffect(() => {
void loadEditContext();
}, [isOpen, loadEditContext]);
useEffect(() => {
if (!isOpen) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
});
const handleClose = useCallback(() => {
if (isSaving) {
return;
}
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 150);
}, [isSaving, onClose]);
onRequestClose();
}, [isSaving, onRequestClose]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleClose]);
useEscapeKey(isOpen, handleClose);
const hasProfileChanges = Boolean(
editingUser
&& originalUser
&& (
editingUser.email !== originalUser.email
|| editingUser.display_name !== originalUser.display_name
)
editingUser &&
originalUser &&
(editingUser.email !== originalUser.email ||
editingUser.display_name !== originalUser.display_name),
);
const hasPasswordChanges = editPassword.length > 0 || editPasswordConfirm.length > 0;
@@ -189,15 +209,12 @@ export const SelfSettingsModal = ({
settings?: Record<string, unknown>;
} = {};
if (
editingUser.edit_capabilities.canEditEmail
&& editingUser.email !== originalUser.email
) {
if (editingUser.edit_capabilities.canEditEmail && editingUser.email !== originalUser.email) {
payload.email = editingUser.email;
}
if (
editingUser.edit_capabilities.canEditDisplayName
&& editingUser.display_name !== originalUser.display_name
editingUser.edit_capabilities.canEditDisplayName &&
editingUser.display_name !== originalUser.display_name
) {
payload.display_name = editingUser.display_name;
}
@@ -246,26 +263,27 @@ export const SelfSettingsModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
tabIndex={-1}
aria-label="Close account settings"
/>
<div
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-(--border-muted) shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative flex h-[85vh] max-h-[750px] w-full max-w-3xl flex-col overflow-hidden rounded-xl border 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-(--border-muted) px-6 py-4">
<h3 id={titleId} className="sr-only">My Account</h3>
<h3 id={titleId} className="sr-only">
My Account
</h3>
{editingUser ? (
<UserIdentityHeader
user={editingUser}
showAuthSource
showInactiveState={false}
/>
<UserIdentityHeader user={editingUser} showAuthSource showInactiveState={false} />
) : (
<div className="text-sm font-medium">My Account</div>
)}
@@ -273,11 +291,17 @@ export const SelfSettingsModal = ({
<button
type="button"
onClick={handleClose}
className="p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
className="hover-action rounded-full p-2 text-gray-500 transition-colors hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-100"
aria-label="Close account settings"
disabled={isSaving}
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -285,69 +309,85 @@ export const SelfSettingsModal = ({
</header>
<div className="flex-1 overflow-y-auto px-6 py-5">
{showInitialLoadingState ? (
<div className="h-full flex items-center justify-center text-sm opacity-60">
Loading account settings...
</div>
) : showInitialLoadErrorState ? (
<div className="h-full flex flex-col items-center justify-center gap-3 text-center">
<p className="text-sm opacity-70">{loadError}</p>
<button
type="button"
onClick={() => { void loadEditContext(); }}
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
>
Retry
</button>
</div>
) : editingUser ? (
<div className="space-y-5">
<FieldWrapper field={THEME_FIELD}>
<SelectField
field={THEME_FIELD}
value={themeValue}
onChange={(value) => {
setThemeValue(value);
setThemePreference(value);
}}
/>
</FieldWrapper>
{(() => {
if (showInitialLoadingState) {
return (
<div className="flex h-full items-center justify-center text-sm opacity-60">
Loading account settings...
</div>
);
}
<UserAccountCardContent
user={editingUser}
onUserChange={setEditingUser}
onSave={() => {}}
saving={isSaving}
onCancel={handleClose}
hideEditActions
editPassword={editPassword}
onEditPasswordChange={setEditPassword}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={setEditPasswordConfirm}
preferencesPlacement="after"
preferencesPanel={{
hideTitle: true,
children: (
<UserOverridesSections
scope="self"
sections={visibleSections}
deliveryPreferences={deliveryPreferences}
searchPreferences={searchPreferences}
notificationPreferences={notificationPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
onTestNotificationRoutes={handleTestNotificationRoutes}
if (showInitialLoadErrorState) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<p className="text-sm opacity-70">{loadError}</p>
<button
type="button"
onClick={() => {
void loadEditContext();
}}
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface)"
>
Retry
</button>
</div>
);
}
if (editingUser) {
return (
<div className="space-y-5">
<FieldWrapper field={THEME_FIELD}>
<SelectField
field={THEME_FIELD}
value={themeValue}
onChange={(value) => {
setThemeValue(value);
setThemePreference(value);
}}
/>
),
}}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-sm opacity-60">
Unable to load account details.
</div>
)}
</FieldWrapper>
<UserAccountCardContent
user={editingUser}
onUserChange={setEditingUser}
onSave={() => undefined}
saving={isSaving}
onCancel={handleClose}
hideEditActions
editPassword={editPassword}
onEditPasswordChange={setEditPassword}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={setEditPasswordConfirm}
preferencesPlacement="after"
preferencesPanel={{
hideTitle: true,
children: (
<UserOverridesSections
scope="self"
sections={visibleSections}
deliveryPreferences={deliveryPreferences}
searchPreferences={searchPreferences}
notificationPreferences={notificationPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
onTestNotificationRoutes={handleTestNotificationRoutes}
/>
),
}}
/>
</div>
);
}
return (
<div className="flex h-full items-center justify-center text-sm opacity-60">
Unable to load account details.
</div>
);
})()}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-(--border-muted) px-6 py-4">
@@ -1,24 +1,14 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type {
SettingsTab,
SettingsField,
ActionResult,
TextFieldConfig,
PasswordFieldConfig,
NumberFieldConfig,
CheckboxFieldConfig,
SelectFieldConfig,
MultiSelectFieldConfig,
TagListFieldConfig,
OrderableListFieldConfig,
OrderableListItem,
ActionButtonConfig,
HeadingFieldConfig,
ShowWhenCondition,
TableFieldConfig,
CustomComponentFieldConfig,
} from '../../types/settings';
import { FieldWrapper, SettingsSaveBar } from './shared';
import type { CustomSettingsFieldLayout } from './customFields';
import { getCustomSettingsFieldLayout, renderCustomSettingsField } from './customFields';
import {
TextField,
PasswordField,
@@ -32,11 +22,7 @@ import {
HeadingField,
TableField,
} from './fields';
import {
CustomSettingsFieldLayout,
getCustomSettingsFieldLayout,
renderCustomSettingsField,
} from './customFields';
import { FieldWrapper, SettingsSaveBar } from './shared';
interface SettingsContentProps {
tab: SettingsTab;
@@ -47,7 +33,10 @@ interface SettingsContentProps {
isSaving: boolean;
hasChanges: boolean;
isUniversalMode?: boolean; // Whether app is in Universal search mode
overrideSummary?: Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>;
overrideSummary?: Record<
string,
{ count: number; users: Array<{ userId: number; username: string; value: unknown }> }
>;
embedded?: boolean;
customFieldContext?: {
authMode?: string;
@@ -58,9 +47,62 @@ interface SettingsContentProps {
};
}
function toComparableString(value: unknown): string | undefined {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return undefined;
}
function toStringValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function toNumberValue(value: unknown, fallback = 0): number {
return typeof value === 'number' ? value : fallback;
}
function toBooleanValue(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
}
function toStringArray(value: unknown, fallback: string[] = []): string[] {
return isStringArray(value) ? value : fallback;
}
function isOrderableListItem(value: unknown): value is OrderableListItem {
return (
value !== null &&
typeof value === 'object' &&
'id' in value &&
typeof value.id === 'string' &&
'enabled' in value &&
typeof value.enabled === 'boolean'
);
}
function isOrderableListItemArray(value: unknown): value is OrderableListItem[] {
return Array.isArray(value) && value.every(isOrderableListItem);
}
function isRecordValue(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function isRecordArray(value: unknown): value is Record<string, unknown>[] {
return Array.isArray(value) && value.every(isRecordValue);
}
function evaluateShowWhenCondition(
showWhen: ShowWhenCondition,
values: Record<string, unknown>
values: Record<string, unknown>,
): boolean {
const currentValue = values[showWhen.field];
@@ -71,16 +113,19 @@ function evaluateShowWhenCondition(
return currentValue !== undefined && currentValue !== null && currentValue !== '';
}
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
: currentValue === showWhen.value;
if (Array.isArray(showWhen.value)) {
const comparableValue = toComparableString(currentValue);
return comparableValue !== undefined && showWhen.value.includes(comparableValue);
}
return currentValue === showWhen.value;
}
// Check if a field should be visible based on showWhen condition and search mode
function isFieldVisible(
field: SettingsField,
values: Record<string, unknown>,
isUniversalMode: boolean
isUniversalMode: boolean,
): boolean {
if ('hiddenInUi' in field && field.hiddenInUi) {
return false;
@@ -105,7 +150,7 @@ function isFieldVisible(
// Returns { disabled: boolean, reason?: string }
function getDisabledState(
field: SettingsField,
values: Record<string, unknown>
values: Record<string, unknown>,
): { disabled: boolean; reason?: string } {
// HeadingField doesn't have disabledWhen
if (field.type === 'HeadingField') {
@@ -135,7 +180,10 @@ function getDisabledState(
// Check if condition is met (handles both array and single value)
const isDisabled = Array.isArray(conditionValue)
? conditionValue.includes(currentValue as string)
? (() => {
const comparableValue = toComparableString(currentValue);
return comparableValue !== undefined && conditionValue.includes(comparableValue);
})()
: currentValue === conditionValue;
return {
@@ -152,14 +200,14 @@ const renderField = (
onAction: () => Promise<ActionResult>,
isDisabled: boolean,
allValues: Record<string, unknown>, // All form values for cascading dropdown support
authMode?: string
authMode?: string,
) => {
switch (field.type) {
case 'TextField':
return (
<TextField
field={field as TextFieldConfig}
value={(value as string) ?? ''}
field={field}
value={toStringValue(value, field.value)}
onChange={onChange}
disabled={isDisabled}
/>
@@ -167,8 +215,8 @@ const renderField = (
case 'PasswordField':
return (
<PasswordField
field={field as PasswordFieldConfig}
value={(value as string) ?? ''}
field={field}
value={toStringValue(value, field.value)}
onChange={onChange}
disabled={isDisabled}
/>
@@ -176,8 +224,8 @@ const renderField = (
case 'NumberField':
return (
<NumberField
field={field as NumberFieldConfig}
value={(value as number) ?? 0}
field={field}
value={toNumberValue(value, field.value)}
onChange={onChange}
disabled={isDisabled}
/>
@@ -185,26 +233,20 @@ const renderField = (
case 'CheckboxField':
return (
<CheckboxField
field={field as CheckboxFieldConfig}
value={(value as boolean) ?? false}
field={field}
value={toBooleanValue(value, field.value)}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'SelectField': {
const selectConfig = field as SelectFieldConfig;
// Get filter value for cascading dropdowns
const rawFilterValue = selectConfig.filterByField
? allValues[selectConfig.filterByField]
: undefined;
const filterValue =
rawFilterValue === undefined || rawFilterValue === null || rawFilterValue === ''
? undefined
: String(rawFilterValue);
const rawFilterValue = field.filterByField ? allValues[field.filterByField] : undefined;
const filterValue = toComparableString(rawFilterValue);
return (
<SelectField
field={selectConfig}
value={(value as string) ?? ''}
field={field}
value={toStringValue(value, field.value)}
onChange={onChange}
disabled={isDisabled}
filterValue={filterValue}
@@ -214,8 +256,8 @@ const renderField = (
case 'MultiSelectField':
return (
<MultiSelectField
field={field as MultiSelectFieldConfig}
value={(value as string[]) ?? []}
field={field}
value={toStringArray(value, field.value)}
onChange={onChange}
disabled={isDisabled}
/>
@@ -223,8 +265,8 @@ const renderField = (
case 'TagListField':
return (
<TagListField
field={field as TagListFieldConfig}
value={(value as string[]) ?? []}
field={field}
value={toStringArray(value, field.value)}
onChange={(v) => onChange(v)}
disabled={isDisabled}
/>
@@ -232,51 +274,54 @@ const renderField = (
case 'OrderableListField':
return (
<OrderableListField
field={field as OrderableListFieldConfig}
value={(value as OrderableListItem[]) ?? []}
field={field}
value={isOrderableListItemArray(value) ? value : field.value}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'ActionButton':
return <ActionButton field={field as ActionButtonConfig} onAction={onAction} disabled={isDisabled} />;
return <ActionButton field={field} onAction={onAction} disabled={isDisabled} />;
case 'TableField':
return (
<TableField
field={field as TableFieldConfig}
value={(value as Record<string, unknown>[]) ?? []}
field={field}
value={isRecordArray(value) ? value : field.value}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'HeadingField':
{
const headingField = field as HeadingFieldConfig;
const normalizedAuthMode = String(authMode || '').toLowerCase();
const dynamicDescription = headingField.descriptionByAuthMode
? (
headingField.descriptionByAuthMode[normalizedAuthMode]
?? headingField.descriptionByAuthMode.default
?? headingField.descriptionByAuthMode.none
?? headingField.description
)
: headingField.description;
case 'HeadingField': {
const headingField = field;
const normalizedAuthMode = (authMode || '').toLowerCase();
const dynamicDescription = headingField.descriptionByAuthMode
? (headingField.descriptionByAuthMode[normalizedAuthMode] ??
headingField.descriptionByAuthMode.default ??
headingField.descriptionByAuthMode.none ??
headingField.description)
: headingField.description;
return (
<HeadingField
field={{
...headingField,
description: dynamicDescription,
}}
/>
);
}
return (
<HeadingField
field={{
...headingField,
description: dynamicDescription,
}}
/>
);
}
case 'CustomComponentField':
return null;
default:
return <div>Unknown field type</div>;
}
};
export const SettingsContent = ({
export const SettingsContent = (props: SettingsContentProps) => (
<SettingsContentPanel key={props.tab.name} {...props} />
);
function SettingsContentPanel({
tab,
values,
onChange,
@@ -288,9 +333,11 @@ export const SettingsContent = ({
overrideSummary,
embedded = false,
customFieldContext,
}: SettingsContentProps) => {
}: SettingsContentProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [customFieldUiState, setCustomFieldUiState] = useState<Record<string, Record<string, unknown>>>({});
const [customFieldUiState, setCustomFieldUiState] = useState<
Record<string, Record<string, unknown>>
>({});
// Reset scroll position when tab changes before paint.
useLayoutEffect(() => {
@@ -302,10 +349,6 @@ export const SettingsContent = ({
}
}, [embedded, tab.name]);
useEffect(() => {
setCustomFieldUiState({});
}, [tab.name]);
const updateCustomFieldUiState = useCallback((fieldKey: string, key: string, value: unknown) => {
setCustomFieldUiState((prev) => {
const previousFieldState = prev[fieldKey] || {};
@@ -325,7 +368,7 @@ export const SettingsContent = ({
// Memoize the visible fields to avoid recalculating on every render
const baseVisibleFields = useMemo(
() => tab.fields.filter((field) => isFieldVisible(field, values, isUniversalMode)),
[tab.fields, values, isUniversalMode]
[tab.fields, values, isUniversalMode],
);
const customFieldLayouts = useMemo(() => {
@@ -335,7 +378,7 @@ export const SettingsContent = ({
return;
}
layouts[field.key] = getCustomSettingsFieldLayout({
field: field as CustomComponentFieldConfig,
field,
tab,
values,
uiState: customFieldUiState[field.key] || {},
@@ -379,12 +422,8 @@ export const SettingsContent = ({
const isTakeOverActive = Boolean(activeTakeOverFieldKey);
const customSaveBar = activeTakeOverLayout?.saveBar;
const saveBarOnSave = isTakeOverActive
? customSaveBar?.onSave
: onSave;
const saveBarIsSaving = isTakeOverActive
? Boolean(customSaveBar?.isSaving)
: isSaving;
const saveBarOnSave = isTakeOverActive ? customSaveBar?.onSave : onSave;
const saveBarIsSaving = isTakeOverActive ? Boolean(customSaveBar?.isSaving) : isSaving;
const saveBarHasChanges = isTakeOverActive
? Boolean(customSaveBar?.hasChanges && customSaveBar?.onSave)
: hasChanges;
@@ -395,43 +434,40 @@ export const SettingsContent = ({
const disabledState = getDisabledState(field, values);
const fieldOverrideSummary = overrideSummary?.[field.key];
const renderedField = field.type === 'CustomComponentField'
? renderCustomSettingsField({
field: field as CustomComponentFieldConfig,
tab,
values,
onChange,
onAction,
uiState: customFieldUiState[field.key] || {},
onUiStateChange: (key, value) => updateCustomFieldUiState(field.key, key, value),
isDisabled: disabledState.disabled,
disabledReason: disabledState.reason,
authMode: customFieldContext?.authMode,
onShowToast: customFieldContext?.onShowToast,
onRefreshOverrideSummary: customFieldContext?.onRefreshOverrideSummary,
onRefreshAuth: customFieldContext?.onRefreshAuth,
onSettingsSaved: customFieldContext?.onSettingsSaved,
})
: renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled,
values,
customFieldContext?.authMode
);
const renderedField =
field.type === 'CustomComponentField'
? renderCustomSettingsField({
field,
tab,
values,
onChange,
onAction,
uiState: customFieldUiState[field.key] || {},
onUiStateChange: (key, value) => updateCustomFieldUiState(field.key, key, value),
isDisabled: disabledState.disabled,
disabledReason: disabledState.reason,
authMode: customFieldContext?.authMode,
onShowToast: customFieldContext?.onShowToast,
onRefreshOverrideSummary: customFieldContext?.onRefreshOverrideSummary,
onRefreshAuth: customFieldContext?.onRefreshAuth,
onSettingsSaved: customFieldContext?.onSettingsSaved,
})
: renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled,
values,
customFieldContext?.authMode,
);
const shouldWrapInFieldWrapper = !(
field.type === 'CustomComponentField' && !(field as CustomComponentFieldConfig).wrapInFieldWrapper
field.type === 'CustomComponentField' && !field.wrapInFieldWrapper
);
if (!shouldWrapInFieldWrapper) {
return (
<div key={`${tab.name}-${field.key}`}>
{renderedField}
</div>
);
return <div key={`${tab.name}-${field.key}`}>{renderedField}</div>;
}
return (
@@ -450,39 +486,41 @@ export const SettingsContent = ({
</div>
);
const saveButton = saveBarHasChanges && saveBarOnSave ? (
<button
onClick={() => { void saveBarOnSave(); }}
disabled={saveBarIsSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{saveBarIsSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
) : null;
const saveButton =
saveBarHasChanges && saveBarOnSave ? (
<button
type="button"
onClick={() => {
void saveBarOnSave();
}}
disabled={saveBarIsSaving}
className="w-full rounded-lg bg-sky-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveBarIsSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
) : null;
if (embedded) {
return (
@@ -494,12 +532,14 @@ export const SettingsContent = ({
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex min-h-0 flex-1 flex-col">
{/* Scrollable content area */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-6"
style={{ paddingBottom: saveBarHasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
style={{
paddingBottom: saveBarHasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem',
}}
>
{renderedFields}
</div>
@@ -510,4 +550,4 @@ export const SettingsContent = ({
)}
</div>
);
};
}
@@ -12,39 +12,37 @@ export const SettingsHeader = ({
onClose,
}: SettingsHeaderProps) => (
<header
className="flex items-center gap-3 px-5 py-4 border-b border-(--border-muted) shrink-0"
className="flex shrink-0 items-center gap-3 border-b border-(--border-muted) px-5 py-4"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
>
{showBack && (
<button
type="button"
onClick={onBack}
className="p-2 -ml-2 rounded-full hover-action transition-colors"
className="hover-action -ml-2 rounded-full p-2 transition-colors"
aria-label="Go back"
>
<svg
className="w-5 h-5"
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<h2 className="text-lg font-semibold flex-1">{title}</h2>
<h2 className="flex-1 text-lg font-semibold">{title}</h2>
<button
type="button"
onClick={onClose}
className="p-2 rounded-full hover-action transition-colors"
className="hover-action rounded-full p-2 transition-colors"
aria-label="Close settings"
>
<svg
className="w-5 h-5"
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -1,11 +1,15 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useSettings } from '../../hooks/useSettings';
import { useState, useCallback, useRef, useMemo } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { useBodyScrollLock } from '../../hooks/useBodyScrollLock';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { useMountEffect } from '../../hooks/useMountEffect';
import { useSettings } from '../../hooks/useSettings';
import { getAdminSettingsOverridesSummary, getSettingsTab } from '../../services/api';
import { primeUsersCache } from './users/useUsersFetch';
import { SettingsContent } from './SettingsContent';
import { SettingsHeader } from './SettingsHeader';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsContent } from './SettingsContent';
interface SettingsModalProps {
isOpen: boolean;
@@ -16,7 +20,154 @@ interface SettingsModalProps {
onRefreshAuth?: () => Promise<void>;
}
export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettingsSaved, onRefreshAuth }: SettingsModalProps) => {
function getStringValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function getStringArrayValue(value: unknown): string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string') ? value : [];
}
function getBooleanValue(value: unknown): boolean {
return typeof value === 'boolean' ? value : false;
}
export const SettingsModal = ({
isOpen,
authMode,
onClose,
onShowToast,
onSettingsSaved,
onRefreshAuth,
}: SettingsModalProps) => {
const isMobile = useMediaQuery('(max-width: 767px)');
const [isClosing, setIsClosing] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const mobileDetailStateRef = useRef<{
isOpen: boolean;
closeDetail: (() => void) | null;
}>({
isOpen: false,
closeDetail: null,
});
const registerMobileDetailState = useCallback(
(detailOpen: boolean, closeDetail: (() => void) | null) => {
mobileDetailStateRef.current = { isOpen: detailOpen, closeDetail };
},
[],
);
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
setSessionVersion((current) => current + 1);
}, 150);
}, [onClose]);
const handleEscape = useCallback(() => {
if (isMobile && mobileDetailStateRef.current.isOpen) {
mobileDetailStateRef.current.closeDetail?.();
return;
}
handleClose();
}, [handleClose, isMobile]);
useBodyScrollLock(isOpen);
useEscapeKey(isOpen, handleEscape);
if (!isOpen && !isClosing) return null;
return (
<SettingsModalSession
key={sessionVersion}
isOpen={isOpen}
isClosing={isClosing}
isMobile={isMobile}
authMode={authMode}
onClose={handleClose}
onShowToast={onShowToast}
onSettingsSaved={onSettingsSaved}
onRefreshAuth={onRefreshAuth}
onMobileDetailChange={registerMobileDetailState}
/>
);
};
interface SettingsModalSessionProps {
isOpen: boolean;
isClosing: boolean;
isMobile: boolean;
authMode: string;
onClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSettingsSaved?: () => void;
onRefreshAuth?: () => Promise<void>;
onMobileDetailChange: (isOpen: boolean, closeDetail: (() => void) | null) => void;
}
interface SettingsModalTabSyncProps {
selectedTab: string;
refreshOverrideSummaryForTab: (tabName: string) => Promise<void>;
setSecurityAccessError: (message: string | null) => void;
}
const SettingsModalTabSync = ({
selectedTab,
refreshOverrideSummaryForTab,
setSecurityAccessError,
}: SettingsModalTabSyncProps) => {
useMountEffect(() => {
let cancelled = false;
void refreshOverrideSummaryForTab(selectedTab);
if (selectedTab !== 'security') {
setSecurityAccessError(null);
} else {
void getSettingsTab('security')
.then(() => {
if (!cancelled) {
setSecurityAccessError(null);
}
})
.catch((err) => {
if (cancelled) {
return;
}
const message = err instanceof Error ? err.message : 'Failed to load security settings';
if (message.toLowerCase().includes('admin access required')) {
setSecurityAccessError(message);
return;
}
setSecurityAccessError(null);
});
}
return () => {
cancelled = true;
};
});
return null;
};
const SettingsModalSession = ({
isOpen,
isClosing,
isMobile,
authMode,
onClose,
onShowToast,
onSettingsSaved,
onRefreshAuth,
onMobileDetailChange,
}: SettingsModalSessionProps) => {
const {
tabs,
groups,
@@ -34,102 +185,24 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
const { isUniversalMode } = useSearchMode();
// Track if we're showing detail view on mobile
const [isMobile, setIsMobile] = useState(false);
const [showMobileDetail, setShowMobileDetail] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [securityAccessError, setSecurityAccessError] = useState<string | null>(null);
const [tabOverrideSummaries, setTabOverrideSummaries] = useState<
Record<string, Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>>
Record<
string,
Record<
string,
{ count: number; users: Array<{ userId: number; username: string; value: unknown }> }
>
>
>({});
// Track previous isOpen state to detect modal open transition
const prevIsOpenRef = useRef(false);
const overrideSummaryRequestIdRef = useRef(0);
// Check for mobile viewport
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 150);
}, [onClose]);
// Handle ESC key
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (isMobile && showMobileDetail) {
setShowMobileDetail(false);
} else {
handleClose();
}
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, isMobile, showMobileDetail, handleClose]);
// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}
}, [isOpen]);
// Reset mobile detail view when modal opens
useEffect(() => {
if (isOpen) {
setShowMobileDetail(false);
setIsClosing(false);
setTabOverrideSummaries({});
void primeUsersCache();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen || selectedTab !== 'security') {
setSecurityAccessError(null);
return;
}
let cancelled = false;
getSettingsTab("security")
.then(() => {
if (cancelled) return;
setSecurityAccessError(null);
})
.catch((err) => {
if (cancelled) return;
const message = err instanceof Error ? err.message : 'Failed to load security settings';
if (message.toLowerCase().includes('admin access required')) {
setSecurityAccessError(message);
return;
}
setSecurityAccessError(null);
});
return () => {
cancelled = true;
};
}, [isOpen, selectedTab]);
setShowMobileDetail(false);
onMobileDetailChange(false, null);
onClose();
}, [onClose, onMobileDetailChange]);
const refreshOverrideSummaryForTab = useCallback(async (tabName: string) => {
const requestId = ++overrideSummaryRequestIdRef.current;
@@ -153,38 +226,22 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
}
}, []);
useEffect(() => {
if (!isOpen || !selectedTab) {
return;
}
void refreshOverrideSummaryForTab(selectedTab);
}, [isOpen, selectedTab, refreshOverrideSummaryForTab]);
// Reset to first tab when modal transitions from closed to open
useEffect(() => {
const justOpened = isOpen && !prevIsOpenRef.current;
prevIsOpenRef.current = isOpen;
// On desktop, select first tab when modal opens (reset on each open)
if (justOpened && !isMobile && tabs.length > 0) {
setSelectedTab(tabs[0].name);
}
}, [isOpen, isMobile, tabs, setSelectedTab]);
const handleBack = useCallback(() => {
setShowMobileDetail(false);
onMobileDetailChange(false, null);
}, [onMobileDetailChange]);
const handleSelectTab = useCallback(
(tabName: string) => {
setSelectedTab(tabName);
if (isMobile) {
setShowMobileDetail(true);
onMobileDetailChange(true, handleBack);
}
},
[isMobile, setSelectedTab]
[handleBack, isMobile, onMobileDetailChange, setSelectedTab],
);
const handleBack = useCallback(() => {
setShowMobileDetail(false);
}, []);
const handleRefreshCurrentTabOverrideSummary = useCallback(() => {
if (!selectedTab) {
return;
@@ -198,9 +255,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
if (result.success) {
void refreshOverrideSummaryForTab(selectedTab);
onShowToast?.(result.message, 'success');
// Notify parent that settings were saved so it can refresh config
onSettingsSaved?.();
// Show additional toast if some settings require restart
if (result.requiresRestart) {
setTimeout(() => {
onShowToast?.('Some settings require a container restart to take effect', 'info');
@@ -221,6 +276,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
setSelectedTab('users');
if (isMobile) {
setShowMobileDetail(true);
onMobileDetailChange(true, handleBack);
}
return { success: true, message: 'Opening Users tab...' };
}
@@ -230,34 +286,44 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
}
return result;
},
[selectedTab, executeAction, isMobile, setSelectedTab, refreshOverrideSummaryForTab]
[
handleBack,
isMobile,
onMobileDetailChange,
refreshOverrideSummaryForTab,
executeAction,
selectedTab,
setSelectedTab,
],
);
// Memoize the field change handler to prevent creating new functions on every render
const handleFieldChange = useCallback(
(key: string, value: unknown) => {
if (!selectedTab) return;
updateValue(selectedTab, key, value);
// Auto-manage OIDC scopes when admin group settings change
if (selectedTab === 'security') {
const tabValues = values[selectedTab] || {};
const currentScopes = (tabValues['OIDC_SCOPES'] as string[]) || [];
const currentScopes = getStringArrayValue(tabValues['OIDC_SCOPES']);
if (key === 'OIDC_USE_ADMIN_GROUP') {
const groupClaim = (tabValues['OIDC_GROUP_CLAIM'] as string) || 'groups';
const groupClaim = getStringValue(tabValues['OIDC_GROUP_CLAIM'], 'groups');
if (value === true && !currentScopes.includes(groupClaim)) {
updateValue(selectedTab, 'OIDC_SCOPES', [...currentScopes, groupClaim]);
} else if (value === false && currentScopes.includes(groupClaim)) {
updateValue(selectedTab, 'OIDC_SCOPES', currentScopes.filter(s => s !== groupClaim));
updateValue(
selectedTab,
'OIDC_SCOPES',
currentScopes.filter((s) => s !== groupClaim),
);
}
}
if (key === 'OIDC_GROUP_CLAIM' && typeof value === 'string') {
const useAdminGroup = tabValues['OIDC_USE_ADMIN_GROUP'] as boolean;
const useAdminGroup = getBooleanValue(tabValues['OIDC_USE_ADMIN_GROUP']);
if (useAdminGroup) {
const oldClaim = (tabValues['OIDC_GROUP_CLAIM'] as string) || 'groups';
const newScopes = currentScopes.filter(s => s !== oldClaim);
const oldClaim = getStringValue(tabValues['OIDC_GROUP_CLAIM'], 'groups');
const newScopes = currentScopes.filter((s) => s !== oldClaim);
if (value && !newScopes.includes(value)) {
newScopes.push(value);
}
@@ -266,25 +332,32 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
}
}
},
[selectedTab, updateValue, values]
[selectedTab, updateValue, values],
);
// Memoize hasChanges to avoid expensive JSON.stringify comparisons on every render
// Must be before early returns to satisfy React's rules of hooks
const currentTabHasChanges = useMemo(
() => (selectedTab ? hasChanges(selectedTab) : false),
[selectedTab, hasChanges, values]
[selectedTab, hasChanges],
);
if (!isOpen && !isClosing) return null;
const currentTab = tabs.find((t) => t.name === selectedTab);
const currentTabDisplayName = currentTab?.displayName || 'Settings';
const selectedTabSyncKey = isOpen && selectedTab ? selectedTab : null;
const tabSync = selectedTabSyncKey ? (
<SettingsModalTabSync
key={selectedTabSyncKey}
selectedTab={selectedTabSyncKey}
refreshOverrideSummaryForTab={refreshOverrideSummaryForTab}
setSecurityAccessError={setSecurityAccessError}
/>
) : null;
const selectedAuthMethod = values.security?.AUTH_METHOD;
const usersAuthMode = typeof selectedAuthMethod === 'string' ? selectedAuthMethod : authMode;
const currentTabContent = currentTab
? ((selectedTab === 'security' && securityAccessError) ? (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-3">
const currentTabContent = currentTab ? (
selectedTab === 'security' && securityAccessError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8">
<p className="text-sm opacity-60">{securityAccessError}</p>
</div>
) : (
@@ -306,24 +379,27 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
onSettingsSaved,
}}
/>
))
: null;
)
) : null;
// Loading state
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
{tabSync}
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
style={{ willChange: 'opacity', contain: 'strict' }}
onClick={handleClose}
tabIndex={-1}
aria-label="Close settings"
/>
<div
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl"
className="relative rounded-xl bg-(--bg) p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="flex items-center gap-3">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -346,23 +422,26 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
);
}
// Error state
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
{tabSync}
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
style={{ willChange: 'opacity', contain: 'strict' }}
onClick={handleClose}
tabIndex={-1}
aria-label="Close settings"
/>
<div
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl max-w-md"
className="relative max-w-md rounded-xl bg-(--bg) p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="text-center space-y-4">
<div className="space-y-4 text-center">
<div className="text-red-500">
<svg
className="w-12 h-12 mx-auto"
className="mx-auto h-12 w-12"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -378,9 +457,9 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
</div>
<p className="text-sm">{error}</p>
<button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface)"
>
Close
</button>
@@ -390,70 +469,63 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
);
}
// Mobile layout
if (isMobile) {
return (
<div
className={`fixed inset-0 z-50 flex flex-col
${isClosing ? 'animate-fade-out' : 'animate-fade-in'}`}
style={{ background: 'var(--bg)' }}
>
{!showMobileDetail ? (
// Category list view
<>
<SettingsHeader title="Settings" onClose={handleClose} />
<SettingsSidebar
tabs={tabs}
groups={groups}
selectedTab={selectedTab}
onSelectTab={handleSelectTab}
mode="list"
/>
</>
) : (
// Detail view
<>
<SettingsHeader
title={currentTabDisplayName}
showBack
onBack={handleBack}
onClose={handleClose}
/>
{currentTabContent}
</>
)}
</div>
<>
{tabSync}
<div
className={`fixed inset-0 z-50 flex flex-col ${isClosing ? 'animate-fade-out' : 'animate-fade-in'}`}
style={{ background: 'var(--bg)' }}
>
{!showMobileDetail ? (
<>
<SettingsHeader title="Settings" onClose={handleClose} />
<SettingsSidebar
tabs={tabs}
groups={groups}
selectedTab={selectedTab}
onSelectTab={handleSelectTab}
mode="list"
/>
</>
) : (
<>
<SettingsHeader
title={currentTabDisplayName}
showBack
onBack={handleBack}
onClose={handleClose}
/>
{currentTabContent}
</>
)}
</div>
</>
);
}
// Desktop layout
return (
<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-[2px] transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
{tabSync}
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
style={{ willChange: 'opacity', contain: 'strict' }}
onClick={handleClose}
tabIndex={-1}
aria-label="Close settings"
/>
{/* Modal */}
<div
className={`relative w-full max-w-4xl h-[85vh] max-h-[750px] rounded-xl
border border-(--border-muted) shadow-2xl
flex flex-col overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative flex h-[85vh] max-h-[750px] w-full max-w-4xl flex-col overflow-hidden rounded-xl border border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-label="Settings"
>
<SettingsHeader
title="Settings"
onClose={handleClose}
/>
<SettingsHeader title="Settings" onClose={handleClose} />
<div className="flex flex-1 min-h-0">
<div className="flex min-h-0 flex-1">
<SettingsSidebar
tabs={tabs}
groups={groups}
@@ -463,7 +535,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
/>
{currentTabContent ?? (
<div className="flex-1 flex items-center justify-center text-sm opacity-60">
<div className="flex flex-1 items-center justify-center text-sm opacity-60">
Select a category to configure
</div>
)}
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { SettingsTab, SettingsGroup } from '../../types/settings';
import type { SettingsTab, SettingsGroup } from '../../types/settings';
interface SettingsSidebarProps {
tabs: SettingsTab[];
@@ -12,79 +13,228 @@ interface SettingsSidebarProps {
// Map icon names to SVG paths
const getIcon = (iconName?: string) => {
switch (iconName) {
case undefined:
return (
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
/>
</svg>
);
case 'settings':
case 'cog':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
case 'folder':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
);
case 'shield':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
case 'globe':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
);
case 'download':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
case 'book':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
);
case 'library':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z"
/>
</svg>
);
case 'search':
return (
<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">
<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" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<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"
/>
</svg>
);
case 'users':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
case 'bell':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0M3.124 7.5A8.969 8.969 0 0 1 5.292 3m13.416 0a8.969 8.969 0 0 1 2.168 4.5" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0M3.124 7.5A8.969 8.969 0 0 1 5.292 3m13.416 0a8.969 8.969 0 0 1 2.168 4.5"
/>
</svg>
);
case 'beaker':
case 'wrench':
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z"
/>
</svg>
);
default:
return (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
/>
</svg>
);
}
@@ -93,7 +243,7 @@ const getIcon = (iconName?: string) => {
// Chevron icon for expandable groups
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`w-5 h-5 opacity-30 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`}
className={`h-5 w-5 opacity-30 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -126,9 +276,7 @@ export const SettingsSidebar = ({
mode,
}: SettingsSidebarProps) => {
// Track which groups are expanded (all closed by default)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set()
);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
const toggleGroup = (groupName: string) => {
setExpandedGroups((prev) => {
@@ -200,7 +348,7 @@ export const SettingsSidebar = ({
if (item.type === 'section') {
return (
<div key={item.label} className="px-5 pt-6 pb-2">
<span className="text-xs font-semibold uppercase tracking-wider opacity-50">
<span className="text-xs font-semibold tracking-wider uppercase opacity-50">
{item.label}
</span>
</div>
@@ -211,15 +359,15 @@ export const SettingsSidebar = ({
return (
<div key={item.tab.name}>
<button
type="button"
onClick={() => onSelectTab(item.tab.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-(--hover-surface) transition-colors text-left"
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors active:bg-(--hover-surface)"
>
<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-(--border-muted)" />
<div className="mr-5 ml-14 border-b border-(--border-muted)" />
)}
</div>
);
@@ -230,9 +378,9 @@ export const SettingsSidebar = ({
return (
<div key={item.group.name}>
<button
type="button"
onClick={() => toggleGroup(item.group.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-(--hover-surface) transition-colors text-left"
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors active:bg-(--hover-surface)"
>
<span className="opacity-50">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
@@ -244,14 +392,14 @@ export const SettingsSidebar = ({
{item.tabs.map((tab, index) => (
<div key={tab.name}>
<button
type="button"
onClick={() => onSelectTab(tab.name)}
className="w-full flex items-center gap-4 pl-14 pr-5 py-3.5
active:bg-(--hover-surface) transition-colors text-left"
className="flex w-full items-center gap-4 py-3.5 pr-5 pl-14 text-left transition-colors active:bg-(--hover-surface)"
>
<span className="flex-1 text-[15px]">{tab.displayName}</span>
</button>
{index < item.tabs.length - 1 && (
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
<div className="mr-5 ml-14 border-b border-(--border-muted)" />
)}
</div>
))}
@@ -259,7 +407,7 @@ export const SettingsSidebar = ({
)}
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
<div className="mr-5 ml-14 border-b border-(--border-muted)" />
)}
</div>
);
@@ -270,12 +418,12 @@ export const SettingsSidebar = ({
// Desktop: Sidebar navigation
return (
<nav className="w-60 border-r border-(--border-muted) py-2 shrink-0 overflow-y-auto">
<nav className="w-60 shrink-0 overflow-y-auto border-r border-(--border-muted) py-2">
{sidebarItems.map((item) => {
if (item.type === 'section') {
return (
<div key={item.label} className="px-4 pt-5 pb-2">
<span className="text-[11px] font-semibold uppercase tracking-wider opacity-40">
<span className="text-[11px] font-semibold tracking-wider uppercase opacity-40">
{item.label}
</span>
</div>
@@ -286,13 +434,13 @@ export const SettingsSidebar = ({
return (
<button
key={item.tab.name}
type="button"
onClick={() => onSelectTab(item.tab.name)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors ${
selectedTab === item.tab.name
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
selectedTab === item.tab.name
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span className="opacity-60">{getIcon(item.tab.icon)}</span>
<span>{item.tab.displayName}</span>
@@ -307,10 +455,9 @@ export const SettingsSidebar = ({
return (
<div key={item.group.name}>
<button
type="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-(--hover-surface)
${hasSelectedTab && !isExpanded ? 'bg-(--hover-action)/50' : ''}`}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm 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,17 +465,17 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="ml-[22px] border-l border-(--border-muted) flex flex-col gap-0.5 pl-3">
<div className="ml-[22px] flex flex-col gap-0.5 border-l border-(--border-muted) pl-3">
{item.tabs.map((tab) => (
<button
key={tab.name}
type="button"
onClick={() => onSelectTab(tab.name)}
className={`w-full flex items-center pl-3 pr-4 py-2 text-sm text-left
transition-colors ${
selectedTab === tab.name
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
className={`flex w-full items-center py-2 pr-4 pl-3 text-left text-sm transition-colors ${
selectedTab === tab.name
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span>{tab.displayName}</span>
</button>
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useMountEffect } from '../../../hooks/useMountEffect';
import { getAdminUsers } from '../../../services/api';
import { CustomSettingsFieldRendererProps } from './types';
import type { CustomSettingsFieldRendererProps } from './types';
const ADMIN_CHECK_KEYS = new Set(['builtin_admin_requirement', 'oidc_admin_requirement']);
@@ -8,27 +10,25 @@ export const OidcAdminHint = ({ field }: CustomSettingsFieldRendererProps) => {
const needsAdminCheck = ADMIN_CHECK_KEYS.has(field.key);
const [visible, setVisible] = useState(!needsAdminCheck);
useEffect(() => {
if (!needsAdminCheck) return;
let cancelled = false;
getAdminUsers()
useMountEffect(() => {
if (!needsAdminCheck) {
setVisible(true);
return;
}
void getAdminUsers()
.then((users) => {
if (!cancelled) {
setVisible(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
}
setVisible(!users.some((u) => u.role === 'admin' && u.auth_source === 'builtin'));
})
.catch(() => {
if (!cancelled) {
setVisible(true);
}
setVisible(true);
});
return () => { cancelled = true; };
}, [needsAdminCheck]);
});
if (!visible) return null;
return (
<div className="text-sm px-3 py-2 rounded-lg bg-amber-500/15 text-amber-700 dark:text-amber-300">
<div className="rounded-lg bg-amber-500/15 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
{field.label}
</div>
);
@@ -1,24 +1,27 @@
import { CustomSettingsFieldRendererProps } from './types';
import type { CustomSettingsFieldRendererProps } from './types';
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
return (
<div className="rounded-lg overflow-hidden border border-(--border-muted)">
<div className="overflow-hidden rounded-lg border border-(--border-muted)">
<div
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-(--border-muted)"
className="border-b border-(--border-muted) px-3 py-1.5 text-xs font-medium opacity-60"
style={{ background: 'var(--bg-soft)' }}
>
docker-compose.yml
</div>
<pre
className="px-3 py-3 text-xs overflow-x-auto"
style={{ background: 'var(--bg-soft)' }}
>
<pre className="overflow-x-auto px-3 py-3 text-xs" style={{ background: 'var(--bg-soft)' }}>
<code>
<span className="opacity-60">environment:</span>{'\n'}
{' '}- <span className="text-blue-400">HIDE_LOCAL_AUTH</span>=<span className="text-green-400">true</span>
{' '}<span className="opacity-40"># Hide the local login form</span>{'\n'}
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=<span className="text-green-400">true</span>
{' '}<span className="opacity-40"># Skip login page, redirect straight to OIDC</span>
<span className="opacity-60">environment:</span>
{'\n'}
{' '}- <span className="text-blue-400">HIDE_LOCAL_AUTH</span>=
<span className="text-green-400">true</span>
{' '}
<span className="opacity-40"># Hide the local login form</span>
{'\n'}
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=
<span className="text-green-400">true</span>
{' '}
<span className="opacity-40"># Skip login page, redirect straight to OIDC</span>
</code>
</pre>
</div>
@@ -1,13 +1,15 @@
import { useMemo } from 'react';
import { SelectFieldConfig, TableFieldConfig } from '../../../types/settings';
import type { SelectFieldConfig, TableFieldConfig } from '../../../types/settings';
import type { RequestPolicyContentType, RequestPolicyMode } from '../users';
import { RequestPolicyGrid } from '../users';
import {
RequestPolicyGrid,
normalizeExplicitRulesForPersistence,
normalizeRequestPolicyDefaults,
normalizeRequestPolicyRules,
parseSourceCapabilitiesFromRulesField,
} from '../users';
import { CustomSettingsFieldRendererProps } from './types';
} from '../users/requestPolicyGridUtils';
import type { CustomSettingsFieldRendererProps } from './types';
export const RequestPolicyGridField = ({
field,
@@ -19,34 +21,31 @@ export const RequestPolicyGridField = ({
() =>
field.boundFields?.find(
(boundField): boundField is TableFieldConfig =>
boundField.key === 'REQUEST_POLICY_RULES' && boundField.type === 'TableField'
boundField.key === 'REQUEST_POLICY_RULES' && boundField.type === 'TableField',
),
[field.boundFields]
[field.boundFields],
);
const defaultEbookField = useMemo(
() =>
field.boundFields?.find(
(boundField): boundField is SelectFieldConfig =>
boundField.key === 'REQUEST_POLICY_DEFAULT_EBOOK' && boundField.type === 'SelectField'
boundField.key === 'REQUEST_POLICY_DEFAULT_EBOOK' && boundField.type === 'SelectField',
),
[field.boundFields]
[field.boundFields],
);
const defaultAudioField = useMemo(
() =>
field.boundFields?.find(
(boundField): boundField is SelectFieldConfig =>
boundField.key === 'REQUEST_POLICY_DEFAULT_AUDIOBOOK' && boundField.type === 'SelectField'
boundField.key === 'REQUEST_POLICY_DEFAULT_AUDIOBOOK' &&
boundField.type === 'SelectField',
),
[field.boundFields]
[field.boundFields],
);
if (!requestRulesField) {
return (
<p className="text-xs opacity-60">
Request policy schema is unavailable for this tab.
</p>
);
return <p className="text-xs opacity-60">Request policy schema is unavailable for this tab.</p>;
}
const globalRequestDefaults = useMemo(
@@ -55,27 +54,40 @@ export const RequestPolicyGridField = ({
ebook: values.REQUEST_POLICY_DEFAULT_EBOOK,
audiobook: values.REQUEST_POLICY_DEFAULT_AUDIOBOOK,
}),
[values.REQUEST_POLICY_DEFAULT_EBOOK, values.REQUEST_POLICY_DEFAULT_AUDIOBOOK]
[values.REQUEST_POLICY_DEFAULT_EBOOK, values.REQUEST_POLICY_DEFAULT_AUDIOBOOK],
);
const explicitGlobalRules = useMemo(
() => normalizeRequestPolicyRules(values.REQUEST_POLICY_RULES),
[values.REQUEST_POLICY_RULES]
[values.REQUEST_POLICY_RULES],
);
const requestSourceCapabilities = useMemo(
() =>
parseSourceCapabilitiesFromRulesField(
requestRulesField,
explicitGlobalRules.map((row) => row.source)
explicitGlobalRules.map((row) => row.source),
),
[requestRulesField, explicitGlobalRules]
[requestRulesField, explicitGlobalRules],
);
const onGlobalDefaultModeChange = (contentType: RequestPolicyContentType, mode: RequestPolicyMode) => {
const onGlobalDefaultModeChange = (
contentType: RequestPolicyContentType,
mode: RequestPolicyMode,
) => {
const key =
contentType === 'ebook' ? 'REQUEST_POLICY_DEFAULT_EBOOK' : 'REQUEST_POLICY_DEFAULT_AUDIOBOOK';
const nextDefaultModes = {
...globalRequestDefaults,
[contentType]: mode,
};
const normalizedRules = normalizeExplicitRulesForPersistence({
explicitRules: explicitGlobalRules,
defaultModes: nextDefaultModes,
sourceCapabilities: requestSourceCapabilities,
});
onChange(key, mode);
onChange('REQUEST_POLICY_RULES', normalizedRules);
};
const onGlobalRulesChange = (
@@ -83,9 +95,14 @@ export const RequestPolicyGridField = ({
source: string;
content_type: 'ebook' | 'audiobook';
mode: 'download' | 'request_release' | 'blocked';
}>
}>,
) => {
onChange('REQUEST_POLICY_RULES', rules);
const normalizedRules = normalizeExplicitRulesForPersistence({
explicitRules: rules,
defaultModes: globalRequestDefaults,
sourceCapabilities: requestSourceCapabilities,
});
onChange('REQUEST_POLICY_RULES', normalizedRules);
};
return (
@@ -1,11 +1,10 @@
import { CustomSettingsFieldRendererProps } from './types';
import type { CustomSettingsFieldRendererProps } from './types';
const interpolate = (text: string): string =>
text.replace(/\{origin\}/g, window.location.origin);
const interpolate = (text: string): string => text.replace(/\{origin\}/g, window.location.origin);
export const SettingsLabel = ({ field }: CustomSettingsFieldRendererProps) => {
return (
<div className="text-sm px-3 py-2 rounded-lg bg-sky-500/20">
<div className="rounded-lg bg-sky-500/20 px-3 py-2 text-sm">
{field.label && <span className="opacity-60">{field.label} </span>}
{field.description && (
<code className="font-mono text-xs">{interpolate(field.description)}</code>
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import {
AdminUser,
testAdminUserNotificationPreferences,
} from '../../../services/api';
import { CustomSettingsFieldRendererProps } from './types';
import { useCallback, useLayoutEffect, useRef } from 'react';
import { useMountEffect } from '../../../hooks/useMountEffect';
import type { AdminUser } from '../../../services/api';
import { testAdminUserNotificationPreferences } from '../../../services/api';
import {
canCreateLocalUsersForAuthMode,
UserListView,
@@ -13,6 +12,7 @@ import {
useUsersFetch,
useUsersPanelState,
} from '../users';
import type { CustomSettingsFieldRendererProps } from './types';
export const UsersManagementField = ({
tab: usersTab,
@@ -27,13 +27,9 @@ export const UsersManagementField = ({
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
const activeEditRequestIdRef = useRef(0);
const {
users,
loading,
loadError,
fetchUsers,
fetchUserEditContext,
} = useUsersFetch({ onShowToast });
const { users, loading, loadError, fetchUsers, fetchUserEditContext } = useUsersFetch({
onShowToast,
});
const {
createForm,
@@ -53,6 +49,7 @@ export const UsersManagementField = ({
userSettings,
setUserSettings,
hasUserSettingsChanges,
hasUserSettingsChangesFor,
beginEditing,
applyUserEditContext,
resetEditContext,
@@ -90,11 +87,11 @@ export const UsersManagementField = ({
activeEditRequestIdRef.current += 1;
}, []);
useEffect(() => {
useMountEffect(() => {
return () => {
invalidateEditContextRequest();
};
}, [invalidateEditContextRequest]);
});
const startEditing = async (user: AdminUser) => {
const requestId = activeEditRequestIdRef.current + 1;
@@ -115,11 +112,16 @@ export const UsersManagementField = ({
};
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode || 'none');
const needsLocalAdmin = !users.some(u => u.role === 'admin' && u.auth_source === 'builtin');
const effectiveRouteKind = route.kind === 'create' && !canCreateLocalUsers ? 'list' : route.kind;
const activeEditUserId = route.kind === 'edit' ? route.userId : null;
const needsLocalAdmin = !users.some((u) => u.role === 'admin' && u.auth_source === 'builtin');
const handleBackToList = () => {
onUiStateChange('routeKind', 'list');
invalidateEditContextRequest();
onUiStateChange('hasChanges', false);
onUiStateChange('isSaving', false);
onUiStateChange('onSave', undefined);
clearEditState();
backToList();
};
@@ -131,17 +133,55 @@ export const UsersManagementField = ({
};
const handleCreate = async () => {
if (!canCreateLocalUsers) {
return;
}
const ok = await createUser();
if (ok) {
onRefreshOverrideSummary?.();
onRefreshAuth?.();
void onRefreshAuth?.();
backToList();
}
};
const handleSaveUserEdit = useCallback(async () => {
const ok = await saveEditedUser({ includeSettings: false });
if (ok) {
onRefreshOverrideSummary?.();
backToList();
}
}, [backToList, onRefreshOverrideSummary, saveEditedUser]);
const handleSaveUserOverrides = useCallback(async () => {
onUiStateChange('isSaving', true);
const ok = await saveEditedUser({
includeProfile: false,
includePassword: false,
includeSettings: true,
});
onUiStateChange('isSaving', false);
if (ok) {
onUiStateChange('hasChanges', false);
onUiStateChange('onSave', undefined);
onSettingsSaved?.();
onRefreshOverrideSummary?.();
backToList();
}
}, [backToList, onRefreshOverrideSummary, onSettingsSaved, onUiStateChange, saveEditedUser]);
const handleSaveUserOverridesRef = useRef(handleSaveUserOverrides);
handleSaveUserOverridesRef.current = handleSaveUserOverrides;
const triggerSaveUserOverrides = useCallback(async () => {
await handleSaveUserOverridesRef.current();
}, []);
const handleOpenOverrides = () => {
if (editingUser) {
onUiStateChange('routeKind', 'edit-overrides');
onUiStateChange('hasChanges', hasUserSettingsChanges);
onUiStateChange('isSaving', saving);
onUiStateChange('onSave', triggerSaveUserOverrides);
openEditOverrides(editingUser.id);
}
};
@@ -162,86 +202,60 @@ export const UsersManagementField = ({
const handleBackToEdit = () => {
if (editingUser) {
onUiStateChange('routeKind', 'edit');
onUiStateChange('hasChanges', false);
onUiStateChange('isSaving', false);
onUiStateChange('onSave', undefined);
openEdit(editingUser.id);
return;
}
onUiStateChange('routeKind', 'list');
onUiStateChange('hasChanges', false);
onUiStateChange('isSaving', false);
onUiStateChange('onSave', undefined);
backToList();
};
useEffect(() => {
if (route.kind === 'create' && !canCreateLocalUsers) {
backToList();
}
}, [backToList, canCreateLocalUsers, route.kind]);
useLayoutEffect(() => {
onUiStateChange('routeKind', route.kind);
}, [onUiStateChange, route.kind]);
onUiStateChange('routeKind', effectiveRouteKind);
}, [effectiveRouteKind, onUiStateChange]);
const handleSaveUserEdit = useCallback(async () => {
const ok = await saveEditedUser({ includeSettings: false });
if (ok) {
onRefreshOverrideSummary?.();
backToList();
}
}, [backToList, onRefreshOverrideSummary, saveEditedUser]);
const handleTestNotificationRoutes = useCallback(
async (routes: Array<Record<string, unknown>>) => {
if (!editingUser) {
return { success: false, message: 'No user selected for notification test.' };
}
return testAdminUserNotificationPreferences(editingUser.id, routes);
},
[editingUser],
);
const handleSaveUserOverrides = useCallback(async () => {
const ok = await saveEditedUser({
includeProfile: false,
includePassword: false,
includeSettings: true,
});
if (ok) {
onSettingsSaved?.();
onRefreshOverrideSummary?.();
backToList();
}
}, [backToList, onRefreshOverrideSummary, onSettingsSaved, saveEditedUser]);
const handleDeleteUser = useCallback(
async (userId: number) => {
const ok = await deleteUser(userId);
if (ok) {
onRefreshOverrideSummary?.();
void onRefreshAuth?.();
}
return ok;
},
[deleteUser, onRefreshAuth, onRefreshOverrideSummary],
);
const handleSaveUserOverridesRef = useRef(handleSaveUserOverrides);
useEffect(() => {
handleSaveUserOverridesRef.current = handleSaveUserOverrides;
}, [handleSaveUserOverrides]);
const triggerSaveUserOverrides = useCallback(async () => {
await handleSaveUserOverridesRef.current();
}, []);
const handleTestNotificationRoutes = useCallback(async (routes: Array<Record<string, unknown>>) => {
if (!editingUser) {
return { success: false, message: 'No user selected for notification test.' };
}
return testAdminUserNotificationPreferences(editingUser.id, routes);
}, [editingUser]);
const handleDeleteUser = useCallback(async (userId: number) => {
const ok = await deleteUser(userId);
if (ok) {
onRefreshOverrideSummary?.();
onRefreshAuth?.();
}
return ok;
}, [deleteUser, onRefreshAuth, onRefreshOverrideSummary]);
useEffect(() => {
if (route.kind !== 'edit-overrides') {
onUiStateChange('hasChanges', false);
onUiStateChange('isSaving', false);
onUiStateChange('onSave', undefined);
return;
}
onUiStateChange('hasChanges', hasUserSettingsChanges);
onUiStateChange('isSaving', saving);
onUiStateChange('onSave', triggerSaveUserOverrides);
}, [hasUserSettingsChanges, onUiStateChange, route.kind, saving, triggerSaveUserOverrides]);
const setUserSettingsWithSaveBarSync = useCallback(
(updater: (prev: typeof userSettings) => typeof userSettings) => {
setUserSettings((prev) => {
const next = updater(prev);
onUiStateChange('hasChanges', hasUserSettingsChangesFor(next));
return next;
});
},
[hasUserSettingsChangesFor, onUiStateChange, setUserSettings],
);
if (route.kind === 'edit-overrides') {
if (!editingUser || editingUser.id !== route.userId) {
return (
<div className="flex items-center justify-center text-sm opacity-60 py-8">
<div className="flex items-center justify-center py-8 text-sm opacity-60">
Loading user details...
</div>
);
@@ -257,7 +271,7 @@ export const UsersManagementField = ({
notificationPreferences={notificationPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
setUserSettings={setUserSettingsWithSaveBarSync}
usersTab={usersTab}
globalUsersSettingsValues={values}
onTestNotificationRoutes={handleTestNotificationRoutes}
@@ -273,24 +287,31 @@ export const UsersManagementField = ({
loadError={loadError}
onRetryLoadUsers={() => void fetchUsers({ force: true })}
onCreate={() => {
if (!canCreateLocalUsers) {
return;
}
if (needsLocalAdmin) {
setCreateForm({ ...createForm, role: 'admin' });
}
openCreate();
}}
needsLocalAdmin={needsLocalAdmin}
showCreateForm={route.kind === 'create'}
showCreateForm={effectiveRouteKind === 'create'}
createForm={createForm}
onCreateFormChange={setCreateForm}
creating={creating}
isFirstUser={users.length === 0}
onCreateSubmit={handleCreate}
onCreateSubmit={() => {
void handleCreate();
}}
onCancelCreate={handleCancelCreate}
showEditForm={route.kind === 'edit'}
activeEditUserId={route.kind === 'edit' ? route.userId : null}
editingUser={route.kind === 'edit' ? editingUser : null}
showEditForm={effectiveRouteKind === 'edit'}
activeEditUserId={activeEditUserId}
editingUser={effectiveRouteKind === 'edit' ? editingUser : null}
onEditingUserChange={setEditingUser}
onEditSave={handleSaveUserEdit}
onEditSave={() => {
void handleSaveUserEdit();
}}
saving={saving}
onCancelEdit={handleBackToList}
editPassword={editPassword}
@@ -299,7 +320,9 @@ export const UsersManagementField = ({
onEditPasswordConfirmChange={setEditPasswordConfirm}
downloadDefaults={downloadDefaults}
onOpenOverrides={handleOpenOverrides}
onEdit={handleEdit}
onEdit={(user) => {
void handleEdit(user);
}}
onDelete={handleDeleteUser}
deletingUserId={deletingUserId}
onSyncCwa={handleSyncCwa}
@@ -1,32 +1,37 @@
import { ComponentType, ReactNode } from 'react';
import type { ComponentType, ReactNode } from 'react';
import { OidcAdminHint } from './OidcAdminHint';
import { OidcEnvInfo } from './OidcEnvInfo';
import { RequestPolicyGridField } from './RequestPolicyGridField';
import { SettingsLabel } from './SettingsLabel';
import { UsersManagementField } from './UsersManagementField';
import {
import type {
CustomSettingsFieldLayout,
CustomSettingsFieldLayoutContext,
CustomSettingsFieldRendererProps,
} from './types';
import { UsersManagementField } from './UsersManagementField';
type CustomFieldRenderer = ComponentType<CustomSettingsFieldRendererProps>;
type CustomFieldLayoutResolver = (context: CustomSettingsFieldLayoutContext) => CustomSettingsFieldLayout;
type CustomFieldLayoutResolver = (
context: CustomSettingsFieldLayoutContext,
) => CustomSettingsFieldLayout;
interface CustomFieldDefinition {
renderer: CustomFieldRenderer;
getLayout?: CustomFieldLayoutResolver;
}
function isSaveHandler(value: unknown): value is () => void | Promise<void> {
return typeof value === 'function';
}
const CUSTOM_FIELD_DEFINITIONS: Record<string, CustomFieldDefinition> = {
users_management: {
renderer: UsersManagementField,
getLayout: ({ uiState }) => {
const routeKind = typeof uiState.routeKind === 'string' ? uiState.routeKind : 'list';
const isSubpage = routeKind === 'edit-overrides';
const onSave = typeof uiState.onSave === 'function'
? (uiState.onSave as () => void | Promise<void>)
: undefined;
const onSave = isSaveHandler(uiState.onSave) ? uiState.onSave : undefined;
return {
takeOverTab: isSubpage,
saveBar: isSubpage
@@ -53,9 +58,7 @@ const CUSTOM_FIELD_DEFINITIONS: Record<string, CustomFieldDefinition> = {
},
};
export const renderCustomSettingsField = (
props: CustomSettingsFieldRendererProps
): ReactNode => {
export const renderCustomSettingsField = (props: CustomSettingsFieldRendererProps): ReactNode => {
const definition = CUSTOM_FIELD_DEFINITIONS[props.field.component];
const Renderer = definition?.renderer;
if (!Renderer) {
@@ -69,7 +72,7 @@ export const renderCustomSettingsField = (
};
export const getCustomSettingsFieldLayout = (
context: CustomSettingsFieldLayoutContext
context: CustomSettingsFieldLayoutContext,
): CustomSettingsFieldLayout => {
const definition = CUSTOM_FIELD_DEFINITIONS[context.field.component];
if (!definition?.getLayout) {
@@ -78,8 +81,4 @@ export const getCustomSettingsFieldLayout = (
return definition.getLayout(context);
};
export type {
CustomSettingsFieldLayout,
CustomSettingsFieldLayoutContext,
CustomSettingsFieldRendererProps,
} from './types';
export type { CustomSettingsFieldLayout } from './types';
@@ -1,4 +1,8 @@
import { ActionResult, CustomComponentFieldConfig, SettingsTab } from '../../../types/settings';
import type {
ActionResult,
CustomComponentFieldConfig,
SettingsTab,
} from '../../../types/settings';
export interface CustomSettingsFieldRendererProps {
field: CustomComponentFieldConfig;
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ActionButtonConfig, ActionResult } from '../../../types/settings';
import type { ActionButtonConfig, ActionResult } from '../../../types/settings';
interface ActionButtonProps {
field: ActionButtonConfig;
@@ -7,6 +8,20 @@ interface ActionButtonProps {
disabled?: boolean;
}
function createDetailEntries(details: string[]): Array<{ key: string; detail: string }> {
const detailCounts = new Map<string, number>();
return details.map((detail) => {
const nextCount = (detailCounts.get(detail) ?? 0) + 1;
detailCounts.set(detail, nextCount);
return {
key: nextCount === 1 ? detail : `${detail}-${nextCount}`,
detail,
};
});
}
export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) => {
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<ActionResult | null>(null);
@@ -30,8 +45,7 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
};
const styleClasses = {
default:
'bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface)',
default: 'bg-(--bg-soft) border 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',
};
@@ -41,15 +55,15 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
<div className="flex items-start gap-3">
<button
type="button"
onClick={handleClick}
onClick={() => {
void handleClick();
}}
disabled={isDisabled}
className={`px-4 py-2 rounded-lg text-sm font-medium
transition-colors disabled:opacity-60 disabled:cursor-not-allowed
${styleClasses[field.style]}`}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${styleClasses[field.style]}`}
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -71,14 +85,12 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
field.label
)}
</button>
{field.description && (
<span className="text-xs opacity-60 pt-2">{field.description}</span>
)}
{field.description && <span className="pt-2 text-xs opacity-60">{field.description}</span>}
</div>
{result && (
<div
className={`text-sm px-3 py-2 rounded-lg ${
className={`rounded-lg px-3 py-2 text-sm ${
result.success
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
@@ -86,9 +98,9 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
>
<p>{result.message}</p>
{Array.isArray(result.details) && result.details.length > 0 && (
<ul className="mt-2 list-disc pl-5 space-y-1 text-xs opacity-90">
{result.details.map((detail, index) => (
<li key={`${detail}-${index}`}>{detail}</li>
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs opacity-90">
{createDetailEntries(result.details).map(({ key, detail }) => (
<li key={key}>{detail}</li>
))}
</ul>
)}
@@ -1,4 +1,4 @@
import { CheckboxFieldConfig } from '../../../types/settings';
import type { CheckboxFieldConfig } from '../../../types/settings';
import { ToggleSwitch } from '../../shared';
interface CheckboxFieldProps {
@@ -9,7 +9,5 @@ interface CheckboxFieldProps {
}
export const CheckboxField = ({ field: _field, value, onChange, disabled }: CheckboxFieldProps) => {
return (
<ToggleSwitch checked={value} onChange={onChange} disabled={disabled} />
);
return <ToggleSwitch checked={value} onChange={onChange} disabled={disabled} />;
};
@@ -1,12 +1,12 @@
import { HeadingFieldConfig } from '../../../types/settings';
import type { HeadingFieldConfig } from '../../../types/settings';
interface HeadingFieldProps {
field: HeadingFieldConfig;
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-1 not-first:pt-5 not-first:mt-1 not-first:border-t not-first:border-(--border-muted)">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
<div className="pb-1 not-first:mt-1 not-first:border-t not-first:border-(--border-muted) not-first:pt-5">
<h3 className="mb-1 text-base font-semibold">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">
{field.description}
@@ -17,7 +17,7 @@ export const HeadingField = ({ field }: HeadingFieldProps) => (
href={field.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="underline text-sky-600 dark:text-sky-400"
className="text-sky-600 underline dark:text-sky-400"
>
{field.linkText || field.linkUrl}
</a>
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { MultiSelectFieldConfig } from '../../../types/settings';
import { useLayoutEffect, useRef, useState } from 'react';
import type { MultiSelectFieldConfig } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
interface MultiSelectFieldProps {
@@ -14,13 +15,22 @@ const COLLAPSE_THRESHOLD_OPTIONS = 12;
// Approximate height for ~4 rows of pills (pills are ~32px + 8px gap)
const COLLAPSED_HEIGHT = 156;
const ALL_OPTION_VALUE = 'all';
const EMPTY_SELECTION: string[] = [];
interface OptionOrderState {
fieldKey: string;
optionsIdentity: string;
selectionIdentity: string;
pendingInternalSelectionIdentity: string | null;
sortedOptions: MultiSelectFieldConfig['options'];
}
/**
* Sort options with selected items first, preserving relative order within each group
*/
const sortOptionsWithSelectedFirst = (
options: MultiSelectFieldConfig['options'],
selectedValues: string[]
selectedValues: string[],
): MultiSelectFieldConfig['options'] => {
const selectedSet = new Set(selectedValues);
const selectedOptions = options.filter((opt) => selectedSet.has(opt.value));
@@ -28,8 +38,19 @@ const sortOptionsWithSelectedFirst = (
return [...selectedOptions, ...unselectedOptions];
};
export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSelectFieldProps) => {
const selected = value ?? [];
const getOptionsIdentity = (options: MultiSelectFieldConfig['options']): string =>
options.map((opt) => `${opt.value}\u0000${opt.label}\u0000${opt.childOf ?? ''}`).join('\u0001');
const getSelectionIdentity = (values: string[]): string =>
values.toSorted((left, right) => left.localeCompare(right)).join('\u0001');
export const MultiSelectField = ({
field,
value: fieldValue,
onChange,
disabled,
}: MultiSelectFieldProps) => {
const selected = fieldValue ?? EMPTY_SELECTION;
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
@@ -51,22 +72,18 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
const normalizeValues = (values: string[]): string[] => {
const deduped = new Set(
values
.map((entry) => String(entry ?? '').trim())
.filter((entry) => entry.length > 0 && optionSet.has(entry))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0 && optionSet.has(entry)),
);
return orderedOptions
.map((opt) => opt.value)
.filter((optValue) => deduped.has(optValue));
return orderedOptions.map((opt) => opt.value).filter((optValue) => deduped.has(optValue));
};
const selectedExplicit = normalizeValues(selected);
const allSelected = hasAllOption && (
selectedExplicit.includes(ALL_OPTION_VALUE)
|| (
nonAllValues.length > 0
&& nonAllValues.every((optValue) => selectedExplicit.includes(optValue))
)
);
const allSelected =
hasAllOption &&
(selectedExplicit.includes(ALL_OPTION_VALUE) ||
(nonAllValues.length > 0 &&
nonAllValues.every((optValue) => selectedExplicit.includes(optValue))));
// Build parent -> children map for cascading selection
const parentChildMap = new Map<string, string[]>();
@@ -118,7 +135,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
return;
}
if (allSelected && includesAll && nextValues.length < optionValues.length) {
onChange(nextValues.filter((value) => value !== ALL_OPTION_VALUE));
onChange(nextValues.filter((entry) => entry !== ALL_OPTION_VALUE));
return;
}
@@ -129,8 +146,8 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
// If user selects every specific option individually, collapse to "all".
if (
nonAllValues.length > 0
&& nonAllValues.every((optValue) => nextValues.includes(optValue))
nonAllValues.length > 0 &&
nonAllValues.every((optValue) => nextValues.includes(optValue))
) {
onChange([ALL_OPTION_VALUE]);
return;
@@ -163,7 +180,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
if (isDisabled) {
return (
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60">
{summaryFormatter()}
</div>
);
@@ -186,49 +203,47 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
const [isExpanded, setIsExpanded] = useState(false);
// Initialize based on option count to avoid flash of expanded content
const [needsCollapse, setNeedsCollapse] = useState(
() => field.options.length > COLLAPSE_THRESHOLD_OPTIONS
() => field.options.length > COLLAPSE_THRESHOLD_OPTIONS,
);
const containerRef = useRef<HTMLDivElement>(null);
const optionsIdentity = getOptionsIdentity(field.options);
const selectionIdentity = getSelectionIdentity(selected);
const [optionOrderState, setOptionOrderState] = useState<OptionOrderState>(() => ({
fieldKey: field.key,
optionsIdentity,
selectionIdentity,
pendingInternalSelectionIdentity: null,
sortedOptions: sortOptionsWithSelectedFirst(field.options, selected),
}));
if (
optionOrderState.fieldKey !== field.key ||
optionOrderState.optionsIdentity !== optionsIdentity
) {
setOptionOrderState({
fieldKey: field.key,
optionsIdentity,
selectionIdentity,
pendingInternalSelectionIdentity: null,
sortedOptions: sortOptionsWithSelectedFirst(field.options, selected),
});
} else if (optionOrderState.selectionIdentity !== selectionIdentity) {
const isInternalToggleConfirmation =
optionOrderState.pendingInternalSelectionIdentity !== null &&
optionOrderState.pendingInternalSelectionIdentity === selectionIdentity;
// Track the last value we set via onChange to detect external changes
const lastInternalValueRef = useRef<string[] | null>(null);
// Sorted options - initialized with selected items first, updated only on external changes
const [sortedOptions, setSortedOptions] = useState(() =>
sortOptionsWithSelectedFirst(field.options, selected)
);
// Detect external value changes (like after save or initial load) and re-sort
useEffect(() => {
// If the value changed and it's not from our own onChange call, re-sort
const lastInternal = lastInternalValueRef.current;
const isExternalChange =
lastInternal === null || // Initial mount
lastInternal.length !== selected.length ||
!lastInternal.every((v) => selected.includes(v));
// Only re-sort if the change wasn't triggered by user interaction
if (isExternalChange && lastInternal !== null) {
// Check if this is truly external (values differ in a way that suggests a save/reset)
const wasInternalToggle =
Math.abs(lastInternal.length - selected.length) === 1 &&
(lastInternal.every((v) => selected.includes(v)) ||
selected.every((v) => lastInternal.includes(v)));
if (!wasInternalToggle) {
setSortedOptions(sortOptionsWithSelectedFirst(field.options, selected));
}
}
}, [selected, field.options]);
// Update sortedOptions when field itself changes (different field or options refreshed)
useEffect(() => {
setSortedOptions(sortOptionsWithSelectedFirst(field.options, selected));
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally omit 'selected' to avoid re-sorting on user toggle
}, [field.key, field.options]);
setOptionOrderState((current) => ({
...current,
selectionIdentity,
pendingInternalSelectionIdentity: null,
sortedOptions: isInternalToggleConfirmation
? current.sortedOptions
: sortOptionsWithSelectedFirst(field.options, selected),
}));
}
const sortedOptions = optionOrderState.sortedOptions;
// Verify collapse need after render (handles edge cases where few options still fit)
useEffect(() => {
useLayoutEffect(() => {
if (containerRef.current) {
if (field.options.length > COLLAPSE_THRESHOLD_OPTIONS) {
const scrollHeight = containerRef.current.scrollHeight;
@@ -247,8 +262,10 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
} else {
newValue = [...selected, optValue];
}
// Track this as an internal change so we don't re-sort
lastInternalValueRef.current = newValue;
setOptionOrderState((current) => ({
...current,
pendingInternalSelectionIdentity: getSelectionIdentity(newValue),
}));
onChange(newValue);
};
@@ -276,14 +293,11 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
type="button"
onClick={() => toggleOption(opt.value)}
disabled={isDisabled}
className={`px-3 py-1.5 rounded-full text-sm font-medium
transition-colors border
disabled:opacity-60 disabled:cursor-not-allowed
${
isSelected
? 'bg-sky-600 text-white border-sky-600'
: 'bg-transparent border-(--border-muted) hover:bg-(--hover-surface)'
}`}
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
isSelected
? 'border-sky-600 bg-sky-600 text-white'
: 'border-(--border-muted) bg-transparent hover:bg-(--hover-surface)'
}`}
>
{opt.label}
</button>
@@ -294,7 +308,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
{/* Gradient fade overlay when collapsed */}
{isCollapsed && (
<div
className="absolute bottom-0 left-0 right-0 h-20 pointer-events-none"
className="pointer-events-none absolute right-0 bottom-0 left-0 h-20"
style={{
background: 'linear-gradient(to top, var(--bg) 0%, transparent 85%)',
}}
@@ -307,17 +321,11 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 text-sm text-sky-500 hover:text-sky-400
transition-colors flex items-center gap-1"
className="mt-2 flex items-center gap-1 text-sm text-sky-500 transition-colors hover:text-sky-400"
>
{isExpanded ? (
<>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -329,12 +337,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -1,4 +1,4 @@
import { NumberFieldConfig } from '../../../types/settings';
import type { NumberFieldConfig } from '../../../types/settings';
interface NumberFieldProps {
field: NumberFieldConfig;
@@ -20,10 +20,7 @@ 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-(--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"
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
/>
);
};
@@ -1,5 +1,6 @@
import { useState, useRef } from 'react';
import {
import type {
OrderableListFieldConfig,
OrderableListItem,
OrderableListOption,
@@ -25,7 +26,7 @@ type MergedItem = OrderableListItem & OrderableListOption;
*/
const mergeValueWithOptions = (
value: OrderableListItem[],
options: OrderableListOption[]
options: OrderableListOption[],
): MergedItem[] => {
const optionsMap = new Map(options.map((opt) => [opt.id, opt]));
const result: MergedItem[] = [];
@@ -67,7 +68,7 @@ export const OrderableListField = ({
return true;
};
const handleDragStart = (e: React.DragEvent, index: number) => {
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
const item = items[index];
if (item?.isPinned) {
e.preventDefault();
@@ -75,7 +76,7 @@ export const OrderableListField = ({
}
setDraggedIndex(index);
dragNodeRef.current = e.currentTarget as HTMLDivElement;
dragNodeRef.current = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
requestAnimationFrame(() => {
@@ -94,7 +95,7 @@ export const OrderableListField = ({
dragNodeRef.current = null;
};
const handleDragOver = (e: React.DragEvent, index: number) => {
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) {
setDropPosition(null);
@@ -114,14 +115,14 @@ export const OrderableListField = ({
setDropPosition({ index, position });
};
const handleDragLeave = (e: React.DragEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(relatedTarget)) {
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
const relatedTarget = e.relatedTarget;
if (!(relatedTarget instanceof Node) || !e.currentTarget.contains(relatedTarget)) {
setDropPosition(null);
}
};
const handleDrop = (e: React.DragEvent) => {
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (draggedIndex === null || dropPosition === null) {
handleDragEnd();
@@ -200,9 +201,7 @@ export const OrderableListField = ({
const getDropGapIndex = (): number | null => {
if (!dropPosition) return null;
return dropPosition.position === 'before'
? dropPosition.index
: dropPosition.index + 1;
return dropPosition.position === 'before' ? dropPosition.index : dropPosition.index + 1;
};
const dropGapIndex = getDropGapIndex();
@@ -215,11 +214,25 @@ export const OrderableListField = ({
const isPinned = item.isPinned ?? false;
const showIndicatorBefore = dropGapIndex === index;
let dragStateClassName = 'cursor-grab';
if (isDragging) {
dragStateClassName = 'cursor-grabbing opacity-50';
} else if (isPinned) {
dragStateClassName = 'cursor-default';
}
let hoverStateClassName = '';
if (isDisabled) {
hoverStateClassName = 'opacity-60';
} else if (!isPinned) {
hoverStateClassName = 'hover:bg-(--hover-surface)';
}
return (
<div key={item.id} className="relative">
{/* Drop indicator */}
{showIndicatorBefore && (
<div className="absolute left-1 right-1 h-1 bg-sky-500 rounded-full z-10 -top-1 -translate-y-1/2" />
<div className="absolute -top-1 right-1 left-1 z-10 h-1 -translate-y-1/2 rounded-full bg-sky-500" />
)}
<div
@@ -229,16 +242,11 @@ export const OrderableListField = ({
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
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-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
`}
className={`flex items-center gap-3 rounded-lg border border-(--border-muted) p-3 transition-all duration-150 ${dragStateClassName} ${hoverStateClassName}`}
>
{/* Reorder Controls - hidden for pinned items */}
{!isPinned ? (
<div className="flex flex-col shrink-0 -my-1">
<div className="-my-1 flex shrink-0 flex-col">
<button
type="button"
onClick={(e) => {
@@ -246,16 +254,20 @@ export const OrderableListField = ({
moveItem(index, 'up');
}}
disabled={!canMoveUp(index)}
className={`
p-1.5 sm:p-0.5 rounded transition-colors
${!canMoveUp(index)
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
}
`}
className={`rounded p-1.5 transition-colors sm:p-0.5 ${
!canMoveUp(index)
? 'cursor-not-allowed text-gray-300 dark:text-gray-600'
: 'text-gray-400 hover:text-gray-600 sm:hover:bg-gray-100 dark:hover:text-gray-300 sm:dark:hover:bg-gray-700'
} `}
aria-label="Move up"
>
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 sm:h-4 sm:w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
@@ -266,35 +278,37 @@ export const OrderableListField = ({
moveItem(index, 'down');
}}
disabled={!canMoveDown(index)}
className={`
p-1.5 sm:p-0.5 rounded transition-colors
${!canMoveDown(index)
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
}
`}
className={`rounded p-1.5 transition-colors sm:p-0.5 ${
!canMoveDown(index)
? 'cursor-not-allowed text-gray-300 dark:text-gray-600'
: 'text-gray-400 hover:text-gray-600 sm:hover:bg-gray-100 dark:hover:text-gray-300 sm:dark:hover:bg-gray-700'
} `}
aria-label="Move down"
>
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 sm:h-4 sm:w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
) : (
<div className="w-5 sm:w-4 shrink-0" />
<div className="w-5 shrink-0 sm:w-4" />
)}
{/* Label and Description */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.label}</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{item.label}</div>
{item.description && (
<div className="text-xs text-(--text-muted) mt-0.5">
{item.description}
</div>
<div className="mt-0.5 text-xs text-(--text-muted)">{item.description}</div>
)}
{item.isLocked && item.disabledReason && (
<div className="text-xs text-amber-500 mt-0.5 flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<div className="mt-0.5 flex items-center gap-1 text-xs text-amber-500">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
@@ -307,7 +321,7 @@ export const OrderableListField = ({
</div>
{/* Toggle Switch */}
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<div className="shrink-0">
<ToggleSwitch
checked={item.enabled && !item.isLocked}
onChange={() => toggleItem(index)}
@@ -321,7 +335,7 @@ export const OrderableListField = ({
{/* Drop indicator after last item */}
{dropGapIndex === items.length && (
<div className="relative h-0">
<div className="absolute left-1 right-1 h-1 bg-sky-500 rounded-full z-10 -top-0.5" />
<div className="absolute -top-0.5 right-1 left-1 z-10 h-1 rounded-full bg-sky-500" />
</div>
)}
</div>
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { PasswordFieldConfig } from '../../../types/settings';
import type { PasswordFieldConfig } from '../../../types/settings';
interface PasswordFieldProps {
field: PasswordFieldConfig;
@@ -21,24 +22,19 @@ 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-(--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"
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 pr-10 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
disabled={isDisabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded
hover:bg-(--hover-action) transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
className="absolute top-1/2 right-2 -translate-y-1/2 rounded p-1 transition-colors hover:bg-(--hover-action) disabled:cursor-not-allowed disabled:opacity-60"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<svg
className="w-4 h-4 opacity-60"
className="h-4 w-4 opacity-60"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -53,7 +49,7 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
</svg>
) : (
<svg
className="w-4 h-4 opacity-60"
className="h-4 w-4 opacity-60"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import { SelectFieldConfig } from '../../../types/settings';
import { useMemo } from 'react';
import type { SelectFieldConfig } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
interface SelectFieldProps {
@@ -10,23 +11,24 @@ interface SelectFieldProps {
filterValue?: string;
}
export const SelectField = ({ field, value, onChange, disabled, filterValue }: SelectFieldProps) => {
export const SelectField = ({
field,
value,
onChange,
disabled,
filterValue,
}: SelectFieldProps) => {
const isDisabled = disabled ?? false;
const prevFilterValue = useRef(filterValue);
const normalizedOptions = useMemo(
() =>
field.options.map((opt) => ({
...opt,
value: String(opt.value),
childOf:
opt.childOf === undefined || opt.childOf === null
? undefined
: String(opt.childOf),
label: opt.label ?? String(opt.value),
value: opt.value,
childOf: opt.childOf,
label: opt.label ?? opt.value,
})),
[field.options]
[field.options],
);
// Filter options based on filterValue (cascading dropdown support)
@@ -38,17 +40,6 @@ export const SelectField = ({ field, value, onChange, disabled, filterValue }: S
return normalizedOptions.filter((opt) => !opt.childOf || opt.childOf === filterValue);
}, [normalizedOptions, filterValue]);
// Clear selection when filter value changes and current value is not in filtered options
useEffect(() => {
if (prevFilterValue.current !== filterValue && filterValue !== undefined) {
const currentValueInOptions = filteredOptions.some((opt) => opt.value === value);
if (!currentValueInOptions && value) {
onChange('');
}
}
prevFilterValue.current = filterValue;
}, [filterValue, filteredOptions, value, onChange]);
// Use field's default value as fallback when value is empty
const effectiveValue = value || field.default || '';
@@ -61,7 +52,7 @@ export const SelectField = ({ field, value, onChange, disabled, filterValue }: S
const handleChange = (newValue: string | string[]) => {
// DropdownList may return string or string[] - we expect string for single select
const val = Array.isArray(newValue) ? newValue[0] ?? '' : newValue;
const val = Array.isArray(newValue) ? (newValue[0] ?? '') : newValue;
onChange(val);
};
@@ -69,7 +60,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-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60">
{selectedOption?.label || 'Select...'}
</div>
);
@@ -1,5 +1,11 @@
import { useMemo, useEffect, CSSProperties } from 'react';
import { MultiSelectFieldConfig, TableFieldConfig, TableFieldColumn } from '../../../types/settings';
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import type {
MultiSelectFieldConfig,
TableFieldConfig,
TableFieldColumn,
} from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
import { MultiSelectField } from './MultiSelectField';
@@ -10,6 +16,25 @@ interface TableFieldProps {
disabled?: boolean;
}
function toPrimitiveString(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return '';
}
function toOptionalPrimitiveString(value: unknown): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
const normalizedValue = toPrimitiveString(value);
return normalizedValue || undefined;
}
function defaultCellValue(column: TableFieldColumn): unknown {
if (column.defaultValue !== undefined) {
return column.defaultValue;
@@ -26,7 +51,7 @@ function defaultCellValue(column: TableFieldColumn): unknown {
function normalizeMultiValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => String(entry ?? '').trim())
.map((entry) => toPrimitiveString(entry).trim())
.filter((entry) => entry.length > 0);
}
if (typeof value === 'string') {
@@ -36,9 +61,12 @@ function normalizeMultiValue(value: unknown): string[] {
return [];
}
function normalizeRows(rows: Record<string, unknown>[], columns: TableFieldColumn[]): Record<string, unknown>[] {
return (rows ?? []).map((row) => {
const normalized: Record<string, unknown> = { ...row };
function normalizeRows(
rows: Record<string, unknown>[],
columns: TableFieldColumn[],
): Record<string, unknown>[] {
return rows.map((row) => {
const normalized: Record<string, unknown> = Object.assign({}, row);
for (const col of columns) {
if (!(col.key in normalized)) {
normalized[col.key] = defaultCellValue(col);
@@ -48,18 +76,60 @@ function normalizeRows(rows: Record<string, unknown>[], columns: TableFieldColum
});
}
function normalizeRowDependencies(
row: Record<string, unknown>,
columns: TableFieldColumn[],
): Record<string, unknown> {
const normalizedRow: Record<string, unknown> = Object.assign({}, row);
columns.forEach((col) => {
if (col.type === 'multiselect') {
const filteredOptions = getFilteredSelectOptions(col, normalizedRow);
const validValues = new Set(filteredOptions.map((opt) => opt.value));
const currentValues = normalizeMultiValue(normalizedRow[col.key]);
normalizedRow[col.key] = currentValues.filter((entry) => validValues.has(entry));
return;
}
if (col.type !== 'select') {
return;
}
const filteredOptions = getFilteredSelectOptions(col, normalizedRow);
const currentValue = toPrimitiveString(normalizedRow[col.key]);
const currentValueIsValid = filteredOptions.some((opt) => opt.value === currentValue);
const nonEmptyOptions = filteredOptions.filter((opt) => opt.value !== '');
if (nonEmptyOptions.length === 1) {
normalizedRow[col.key] = nonEmptyOptions[0].value;
return;
}
if (currentValue && !currentValueIsValid) {
normalizedRow[col.key] = '';
}
});
return normalizedRow;
}
function normalizeTableRows(
rows: Record<string, unknown>[],
columns: TableFieldColumn[],
): Record<string, unknown>[] {
return normalizeRows(rows, columns).map((row) => normalizeRowDependencies(row, columns));
}
function getFilteredSelectOptions(
column: TableFieldColumn,
row: Record<string, unknown>
row: Record<string, unknown>,
): Array<{ value: string; label: string; description?: string; childOf?: string }> {
const options = (column.options ?? []).map((opt) => ({
value: String(opt.value),
label: opt.label ?? String(opt.value),
value: opt.value,
label: opt.label ?? opt.value,
description: opt.description,
childOf:
opt.childOf === undefined || opt.childOf === null
? undefined
: String(opt.childOf),
childOf: opt.childOf,
}));
const filterByField = column.filterByField;
@@ -68,10 +138,7 @@ function getFilteredSelectOptions(
}
const rawFilterValue = row[filterByField];
const filterValue =
rawFilterValue === undefined || rawFilterValue === null || rawFilterValue === ''
? undefined
: String(rawFilterValue);
const filterValue = toOptionalPrimitiveString(rawFilterValue);
if (!filterValue) {
return options.filter((opt) => !opt.childOf);
@@ -80,11 +147,46 @@ function getFilteredSelectOptions(
return options.filter((opt) => !opt.childOf || opt.childOf === filterValue);
}
function serializeRowKeyValue(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
return value.map((entry) => serializeRowKeyValue(entry)).join('|');
}
try {
return JSON.stringify(value) ?? '';
} catch {
return '[unserializable]';
}
}
export const TableField = ({ field, value, onChange, disabled }: TableFieldProps) => {
const isDisabled = disabled ?? false;
const columns = useMemo(() => field.columns ?? [], [field.columns]);
const rows = useMemo(() => normalizeRows(value ?? [], columns), [value, columns]);
const rows = useMemo(() => normalizeTableRows(value ?? [], columns), [value, columns]);
const rowEntries = useMemo(() => {
const rowOccurrences = new Map<string, number>();
return rows.map((row) => {
const baseKey = columns
.map((col) => `${col.key}:${serializeRowKeyValue(row[col.key])}`)
.join('||');
const occurrence = rowOccurrences.get(baseKey) ?? 0;
rowOccurrences.set(baseKey, occurrence + 1);
return {
row,
key: `${baseKey}::${occurrence}`,
};
});
}, [rows, columns]);
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
// Use fixed width for delete button column to ensure header/data alignment.
@@ -98,10 +200,15 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
const colDefs = columns.map((_, idx) => (idx === 0 ? 'minmax(0,180px)' : 'minmax(0,1fr)'));
return `${colDefs.join(' ')} 2rem`;
}, [columns]);
const tableStyle: CSSProperties & { '--table-cols': string } = { '--table-cols': tableCols };
const commitRows = (nextRows: Record<string, unknown>[]) => {
onChange(normalizeTableRows(nextRows, columns));
};
const updateCell = (rowIndex: number, key: string, cellValue: unknown) => {
const next = rows.map((row, idx) => (idx === rowIndex ? { ...row, [key]: cellValue } : row));
onChange(next);
commitRows(next);
};
const addRow = () => {
@@ -109,63 +216,14 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
columns.forEach((col) => {
newRow[col.key] = defaultCellValue(col);
});
onChange([...(rows ?? []), newRow]);
commitRows([...rows, newRow]);
};
const removeRow = (rowIndex: number) => {
const next = rows.filter((_, idx) => idx !== rowIndex);
onChange(next);
commitRows(next);
};
useEffect(() => {
if (rows.length === 0) return;
const nextRows = rows.map((row) => ({ ...row }));
let hasChanges = false;
rows.forEach((row, rowIndex) => {
columns.forEach((col) => {
if (col.type === 'multiselect') {
const filteredOptions = getFilteredSelectOptions(col, row);
const validValues = new Set(filteredOptions.map((opt) => opt.value));
const currentValues = normalizeMultiValue(row[col.key]);
const normalizedValues = currentValues.filter((entry) => validValues.has(entry));
if (JSON.stringify(currentValues) !== JSON.stringify(normalizedValues)) {
nextRows[rowIndex][col.key] = normalizedValues;
hasChanges = true;
}
return;
}
if (col.type !== 'select') return;
const filteredOptions = getFilteredSelectOptions(col, row);
const currentValue = String(row[col.key] ?? '');
const currentValueIsValid = filteredOptions.some((opt) => opt.value === currentValue);
const nonEmptyOptions = filteredOptions.filter((opt) => opt.value !== '');
if (nonEmptyOptions.length === 1) {
const onlyOption = nonEmptyOptions[0].value;
if (currentValue !== onlyOption) {
nextRows[rowIndex][col.key] = onlyOption;
hasChanges = true;
}
return;
}
if (currentValue && !currentValueIsValid) {
nextRows[rowIndex][col.key] = '';
hasChanges = true;
}
});
});
if (hasChanges) {
onChange(nextRows);
}
}, [rows, columns, onChange]);
if (rows.length === 0) {
return (
<div className="space-y-3">
@@ -174,9 +232,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
type="button"
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
className="hover-action rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
>
{field.addLabel || 'Add'}
</button>
@@ -185,8 +241,10 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
}
return (
<div className="space-y-3 min-w-0" style={{ '--table-cols': tableCols } as CSSProperties}>
<div className={`hidden sm:grid ${gridTemplate} gap-3 items-start min-w-0 text-xs font-medium opacity-70`}>
<div className="min-w-0 space-y-3" style={tableStyle}>
<div
className={`hidden sm:grid ${gridTemplate} min-w-0 items-start gap-3 text-xs font-medium opacity-70`}
>
{columns.map((col) => (
<div key={col.key} className="min-w-0 truncate">
{col.label}
@@ -195,21 +253,23 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
<div />
</div>
<div className="space-y-3 min-w-0">
{rows.map((row, rowIndex) => (
<div className="min-w-0 space-y-3">
{rowEntries.map(({ row, key: rowKey }, rowIndex) => (
<div
key={rowIndex}
className={`grid grid-cols-1 ${gridTemplate} gap-3 items-start min-w-0`}
key={rowKey}
className={`grid grid-cols-1 ${gridTemplate} min-w-0 items-start gap-3`}
style={{ overflow: 'visible' }}
>
{columns.map((col) => {
const cellValue = row[col.key];
const mobileLabel = <div className="sm:hidden text-xs font-medium opacity-70">{col.label}</div>;
const mobileLabel = (
<div className="text-xs font-medium opacity-70 sm:hidden">{col.label}</div>
);
if (col.type === 'checkbox') {
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
<div key={col.key} className="flex min-w-0 flex-col gap-1">
{mobileLabel}
<div className="pt-2">
<input
@@ -217,8 +277,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
checked={Boolean(cellValue)}
onChange={(e) => updateCell(rowIndex, col.key, e.target.checked)}
disabled={isDisabled}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500
disabled:opacity-60 disabled:cursor-not-allowed"
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
</div>
@@ -233,17 +292,20 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
}));
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
<div key={col.key} className="flex min-w-0 flex-col gap-1">
{mobileLabel}
{isDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border 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 className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60 shadow-sm">
{options.find((o) => o.value === toPrimitiveString(cellValue))?.label ||
'Select...'}
</div>
) : (
<DropdownList
options={options}
value={String(cellValue ?? '')}
onChange={(val) => updateCell(rowIndex, col.key, Array.isArray(val) ? val[0] : val)}
value={toPrimitiveString(cellValue)}
onChange={(val) =>
updateCell(rowIndex, col.key, Array.isArray(val) ? val[0] : val)
}
placeholder={col.placeholder || 'Select...'}
widthClassName="w-full"
/>
@@ -260,7 +322,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
childOf: opt.childOf,
}));
const selectedValues = normalizeMultiValue(cellValue).filter((entry) =>
options.some((option) => option.value === entry)
options.some((option) => option.value === entry),
);
const multiSelectField: MultiSelectFieldConfig = {
type: 'MultiSelectField',
@@ -273,14 +335,14 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
};
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
<div key={col.key} className="flex min-w-0 flex-col gap-1">
{mobileLabel}
<MultiSelectField
field={multiSelectField}
value={selectedValues}
onChange={(nextValues) => {
const normalizedValues = (nextValues ?? [])
.map((entry) => String(entry ?? '').trim())
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
updateCell(rowIndex, col.key, normalizedValues);
}}
@@ -292,18 +354,15 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
// text/path
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
<div key={col.key} className="flex min-w-0 flex-col gap-1">
{mobileLabel}
<input
type="text"
value={String(cellValue ?? '')}
value={toPrimitiveString(cellValue)}
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-(--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"
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
);
@@ -314,12 +373,11 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
type="button"
onClick={() => removeRow(rowIndex)}
disabled={isDisabled}
className="p-1.5 rounded-full hover-action
disabled:opacity-60 disabled:cursor-not-allowed"
className="hover-action rounded-full p-1.5 disabled:cursor-not-allowed disabled:opacity-60"
aria-label="Remove row"
>
<svg
className="w-4 h-4"
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -340,9 +398,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
type="button"
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
className="hover-action rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
>
{field.addLabel || 'Add'}
</button>
@@ -1,12 +1,28 @@
import { useMemo, useRef, useState } from 'react';
import { TagListFieldConfig } from '../../../types/settings';
import type { TagListFieldConfig } from '../../../types/settings';
interface TagListFieldProps {
field: TagListFieldConfig;
value: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
requiredTags?: string[]; // Tags that cannot be removed
requiredTags?: string[]; // Tags that cannot be removed
}
function createTagEntries(tags: string[]): Array<{ key: string; tag: string; tagIndex: number }> {
const tagCounts = new Map<string, number>();
return tags.map((tag, tagIndex) => {
const nextCount = (tagCounts.get(tag) ?? 0) + 1;
tagCounts.set(tag, nextCount);
return {
key: nextCount === 1 ? tag : `${tag}-${nextCount}`,
tag,
tagIndex,
};
});
}
function normalizeTag(raw: string, normalizeUrls: boolean): string {
@@ -14,10 +30,7 @@ function normalizeTag(raw: string, normalizeUrls: boolean): string {
if (!s) return '';
// Strip wrapping quotes from pasted env/JSON style strings.
if (
(s.startsWith('"') && s.endsWith('"')) ||
(s.startsWith("'") && s.endsWith("'"))
) {
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1).trim();
}
@@ -36,7 +49,13 @@ function normalizeTag(raw: string, normalizeUrls: boolean): string {
return s.trim();
}
export const TagListField = ({ field, value, onChange, disabled, requiredTags }: TagListFieldProps) => {
export const TagListField = ({
field,
value,
onChange,
disabled,
requiredTags,
}: TagListFieldProps) => {
const isDisabled = disabled ?? false;
const required = requiredTags ?? [];
const inputRef = useRef<HTMLInputElement>(null);
@@ -44,6 +63,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
const normalizeUrls = field.normalizeUrls ?? true;
const tags = useMemo(() => (value ?? []).map(String).filter((t) => t.trim() !== ''), [value]);
const tagEntries = useMemo(() => createTagEntries(tags), [tags]);
const addFromRaw = (raw: string) => {
if (isDisabled) return;
@@ -82,37 +102,28 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
return (
<div
className={`w-full px-3 py-2 rounded-lg border 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={() => {
if (isDisabled) return;
inputRef.current?.focus();
}}
className={`w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:outline-hidden ${isDisabled ? 'cursor-not-allowed opacity-60' : 'cursor-text'}`}
>
<div className="flex flex-wrap gap-1 items-center min-h-5">
{tags.map((tag, idx) => (
<div className="flex min-h-5 flex-wrap items-center gap-1">
{tagEntries.map(({ key, tag, tagIndex }) => (
<span
key={`${tag}-${idx}`}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md
border border-(--border-muted) bg-(--bg)
max-w-full"
key={key}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-(--border-muted) bg-(--bg) px-2.5 py-1"
title={tag}
>
<span className="truncate max-w-88">{tag}</span>
<span className="max-w-88 truncate">{tag}</span>
{!isDisabled && !isRequired(tag) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeAt(idx);
removeAt(tagIndex);
}}
className="p-0.5 rounded-sm hover:bg-(--hover-surface)"
className="rounded-sm p-0.5 hover:bg-(--hover-surface)"
aria-label={`Remove ${tag}`}
>
<svg
className="w-4 h-4"
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -145,7 +156,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
}}
onBlur={() => commitDraft()}
placeholder={tags.length === 0 ? field.placeholder : ''}
className="flex-1 min-w-16 bg-transparent outline-hidden px-1 py-0"
className="min-w-16 flex-1 bg-transparent px-1 py-0 outline-hidden"
/>
)}
@@ -1,4 +1,4 @@
import { TextFieldConfig } from '../../../types/settings';
import type { TextFieldConfig } from '../../../types/settings';
interface TextFieldProps {
field: TextFieldConfig;
@@ -19,10 +19,7 @@ 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-(--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"
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
/>
);
};
@@ -1,5 +1,2 @@
export { SettingsModal } from './SettingsModal';
export { SelfSettingsModal } from './SelfSettingsModal';
export { SettingsHeader } from './SettingsHeader';
export { SettingsSidebar } from './SettingsSidebar';
export { SettingsContent } from './SettingsContent';
@@ -4,15 +4,11 @@ interface EnvLockBadgeProps {
export const EnvLockBadge = ({ className = '' }: EnvLockBadgeProps) => (
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5
text-[10px] font-medium uppercase tracking-wide
bg-gray-200 dark:bg-gray-700
text-gray-600 dark:text-gray-400
rounded ${className}`}
className={`inline-flex items-center gap-1 rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-gray-600 uppercase dark:bg-gray-700 dark:text-gray-400 ${className}`}
title="This setting is controlled by an environment variable"
>
<svg
className="w-3 h-3"
className="h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { SettingsField } from '../../../types/settings';
import type { ReactNode } from 'react';
import type { SettingsField } from '../../../types/settings';
import { Tooltip } from '../../shared/Tooltip';
import { EnvLockBadge } from './EnvLockBadge';
@@ -19,7 +20,7 @@ const renderDescriptionWithLinks = (description: string): ReactNode => {
parts.push(
<span key={`text-${lastIndex}-${matchIndex}`} className="opacity-60">
{description.slice(lastIndex, matchIndex)}
</span>
</span>,
);
}
@@ -29,10 +30,10 @@ const renderDescriptionWithLinks = (description: string): ReactNode => {
href={url}
target="_blank"
rel="noopener noreferrer"
className="underline text-sky-600 dark:text-sky-400"
className="text-sky-600 underline dark:text-sky-400"
>
{label}
</a>
</a>,
);
lastIndex = matchIndex + fullMatch.length;
@@ -43,7 +44,7 @@ const renderDescriptionWithLinks = (description: string): ReactNode => {
parts.push(
<span key={`text-${lastIndex}-end`} className="opacity-60">
{description.slice(lastIndex)}
</span>
</span>,
);
}
@@ -77,12 +78,11 @@ interface FieldWrapperProps {
// Badge shown when a field is disabled
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"
className="inline-flex items-center gap-1 rounded border border-zinc-500/30 bg-zinc-500/20 px-1.5 py-0.5 text-xs text-zinc-400"
title={reason || 'This setting is not available'}
>
<svg
className="w-3 h-3"
className="h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -102,12 +102,11 @@ const DisabledBadge = ({ reason }: { reason?: string }) => (
// Badge shown when changing a field requires a container restart
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"
className="inline-flex items-center gap-1 rounded border border-amber-500/30 bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-600 dark:text-amber-400"
title="Changing this setting requires a container restart to take effect"
>
<svg
className="w-3 h-3"
className="h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -124,6 +123,22 @@ const RestartRequiredBadge = () => (
</span>
);
function formatUserOverrideValue(value: unknown): string {
if (value === null || value === undefined) return '(empty)';
if (typeof value === 'string') return value || '(empty)';
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (typeof value === 'symbol') {
return value.description ?? value.toString();
}
try {
return JSON.stringify(value) ?? '(empty)';
} catch {
return '[unserializable value]';
}
}
const UserOverriddenBadge = ({
count,
details = [],
@@ -131,39 +146,24 @@ const UserOverriddenBadge = ({
count: number;
details?: Array<{ userId: number; username: string; value: unknown }>;
}) => {
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '(empty)';
if (typeof value === 'string') return value || '(empty)';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const visibleDetails = details.slice(0, 10);
const extraCount = Math.max(details.length - visibleDetails.length, 0);
const content = (
<div className="space-y-1 max-w-xs">
<div className="max-w-xs space-y-1">
{visibleDetails.map((entry) => (
<div key={entry.userId} className="text-[11px] leading-snug">
<span className="font-medium">{entry.username}</span>
<span className="opacity-70">: {formatValue(entry.value)}</span>
<span className="opacity-70">: {formatUserOverrideValue(entry.value)}</span>
</div>
))}
{extraCount > 0 && (
<div className="text-[11px] opacity-70">and {extraCount} more...</div>
)}
{extraCount > 0 && <div className="text-[11px] opacity-70">and {extraCount} more...</div>}
</div>
);
return (
<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"
>
<span className="inline-flex items-center gap-1 rounded border border-sky-500/30 bg-sky-500/15 px-1.5 py-0.5 text-xs text-sky-500 dark:text-sky-400">
User overridden{count > 1 ? ` (${count})` : ''}
</span>
</Tooltip>
@@ -183,8 +183,7 @@ const ResetActionButton = ({
type="button"
onClick={onClick}
disabled={disabled}
className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors shrink-0
disabled:opacity-50 disabled:cursor-not-allowed"
className="shrink-0 text-xs font-medium text-sky-500 transition-colors hover:text-sky-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{label}
</button>
@@ -202,7 +201,7 @@ export const FieldWrapper = ({
}: FieldWrapperProps) => {
// Action buttons and headings handle their own layout
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return <>{children}</>;
return children;
}
// At this point, field is a regular input field with standard properties
@@ -213,8 +212,12 @@ export const FieldWrapper = ({
const hasUserOverrides = Boolean(userOverrideCount) && (userOverrideCount || 0) > 0;
const hasLabel = Boolean(field.label && field.label.trim().length > 0);
const hasResetAction = Boolean(resetAction);
const showHeaderLeft = hasLabel || field.fromEnv || (requiresRestart && !isDisabled && !field.fromEnv)
|| (isDisabled && !field.fromEnv) || hasUserOverrides;
const showHeaderLeft =
hasLabel ||
field.fromEnv ||
(requiresRestart && !isDisabled && !field.fromEnv) ||
(isDisabled && !field.fromEnv) ||
hasUserOverrides;
const showHeader = showHeaderLeft || hasResetAction || Boolean(headerRight);
// ENV-locked fields should only dim the control, not the label/description
@@ -223,25 +226,22 @@ export const FieldWrapper = ({
<div className="space-y-1.5">
{showHeader && (
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasLabel && (
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
{field.required && !isDisabled && <span className="ml-0.5 text-red-500">*</span>}
</label>
)}
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
{hasUserOverrides && (
<UserOverriddenBadge
count={userOverrideCount || 0}
details={userOverrideDetails}
/>
<UserOverriddenBadge count={userOverrideCount || 0} details={userOverrideDetails} />
)}
</div>
{(hasResetAction || headerRight) && (
<div className="flex items-center gap-2 shrink-0">
<div className="flex shrink-0 items-center gap-2">
{hasResetAction && resetAction && (
<ResetActionButton
label={resetAction.label}
@@ -5,19 +5,20 @@ interface SettingsSaveBarProps {
export const SettingsSaveBar = ({ onSave, isSaving }: SettingsSaveBarProps) => (
<div
className="shrink-0 px-6 py-4 border-t border-(--border-muted) bg-(--bg) animate-slide-up"
className="animate-slide-up shrink-0 border-t border-(--border-muted) bg-(--bg) px-6 py-4"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
onClick={() => { void onSave(); }}
type="button"
onClick={() => {
void onSave();
}}
disabled={isSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full rounded-lg bg-sky-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -1,18 +1,17 @@
import { ReactNode } from 'react';
import type { ReactNode } from 'react';
interface SettingsSubpageProps {
children: ReactNode;
hasBottomSaveBar?: boolean;
}
export const SettingsSubpage = ({
children,
hasBottomSaveBar = false,
}: SettingsSubpageProps) => {
export const SettingsSubpage = ({ children, hasBottomSaveBar = false }: SettingsSubpageProps) => {
return (
<div
className="flex-1 overflow-y-auto p-6"
style={{ paddingBottom: hasBottomSaveBar ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
style={{
paddingBottom: hasBottomSaveBar ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem',
}}
>
{children}
</div>
@@ -1,4 +1,3 @@
export { EnvLockBadge } from './EnvLockBadge';
export { FieldWrapper } from './FieldWrapper';
export { SettingsSaveBar } from './SettingsSaveBar';
export { SettingsSubpage } from './SettingsSubpage';
@@ -1,20 +1,24 @@
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
import type { RequestPolicyMode } from '../../../types';
import { DropdownList } from '../../DropdownList';
import { RequestPolicyMode } from '../../../types';
import type {
RequestPolicyContentType,
RequestPolicyDefaultsValue,
RequestPolicyRuleRow,
RequestPolicySourceCapability,
} from './requestPolicyGridUtils';
import {
areRuleSetsEqual,
getAllowedMatrixModes,
getEffectiveCellMode,
getInheritedCellMode,
isMatrixConfigurable,
normalizeExplicitRulesForPersistence,
normalizeRequestPolicyMatrixMode,
normalizeRequestPolicyMode,
normalizeRequestPolicyRules,
REQUEST_POLICY_DEFAULT_OPTIONS,
REQUEST_POLICY_MODE_LABELS,
RequestPolicyContentType,
RequestPolicyDefaultsValue,
RequestPolicyRuleRow,
RequestPolicySourceCapability,
} from './requestPolicyGridUtils';
interface RequestPolicyGridProps {
@@ -42,7 +46,12 @@ const formatSourceLabel = (source: string): string => {
.join(' ');
};
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
const toRuleKey = (source: string, contentType: RequestPolicyContentType) =>
`${source}::${contentType}`;
const getDropdownValue = (value: string | string[]): string => {
return Array.isArray(value) ? (value[0] ?? '') : value;
};
const modeDescriptions: Record<RequestPolicyMode, string> = {
download: 'Users can download directly.',
@@ -74,15 +83,9 @@ export const RequestPolicyGrid = ({
defaultModes,
sourceCapabilities,
}),
[explicitRules, baseRules, defaultModes, sourceCapabilities]
[explicitRules, baseRules, defaultModes, sourceCapabilities],
);
useEffect(() => {
if (!areRuleSetsEqual(normalizedExplicitRules, normalizeRequestPolicyRules(explicitRules))) {
onExplicitRulesChange(normalizedExplicitRules);
}
}, [normalizedExplicitRules, explicitRules, onExplicitRulesChange]);
const explicitRuleMap = useMemo(() => {
const map = new Map<string, RequestPolicyRuleRow>();
normalizedExplicitRules.forEach((rule) => {
@@ -97,24 +100,28 @@ export const RequestPolicyGrid = ({
}));
const hasConfigurableColumn = CONTENT_TYPES.some((contentType) =>
isMatrixConfigurable(defaultModes[contentType])
isMatrixConfigurable(defaultModes[contentType]),
);
const updateCellRule = (
source: string,
contentType: RequestPolicyContentType,
nextMode: RequestPolicyMode
nextMode: RequestPolicyMode,
) => {
const inheritedMode = getInheritedCellMode(source, contentType, defaultModes, baseRules);
const nextExplicitRules = normalizedExplicitRules.filter(
(rule) => !(rule.source === source && rule.content_type === contentType)
(rule) => !(rule.source === source && rule.content_type === contentType),
);
if (nextMode !== inheritedMode) {
const matrixMode = normalizeRequestPolicyMatrixMode(nextMode);
if (!matrixMode) {
return;
}
nextExplicitRules.push({
source,
content_type: contentType,
mode: nextMode as RequestPolicyRuleRow['mode'],
mode: matrixMode,
});
}
@@ -129,7 +136,7 @@ export const RequestPolicyGrid = ({
const resetCellRule = (source: string, contentType: RequestPolicyContentType) => {
const nextRules = normalizedExplicitRules.filter(
(rule) => !(rule.source === source && rule.content_type === contentType)
(rule) => !(rule.source === source && rule.content_type === contentType),
);
onExplicitRulesChange(nextRules);
};
@@ -142,7 +149,7 @@ export const RequestPolicyGrid = ({
type="button"
onClick={onClearOverrides}
disabled={clearOverridesDisabled}
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg border border-(--border-muted) bg-(--bg) px-3 py-1.5 text-xs font-medium transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-50"
>
Clear all overrides
</button>
@@ -151,16 +158,16 @@ export const RequestPolicyGrid = ({
<div className="rounded-lg border 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-(--bg-soft) text-xs font-medium opacity-60 border-b border-(--border-muted) rounded-t-lg">
<div className="hidden gap-3 rounded-t-lg border-b border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-xs font-medium opacity-60 sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)]">
<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-(--bg-soft) border-b-2 border-(--border-muted)">
<div className="grid grid-cols-1 items-center gap-3 border-b-2 border-(--border-muted) bg-(--bg-soft) px-3 py-2.5 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)]">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">Default</p>
<p className="truncate text-sm font-semibold">Default</p>
</div>
{CONTENT_TYPES.map((contentType) => {
@@ -169,7 +176,7 @@ export const RequestPolicyGrid = ({
const isDisabled = Boolean(defaultModeDisabled?.[contentType]);
const mobileLabel = (
<span className="sm:hidden text-xs font-medium opacity-50 mr-2">
<span className="mr-2 text-xs font-medium opacity-50 sm:hidden">
{contentType === 'ebook' ? 'Ebook:' : 'Audiobook:'}
</span>
);
@@ -178,7 +185,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-(--border-muted) bg-(--bg) text-sm opacity-60 cursor-not-allowed">
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg) px-3 py-2 text-sm opacity-60">
{REQUEST_POLICY_MODE_LABELS[mode]}
</div>
) : (
@@ -190,12 +197,13 @@ export const RequestPolicyGrid = ({
<DropdownList
options={REQUEST_POLICY_DEFAULT_OPTIONS}
value={mode}
onChange={(value) =>
onDefaultModeChange(
contentType,
(Array.isArray(value) ? value[0] : value) as RequestPolicyMode
)
}
onChange={(value) => {
const nextMode = normalizeRequestPolicyMode(getDropdownValue(value));
if (!nextMode) {
return;
}
onDefaultModeChange(contentType, nextMode);
}}
widthClassName="w-full"
/>
</div>
@@ -205,7 +213,7 @@ export const RequestPolicyGrid = ({
type="button"
onClick={() => onDefaultModeReset(contentType)}
disabled={isDisabled}
className="text-xs text-sky-500 hover:text-sky-400 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
className="shrink-0 text-xs text-sky-500 transition-colors hover:text-sky-400 disabled:cursor-not-allowed disabled:opacity-50"
>
Reset
</button>
@@ -220,12 +228,12 @@ export const RequestPolicyGrid = ({
sourceRows.map((sourceRow, index) => (
<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 ${
className={`grid grid-cols-1 items-center gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] ${
index > 0 ? 'border-t border-(--border-muted)' : ''
}`}
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{sourceRow.displayName}</p>
<p className="truncate text-sm font-medium">{sourceRow.displayName}</p>
</div>
{CONTENT_TYPES.map((contentType) => {
@@ -238,20 +246,20 @@ export const RequestPolicyGrid = ({
contentType,
defaultModes,
baseRules,
normalizedExplicitRules
normalizedExplicitRules,
);
const explicitRule = explicitRuleMap.get(key);
const isOverridden = Boolean(explicitRule);
const mobileLabel = (
<span className="sm:hidden text-xs font-medium opacity-50 mr-2">
<span className="mr-2 text-xs font-medium opacity-50 sm:hidden">
{contentType === 'ebook' ? 'Ebook:' : 'Audiobook:'}
</span>
);
if (!isSupported) {
return (
<div key={key} className="flex items-center justify-center min-h-[36px]">
<div key={key} className="flex min-h-[36px] items-center justify-center">
{mobileLabel}
<span className="text-xs opacity-40">Not supported</span>
</div>
@@ -260,7 +268,7 @@ export const RequestPolicyGrid = ({
if (!isConfigurable) {
return (
<div key={key} className="flex items-center justify-center min-h-[36px]">
<div key={key} className="flex min-h-[36px] items-center justify-center">
{mobileLabel}
<span className="text-xs opacity-40">
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
@@ -273,9 +281,16 @@ export const RequestPolicyGrid = ({
// When the effective mode isn't an allowed matrix mode (e.g. request_book
// as a ceiling default), include it as the first option so the dropdown
// shows the current state and lets the user switch away from it.
const effectiveMatrixMode = normalizeRequestPolicyMatrixMode(effectiveMode);
const effectiveModeOption =
!allowedModes.includes(effectiveMode as typeof allowedModes[number])
? [{ value: effectiveMode, label: REQUEST_POLICY_MODE_LABELS[effectiveMode], description: modeDescriptions[effectiveMode] }]
!effectiveMatrixMode || !allowedModes.includes(effectiveMatrixMode)
? [
{
value: effectiveMode,
label: REQUEST_POLICY_MODE_LABELS[effectiveMode],
description: modeDescriptions[effectiveMode],
},
]
: [];
const options = [
...effectiveModeOption,
@@ -295,7 +310,7 @@ export const RequestPolicyGrid = ({
}`}
>
{rulesDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60">
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
</div>
) : (
@@ -303,7 +318,10 @@ export const RequestPolicyGrid = ({
options={options}
value={effectiveMode}
onChange={(value) => {
const nextMode = (Array.isArray(value) ? value[0] : value) as RequestPolicyMode;
const nextMode = normalizeRequestPolicyMode(getDropdownValue(value));
if (!nextMode) {
return;
}
updateCellRule(sourceRow.source, contentType, nextMode);
}}
widthClassName="w-full"
@@ -314,7 +332,7 @@ export const RequestPolicyGrid = ({
<button
type="button"
onClick={() => resetCellRule(sourceRow.source, contentType)}
className="text-xs text-sky-500 hover:text-sky-400 transition-colors shrink-0"
className="shrink-0 text-xs text-sky-500 transition-colors hover:text-sky-400"
aria-label={`Reset ${sourceRow.displayName} ${contentType} override`}
>
Reset
@@ -328,7 +346,8 @@ export const RequestPolicyGrid = ({
) : (
<div className="px-3 py-3">
<p className="text-xs opacity-60">
Per-source settings become available when a default is set to Download or Request Release.
Per-source settings become available when a default is set to Download or Request
Release.
</p>
</div>
)}
@@ -1,8 +1,5 @@
import { AdminUser } from '../../../services/api';
import {
AUTH_SOURCE_BADGE_CLASSES,
AUTH_SOURCE_LABEL,
} from './types';
import type { AdminUser } from '../../../services/api';
import { AUTH_SOURCE_BADGE_CLASSES, AUTH_SOURCE_LABEL } from './types';
interface UserAuthSourceBadgeProps {
user: AdminUser;
@@ -11,8 +8,9 @@ interface UserAuthSourceBadgeProps {
export const UserAuthSourceBadge = ({ user, showInactive = true }: UserAuthSourceBadgeProps) => {
const authSource = user.auth_source;
const active = user.is_active !== false;
const badgeBase = 'inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none';
const active = user.is_active;
const badgeBase =
'inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none';
return (
<>
@@ -20,9 +18,7 @@ export const UserAuthSourceBadge = ({ user, showInactive = true }: UserAuthSourc
{AUTH_SOURCE_LABEL[authSource]}
</span>
{showInactive && !active && (
<span className={`${badgeBase} bg-zinc-500/10 opacity-80`}>
Inactive
</span>
<span className={`${badgeBase} bg-zinc-500/10 opacity-80`}>Inactive</span>
)}
</>
);
@@ -1,15 +1,21 @@
import { ReactNode } from 'react';
import { AdminUser } from '../../../services/api';
import { PasswordFieldConfig, SelectFieldConfig, SelectOption, TextFieldConfig } from '../../../types/settings';
import type { ReactNode } from 'react';
import type { AdminUser } from '../../../services/api';
import type {
PasswordFieldConfig,
SelectFieldConfig,
SelectOption,
TextFieldConfig,
} from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
import { Tooltip } from '../../shared/Tooltip';
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
import { PasswordField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { CreateUserFormState } from './types';
import type { CreateUserFormState } from './types';
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
const UserCardShell = ({ title, children }: { title: string; children: ReactNode }) => (
<div className="space-y-5 p-4 rounded-lg border border-(--border-muted) bg-(--bg)">
<div className="space-y-5 rounded-lg border border-(--border-muted) bg-(--bg) p-4">
<h3 className="text-sm font-medium">{title}</h3>
{children}
</div>
@@ -99,11 +105,10 @@ const renderPasswordField = (
const getRoleLabel = (role: string) => role.charAt(0).toUpperCase() + role.slice(1);
const getRoleBadgeClassName = (role: string, disabled = false) => (
const getRoleBadgeClassName = (role: string, disabled = false) =>
`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none ${
disabled ? 'cursor-not-allowed' : ''
} ${role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`
);
} ${role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`;
const getRoleDisabledReason = (user: AdminUser, oidcAdminGroup?: string): string => {
if (user.edit_capabilities.authSource === 'oidc') {
@@ -143,7 +148,7 @@ export const UserRoleControl = ({
options={EDIT_ROLE_OPTIONS}
value={user.role}
onChange={(value) => {
const nextRole = Array.isArray(value) ? value[0] ?? '' : value;
const nextRole = Array.isArray(value) ? (value[0] ?? '') : value;
onUserChange({ ...user, role: nextRole });
}}
widthClassName="w-28"
@@ -159,18 +164,12 @@ export const UserRoleControl = ({
if (onUserChange && !user.edit_capabilities.canEditRole) {
return (
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position={tooltipPosition}>
<span className={getRoleBadgeClassName(user.role, true)}>
{roleLabel}
</span>
<span className={getRoleBadgeClassName(user.role, true)}>{roleLabel}</span>
</Tooltip>
);
}
return (
<span className={getRoleBadgeClassName(user.role)}>
{roleLabel}
</span>
);
return <span className={getRoleBadgeClassName(user.role)}>{roleLabel}</span>;
};
interface UserIdentityHeaderProps {
@@ -184,31 +183,26 @@ export const UserIdentityHeader = ({
showAuthSource = true,
showInactiveState = true,
}: UserIdentityHeaderProps) => {
const active = user.is_active !== false;
const active = user.is_active;
return (
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/20'}`}
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-medium ${user.role === 'admin' ? 'bg-sky-500/20 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/20'}`}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">
{user.display_name || user.username}
</span>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{user.display_name || user.username}</span>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
<span className="truncate text-xs opacity-40">@{user.username}</span>
)}
{showAuthSource && <UserAuthSourceBadge user={user} showInactive={false} />}
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
<div className="truncate text-xs opacity-50">{user.email || 'No email'}</div>
{showInactiveState && !active && (
<div className="text-[11px] opacity-60 truncate">
<div className="truncate text-[11px] opacity-60">
Inactive for current authentication mode
</div>
)}
@@ -251,7 +245,7 @@ export const UserEditActions = ({
type="button"
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) shadow-sm hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="hover-action rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium shadow-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
@@ -259,13 +253,25 @@ export const UserEditActions = ({
type="button"
onClick={onSave}
disabled={saveDisabled}
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center gap-2"
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</>
@@ -278,19 +284,21 @@ export const UserEditActions = ({
}
return (
<div className="flex flex-col gap-2 pt-3 border-t border-(--border-muted) sm:flex-row sm:items-center">
<div className="flex flex-col gap-2 border-t border-(--border-muted) pt-3 sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onSave}
disabled={saveDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="rounded-lg border border-(--border-muted) bg-(--bg) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-60"
>
Cancel
</button>
@@ -300,26 +308,27 @@ export const UserEditActions = ({
{isDeletePending ? (
<>
<button
type="button"
onClick={onConfirmDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
type="button"
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted)
bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="rounded-lg border border-(--border-muted) bg-(--bg) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface) disabled:cursor-not-allowed disabled:opacity-60"
>
Cancel
</button>
</>
) : (
<button
type="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"
className="rounded-lg border border-red-500/40 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-500/10"
>
Delete User
</button>
@@ -351,9 +360,20 @@ export const UserCreateCard = ({
}: UserCreateCardProps) => {
const usernameField = createTextField('username', 'Username', form.username, 'username', true);
const roleField = createRoleField(form.role, CREATE_ROLE_OPTIONS);
const displayNameField = createTextField('display_name', 'Display Name', form.display_name, 'Display name');
const displayNameField = createTextField(
'display_name',
'Display Name',
form.display_name,
'Display name',
);
const emailField = createTextField('email', 'Email', form.email, 'user@example.com');
const passwordField = createPasswordField('password', 'Password', form.password, 'Min 4 characters', true);
const passwordField = createPasswordField(
'password',
'Password',
form.password,
'Min 4 characters',
true,
);
const confirmPasswordField = createPasswordField(
'confirm_password',
'Confirm Password',
@@ -375,32 +395,38 @@ export const UserCreateCard = ({
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{renderTextField(usernameField, form.username, (value) => onChange({ ...form, username: value }))}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{renderTextField(usernameField, form.username, (value) =>
onChange({ ...form, username: value }),
)}
{renderSelectField(roleField, form.role, (value) => onChange({ ...form, role: value }))}
{renderTextField(displayNameField, form.display_name, (value) => onChange({ ...form, display_name: value }))}
{renderTextField(displayNameField, form.display_name, (value) =>
onChange({ ...form, display_name: value }),
)}
{renderTextField(emailField, form.email, (value) => onChange({ ...form, email: value }))}
</div>
{renderPasswordField(passwordField, form.password, (value) => onChange({ ...form, password: value }))}
{renderPasswordField(passwordField, form.password, (value) =>
onChange({ ...form, password: value }),
)}
{renderPasswordField(
confirmPasswordField,
form.password_confirm,
(value) => onChange({ ...form, password_confirm: value }),
{renderPasswordField(confirmPasswordField, form.password_confirm, (value) =>
onChange({ ...form, password_confirm: value }),
)}
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={onSubmit}
disabled={creating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Local User'}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
className="rounded-lg border border-(--border-muted) bg-(--bg) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface)"
>
Cancel
</button>
@@ -427,7 +453,7 @@ interface UserEditFieldsProps {
deleting?: boolean;
}
export const UserEditFields = ({
const UserEditFields = ({
user,
onUserChange,
onSave,
@@ -447,24 +473,42 @@ export const UserEditFields = ({
const capabilities = user.edit_capabilities;
const { authSource, canSetPassword, canEditEmail, canEditDisplayName } = capabilities;
const displayNameField = createTextField('display_name', 'Display Name', user.display_name || '', 'Display name');
const displayNameField = createTextField(
'display_name',
'Display Name',
user.display_name || '',
'Display name',
);
const emailField = createTextField('email', 'Email', user.email || '', 'user@example.com');
const newPasswordField = createPasswordField('new_password', 'New Password', editPassword, 'Leave empty to keep current');
const confirmPasswordField = createPasswordField('confirm_password', 'Confirm Password', editPasswordConfirm, 'Confirm new password', true);
const newPasswordField = createPasswordField(
'new_password',
'New Password',
editPassword,
'Leave empty to keep current',
);
const confirmPasswordField = createPasswordField(
'confirm_password',
'Confirm Password',
editPasswordConfirm,
'Confirm new password',
true,
);
const displayNameDisabledReason = !canEditDisplayName
? 'Display name is managed by the identity provider.'
: undefined;
const emailDisabledReason = !canEditEmail
? (authSource === 'cwa'
? 'Email is synced from Calibre-Web.'
: 'Email is managed by your identity provider.')
: undefined;
let emailDisabledReason: string | undefined;
if (!canEditEmail) {
emailDisabledReason =
authSource === 'cwa'
? 'Email is synced from Calibre-Web.'
: 'Email is managed by your identity provider.';
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{renderTextField(
displayNameField,
user.display_name || '',
@@ -486,7 +530,12 @@ export const UserEditFields = ({
<>
{renderPasswordField(newPasswordField, editPassword, onEditPasswordChange)}
{editPassword && renderPasswordField(confirmPasswordField, editPasswordConfirm, onEditPasswordConfirmChange)}
{editPassword &&
renderPasswordField(
confirmPasswordField,
editPasswordConfirm,
onEditPasswordConfirmChange,
)}
</>
)}
@@ -525,17 +574,15 @@ const renderPreferencesPanel = (panel: UserPreferencesPanelProps) => (
<div className="space-y-3">
{(!panel.hideTitle || panel.onAction) && (
<div>
{!panel.hideTitle && (
<label className="text-sm font-medium">User Preferences</label>
)}
{!panel.hideTitle && <p className="text-sm font-medium">User Preferences</p>}
{!panel.hideTitle && panel.description && (
<p className="text-xs opacity-60 mt-0.5">{panel.description}</p>
<p className="mt-0.5 text-xs opacity-60">{panel.description}</p>
)}
{panel.onAction && (
<button
type="button"
onClick={panel.onAction}
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
bg-sky-600 hover:bg-sky-700 transition-colors"
className="mt-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700"
>
{panel.actionLabel || 'Open User Preferences'}
</button>
@@ -1,10 +1,14 @@
import { useState } from 'react';
import { AdminUser, DownloadDefaults } from '../../../services/api';
import type { AdminUser, DownloadDefaults } from '../../../services/api';
import type { CreateUserFormState } from './types';
import { canCreateLocalUsersForAuthMode } from './types';
import {
canCreateLocalUsersForAuthMode,
CreateUserFormState,
} from './types';
import { UserAccountCardContent, UserCreateCard, UserIdentityHeader, UserRoleControl } from './UserCard';
UserAccountCardContent,
UserCreateCard,
UserIdentityHeader,
UserRoleControl,
} from './UserCard';
interface UserListViewProps {
authMode: string;
@@ -77,7 +81,8 @@ export const UserListView = ({
}: UserListViewProps) => {
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
const isCwaMode = String(authMode || 'none').toLowerCase() === 'cwa';
const isCwaMode = (authMode || 'none').toLowerCase() === 'cwa';
const handleDelete = async (userId: number) => {
const ok = await onDelete(userId);
if (ok) {
@@ -87,136 +92,153 @@ export const UserListView = ({
return (
<div className="space-y-4">
{(loadingUsers && users.length === 0) ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm opacity-50">Loading users...</p>
</div>
) : (loadError && users.length === 0) ? (
<div className="text-center py-8 space-y-3">
<p className="text-sm opacity-60">{loadError}</p>
<button
onClick={onRetryLoadUsers}
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
>
Retry
</button>
</div>
) : users.length === 0 ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm opacity-50">No users yet.</p>
<p className="text-xs opacity-40">
Create a local admin account before enabling OIDC to avoid getting locked out.
</p>
</div>
) : (
<div className="space-y-2">
{users.map((user) => {
const active = user.is_active !== false;
const isEditingRow = showEditForm && activeEditUserId === user.id;
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
return (
<div
key={user.id}
className={`rounded-lg border border-(--border-muted) bg-(--bg-soft) transition-colors ${active ? '' : 'opacity-60'}`}
{(() => {
if (loadingUsers && users.length === 0) {
return (
<div className="space-y-2 py-8 text-center">
<p className="text-sm opacity-50">Loading users...</p>
</div>
);
}
if (loadError && users.length === 0) {
return (
<div className="space-y-3 py-8 text-center">
<p className="text-sm opacity-60">{loadError}</p>
<button
type="button"
onClick={onRetryLoadUsers}
className="rounded-lg border border-(--border-muted) bg-(--bg-soft) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface)"
>
Retry
</button>
</div>
);
}
if (users.length === 0) {
return (
<div className="space-y-2 py-8 text-center">
<p className="text-sm opacity-50">No users yet.</p>
<p className="text-xs opacity-40">
Create a local admin account before enabling OIDC to avoid getting locked out.
</p>
</div>
);
}
return (
<div className="space-y-2">
{users.map((user) => {
const active = user.is_active;
const isEditingRow = showEditForm && activeEditUserId === user.id;
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
const editorPanelId = `user-editor-${user.id}`;
const toggleUserEditor = () => {
setConfirmDelete(null);
if (isEditingRow) {
onCancelEdit();
return;
}
onEdit(user);
};
return (
<div
role="button"
tabIndex={0}
onClick={(e) => {
// Don't toggle when clicking interactive elements inside the header (e.g. role dropdown)
if ((e.target as HTMLElement).closest('button:not([data-card-toggle]), [role="listbox"], [data-dropdown]')) return;
setConfirmDelete(null);
if (isEditingRow) {
onCancelEdit();
} else {
onEdit(user);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setConfirmDelete(null);
if (isEditingRow) {
onCancelEdit();
} else {
onEdit(user);
}
}
}}
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-(--border-muted)' : 'rounded-b-lg'}`}
aria-expanded={isEditingRow}
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
key={user.id}
className={`rounded-lg border border-(--border-muted) bg-(--bg-soft) transition-colors ${active ? '' : 'opacity-60'}`}
>
<UserIdentityHeader user={user} />
<div
className={`hover-surface relative flex cursor-pointer flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between ${isEditingRow ? 'rounded-t-lg border-b border-(--border-muted)' : 'rounded-lg'}`}
>
<button
type="button"
onClick={toggleUserEditor}
aria-expanded={isEditingRow}
aria-controls={editorPanelId}
aria-label={
isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`
}
className={`absolute inset-0 appearance-none border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:ring-sky-500/50 focus-visible:outline-hidden ${isEditingRow ? 'rounded-t-lg' : 'rounded-lg'}`}
/>
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
{hasLoadedEditUser && editingUser ? (
<UserRoleControl
user={editingUser}
onUserChange={onEditingUserChange}
oidcAdminGroup={downloadDefaults?.OIDC_ADMIN_GROUP}
tooltipPosition="bottom"
/>
) : (
<UserRoleControl user={user} />
)}
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
<UserIdentityHeader user={user} />
</div>
<div
className="p-2 rounded-full text-gray-500 dark:text-gray-400"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`w-[18px] h-[18px] transition-transform duration-200 ${isEditingRow ? 'rotate-180' : ''}`}
<div className="pointer-events-none relative z-10 flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
<div className="pointer-events-auto">
{hasLoadedEditUser && editingUser ? (
<UserRoleControl
user={editingUser}
onUserChange={onEditingUserChange}
oidcAdminGroup={downloadDefaults?.OIDC_ADMIN_GROUP}
tooltipPosition="bottom"
/>
) : (
<UserRoleControl user={user} />
)}
</div>
<div
className="rounded-full p-2 text-gray-500 dark:text-gray-400"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`h-[18px] w-[18px] transition-transform duration-200 ${isEditingRow ? 'rotate-180' : ''}`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</div>
</div>
</div>
</div>
{isEditingRow && (
<div className="p-4 space-y-5 bg-(--bg) rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<UserAccountCardContent
user={editingUser}
onUserChange={onEditingUserChange}
onSave={onEditSave}
saving={saving}
onCancel={onCancelEdit}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
onDelete={() => setConfirmDelete(user.id)}
onConfirmDelete={() => handleDelete(user.id)}
onCancelDelete={() => setConfirmDelete(null)}
isDeletePending={confirmDelete === user.id}
deleting={deletingUserId === user.id}
preferencesPanel={{
description: 'Customise delivery and request settings for this user.',
actionLabel: 'Open User Preferences',
onAction: onOpenOverrides,
}}
/>
) : (
<div className="text-sm opacity-60">Loading user details...</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{isEditingRow && (
<div id={editorPanelId} className="space-y-5 rounded-b-lg bg-(--bg) p-4">
{hasLoadedEditUser && editingUser ? (
<UserAccountCardContent
user={editingUser}
onUserChange={onEditingUserChange}
onSave={onEditSave}
saving={saving}
onCancel={onCancelEdit}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
onDelete={() => setConfirmDelete(user.id)}
onConfirmDelete={() => {
void handleDelete(user.id);
}}
onCancelDelete={() => setConfirmDelete(null)}
isDeletePending={confirmDelete === user.id}
deleting={deletingUserId === user.id}
preferencesPanel={{
description: 'Customise delivery and request settings for this user.',
actionLabel: 'Open User Preferences',
onAction: onOpenOverrides,
}}
/>
) : (
<div className="text-sm opacity-60">Loading user details...</div>
)}
</div>
)}
</div>
);
})}
</div>
);
})()}
{canCreateLocalUsers && (
<div>
@@ -232,8 +254,9 @@ export const UserListView = ({
/>
) : (
<button
type="button"
onClick={onCreate}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700"
>
Create Local User
</button>
@@ -244,9 +267,12 @@ export const UserListView = ({
{!canCreateLocalUsers && isCwaMode && (
<div>
<button
onClick={onSyncCwa}
type="button"
onClick={() => {
void onSyncCwa();
}}
disabled={syncingCwa}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{syncingCwa ? 'Syncing with CWA...' : 'Sync with CWA'}
</button>
@@ -1,5 +1,5 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import {
import type { DeliveryPreferencesResponse } from '../../../services/api';
import type {
ActionButtonConfig,
ActionResult,
HeadingFieldConfig,
@@ -7,8 +7,13 @@ import {
} from '../../../types/settings';
import { ActionButton, HeadingField, TableField } from '../fields';
import { FieldWrapper } from '../shared';
import { getFieldByKey } from './fieldHelpers';
import { PerUserSettings } from './types';
import {
getFieldByKey,
isRecord,
toNormalizedLowercaseTextValue,
toTrimmedTextValue,
} from './fieldHelpers';
import type { PerUserSettings } from './types';
interface UserNotificationOverridesSectionProps {
notificationPreferences: DeliveryPreferencesResponse | null;
@@ -30,17 +35,18 @@ const USER_ROUTE_EVENT_OPTIONS = [
{ value: 'download_failed', label: 'Download failed' },
];
const ALLOWED_ROUTE_EVENTS = new Set(USER_ROUTE_EVENT_OPTIONS.map((option) => option.value));
const ROUTE_EVENT_ORDER = new Map(USER_ROUTE_EVENT_OPTIONS.map((option, index) => [option.value, index]));
const ROUTE_EVENT_ORDER = new Map(
USER_ROUTE_EVENT_OPTIONS.map((option, index) => [option.value, index]),
);
const fallbackRoutesField: TableFieldConfig = {
type: 'TableField',
key: 'USER_NOTIFICATION_ROUTES',
label: '',
description: (
'Create one route per URL. Start with All, then add event-specific routes '
+ 'for targeted delivery. Need format examples? '
+ '[View Apprise URL formats](https://appriseit.com/services/).'
),
description:
'Create one route per URL. Start with All, then add event-specific routes ' +
'for targeted delivery. Need format examples? ' +
'[View Apprise URL formats](https://appriseit.com/services/).',
value: [{ event: [ROUTE_EVENT_ALL], url: '' }],
columns: [
{
@@ -66,7 +72,8 @@ const notificationHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'notification_preferences_heading',
title: 'Notifications',
description: 'Personal notification preferences for this user. Reset to inherit global defaults from the Notifications tab.',
description:
'Personal notification preferences for this user. Reset to inherit global defaults from the Notifications tab.',
};
const testNotificationActionField: ActionButtonConfig = {
@@ -77,6 +84,35 @@ const testNotificationActionField: ActionButtonConfig = {
style: 'primary',
};
const normalizeRouteEvents = (rawEventValue: unknown): string[] => {
let rawValues: unknown[] = [];
if (Array.isArray(rawEventValue)) {
rawValues = rawEventValue;
} else if (rawEventValue !== undefined && rawEventValue !== null) {
rawValues = [rawEventValue];
}
const deduped = new Set<string>();
rawValues.forEach((rawEvent) => {
const event = toNormalizedLowercaseTextValue(rawEvent);
if (!ALLOWED_ROUTE_EVENTS.has(event)) {
return;
}
deduped.add(event);
});
if (deduped.has(ROUTE_EVENT_ALL)) {
return [ROUTE_EVENT_ALL];
}
return Array.from(deduped).toSorted((a, b) => {
return (
(ROUTE_EVENT_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) -
(ROUTE_EVENT_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER)
);
});
};
function normalizeRoutesValue(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) {
return [{ event: [ROUTE_EVENT_ALL], url: '' }];
@@ -85,41 +121,17 @@ function normalizeRoutesValue(value: unknown): Array<Record<string, unknown>> {
const normalized: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
const normalizeRouteEvents = (rawEventValue: unknown): string[] => {
const rawValues = Array.isArray(rawEventValue)
? rawEventValue
: (rawEventValue === undefined || rawEventValue === null ? [] : [rawEventValue]);
const deduped = new Set<string>();
rawValues.forEach((rawEvent) => {
const event = String(rawEvent ?? '').trim().toLowerCase();
if (!ALLOWED_ROUTE_EVENTS.has(event)) {
return;
}
deduped.add(event);
});
if (deduped.has(ROUTE_EVENT_ALL)) {
return [ROUTE_EVENT_ALL];
}
return Array.from(deduped).sort((a, b) => {
return (ROUTE_EVENT_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER)
- (ROUTE_EVENT_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
});
};
value.forEach((row) => {
if (!row || typeof row !== 'object') {
if (!isRecord(row)) {
return;
}
const events = normalizeRouteEvents((row as Record<string, unknown>).event);
const events = normalizeRouteEvents(row.event);
if (events.length === 0) {
return;
}
const url = String((row as Record<string, unknown>).url ?? '').trim();
const url = toTrimmedTextValue(row.url);
const key = `${events.join('|')}::${url}`;
if (seen.has(key)) {
return;
@@ -146,23 +158,21 @@ export const UserNotificationOverridesSection = ({
const fields = notificationPreferences.fields ?? [];
const globalValues = notificationPreferences.globalValues ?? {};
const routesField = getFieldByKey<TableFieldConfig>(
fields,
'USER_NOTIFICATION_ROUTES',
fallbackRoutesField
);
const routesField = getFieldByKey(fields, 'USER_NOTIFICATION_ROUTES', fallbackRoutesField);
const isOverridden = (key: NotificationSettingKey): boolean => {
if (
!Object.prototype.hasOwnProperty.call(userSettings, key)
|| userSettings[key] === null
|| userSettings[key] === undefined
!Object.prototype.hasOwnProperty.call(userSettings, key) ||
userSettings[key] === null ||
userSettings[key] === undefined
) {
return false;
}
return JSON.stringify(normalizeRoutesValue(userSettings[key]))
!== JSON.stringify(normalizeRoutesValue(globalValues[key]));
return (
JSON.stringify(normalizeRoutesValue(userSettings[key])) !==
JSON.stringify(normalizeRoutesValue(globalValues[key]))
);
};
const resetKeys = (keys: NotificationSettingKey[]) => {
@@ -200,18 +210,20 @@ export const UserNotificationOverridesSection = ({
<FieldWrapper
field={routesField}
resetAction={
isOverridden('USER_NOTIFICATION_ROUTES') ? (
{
disabled: Boolean(routesField.fromEnv),
onClick: () => resetKeys(['USER_NOTIFICATION_ROUTES']),
}
) : undefined
isOverridden('USER_NOTIFICATION_ROUTES')
? {
disabled: Boolean(routesField.fromEnv),
onClick: () => resetKeys(['USER_NOTIFICATION_ROUTES']),
}
: undefined
}
>
<TableField
field={routesField}
value={routesValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, USER_NOTIFICATION_ROUTES: value }))}
onChange={(value) =>
setUserSettings((prev) => ({ ...prev, USER_NOTIFICATION_ROUTES: value }))
}
disabled={Boolean(routesField.fromEnv)}
/>
</FieldWrapper>
@@ -1,5 +1,5 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import {
import type { DeliveryPreferencesResponse } from '../../../services/api';
import type {
HeadingFieldConfig,
MultiSelectFieldConfig,
SelectFieldConfig,
@@ -7,8 +7,8 @@ import {
} from '../../../types/settings';
import { HeadingField, MultiSelectField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { getFieldByKey } from './fieldHelpers';
import { PerUserSettings } from './types';
import { getFieldByKey, toNormalizedLowercaseTextValue, toTextValue } from './fieldHelpers';
import type { PerUserSettings } from './types';
interface UserOverridesSectionProps {
deliveryPreferences: DeliveryPreferencesResponse | null;
@@ -45,7 +45,7 @@ const fallbackDestinationAudiobookField: TextFieldConfig = {
type: 'TextField',
key: 'DESTINATION_AUDIOBOOK',
label: 'Destination',
description: 'Directory for this user\'s audiobook downloads.',
description: "Directory for this user's audiobook downloads.",
value: '',
};
@@ -81,7 +81,8 @@ const fallbackBrowserDownloadField: MultiSelectFieldConfig = {
type: 'MultiSelectField',
key: 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES',
label: 'Download to Browser',
description: 'Automatically download completed files to this browser for the selected content types.',
description:
'Automatically download completed files to this browser for the selected content types.',
value: [],
variant: 'dropdown',
options: [
@@ -93,7 +94,7 @@ const fallbackBrowserDownloadField: MultiSelectFieldConfig = {
type DeliverySettingKey = keyof PerUserSettings;
function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' {
const mode = String(value || '').trim().toLowerCase();
const mode = toNormalizedLowercaseTextValue(value);
if (mode === 'booklore' || mode === 'email') {
return mode;
}
@@ -101,8 +102,7 @@ function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' {
}
function toStringValue(value: unknown): string {
if (value === undefined || value === null) return '';
return String(value);
return toTextValue(value);
}
const deliveryHeading: HeadingFieldConfig = {
@@ -123,7 +123,8 @@ const audiobooksHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_audiobooks_heading',
title: 'Audiobooks',
description: 'Audiobooks always use folder output. Use Reset to inherit the global audiobook destination.',
description:
'Audiobooks always use folder output. Use Reset to inherit the global audiobook destination.',
};
const BOOK_PREFERENCE_KEYS: DeliverySettingKey[] = [
@@ -146,13 +147,25 @@ export const UserOverridesSection = ({
const globalValues = deliveryPreferences?.globalValues ?? {};
const preferenceKeys = deliveryPreferences?.keys ?? [];
const outputModeField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKS_OUTPUT_MODE', fallbackOutputModeField);
const destinationField = getFieldByKey<TextFieldConfig>(fields, 'DESTINATION', fallbackDestinationField);
const destinationAudiobookField = getFieldByKey<TextFieldConfig>(fields, 'DESTINATION_AUDIOBOOK', fallbackDestinationAudiobookField);
const bookloreLibraryField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_LIBRARY_ID', fallbackBookloreLibraryField);
const booklorePathField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_PATH_ID', fallbackBooklorePathField);
const emailRecipientFieldSource = getFieldByKey<TextFieldConfig>(fields, 'EMAIL_RECIPIENT', fallbackEmailRecipientField);
const browserDownloadField = getFieldByKey<MultiSelectFieldConfig>(
const outputModeField = getFieldByKey(fields, 'BOOKS_OUTPUT_MODE', fallbackOutputModeField);
const destinationField = getFieldByKey(fields, 'DESTINATION', fallbackDestinationField);
const destinationAudiobookField = getFieldByKey(
fields,
'DESTINATION_AUDIOBOOK',
fallbackDestinationAudiobookField,
);
const bookloreLibraryField = getFieldByKey(
fields,
'BOOKLORE_LIBRARY_ID',
fallbackBookloreLibraryField,
);
const booklorePathField = getFieldByKey(fields, 'BOOKLORE_PATH_ID', fallbackBooklorePathField);
const emailRecipientFieldSource = getFieldByKey(
fields,
'EMAIL_RECIPIENT',
fallbackEmailRecipientField,
);
const browserDownloadField = getFieldByKey(
fields,
'DOWNLOAD_TO_BROWSER_CONTENT_TYPES',
fallbackBrowserDownloadField,
@@ -163,14 +176,14 @@ export const UserOverridesSection = ({
description: 'Email address used for this user in Email output mode.',
};
const browserDownloadGlobalValue = Array.isArray(globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES)
? globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES
.map((entry) => String(entry).trim())
.filter((entry) => entry.length > 0)
? globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES.map((entry) => String(entry).trim()).filter(
(entry) => entry.length > 0,
)
: [];
const browserDownloadUserValue = Array.isArray(userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES)
? userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES
.map((entry) => String(entry).trim())
.filter((entry) => entry.length > 0)
? userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES.map((entry) => entry.trim()).filter(
(entry) => entry.length > 0,
)
: [];
const isOverridden = (key: DeliverySettingKey): boolean => {
@@ -183,15 +196,14 @@ export const UserOverridesSection = ({
}
const userValue = toStringValue(userSettings[key]);
const globalValue = toStringValue(globalValues[key as string]);
const globalValue = toStringValue(globalValues[key]);
return userValue !== globalValue;
};
const isBrowserDownloadOverridden = (
Object.prototype.hasOwnProperty.call(userSettings, 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES')
&& userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES !== null
&& JSON.stringify(browserDownloadUserValue) !== JSON.stringify(browserDownloadGlobalValue)
);
const isBrowserDownloadOverridden =
Object.prototype.hasOwnProperty.call(userSettings, 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES') &&
userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES !== null &&
JSON.stringify(browserDownloadUserValue) !== JSON.stringify(browserDownloadGlobalValue);
const resetKeys = (keys: DeliverySettingKey[]) => {
setUserSettings((prev) => {
@@ -203,6 +215,10 @@ export const UserOverridesSection = ({
});
};
const resetBookloreLibrary = () => {
resetKeys(['BOOKLORE_LIBRARY_ID', 'BOOKLORE_PATH_ID']);
};
const readValue = (key: DeliverySettingKey, fallback = ''): string => {
if (isOverridden(key)) {
return toStringValue(userSettings[key]);
@@ -225,11 +241,17 @@ export const UserOverridesSection = ({
const pathValue = readValue('BOOKLORE_PATH_ID');
const emailRecipientValue = readValue('EMAIL_RECIPIENT');
const availableBookPreferenceKeys = BOOK_PREFERENCE_KEYS.filter((key) => preferenceKeys.includes(String(key)));
const availableAudiobookPreferenceKeys = AUDIOBOOK_PREFERENCE_KEYS.filter((key) => preferenceKeys.includes(String(key)));
const availableBookPreferenceKeys = BOOK_PREFERENCE_KEYS.filter((key) =>
preferenceKeys.includes(String(key)),
);
const availableAudiobookPreferenceKeys = AUDIOBOOK_PREFERENCE_KEYS.filter((key) =>
preferenceKeys.includes(String(key)),
);
const hasBookDeliveryOverride = availableBookPreferenceKeys.some((key) => isOverridden(key));
const hasAudiobookDeliveryOverride = availableAudiobookPreferenceKeys.some((key) => isOverridden(key));
const hasAudiobookDeliveryOverride = availableAudiobookPreferenceKeys.some((key) =>
isOverridden(key),
);
const canOverrideOutputMode = isUserOverridable('BOOKS_OUTPUT_MODE');
const canOverrideBrowserDownload = isUserOverridable('DOWNLOAD_TO_BROWSER_CONTENT_TYPES');
@@ -262,10 +284,12 @@ export const UserOverridesSection = ({
<MultiSelectField
field={browserDownloadField}
value={browserDownloadContentTypes}
onChange={(value) => setUserSettings((prev) => ({
...prev,
DOWNLOAD_TO_BROWSER_CONTENT_TYPES: value,
}))}
onChange={(value) =>
setUserSettings((prev) => ({
...prev,
DOWNLOAD_TO_BROWSER_CONTENT_TYPES: value,
}))
}
disabled={Boolean(browserDownloadField.fromEnv)}
/>
</FieldWrapper>
@@ -277,12 +301,12 @@ export const UserOverridesSection = ({
<FieldWrapper
field={outputModeField}
resetAction={
hasBookDeliveryOverride ? (
{
label: 'Reset all',
onClick: () => resetKeys(availableBookPreferenceKeys),
}
) : undefined
hasBookDeliveryOverride
? {
label: 'Reset all',
onClick: () => resetKeys(availableBookPreferenceKeys),
}
: undefined
}
>
<SelectField
@@ -322,7 +346,7 @@ export const UserOverridesSection = ({
isOverridden('BOOKLORE_LIBRARY_ID')
? {
disabled: Boolean(bookloreLibraryField.fromEnv),
onClick: () => resetKeys(['BOOKLORE_LIBRARY_ID']),
onClick: resetBookloreLibrary,
}
: undefined
}
@@ -399,12 +423,14 @@ export const UserOverridesSection = ({
: undefined
}
>
<TextField
field={destinationAudiobookField}
value={destinationAudiobookValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DESTINATION_AUDIOBOOK: value }))}
disabled={Boolean(destinationAudiobookField.fromEnv)}
/>
<TextField
field={destinationAudiobookField}
value={destinationAudiobookValue}
onChange={(value) =>
setUserSettings((prev) => ({ ...prev, DESTINATION_AUDIOBOOK: value }))
}
disabled={Boolean(destinationAudiobookField.fromEnv)}
/>
</FieldWrapper>
</>
)}
@@ -1,15 +1,17 @@
import { Fragment, ReactElement } from 'react';
import { DeliveryPreferencesResponse } from '../../../services/api';
import { ActionResult } from '../../../types/settings';
import { SettingsTab } from '../../../types/settings';
import type { ReactElement } from 'react';
import { Fragment } from 'react';
import type { DeliveryPreferencesResponse } from '../../../services/api';
import type { ActionResult, SettingsTab } from '../../../types/settings';
import { toTrimmedTextValue } from './fieldHelpers';
import type { PerUserSettings } from './types';
import { UserNotificationOverridesSection } from './UserNotificationOverridesSection';
import { UserOverridesSection } from './UserOverridesSection';
import { UserRequestPolicyOverridesSection } from './UserRequestPolicyOverridesSection';
import { UserSearchPreferencesSection } from './UserSearchPreferencesSection';
import { PerUserSettings } from './types';
export type UserOverrideScope = 'admin' | 'self';
export type UserOverrideSectionId = 'delivery' | 'search' | 'notifications' | 'requestPolicy';
type UserOverrideScope = 'admin' | 'self';
type UserOverrideSectionId = 'delivery' | 'search' | 'notifications' | 'requestPolicy';
interface UserOverridesSectionsProps {
scope: UserOverrideScope;
@@ -42,9 +44,10 @@ const USER_OVERRIDE_SECTION_DEFINITIONS: UserOverrideSectionDefinition[] = [
{ id: 'requestPolicy', adminOnly: true },
];
const USER_OVERRIDE_SECTION_ORDER: UserOverrideSectionId[] =
USER_OVERRIDE_SECTION_DEFINITIONS.map((section) => section.id);
const USER_OVERRIDE_SECTION_ID_SET = new Set<UserOverrideSectionId>(USER_OVERRIDE_SECTION_ORDER);
const USER_OVERRIDE_SECTION_ORDER: UserOverrideSectionId[] = USER_OVERRIDE_SECTION_DEFINITIONS.map(
(section) => section.id,
);
const USER_OVERRIDE_SECTION_ID_SET = new Set<string>(USER_OVERRIDE_SECTION_ORDER);
const USER_OVERRIDE_SECTION_META: Record<UserOverrideSectionId, UserOverrideSectionDefinition> = {
delivery: { id: 'delivery', adminOnly: false },
@@ -54,19 +57,19 @@ const USER_OVERRIDE_SECTION_META: Record<UserOverrideSectionId, UserOverrideSect
};
export const DEFAULT_SELF_USER_OVERRIDE_SECTIONS: UserOverrideSectionId[] =
USER_OVERRIDE_SECTION_ORDER.filter((sectionId) => !USER_OVERRIDE_SECTION_META[sectionId].adminOnly);
USER_OVERRIDE_SECTION_ORDER.filter(
(sectionId) => !USER_OVERRIDE_SECTION_META[sectionId].adminOnly,
);
const isUserOverrideSectionId = (value: string): value is UserOverrideSectionId => (
USER_OVERRIDE_SECTION_ID_SET.has(value as UserOverrideSectionId)
);
const isUserOverrideSectionId = (value: string): value is UserOverrideSectionId =>
USER_OVERRIDE_SECTION_ID_SET.has(value);
export const normalizeUserOverrideSections = (
sections: Iterable<unknown> | null | undefined,
scope: UserOverrideScope,
): UserOverrideSectionId[] => {
const fallbackSections = scope === 'self'
? DEFAULT_SELF_USER_OVERRIDE_SECTIONS
: USER_OVERRIDE_SECTION_ORDER;
const fallbackSections =
scope === 'self' ? DEFAULT_SELF_USER_OVERRIDE_SECTIONS : USER_OVERRIDE_SECTION_ORDER;
if (!sections) {
return fallbackSections;
@@ -79,7 +82,7 @@ export const normalizeUserOverrideSections = (
const requestedIds = new Set<UserOverrideSectionId>();
requestedValues.forEach((value) => {
const normalizedValue = String(value ?? '').trim();
const normalizedValue = toTrimmedTextValue(value);
if (isUserOverrideSectionId(normalizedValue)) {
requestedIds.add(normalizedValue);
}
@@ -1,7 +1,7 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import { PerUserSettings } from './types';
import type { DeliveryPreferencesResponse } from '../../../services/api';
import type { ActionResult, SettingsTab } from '../../../types/settings';
import { SettingsSubpage } from '../shared';
import { ActionResult, SettingsTab } from '../../../types/settings';
import type { PerUserSettings } from './types';
import { UserOverridesSections } from './UserOverridesSections';
interface UserOverridesViewProps {
@@ -37,11 +37,12 @@ export const UserOverridesView = ({
<div className="space-y-5">
<div>
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
className="inline-flex items-center gap-2 rounded-lg border border-(--border-muted) bg-(--bg) px-4 py-2 text-sm font-medium transition-colors hover:bg-(--hover-surface)"
>
<svg
className="w-4 h-4"
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -73,9 +74,5 @@ export const UserOverridesView = ({
return content;
}
return (
<SettingsSubpage hasBottomSaveBar={hasChanges}>
{content}
</SettingsSubpage>
);
return <SettingsSubpage hasBottomSaveBar={hasChanges}>{content}</SettingsSubpage>;
};
@@ -1,4 +1,4 @@
import {
import type {
CustomComponentFieldConfig,
HeadingFieldConfig,
SelectFieldConfig,
@@ -6,14 +6,15 @@ import {
TableFieldConfig,
} from '../../../types/settings';
import { HeadingField } from '../fields';
import { PerUserSettings } from './types';
import { RequestPolicyGrid } from './RequestPolicyGrid';
import type { RequestPolicyRuleRow } from './requestPolicyGridUtils';
import {
normalizeRequestPolicyDefaults,
normalizeRequestPolicyRules,
normalizeExplicitRulesForPersistence,
parseSourceCapabilitiesFromRulesField,
} from './requestPolicyGridUtils';
import type { PerUserSettings } from './types';
interface UserRequestPolicyOverridesSectionProps {
usersTab: SettingsTab;
@@ -36,11 +37,16 @@ const requestPolicyHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'request_policy_overrides_heading',
title: 'Requests',
description: 'Custom request settings for this user. Reset any to fall back to the global defaults.',
description:
'Custom request settings for this user. Reset any to fall back to the global defaults.',
};
const hasOwnNonNull = (settings: PerUserSettings, key: keyof PerUserSettings): boolean => {
return Object.prototype.hasOwnProperty.call(settings, key) && settings[key] !== null && settings[key] !== undefined;
return (
Object.prototype.hasOwnProperty.call(settings, key) &&
settings[key] !== null &&
settings[key] !== undefined
);
};
const toBoolean = (value: unknown): boolean => {
@@ -53,6 +59,14 @@ const toBoolean = (value: unknown): boolean => {
return Boolean(value);
};
const toStoredRequestPolicyRule = (rule: RequestPolicyRuleRow): Record<string, unknown> => {
return {
source: rule.source,
content_type: rule.content_type,
mode: rule.mode,
};
};
export const UserRequestPolicyOverridesSection = ({
usersTab,
globalUsersSettingsValues,
@@ -64,7 +78,7 @@ export const UserRequestPolicyOverridesSection = ({
const effectiveRequestsEnabled = toBoolean(
requestsEnabledOverridePresent
? userSettings.REQUESTS_ENABLED
: globalUsersSettingsValues.REQUESTS_ENABLED
: globalUsersSettingsValues.REQUESTS_ENABLED,
);
if (!effectiveRequestsEnabled) {
return null;
@@ -72,19 +86,19 @@ export const UserRequestPolicyOverridesSection = ({
const requestPolicyEditorField = usersTab.fields.find(
(field): field is CustomComponentFieldConfig =>
field.key === 'request_policy_editor' && field.type === 'CustomComponentField'
field.key === 'request_policy_editor' && field.type === 'CustomComponentField',
);
const rulesField = requestPolicyEditorField?.boundFields?.find(
(field): field is TableFieldConfig =>
field.key === 'REQUEST_POLICY_RULES' && field.type === 'TableField'
field.key === 'REQUEST_POLICY_RULES' && field.type === 'TableField',
);
const defaultEbookField = requestPolicyEditorField?.boundFields?.find(
(field): field is SelectFieldConfig =>
field.key === 'REQUEST_POLICY_DEFAULT_EBOOK' && field.type === 'SelectField'
field.key === 'REQUEST_POLICY_DEFAULT_EBOOK' && field.type === 'SelectField',
);
const defaultAudioField = requestPolicyEditorField?.boundFields?.find(
(field): field is SelectFieldConfig =>
field.key === 'REQUEST_POLICY_DEFAULT_AUDIOBOOK' && field.type === 'SelectField'
field.key === 'REQUEST_POLICY_DEFAULT_AUDIOBOOK' && field.type === 'SelectField',
);
if (!rulesField) {
@@ -121,7 +135,10 @@ export const UserRequestPolicyOverridesSection = ({
...explicitUserRules.map((row) => row.source),
]);
const setRulesOverride = (nextRulesRaw: typeof explicitUserRules, nextDefaults = effectiveDefaults) => {
const setRulesOverride = (
nextRulesRaw: typeof explicitUserRules,
nextDefaults = effectiveDefaults,
) => {
const normalized = normalizeExplicitRulesForPersistence({
explicitRules: nextRulesRaw,
baseRules: globalRules,
@@ -134,14 +151,14 @@ export const UserRequestPolicyOverridesSection = ({
if (normalized.length === 0) {
delete next.REQUEST_POLICY_RULES;
} else {
next.REQUEST_POLICY_RULES = normalized as unknown as Array<Record<string, unknown>>;
next.REQUEST_POLICY_RULES = normalized.map(toStoredRequestPolicyRule);
}
return next;
});
};
const hasAnyRequestOverrides = REQUEST_POLICY_OVERRIDE_KEYS.some((key) =>
hasOwnNonNull(userSettings, key)
hasOwnNonNull(userSettings, key),
);
return (
@@ -1,9 +1,9 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import { HeadingFieldConfig, SelectFieldConfig } from '../../../types/settings';
import type { DeliveryPreferencesResponse } from '../../../services/api';
import type { HeadingFieldConfig, SelectFieldConfig } from '../../../types/settings';
import { HeadingField, SelectField } from '../fields';
import { FieldWrapper } from '../shared';
import { getFieldByKey } from './fieldHelpers';
import { PerUserSettings } from './types';
import { getFieldByKey, toNormalizedLowercaseTextValue, toTextValue } from './fieldHelpers';
import type { PerUserSettings } from './types';
interface UserSearchPreferencesSectionProps {
searchPreferences: DeliveryPreferencesResponse | null;
@@ -72,19 +72,17 @@ const searchHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'search_preferences_heading',
title: 'Search Preferences',
description: 'Personal search settings for this user. Reset to inherit global defaults from Search Mode.',
description:
'Personal search settings for this user. Reset to inherit global defaults from Search Mode.',
};
const normalizeSearchMode = (value: unknown): 'direct' | 'universal' => {
const normalized = String(value || '').trim().toLowerCase();
const normalized = toNormalizedLowercaseTextValue(value);
return normalized === 'universal' ? 'universal' : 'direct';
};
const toStringValue = (value: unknown): string => {
if (value === undefined || value === null) {
return '';
}
return String(value);
return toTextValue(value);
};
export const UserSearchPreferencesSection = ({
@@ -101,37 +99,33 @@ export const UserSearchPreferencesSection = ({
const globalValues = searchPreferences.globalValues ?? {};
const preferenceKeySet = new Set(searchPreferences.keys ?? []);
const searchModeField = getFieldByKey<SelectFieldConfig>(
fields,
'SEARCH_MODE',
fallbackSearchModeField
);
const metadataProviderField = getFieldByKey<SelectFieldConfig>(
const searchModeField = getFieldByKey(fields, 'SEARCH_MODE', fallbackSearchModeField);
const metadataProviderField = getFieldByKey(
fields,
'METADATA_PROVIDER',
fallbackMetadataProviderField
fallbackMetadataProviderField,
);
const metadataProviderAudiobookField = getFieldByKey<SelectFieldConfig>(
const metadataProviderAudiobookField = getFieldByKey(
fields,
'METADATA_PROVIDER_AUDIOBOOK',
fallbackAudiobookMetadataProviderField
fallbackAudiobookMetadataProviderField,
);
const defaultReleaseSourceField = getFieldByKey<SelectFieldConfig>(
const defaultReleaseSourceField = getFieldByKey(
fields,
'DEFAULT_RELEASE_SOURCE',
fallbackDefaultReleaseSourceField
fallbackDefaultReleaseSourceField,
);
const defaultAudiobookReleaseSourceField = getFieldByKey<SelectFieldConfig>(
const defaultAudiobookReleaseSourceField = getFieldByKey(
fields,
'DEFAULT_RELEASE_SOURCE_AUDIOBOOK',
fallbackDefaultAudiobookReleaseSourceField
fallbackDefaultAudiobookReleaseSourceField,
);
const isOverridden = (key: SearchSettingKey): boolean => {
if (
!Object.prototype.hasOwnProperty.call(userSettings, key)
|| userSettings[key] === null
|| userSettings[key] === undefined
!Object.prototype.hasOwnProperty.call(userSettings, key) ||
userSettings[key] === null ||
userSettings[key] === undefined
) {
return false;
}
@@ -166,22 +160,25 @@ export const UserSearchPreferencesSection = ({
const defaultReleaseSourceValue = readValue('DEFAULT_RELEASE_SOURCE', 'direct_download');
const defaultAudiobookReleaseSourceValue = readValue('DEFAULT_RELEASE_SOURCE_AUDIOBOOK');
const canOverrideSearchMode = isUserOverridable('SEARCH_MODE') && preferenceKeySet.has('SEARCH_MODE');
const canOverrideMetadataProvider = isUserOverridable('METADATA_PROVIDER')
&& preferenceKeySet.has('METADATA_PROVIDER');
const canOverrideAudiobookMetadataProvider = isUserOverridable('METADATA_PROVIDER_AUDIOBOOK')
&& preferenceKeySet.has('METADATA_PROVIDER_AUDIOBOOK');
const canOverrideDefaultReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE')
&& preferenceKeySet.has('DEFAULT_RELEASE_SOURCE');
const canOverrideDefaultAudiobookReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE_AUDIOBOOK')
&& preferenceKeySet.has('DEFAULT_RELEASE_SOURCE_AUDIOBOOK');
const canOverrideSearchMode =
isUserOverridable('SEARCH_MODE') && preferenceKeySet.has('SEARCH_MODE');
const canOverrideMetadataProvider =
isUserOverridable('METADATA_PROVIDER') && preferenceKeySet.has('METADATA_PROVIDER');
const canOverrideAudiobookMetadataProvider =
isUserOverridable('METADATA_PROVIDER_AUDIOBOOK') &&
preferenceKeySet.has('METADATA_PROVIDER_AUDIOBOOK');
const canOverrideDefaultReleaseSource =
isUserOverridable('DEFAULT_RELEASE_SOURCE') && preferenceKeySet.has('DEFAULT_RELEASE_SOURCE');
const canOverrideDefaultAudiobookReleaseSource =
isUserOverridable('DEFAULT_RELEASE_SOURCE_AUDIOBOOK') &&
preferenceKeySet.has('DEFAULT_RELEASE_SOURCE_AUDIOBOOK');
if (
!canOverrideSearchMode
&& !canOverrideMetadataProvider
&& !canOverrideAudiobookMetadataProvider
&& !canOverrideDefaultReleaseSource
&& !canOverrideDefaultAudiobookReleaseSource
!canOverrideSearchMode &&
!canOverrideMetadataProvider &&
!canOverrideAudiobookMetadataProvider &&
!canOverrideDefaultReleaseSource &&
!canOverrideDefaultAudiobookReleaseSource
) {
return null;
}
@@ -247,7 +244,9 @@ export const UserSearchPreferencesSection = ({
<SelectField
field={metadataProviderAudiobookField}
value={metadataProviderAudiobookValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, METADATA_PROVIDER_AUDIOBOOK: value }))}
onChange={(value) =>
setUserSettings((prev) => ({ ...prev, METADATA_PROVIDER_AUDIOBOOK: value }))
}
disabled={Boolean(metadataProviderAudiobookField.fromEnv)}
/>
</FieldWrapper>
@@ -268,7 +267,9 @@ export const UserSearchPreferencesSection = ({
<SelectField
field={defaultReleaseSourceField}
value={defaultReleaseSourceValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DEFAULT_RELEASE_SOURCE: value }))}
onChange={(value) =>
setUserSettings((prev) => ({ ...prev, DEFAULT_RELEASE_SOURCE: value }))
}
disabled={Boolean(defaultReleaseSourceField.fromEnv)}
/>
</FieldWrapper>
@@ -289,10 +290,12 @@ export const UserSearchPreferencesSection = ({
<SelectField
field={defaultAudiobookReleaseSourceField}
value={defaultAudiobookReleaseSourceValue}
onChange={(value) => setUserSettings((prev) => ({
...prev,
DEFAULT_RELEASE_SOURCE_AUDIOBOOK: value,
}))}
onChange={(value) =>
setUserSettings((prev) => ({
...prev,
DEFAULT_RELEASE_SOURCE_AUDIOBOOK: value,
}))
}
disabled={Boolean(defaultAudiobookReleaseSourceField.fromEnv)}
/>
</FieldWrapper>
@@ -1,13 +1,62 @@
import { SettingsField } from '../../../types/settings';
import type { SettingsField } from '../../../types/settings';
export { isRecord } from '../../../utils/objectHelpers';
const hasMatchingFieldType = <T extends SettingsField>(
field: SettingsField,
fallback: T,
): field is T => field.type === fallback.type;
const isPrimitiveTextValue = (value: unknown): value is string | number | boolean | bigint => {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'bigint'
);
};
export const toTextValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
return isPrimitiveTextValue(value) ? String(value) : '';
};
export const toTrimmedTextValue = (value: unknown): string => {
return toTextValue(value).trim();
};
export const toNormalizedLowercaseTextValue = (value: unknown): string => {
return toTrimmedTextValue(value).toLowerCase();
};
export const toComparableValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
if (isPrimitiveTextValue(value)) {
return String(value);
}
if (typeof value !== 'object') {
return '';
}
try {
return JSON.stringify(value) ?? '';
} catch {
return '';
}
};
export const getFieldByKey = <T extends SettingsField>(
fields: SettingsField[] | undefined,
key: string,
fallback: T
fallback: T,
): T => {
const found = fields?.find((field) => field.key === key);
if (!found) {
return fallback;
if (found && hasMatchingFieldType(found, fallback)) {
return found;
}
return found as T;
return fallback;
};
@@ -1,16 +1,5 @@
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
export {
UserAccountCardContent,
UserCreateCard,
UserEditActions,
UserEditFields,
UserIdentityHeader,
UserRoleControl,
} from './UserCard';
export { UserListView } from './UserListView';
export { RequestPolicyGrid } from './RequestPolicyGrid';
export { UserNotificationOverridesSection } from './UserNotificationOverridesSection';
export { UserOverridesSection } from './UserOverridesSection';
export {
UserOverridesSections,
DEFAULT_SELF_USER_OVERRIDE_SECTIONS,
@@ -19,17 +8,8 @@ export {
export { UserOverridesView } from './UserOverridesView';
export { useUserForm } from './useUserForm';
export { useUserMutations } from './useUserMutations';
export { useUserOverridesState } from './useUserOverridesState';
export { useUsersFetch } from './useUsersFetch';
export { useUsersPanelState } from './useUsersPanelState';
export { canCreateLocalUsersForAuthMode, getUsersHeadingDescriptionForAuthMode } from './types';
export {
normalizeRequestPolicyDefaults,
normalizeRequestPolicyRules,
parseSourceCapabilitiesFromRulesField,
} from './requestPolicyGridUtils';
export type {
RequestPolicyContentType,
} from './requestPolicyGridUtils';
export type { UserOverrideSectionId } from './UserOverridesSections';
export { canCreateLocalUsersForAuthMode } from './types';
export type { RequestPolicyContentType } from './requestPolicyGridUtils';
export type { RequestPolicyMode } from '../../../types';
@@ -1,5 +1,6 @@
import { RequestPolicyMode } from '../../../types';
import { TableFieldConfig } from '../../../types/settings';
import type { RequestPolicyMode } from '../../../types';
import type { TableFieldConfig } from '../../../types/settings';
import { isRecord, toNormalizedLowercaseTextValue } from './fieldHelpers';
export type RequestPolicyContentType = 'ebook' | 'audiobook';
export type RequestPolicyMatrixMode = Exclude<RequestPolicyMode, 'request_book'>;
@@ -57,6 +58,12 @@ export const REQUEST_POLICY_MODE_LABELS: Record<RequestPolicyMode, string> = {
const MATRIX_MODES: RequestPolicyMatrixMode[] = ['download', 'request_release', 'blocked'];
const CONTENT_TYPES: RequestPolicyContentType[] = ['ebook', 'audiobook'];
const REQUEST_POLICY_MODES: RequestPolicyMode[] = [
'download',
'request_release',
'request_book',
'blocked',
];
const MODE_RANK: Record<RequestPolicyMode, number> = {
download: 0,
request_release: 1,
@@ -64,27 +71,32 @@ const MODE_RANK: Record<RequestPolicyMode, number> = {
blocked: 3,
};
const normalizeSource = (value: unknown): string => String(value || '').trim().toLowerCase();
const normalizeSource = (value: unknown): string => toNormalizedLowercaseTextValue(value);
const normalizeContentType = (value: unknown): RequestPolicyContentType | null => {
const normalized = String(value || '').trim().toLowerCase();
const normalized = toNormalizedLowercaseTextValue(value);
if (normalized === 'ebook') return 'ebook';
if (normalized === 'audiobook') return 'audiobook';
return null;
};
const normalizeMode = (
value: unknown,
options: readonly RequestPolicyMode[] = ['download', 'request_release', 'request_book', 'blocked']
): RequestPolicyMode | null => {
const normalized = String(value || '').trim().toLowerCase() as RequestPolicyMode;
return options.includes(normalized) ? normalized : null;
export const normalizeRequestPolicyMode = (value: unknown): RequestPolicyMode | null => {
const normalized = toNormalizedLowercaseTextValue(value);
return REQUEST_POLICY_MODES.find((option) => option === normalized) ?? null;
};
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
export const normalizeRequestPolicyMatrixMode = (
value: unknown,
): RequestPolicyMatrixMode | null => {
const normalized = toNormalizedLowercaseTextValue(value);
return MATRIX_MODES.find((option) => option === normalized) ?? null;
};
export const sortRules = (rules: RequestPolicyRuleRow[]): RequestPolicyRuleRow[] =>
[...rules].sort((a, b) => {
const toRuleKey = (source: string, contentType: RequestPolicyContentType) =>
`${source}::${contentType}`;
const sortRules = (rules: RequestPolicyRuleRow[]): RequestPolicyRuleRow[] =>
rules.toSorted((a, b) => {
const sourceCmp = a.source.localeCompare(b.source);
if (sourceCmp !== 0) return sourceCmp;
return a.content_type.localeCompare(b.content_type);
@@ -92,12 +104,12 @@ export const sortRules = (rules: RequestPolicyRuleRow[]): RequestPolicyRuleRow[]
export const normalizeRequestPolicyDefaults = (
raw: Partial<Record<RequestPolicyContentType, unknown>>,
fallback: RequestPolicyMode = 'download'
fallback: RequestPolicyMode = 'download',
): RequestPolicyDefaultsValue => {
const fallbackMode = normalizeMode(fallback) || 'download';
const fallbackMode = normalizeRequestPolicyMode(fallback) || 'download';
return {
ebook: normalizeMode(raw.ebook) || fallbackMode,
audiobook: normalizeMode(raw.audiobook) || fallbackMode,
ebook: normalizeRequestPolicyMode(raw.ebook) || fallbackMode,
audiobook: normalizeRequestPolicyMode(raw.audiobook) || fallbackMode,
};
};
@@ -108,13 +120,12 @@ export const normalizeRequestPolicyRules = (rawRules: unknown): RequestPolicyRul
const byKey = new Map<string, RequestPolicyRuleRow>();
rawRules.forEach((rawRule) => {
if (!rawRule || typeof rawRule !== 'object') {
if (!isRecord(rawRule)) {
return;
}
const row = rawRule as Record<string, unknown>;
const source = normalizeSource(row.source);
const contentType = normalizeContentType(row.content_type);
const mode = normalizeMode(row.mode, MATRIX_MODES) as RequestPolicyMatrixMode | null;
const source = normalizeSource(rawRule.source);
const contentType = normalizeContentType(rawRule.content_type);
const mode = normalizeRequestPolicyMatrixMode(rawRule.mode);
if (!source || !contentType || !mode) {
return;
}
@@ -125,10 +136,10 @@ export const normalizeRequestPolicyRules = (rawRules: unknown): RequestPolicyRul
});
});
return sortRules([...byKey.values()]);
return sortRules(Array.from(byKey.values()));
};
export const capPolicyMode = (mode: RequestPolicyMode, ceiling: RequestPolicyMode): RequestPolicyMode => {
const capPolicyMode = (mode: RequestPolicyMode, ceiling: RequestPolicyMode): RequestPolicyMode => {
return MODE_RANK[mode] < MODE_RANK[ceiling] ? ceiling : mode;
};
@@ -136,7 +147,9 @@ export const isMatrixConfigurable = (defaultMode: RequestPolicyMode): boolean =>
return defaultMode !== 'blocked';
};
export const getAllowedMatrixModes = (defaultMode: RequestPolicyMode): RequestPolicyMatrixMode[] => {
export const getAllowedMatrixModes = (
defaultMode: RequestPolicyMode,
): RequestPolicyMatrixMode[] => {
if (!isMatrixConfigurable(defaultMode)) {
return [];
}
@@ -146,7 +159,7 @@ export const getAllowedMatrixModes = (defaultMode: RequestPolicyMode): RequestPo
export const mergeRequestPolicyRuleLayers = (
baseRules: RequestPolicyRuleRow[],
overrideRules: RequestPolicyRuleRow[]
overrideRules: RequestPolicyRuleRow[],
): RequestPolicyRuleRow[] => {
const merged = new Map<string, RequestPolicyRuleRow>();
baseRules.forEach((rule) => {
@@ -155,19 +168,18 @@ export const mergeRequestPolicyRuleLayers = (
overrideRules.forEach((rule) => {
merged.set(toRuleKey(rule.source, rule.content_type), rule);
});
return sortRules([...merged.values()]);
return sortRules(Array.from(merged.values()));
};
const findRule = (
rules: RequestPolicyRuleRow[],
source: string,
contentType: RequestPolicyContentType
contentType: RequestPolicyContentType,
): RequestPolicyRuleRow | null => {
const normalizedSource = normalizeSource(source);
return (
rules.find(
(rule) => rule.source === normalizedSource && rule.content_type === contentType
) || null
rules.find((rule) => rule.source === normalizedSource && rule.content_type === contentType) ||
null
);
};
@@ -175,7 +187,7 @@ export const getInheritedCellMode = (
source: string,
contentType: RequestPolicyContentType,
defaultModes: RequestPolicyDefaultsValue,
baseRules: RequestPolicyRuleRow[]
baseRules: RequestPolicyRuleRow[],
): RequestPolicyMode => {
const ceiling = defaultModes[contentType];
const fromRule = findRule(baseRules, source, contentType)?.mode;
@@ -187,7 +199,7 @@ export const getEffectiveCellMode = (
contentType: RequestPolicyContentType,
defaultModes: RequestPolicyDefaultsValue,
baseRules: RequestPolicyRuleRow[],
explicitRules: RequestPolicyRuleRow[]
explicitRules: RequestPolicyRuleRow[],
): RequestPolicyMode => {
const ceiling = defaultModes[contentType];
const explicit = findRule(explicitRules, source, contentType)?.mode;
@@ -199,7 +211,7 @@ export const getEffectiveCellMode = (
export const parseSourceCapabilitiesFromRulesField = (
rulesField: TableFieldConfig | null | undefined,
fallbackSources: string[] = []
fallbackSources: string[] = [],
): RequestPolicySourceCapability[] => {
if (!rulesField || !Array.isArray(rulesField.columns)) {
return fallbackSources.map((source) => ({
@@ -262,15 +274,25 @@ export const parseSourceCapabilitiesFromRulesField = (
const orderedSources = sourceOptions
.map((option) => normalizeSource(option.value))
.filter((source) => source && bySource.has(source));
const extraSources = [...bySource.keys()].filter((source) => !orderedSources.includes(source));
const extraSources = Array.from(bySource.keys()).filter(
(source) => !orderedSources.includes(source),
);
return [...orderedSources, ...extraSources].map((source) => {
const row = bySource.get(source)!;
const row = bySource.get(source);
if (!row) {
return {
source,
displayName: source,
supportedContentTypes: [],
};
}
const supported = CONTENT_TYPES.filter((contentType) =>
row.supportedContentTypes.includes(contentType)
row.supportedContentTypes.includes(contentType),
);
return {
...row,
source: row.source,
displayName: row.displayName,
supportedContentTypes: supported,
};
});
@@ -279,7 +301,7 @@ export const parseSourceCapabilitiesFromRulesField = (
const isSourceContentTypeSupported = (
sourceCapabilities: RequestPolicySourceCapability[],
source: string,
contentType: RequestPolicyContentType
contentType: RequestPolicyContentType,
): boolean => {
const normalizedSource = normalizeSource(source);
const sourceCapability = sourceCapabilities.find((row) => row.source === normalizedSource);
@@ -315,9 +337,3 @@ export const normalizeExplicitRulesForPersistence = ({
return sortRules(filtered);
};
export const areRuleSetsEqual = (left: RequestPolicyRuleRow[], right: RequestPolicyRuleRow[]): boolean => {
const leftSorted = sortRules(left);
const rightSorted = sortRules(right);
return JSON.stringify(leftSorted) === JSON.stringify(rightSorted);
};
@@ -1,55 +1,50 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import { PerUserSettings } from './types';
import type { DeliveryPreferencesResponse } from '../../../services/api';
import { toComparableValue } from './fieldHelpers';
import type { PerUserSettings } from './types';
const normalizeComparableValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
return toComparableValue(value);
};
export const buildUserSettingsPayload = (
userSettings: PerUserSettings,
userOverridableSettings: Set<string>,
preferenceGroups: Array<DeliveryPreferencesResponse | null>,
): Record<string, unknown> =>
Array.from(new Set([
...preferenceGroups.flatMap((preferences) => preferences?.keys || []),
...userOverridableSettings,
]))
.map(String)
.sort()
.reduce<Record<string, unknown>>((payload, key) => {
const typedKey = key as keyof PerUserSettings;
const hasUserValue = Object.prototype.hasOwnProperty.call(userSettings, typedKey)
&& userSettings[typedKey] !== null
&& userSettings[typedKey] !== undefined;
): Record<string, unknown> => {
const settingKeys = Array.from(
new Set([
...preferenceGroups.flatMap((preferences) => preferences?.keys || []),
...userOverridableSettings,
]),
String,
).toSorted();
if (!hasUserValue) {
payload[key] = null;
return payload;
}
return settingKeys.reduce<Record<string, unknown>>((payload, key) => {
const typedKey = key as keyof PerUserSettings;
const hasUserValue =
Object.prototype.hasOwnProperty.call(userSettings, typedKey) &&
userSettings[typedKey] !== null &&
userSettings[typedKey] !== undefined;
const userValue = userSettings[typedKey];
const matchingPreferences = preferenceGroups.find((preferences) =>
preferences?.keys?.includes(key)
);
const hasGlobalValue = Boolean(
matchingPreferences
&& Object.prototype.hasOwnProperty.call(matchingPreferences.globalValues, key)
);
const globalValue = matchingPreferences?.globalValues?.[key];
const isDifferentFromGlobal = hasGlobalValue
? normalizeComparableValue(userValue) !== normalizeComparableValue(globalValue)
: true;
payload[key] = isDifferentFromGlobal ? userValue : null;
if (!hasUserValue) {
payload[key] = null;
return payload;
}, {});
}
const userValue = userSettings[typedKey];
const matchingPreferences = preferenceGroups.find((preferences) =>
preferences?.keys?.includes(key),
);
const hasGlobalValue = Boolean(
matchingPreferences &&
Object.prototype.hasOwnProperty.call(matchingPreferences.globalValues, key),
);
const globalValue = matchingPreferences?.globalValues?.[key];
const isDifferentFromGlobal = hasGlobalValue
? normalizeComparableValue(userValue) !== normalizeComparableValue(globalValue)
: true;
payload[key] = isDifferentFromGlobal ? userValue : null;
return payload;
}, {});
};
@@ -1,4 +1,4 @@
import { AdminUser } from '../../../services/api';
import type { AdminUser } from '../../../services/api';
export interface PerUserSettings {
[key: string]: unknown;
@@ -47,7 +47,7 @@ export type UsersPanelRoute =
| { kind: 'edit'; userId: number }
| { kind: 'edit-overrides'; userId: number };
export type AuthSource = AdminUser['auth_source'];
type AuthSource = AdminUser['auth_source'];
export const AUTH_SOURCE_LABEL: Record<AuthSource, string> = {
builtin: 'Local',
@@ -64,24 +64,6 @@ export const AUTH_SOURCE_BADGE_CLASSES: Record<AuthSource, string> = {
};
export const canCreateLocalUsersForAuthMode = (authMode?: string): boolean => {
const normalized = String(authMode || 'none').toLowerCase();
const normalized = (authMode || 'none').toLowerCase();
return normalized === 'none' || normalized === 'builtin' || normalized === 'oidc';
};
export const getUsersHeadingDescriptionForAuthMode = (authMode?: string): string => {
const normalized = String(authMode || 'none').toLowerCase();
if (normalized === 'builtin') {
return 'Create and manage user accounts directly. Passwords are stored locally and users sign in with their username and password.';
}
if (normalized === 'oidc') {
return 'Users sign in through your identity provider. New accounts can be created automatically on first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll be linked by email on first sign-in.';
}
if (normalized === 'proxy') {
return 'Users are authenticated by your reverse proxy. Accounts are automatically created on first sign-in. If a local user with a matching username already exists, it will be linked instead.';
}
if (normalized === 'cwa') {
return 'User accounts are synced from your Calibre-Web database. Users are matched by email, and new accounts are created here when new CWA users are found.';
}
return 'Authentication is disabled. Anyone can access Shelfmark without signing in.';
};
@@ -1,21 +1,30 @@
import { useMemo, useState } from 'react';
import { AdminUser, DeliveryPreferencesResponse, DownloadDefaults } from '../../../services/api';
import { CreateUserFormState, INITIAL_CREATE_FORM } from './types';
import { UserEditContext } from './useUsersFetch';
import type {
AdminUser,
DeliveryPreferencesResponse,
DownloadDefaults,
} from '../../../services/api';
import { INITIAL_CREATE_FORM } from './types';
import { useUserOverridesState } from './useUserOverridesState';
import type { UserEditContext } from './useUsersFetch';
export const useUserForm = () => {
const [createForm, setCreateForm] = useState<CreateUserFormState>({ ...INITIAL_CREATE_FORM });
const [createForm, setCreateForm] = useState({ ...INITIAL_CREATE_FORM });
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [searchPreferences, setSearchPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [notificationPreferences, setNotificationPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [deliveryPreferences, setDeliveryPreferences] =
useState<DeliveryPreferencesResponse | null>(null);
const [searchPreferences, setSearchPreferences] = useState<DeliveryPreferencesResponse | null>(
null,
);
const [notificationPreferences, setNotificationPreferences] =
useState<DeliveryPreferencesResponse | null>(null);
const preferenceGroups = useMemo(
() => [deliveryPreferences, searchPreferences, notificationPreferences],
[deliveryPreferences, searchPreferences, notificationPreferences]
[deliveryPreferences, searchPreferences, notificationPreferences],
);
const {
userSettings,
@@ -23,6 +32,7 @@ export const useUserForm = () => {
userOverridableSettings,
isUserOverridable,
hasUserSettingsChanges,
hasUserSettingsChangesFor,
applyUserOverridesContext,
resetUserOverridesState,
} = useUserOverridesState({ preferenceGroups });
@@ -83,6 +93,7 @@ export const useUserForm = () => {
userSettings,
setUserSettings,
hasUserSettingsChanges,
hasUserSettingsChangesFor,
userOverridableSettings,
isUserOverridable,
};
@@ -1,14 +1,14 @@
import { useState } from 'react';
import type { AdminUser, DeliveryPreferencesResponse } from '../../../services/api';
import {
AdminUser,
DeliveryPreferencesResponse,
createAdminUser,
deleteAdminUser,
syncAdminCwaUsers,
updateAdminUser,
} from '../../../services/api';
import { CreateUserFormState, PerUserSettings } from './types';
import { buildUserSettingsPayload } from './settingsPayload';
import type { CreateUserFormState, PerUserSettings } from './types';
const MIN_PASSWORD_LENGTH = 4;
interface UseUserMutationsParams {
@@ -36,7 +36,8 @@ interface SaveEditedUserOptions {
const getPasswordError = (password: string, passwordConfirm: string) => {
if (!password) return null;
if (password.length < MIN_PASSWORD_LENGTH) return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
if (password.length < MIN_PASSWORD_LENGTH)
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
return password === passwordConfirm ? null : 'Passwords do not match';
};
@@ -73,7 +74,8 @@ export const useUserMutations = ({
const fail = (message: string) => (onShowToast?.(message, 'error'), false);
const createUser = async () => {
if (!createForm.username || !createForm.password) return fail('Username and password are required');
if (!createForm.username || !createForm.password)
return fail('Username and password are required');
const createPasswordError = getPasswordError(createForm.password, createForm.password_confirm);
if (createPasswordError) return fail(createPasswordError);
@@ -103,17 +105,19 @@ export const useUserMutations = ({
includeSettings = true,
}: SaveEditedUserOptions = {}) => {
if (!editingUser) return false;
const passwordError = includePassword ? getPasswordError(editPassword, editPasswordConfirm) : null;
const passwordError = includePassword
? getPasswordError(editPassword, editPasswordConfirm)
: null;
if (passwordError) return fail(passwordError);
const localAdminsBeforeSave = includeProfile ? countLocalPasswordAdmins(users) : 0;
const caps = editingUser.edit_capabilities;
const settingsPayload = includeSettings
? buildUserSettingsPayload(
userSettings,
userOverridableSettings,
[deliveryPreferences, searchPreferences, notificationPreferences]
)
? buildUserSettingsPayload(userSettings, userOverridableSettings, [
deliveryPreferences,
searchPreferences,
notificationPreferences,
])
: null;
const updatePayload: Partial<Pick<AdminUser, 'role' | 'email' | 'display_name'>> & {
password?: string;
@@ -143,11 +147,17 @@ export const useUserMutations = ({
await updateAdminUser(editingUser.id, updatePayload);
onEditSaveSuccess?.();
onShowToast?.(
includeSettings && !includeProfile && !includePassword ? 'User preferences updated' : 'User updated',
includeSettings && !includeProfile && !includePassword
? 'User preferences updated'
: 'User updated',
'success',
);
const refreshedUsers = await fetchUsers({ force: true });
if (includeProfile && localAdminsBeforeSave > 0 && countLocalPasswordAdmins(refreshedUsers) === 0) {
if (
includeProfile &&
localAdminsBeforeSave > 0 &&
countLocalPasswordAdmins(refreshedUsers) === 0
) {
onShowToast?.(
"No local admin accounts remain. Authentication will fall back to 'No Authentication' until a local admin is created.",
'info',
@@ -1,7 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { DeliveryPreferencesResponse } from '../../../services/api';
import type { DeliveryPreferencesResponse } from '../../../services/api';
import { buildUserSettingsPayload } from './settingsPayload';
import { PerUserSettings } from './types';
import type { PerUserSettings } from './types';
interface UseUserOverridesStateParams {
preferenceGroups: Array<DeliveryPreferencesResponse | null>;
@@ -14,32 +15,32 @@ interface ApplyUserOverridesContextParams {
const normalizeUserSettings = (settings: PerUserSettings): PerUserSettings => {
const normalized: PerUserSettings = {};
Object.keys(settings).sort().forEach((key) => {
const typedKey = key as keyof PerUserSettings;
const value = settings[typedKey];
if (value !== null && value !== undefined) {
normalized[typedKey] = value;
}
});
Object.keys(settings)
.toSorted()
.forEach((key) => {
const typedKey = key as keyof PerUserSettings;
const value = settings[typedKey];
if (value !== null && value !== undefined) {
normalized[typedKey] = value;
}
});
return normalized;
};
export const useUserOverridesState = ({
preferenceGroups,
}: UseUserOverridesStateParams) => {
export const useUserOverridesState = ({ preferenceGroups }: UseUserOverridesStateParams) => {
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const [userOverridableSettings, setUserOverridableSettings] = useState(new Set<string>());
const applyUserOverridesContext = useCallback(({
settings,
userOverridableKeys,
}: ApplyUserOverridesContextParams) => {
const normalizedSettings = normalizeUserSettings(settings);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(userOverridableKeys));
}, []);
const applyUserOverridesContext = useCallback(
({ settings, userOverridableKeys }: ApplyUserOverridesContextParams) => {
const normalizedSettings = normalizeUserSettings(settings);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(userOverridableKeys));
},
[],
);
const resetUserOverridesState = useCallback(() => {
setUserSettings({});
@@ -49,25 +50,29 @@ export const useUserOverridesState = ({
const isUserOverridable = useCallback(
(key: keyof PerUserSettings) => userOverridableSettings.has(String(key)),
[userOverridableSettings]
[userOverridableSettings],
);
const currentSettingsPayload = useMemo(
() => buildUserSettingsPayload(
userSettings,
userOverridableSettings,
preferenceGroups
),
[preferenceGroups, userOverridableSettings, userSettings]
() => buildUserSettingsPayload(userSettings, userOverridableSettings, preferenceGroups),
[preferenceGroups, userOverridableSettings, userSettings],
);
const originalSettingsPayload = useMemo(
() => buildUserSettingsPayload(
originalUserSettings,
userOverridableSettings,
preferenceGroups
),
[originalUserSettings, preferenceGroups, userOverridableSettings]
() => buildUserSettingsPayload(originalUserSettings, userOverridableSettings, preferenceGroups),
[originalUserSettings, preferenceGroups, userOverridableSettings],
);
const hasUserSettingsChangesFor = useCallback(
(nextSettings: PerUserSettings) => {
const nextSettingsPayload = buildUserSettingsPayload(
nextSettings,
userOverridableSettings,
preferenceGroups,
);
return JSON.stringify(nextSettingsPayload) !== JSON.stringify(originalSettingsPayload);
},
[originalSettingsPayload, preferenceGroups, userOverridableSettings],
);
const hasUserSettingsChanges =
@@ -81,6 +86,7 @@ export const useUserOverridesState = ({
isUserOverridable,
currentSettingsPayload,
hasUserSettingsChanges,
hasUserSettingsChangesFor,
applyUserOverridesContext,
resetUserOverridesState,
};
@@ -1,207 +1,2 @@
import { useCallback, useEffect, useState } from 'react';
import {
AdminUser,
DeliveryPreferencesResponse,
DownloadDefaults,
getAdminDeliveryPreferences,
getAdminSearchPreferences,
getAdminNotificationPreferences,
getAdminUser,
getAdminUsers,
getDownloadDefaults,
getSettingsTab,
} from '../../../services/api';
import { SettingsField } from '../../../types/settings';
import { PerUserSettings } from './types';
interface UseUsersFetchParams {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
let cachedUsers: AdminUser[] | null = null;
let cachedLoadError: string | null = null;
let usersCacheLoadPromise: Promise<AdminUser[]> | null = null;
const shouldSuppressAccessToast = (message: string): boolean =>
message.toLowerCase().includes('admin access required');
const toLoadErrorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'Failed to load users';
interface LoadUsersOptions {
force?: boolean;
}
const loadUsersIntoCache = async ({ force = false }: LoadUsersOptions = {}): Promise<AdminUser[]> => {
if (!force && cachedUsers !== null) {
return cachedUsers;
}
if (usersCacheLoadPromise) {
return usersCacheLoadPromise;
}
usersCacheLoadPromise = getAdminUsers()
.then((data) => {
cachedUsers = data;
cachedLoadError = null;
return data;
})
.finally(() => {
usersCacheLoadPromise = null;
});
return usersCacheLoadPromise;
};
export const primeUsersCache = async (): Promise<void> => {
try {
await loadUsersIntoCache();
} catch {
// Silent best-effort warmup.
}
};
export interface UserEditContext {
user: AdminUser;
downloadDefaults: DownloadDefaults;
deliveryPreferences: DeliveryPreferencesResponse | null;
searchPreferences: DeliveryPreferencesResponse | null;
notificationPreferences: DeliveryPreferencesResponse | null;
userSettings: PerUserSettings;
userOverridableSettings: Set<string>;
}
const getUserOverridableKeys = (fields: SettingsField[]): string[] => {
const keys: string[] = [];
const seen = new Set<string>();
const collect = (candidateFields: SettingsField[]) => {
candidateFields.forEach((field) => {
if (field.type === 'CustomComponentField') {
if (field.boundFields && field.boundFields.length > 0) {
collect(field.boundFields);
}
return;
}
if (field.type === 'HeadingField') {
return;
}
if ((field as { userOverridable?: boolean }).userOverridable && !seen.has(field.key)) {
seen.add(field.key);
keys.push(field.key);
}
});
};
collect(fields);
return keys;
};
export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
const [users, setUsers] = useState<AdminUser[]>(() => cachedUsers ?? []);
const [loading, setLoading] = useState<boolean>(() => cachedUsers === null);
const [loadError, setLoadError] = useState<string | null>(() => cachedLoadError);
const fetchUsers = useCallback(async ({ force = false }: LoadUsersOptions = {}): Promise<AdminUser[]> => {
const hasCachedResult = !force && cachedUsers !== null;
try {
if (!hasCachedResult) {
setLoading(true);
}
setLoadError(null);
const data = await loadUsersIntoCache({ force });
setUsers(data);
return data;
} catch (err) {
const message = toLoadErrorMessage(err);
cachedLoadError = message;
setLoadError(message);
if (!shouldSuppressAccessToast(message)) {
onShowToast?.(message, 'error');
}
return [];
} finally {
setLoading(false);
}
}, [onShowToast]);
useEffect(() => {
void fetchUsers();
}, [fetchUsers]);
const fetchUserEditContext = useCallback(async (userId: number): Promise<UserEditContext> => {
const [fullUser, defaults] = await Promise.all([
getAdminUser(userId),
getDownloadDefaults(),
]);
let deliveryPreferences: DeliveryPreferencesResponse | null = null;
let searchPreferences: DeliveryPreferencesResponse | null = null;
let notificationPreferences: DeliveryPreferencesResponse | null = null;
let userSettings = {
...(fullUser.settings || {}),
} as PerUserSettings;
let userOverridableSettings = new Set<string>();
const [deliveryResult, searchResult, notificationResult] = await Promise.allSettled([
getAdminDeliveryPreferences(userId),
getAdminSearchPreferences(userId),
getAdminNotificationPreferences(userId),
]);
if (deliveryResult.status === 'fulfilled') {
deliveryPreferences = deliveryResult.value;
userSettings = {
...userSettings,
...(deliveryResult.value.userOverrides || {}),
} as PerUserSettings;
deliveryResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
if (searchResult.status === 'fulfilled') {
searchPreferences = searchResult.value;
userSettings = {
...userSettings,
...(searchResult.value.userOverrides || {}),
} as PerUserSettings;
searchResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
if (notificationResult.status === 'fulfilled') {
notificationPreferences = notificationResult.value;
userSettings = {
...userSettings,
...(notificationResult.value.userOverrides || {}),
} as PerUserSettings;
notificationResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
try {
const usersTab = await getSettingsTab('users');
const usersOverridableKeys = getUserOverridableKeys(usersTab.fields);
usersOverridableKeys.forEach((key) => userOverridableSettings.add(key));
} catch {
// Users-tab metadata is best-effort; save still validates server-side.
}
return {
user: fullUser,
downloadDefaults: defaults,
deliveryPreferences,
searchPreferences,
notificationPreferences,
userSettings,
userOverridableSettings,
};
}, []);
return {
users,
loading,
loadError,
fetchUsers,
fetchUserEditContext,
};
};
export { useUsersFetch } from '../../../hooks/useUsersFetch';
export type { UserEditContext } from '../../../hooks/useUsersFetch';

Some files were not shown because too many files have changed in this diff Show More