Files
ReadMeABook/src/contexts/PreferencesContext.tsx
T
kikootwo d25a6ebf79 Add custom search terms & retry download (admin)
Add support for per-request custom search terms and an admin retry-download flow.

- DB/schema: add custom_search_terms column via Prisma migration and schema update.
- Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions.
- API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata.
- Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms.
- UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata.
- Misc: add connection-errors util and update related processors/services and tests to cover the new flows.

These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly.
2026-03-02 17:05:21 -05:00

161 lines
5.6 KiB
TypeScript

/**
* Component: User Preferences Context Provider
* Documentation: Manages user preferences (card size, etc.) with localStorage persistence
*/
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface Preferences {
cardSize: number; // 1-9, default 5
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
hideAvailable: boolean; // true = hide "In Your Library" titles
}
interface PreferencesContextType {
cardSize: number;
setCardSize: (size: number) => void;
squareCovers: boolean;
setSquareCovers: (enabled: boolean) => void;
hideAvailable: boolean;
setHideAvailable: (enabled: boolean) => void;
}
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
const DEFAULT_PREFERENCES: Preferences = {
cardSize: 5,
squareCovers: true,
hideAvailable: false,
};
const STORAGE_KEY = 'preferences';
export function PreferencesProvider({ children }: { children: ReactNode }) {
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
const [hideAvailable, setHideAvailableState] = useState<boolean>(DEFAULT_PREFERENCES.hideAvailable);
// Load preferences from localStorage on mount
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const preferences: Preferences = JSON.parse(stored);
// Validate cardSize is within range 1-9
if (preferences.cardSize >= 1 && preferences.cardSize <= 9) {
setCardSizeState(preferences.cardSize);
} else {
// Invalid size, reset to default
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
}
// Load squareCovers preference (defaults to false if not set)
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
// Load hideAvailable preference
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
}
} catch (error) {
console.error('Failed to load preferences from localStorage:', error);
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
setSquareCoversState(DEFAULT_PREFERENCES.squareCovers);
setHideAvailableState(DEFAULT_PREFERENCES.hideAvailable);
}
}, []);
// Update card size in state and localStorage
const setCardSize = (size: number) => {
if (typeof window === 'undefined') return;
// Validate size is within range 1-9
const validSize = Math.max(1, Math.min(9, size));
setCardSizeState(validSize);
try {
const stored = localStorage.getItem(STORAGE_KEY);
const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES };
preferences.cardSize = validSize;
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch (error) {
console.error('Failed to save preferences to localStorage:', error);
}
};
// Update square covers preference in state and localStorage
const setSquareCovers = (enabled: boolean) => {
if (typeof window === 'undefined') return;
setSquareCoversState(enabled);
try {
const stored = localStorage.getItem(STORAGE_KEY);
const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES };
preferences.squareCovers = enabled;
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch (error) {
console.error('Failed to save preferences to localStorage:', error);
}
};
// Update hideAvailable preference in state and localStorage
const setHideAvailable = (enabled: boolean) => {
if (typeof window === 'undefined') return;
setHideAvailableState(enabled);
try {
const stored = localStorage.getItem(STORAGE_KEY);
const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES };
preferences.hideAvailable = enabled;
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch (error) {
console.error('Failed to save preferences to localStorage:', error);
}
};
// Listen for storage changes in other tabs (cross-tab sync)
useEffect(() => {
if (typeof window === 'undefined') return;
const handleStorageChange = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && e.newValue) {
try {
const preferences: Preferences = JSON.parse(e.newValue);
// Validate cardSize is within range 1-9
if (preferences.cardSize >= 1 && preferences.cardSize <= 9) {
setCardSizeState(preferences.cardSize);
}
// Sync squareCovers preference
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
// Sync hideAvailable preference
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
} catch (error) {
console.error('Failed to parse preferences from storage event:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
return (
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable }}>
{children}
</PreferencesContext.Provider>
);
}
export function usePreferences() {
const context = useContext(PreferencesContext);
if (context === undefined) {
throw new Error('usePreferences must be used within a PreferencesProvider');
}
return context;
}