mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 12:31:11 +01:00
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:
@@ -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
|
||||
|
||||
@@ -74,6 +74,7 @@ pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
src/frontend/coverage/
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**"]
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+2340
-54
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
plugins: {},
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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}'s output preferences and destination settings.
|
||||
This download will use {actingAsName}'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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user