File processing restructure and further feature additions (#390)

- Restructured the file processing settings to make more coherent
- Added hide settings UI for CWA non-users
- Added sort options for ReleaseModal listview entries
- Added separate Audiobook category selection for download clients -
This commit is contained in:
Alex
2026-01-03 10:44:34 +00:00
committed by GitHub
parent 8cb5335234
commit b0206f76f8
20 changed files with 1106 additions and 308 deletions
+3 -2
View File
@@ -60,6 +60,7 @@ function App() {
isAuthenticated,
authRequired,
authChecked,
isAdmin,
loginError,
isLoggingIn,
setIsAuthenticated,
@@ -530,13 +531,13 @@ function App() {
searchInput={searchInput}
onSearchChange={setSearchInput}
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
onSettingsClick={() => {
onSettingsClick={isAdmin ? () => {
if (config?.settings_enabled) {
setSettingsOpen(true);
} else {
setConfigBannerOpen(true);
}
}}
} : undefined}
statusCounts={statusCounts}
onLogoClick={() => handleResetSearch(config)}
authRequired={authRequired}
+247 -10
View File
@@ -55,6 +55,85 @@ function setCachedReleases(provider: string, providerId: string, source: string,
cacheTimestamps.set(key, Date.now());
}
// LocalStorage helpers for persisting sort preferences per source
const SORT_STORAGE_PREFIX = 'cwa-bd-release-sort-';
interface SortState {
key: string;
direction: 'asc' | 'desc';
}
function getSavedSort(sourceName: string): SortState | null {
try {
const saved = localStorage.getItem(`${SORT_STORAGE_PREFIX}${sourceName}`);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.key && parsed.direction) {
return parsed as SortState;
}
}
return null;
} catch {
return null;
}
}
function saveSort(sourceName: string, sortState: SortState): void {
try {
localStorage.setItem(`${SORT_STORAGE_PREFIX}${sourceName}`, JSON.stringify(sortState));
} catch {
// localStorage may be unavailable in private browsing
}
}
// Get nested value from an object using dot notation path
function getNestedSortValue(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce<unknown>((current, key) => {
if (current && typeof current === 'object' && key in (current as Record<string, unknown>)) {
return (current as Record<string, unknown>)[key];
}
return undefined;
}, obj);
}
// Infer default sort direction from column render type
function inferDefaultDirection(renderType: string): 'asc' | 'desc' {
// Numeric types sort descending by default (bigger is usually better)
if (renderType === 'size' || renderType === 'number' || renderType === 'peers') {
return 'desc';
}
// Text/badge types sort ascending (alphabetical)
return 'asc';
}
// Sort releases by a column
function sortReleases(
releases: Release[],
sortKey: string,
direction: 'asc' | 'desc'
): Release[] {
return [...releases].sort((a, b) => {
const aVal = getNestedSortValue(a as unknown as Record<string, unknown>, sortKey);
const bVal = getNestedSortValue(b as unknown as Record<string, unknown>, sortKey);
// Handle null/undefined - sort them to the end
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Numeric comparison
if (typeof aVal === 'number' && typeof bVal === 'number') {
return direction === 'asc' ? aVal - bVal : bVal - aVal;
}
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
const cmp = aStr.localeCompare(bStr);
return direction === 'asc' ? cmp : -cmp;
});
}
// Default column configuration (fallback when backend doesn't provide one)
const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = {
columns: [
@@ -575,6 +654,10 @@ export const ReleaseModal = ({
const [formatFilter, setFormatFilter] = useState<string>('');
const [languageFilter, setLanguageFilter] = useState<string[]>([LANGUAGE_OPTION_DEFAULT]);
// Sort state - keyed by source name, persisted to localStorage
// null means "Default" (backend order), undefined means "not set yet"
const [sortBySource, setSortBySource] = useState<Record<string, SortState | null>>({});
// Description expansion
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [descriptionOverflows, setDescriptionOverflows] = useState(false);
@@ -943,12 +1026,85 @@ export const ReleaseModal = ({
return getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
}, [languageFilter, bookLanguages, defaultLanguages]);
// Filter releases based on settings and user selection
// Get column config from response or use default (moved before filteredReleases for sorting)
const columnConfig = useMemo((): ReleaseColumnConfig => {
const response = releasesBySource[activeTab];
if (response?.column_config) {
return response.column_config;
}
return DEFAULT_COLUMN_CONFIG;
}, [releasesBySource, activeTab]);
// Get sortable columns from column config
const sortableColumns = useMemo(() => {
return columnConfig.columns.filter(col => col.sortable) || [];
}, [columnConfig]);
// Get current sort state for active tab (from state, localStorage, or default to null = backend order)
const currentSort = useMemo((): SortState | null => {
// Check state first - explicit null means "Default" was selected
if (activeTab in sortBySource) {
return sortBySource[activeTab];
}
// Check localStorage
const saved = getSavedSort(activeTab);
if (saved) {
// Verify the saved sort is still valid for this source
const isValid = sortableColumns.some(col => (col.sort_key || col.key) === saved.key);
if (isValid) {
return saved;
}
}
// Default to null (backend order / no client-side sorting)
return null;
}, [activeTab, sortBySource, sortableColumns]);
// Handle sort change - null means "Default" (backend order), otherwise toggle direction or set new column
const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => {
if (sortKey === null) {
// "Default" selected - clear client-side sorting
setSortBySource(prev => {
const next = { ...prev };
delete next[activeTab];
return next;
});
// Clear from localStorage
try {
localStorage.removeItem(`${SORT_STORAGE_PREFIX}${activeTab}`);
} catch {
// Ignore localStorage errors
}
return;
}
const currentState = sortBySource[activeTab] ?? currentSort;
let newState: SortState;
if (currentState && currentState.key === sortKey) {
// Same column - toggle direction
newState = {
key: sortKey,
direction: currentState.direction === 'asc' ? 'desc' : 'asc',
};
} else {
// New column - use default direction for this column type
newState = {
key: sortKey,
direction: inferDefaultDirection(column!.render_type),
};
}
setSortBySource(prev => ({ ...prev, [activeTab]: newState }));
saveSort(activeTab, newState);
}, [activeTab, sortBySource, currentSort]);
// Filter and sort releases based on settings and user selection
const filteredReleases = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
return releases.filter((r) => {
// First, filter
let filtered = releases.filter((r) => {
// Format filtering
const fmt = r.format?.toLowerCase();
@@ -969,16 +1125,14 @@ export const ReleaseModal = ({
return true;
});
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages]);
// Get column config from response or use default
const columnConfig = useMemo((): ReleaseColumnConfig => {
const response = releasesBySource[activeTab];
if (response?.column_config) {
return response.column_config;
// Then, sort if we have a current sort and sortable columns
if (currentSort && sortableColumns.length > 0) {
filtered = sortReleases(filtered, currentSort.key, currentSort.direction);
}
return DEFAULT_COLUMN_CONFIG;
}, [releasesBySource, activeTab]);
return filtered;
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, currentSort, sortableColumns]);
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
const displayFields = useMemo(() => {
@@ -1281,6 +1435,89 @@ export const ReleaseModal = ({
</div>
</div>
{/* Sort dropdown - only show if source has sortable columns */}
{sortableColumns.length > 0 && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-48"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative p-2 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
}`}
aria-label="Sort releases"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg>
{currentSort && (
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
)}
</button>
)}
>
{({ close }) => (
<div className="py-1">
{/* Default option - no client-side sorting */}
<button
type="button"
onClick={() => {
handleSortChange(null, null);
close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
!currentSort
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<span>Default</span>
{!currentSort && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
{sortableColumns.map((col) => {
const sortKey = col.sort_key || col.key;
const isSelected = currentSort?.key === sortKey;
const direction = isSelected ? currentSort?.direction : null;
return (
<button
key={sortKey}
type="button"
onClick={() => {
handleSortChange(sortKey, col);
// Don't close - allow toggling direction
if (!isSelected) close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
isSelected
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<span>{col.label}</span>
{isSelected && direction && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
{direction === 'asc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
)}
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
)}
{/* Filter funnel button - stays fixed */}
{/* Only show filter button if source supports at least one filter type */}
{((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) ||
@@ -19,6 +19,7 @@ export const SelectField = ({ field, value, onChange, disabled }: SelectFieldPro
const dropdownOptions = field.options.map((opt) => ({
value: opt.value,
label: opt.label,
description: opt.description,
}));
const handleChange = (newValue: string | string[]) => {
+9
View File
@@ -12,6 +12,7 @@ interface UseAuthReturn {
isAuthenticated: boolean;
authRequired: boolean;
authChecked: boolean;
isAdmin: boolean;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
@@ -26,6 +27,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
@@ -36,13 +38,16 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const response = await checkAuth();
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false;
const admin = response.is_admin || false;
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
setIsAdmin(admin);
} catch (error) {
console.error('Auth check failed:', error);
setAuthRequired(true);
setIsAuthenticated(false);
setIsAdmin(false);
} finally {
setAuthChecked(true);
}
@@ -56,7 +61,10 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
try {
const response = await login(credentials);
if (response.success) {
// Re-check auth to get updated admin status from session
const authResponse = await checkAuth();
setIsAuthenticated(true);
setIsAdmin(authResponse.is_admin || false);
setLoginError(null);
navigate('/', { replace: true });
} else {
@@ -89,6 +97,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
isAuthenticated,
authRequired,
authChecked,
isAdmin,
loginError,
isLoggingIn,
setIsAuthenticated,
+3
View File
@@ -177,6 +177,7 @@ export interface AuthResponse {
success?: boolean;
authenticated?: boolean;
auth_required?: boolean;
is_admin?: boolean;
error?: string;
}
@@ -216,6 +217,8 @@ export interface ColumnSchema {
color_hint?: ColumnColorHint | null;
fallback: string; // Value when data is missing
uppercase: boolean; // Force uppercase display
sortable?: boolean; // Show in sort dropdown (opt-in)
sort_key?: string; // Field to sort by (defaults to key if not specified)
}
// Leading cell config - what to show in the left-most position of each row
+1
View File
@@ -14,6 +14,7 @@ export type FieldType =
export interface SelectOption {
value: string;
label: string;
description?: string; // Optional description shown below the label in dropdowns
childOf?: string; // Parent value - when parent is selected, this option is auto-selected and disabled
}