Requests: Various fixes and improvements (#617)

- Refactored activity backend for full user-level management, using the
db file
- Revamped the activity sidebar UX and categorisation
- Added download history and user filtering
- Added User Preferences modal, giving limited configuration for
non-admins - replaces the "restrict settings" config option.
- Many many bug fixes
- Many many new tests
This commit is contained in:
Alex
2026-02-14 18:24:28 +00:00
committed by GitHub
parent 68608b6162
commit b7bee132a1
42 changed files with 5118 additions and 467 deletions

View File

@@ -17,7 +17,6 @@ import {
downloadBook,
downloadRelease,
cancelDownload,
clearCompleted,
getConfig,
createRequest,
isApiResponseError,
@@ -31,6 +30,7 @@ import { useDownloadTracking } from './hooks/useDownloadTracking';
import { useRequestPolicy } from './hooks/useRequestPolicy';
import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore';
import { useRequests } from './hooks/useRequests';
import { useActivity } from './hooks/useActivity';
import { Header } from './components/Header';
import { SearchSection } from './components/SearchSection';
import { AdvancedFilters } from './components/AdvancedFilters';
@@ -40,9 +40,9 @@ import { ReleaseModal } from './components/ReleaseModal';
import { RequestConfirmationModal } from './components/RequestConfirmationModal';
import { ToastContainer } from './components/ToastContainer';
import { Footer } from './components/Footer';
import { ActivitySidebar, requestToActivityItem } from './components/activity';
import { ActivitySidebar } from './components/activity';
import { LoginPage } from './pages/LoginPage';
import { SettingsModal } from './components/settings';
import { SelfSettingsModal, SettingsModal } from './components/settings';
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
import { OnboardingModal } from './components/OnboardingModal';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
@@ -62,6 +62,7 @@ import {
import { bookFromRequestData } from './utils/requestFulfil';
import { policyTrace } from './utils/policyTrace';
import { SearchModeProvider } from './contexts/SearchModeContext';
import { useSocket } from './contexts/SocketContext';
import './styles.css';
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
@@ -79,7 +80,6 @@ const getInitialContentType = (): ContentType => {
};
const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']);
const isPolicyGuardError = (error: unknown): boolean => {
return (
isApiResponseError(error) &&
@@ -117,6 +117,7 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
function App() {
const { toasts, showToast, removeToast } = useToast();
const { socket } = useSocket();
// Realtime status with WebSocket and polling fallback
// Socket connection is managed by SocketProvider in main.tsx
@@ -144,7 +145,7 @@ function App() {
isAuthenticated,
authRequired,
authChecked,
isAdmin: authCanAccessSettings,
isAdmin: authIsAdmin,
authMode,
username,
displayName,
@@ -163,9 +164,9 @@ function App() {
if (!authChecked || !isAuthenticated) {
return;
}
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authCanAccessSettings, username });
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authIsAdmin, username });
void fetchStatus();
}, [authChecked, isAuthenticated, authCanAccessSettings, username, fetchStatus]);
}, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]);
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
@@ -187,14 +188,12 @@ function App() {
refresh: refreshRequestPolicy,
} = useRequestPolicy({
enabled: isAuthenticated,
isAdmin: authCanAccessSettings,
isAdmin: authIsAdmin,
});
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
const {
requests,
pendingCount: pendingRequestCount,
isLoading: isRequestsLoading,
cancelRequest: cancelUserRequest,
fulfilRequest: fulfilSidebarRequest,
@@ -204,58 +203,28 @@ function App() {
enabled: isAuthenticated,
});
const dismissedRequestStorageKey = useMemo(() => {
const roleScope = requestRoleIsAdmin ? 'admin' : 'user';
const userScope = username?.trim().toLowerCase() || 'anonymous';
return `activity-dismissed-requests:${roleScope}:${userScope}`;
}, [requestRoleIsAdmin, username]);
const [dismissedRequestIds, setDismissedRequestIds] = useState<number[]>([]);
useEffect(() => {
if (!isAuthenticated) {
setDismissedRequestIds([]);
return;
}
try {
const raw = window.localStorage.getItem(dismissedRequestStorageKey);
if (!raw) {
setDismissedRequestIds([]);
return;
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
setDismissedRequestIds([]);
return;
}
const ids = parsed.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
setDismissedRequestIds(ids);
} catch {
setDismissedRequestIds([]);
}
}, [dismissedRequestStorageKey, isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) {
return;
}
try {
window.localStorage.setItem(dismissedRequestStorageKey, JSON.stringify(dismissedRequestIds));
} catch {
// Ignore storage failures in restricted/private contexts.
}
}, [dismissedRequestIds, dismissedRequestStorageKey, isAuthenticated]);
const requestItems = useMemo(
() =>
requests
.filter((record) => !dismissedRequestIds.includes(record.id))
.map((record) => requestToActivityItem(record, requestRoleIsAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp),
[requests, requestRoleIsAdmin, dismissedRequestIds]
);
const {
requestItems,
dismissedActivityKeys,
historyItems,
pendingRequestCount,
isActivitySnapshotLoading,
activityHistoryLoading,
activityHistoryHasMore,
refreshActivitySnapshot,
resetActivity,
handleActivityTabChange,
handleActivityHistoryLoadMore,
handleRequestDismiss,
handleDownloadDismiss,
handleClearCompleted,
handleClearHistory,
} = useActivity({
isAuthenticated,
isAdmin: requestRoleIsAdmin,
showToast,
socket,
});
const showRequestsTab = useMemo(() => {
if (requestRoleIsAdmin) {
@@ -317,7 +286,10 @@ function App() {
clearTracking();
setPendingRequestPayload(null);
setFulfillingRequest(null);
}, [handleLogout, setBooks, clearTracking]);
resetActivity();
setSettingsOpen(false);
setSelfSettingsOpen(false);
}, [handleLogout, setBooks, clearTracking, resetActivity]);
// UI state
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
@@ -341,6 +313,7 @@ function App() {
headerObserverRef.current = observer;
}, []);
const [settingsOpen, setSettingsOpen] = useState(false);
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
const [configBannerOpen, setConfigBannerOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
@@ -363,26 +336,45 @@ function App() {
// Calculate status counts for header badges (memoized)
const statusCounts = useMemo(() => {
const dismissedKeySet = new Set(dismissedActivityKeys);
const countVisibleDownloads = (
bucket: Record<string, Book> | undefined,
options: { filterDismissed: boolean }
): number => {
const { filterDismissed } = options;
if (!bucket) {
return 0;
}
if (!filterDismissed) {
return Object.keys(bucket).length;
}
return Object.keys(bucket).filter((taskId) => !dismissedKeySet.has(`download:${taskId}`)).length;
};
const ongoing = [
currentStatus.queued,
currentStatus.resolving,
currentStatus.locating,
currentStatus.downloading,
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
].reduce((sum, status) => sum + countVisibleDownloads(status, { filterDismissed: false }), 0);
const completed = currentStatus.complete
? Object.keys(currentStatus.complete).length
: 0;
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
const completed = countVisibleDownloads(currentStatus.complete, { filterDismissed: true });
const errored = countVisibleDownloads(currentStatus.error, { filterDismissed: true });
const pendingVisibleRequests = requestItems.filter((item) => {
const requestId = item.requestId;
if (!requestId || item.requestRecord?.status !== 'pending') {
return false;
}
return !dismissedKeySet.has(`request:${requestId}`);
}).length;
return {
ongoing,
completed,
errored,
pendingRequests: pendingRequestCount,
pendingRequests: pendingVisibleRequests,
};
}, [currentStatus, pendingRequestCount]);
}, [currentStatus, dismissedActivityKeys, requestItems]);
// Compute visibility states
@@ -651,6 +643,7 @@ function App() {
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
try {
await createRequest(payload);
await refreshActivitySnapshot();
showToast(successMessage, 'success');
await refreshRequestPolicy({ force: true });
return true;
@@ -663,7 +656,7 @@ function App() {
return false;
}
},
[showToast, refreshRequestPolicy]
[showToast, refreshRequestPolicy, refreshActivitySnapshot]
);
const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => {
@@ -768,17 +761,6 @@ function App() {
}
};
// Clear completed
const handleClearCompleted = async () => {
try {
await clearCompleted();
await fetchStatus();
} catch (error) {
console.error('Clear completed failed:', error);
showToast('Failed to clear finished downloads', 'error');
}
};
// Universal-mode "Get" action (open releases, request-book, or block by policy).
const handleGetReleases = async (book: Book) => {
let mode = getUniversalDefaultPolicyMode();
@@ -969,20 +951,15 @@ function App() {
async (requestId: number) => {
try {
await cancelUserRequest(requestId);
await refreshActivitySnapshot();
showToast('Request cancelled', 'success');
} catch (error) {
showToast(getErrorMessage(error, 'Failed to cancel request'), 'error');
}
},
[cancelUserRequest, showToast]
[cancelUserRequest, refreshActivitySnapshot, showToast]
);
const handleRequestDismiss = useCallback((requestId: number) => {
setDismissedRequestIds((previous) =>
previous.includes(requestId) ? previous : [...previous, requestId]
);
}, []);
const handleRequestReject = useCallback(
async (requestId: number, adminNote?: string) => {
if (!requestRoleIsAdmin) {
@@ -991,12 +968,13 @@ function App() {
try {
await rejectSidebarRequest(requestId, adminNote);
await refreshActivitySnapshot();
showToast('Request rejected', 'success');
} catch (error) {
showToast(getErrorMessage(error, 'Failed to reject request'), 'error');
}
},
[requestRoleIsAdmin, rejectSidebarRequest, showToast]
[refreshActivitySnapshot, requestRoleIsAdmin, rejectSidebarRequest, showToast]
);
const handleRequestApprove = useCallback(
@@ -1008,6 +986,7 @@ function App() {
if (record.request_level === 'release') {
try {
await fulfilSidebarRequest(requestId, record.release_data || undefined);
await refreshActivitySnapshot();
showToast('Request approved', 'success');
await fetchStatus();
} catch (error) {
@@ -1023,7 +1002,7 @@ function App() {
contentType: record.content_type,
});
},
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus]
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
);
const handleBrowseFulfilDownload = useCallback(
@@ -1037,6 +1016,7 @@ function App() {
fulfillingRequest.requestId,
buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType))
);
await refreshActivitySnapshot();
showToast(`Request approved: ${book.title || 'Untitled'}`, 'success');
setFulfillingRequest(null);
await fetchStatus();
@@ -1046,7 +1026,7 @@ function App() {
throw error;
}
},
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus]
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
);
const getDirectActionButtonState = useCallback(
@@ -1128,13 +1108,17 @@ function App() {
onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)}
onSettingsClick={() => {
if (config?.settings_enabled) {
setSettingsOpen(true);
if (authIsAdmin) {
setSettingsOpen(true);
} else {
setSelfSettingsOpen(true);
}
} else {
setConfigBannerOpen(true);
}
}}
isAdmin={requestRoleIsAdmin}
canAccessSettings={authCanAccessSettings}
canAccessSettings={isAuthenticated}
username={username}
displayName={displayName}
statusCounts={statusCounts}
@@ -1176,6 +1160,7 @@ function App() {
bottom: 0,
left: 0,
right: '25rem',
zIndex: 40,
}
: { paddingTop: `${headerHeight}px` }
}
@@ -1306,10 +1291,18 @@ function App() {
isAdmin={requestRoleIsAdmin}
onClearCompleted={handleClearCompleted}
onCancel={handleCancel}
onDownloadDismiss={handleDownloadDismiss}
requestItems={requestItems}
dismissedItemKeys={dismissedActivityKeys}
historyItems={historyItems}
historyHasMore={activityHistoryHasMore}
historyLoading={activityHistoryLoading}
onHistoryLoadMore={handleActivityHistoryLoadMore}
onClearHistory={handleClearHistory}
onActiveTabChange={handleActivityTabChange}
pendingRequestCount={pendingRequestCount}
showRequestsTab={showRequestsTab}
isRequestsLoading={isRequestsLoading}
isRequestsLoading={isRequestsLoading || isActivitySnapshotLoading}
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
@@ -1328,6 +1321,12 @@ function App() {
onSettingsSaved={handleSettingsSaved}
/>
<SelfSettingsModal
isOpen={selfSettingsOpen}
onClose={() => setSelfSettingsOpen(false)}
onShowToast={showToast}
/>
{/* Auto-show banner on startup for users without config */}
{config && (
<ConfigSetupBanner settingsEnabled={config.settings_enabled} />
@@ -1339,7 +1338,11 @@ function App() {
onClose={() => setConfigBannerOpen(false)}
onContinue={() => {
setConfigBannerOpen(false);
setSettingsOpen(true);
if (authIsAdmin) {
setSettingsOpen(true);
} else {
setSelfSettingsOpen(true);
}
}}
/>

View File

@@ -14,6 +14,7 @@ interface ActivityCardProps {
item: ActivityItem;
isAdmin: boolean;
onDownloadCancel?: (bookId: string) => void;
onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void;
onRequestCancel?: (requestId: number) => void;
onRequestApprove?: (requestId: number, record: RequestRecord) => void;
onRequestReject?: (requestId: number) => void;
@@ -145,6 +146,7 @@ export const ActivityCard = ({
item,
isAdmin,
onDownloadCancel,
onDownloadDismiss,
onRequestCancel,
onRequestApprove,
onRequestReject,
@@ -205,10 +207,7 @@ export const ActivityCard = ({
onDownloadCancel?.(action.bookId);
break;
case 'download-dismiss':
onDownloadCancel?.(action.bookId);
if (action.linkedRequestId) {
onRequestDismiss?.(action.linkedRequestId);
}
onDownloadDismiss?.(action.bookId, action.linkedRequestId);
break;
case 'request-approve':
onRequestApprove?.(action.requestId, action.record);
@@ -231,8 +230,9 @@ export const ActivityCard = ({
switch (action.kind) {
case 'download-remove':
case 'download-stop':
case 'download-dismiss':
return Boolean(onDownloadCancel);
case 'download-dismiss':
return Boolean(onDownloadDismiss);
case 'request-approve':
return Boolean(onRequestApprove);
case 'request-reject':
@@ -309,7 +309,7 @@ export const ActivityCard = ({
</div>
</div>
<p className="text-xs opacity-60 truncate mt-0.5" title={item.metaLine}>
<p className="text-[11px] leading-tight opacity-60 truncate mt-0.5" title={item.metaLine}>
{item.metaLine}
</p>

View File

@@ -4,15 +4,24 @@ import { downloadToActivityItem, DownloadStatusKey } from './activityMappers';
import { ActivityItem } from './activityTypes';
import { ActivityCard } from './ActivityCard';
import { RejectDialog } from './RejectDialog';
import { Dropdown } from '../Dropdown';
interface ActivitySidebarProps {
isOpen: boolean;
onClose: () => void;
status: StatusData;
isAdmin: boolean;
onClearCompleted: () => void;
onClearCompleted: (items: ActivityDismissTarget[]) => void;
onCancel: (id: string) => void;
onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void;
requestItems: ActivityItem[];
dismissedItemKeys?: string[];
historyItems?: ActivityItem[];
historyHasMore?: boolean;
historyLoading?: boolean;
onHistoryLoadMore?: () => void;
onClearHistory?: () => void;
onActiveTabChange?: (tab: ActivityTabKey) => void;
pendingRequestCount: number;
showRequestsTab: boolean;
isRequestsLoading?: boolean;
@@ -24,6 +33,11 @@ interface ActivitySidebarProps {
pinnedTopOffset?: number;
}
export interface ActivityDismissTarget {
itemType: 'download' | 'request';
itemKey: string;
}
export const ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY = 'activity-sidebar-pinned';
const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
@@ -37,53 +51,95 @@ const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
];
type ActivityCategoryKey =
| 'downloads'
| 'pending_requests'
| 'fulfilled_requests'
| 'other_requests';
| 'needs_review'
| 'in_progress'
| 'complete'
| 'failed';
type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history';
const ALL_USERS_FILTER = '__all_users__';
const getCategoryLabel = (
key: ActivityCategoryKey,
isAdmin: boolean
): string => {
if (key === 'downloads') {
return 'Downloads';
if (key === 'needs_review') {
return isAdmin ? 'Needs Review' : 'Waiting';
}
if (key === 'pending_requests') {
return 'Pending Requests';
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'fulfilled_requests') {
return isAdmin ? 'Fulfilled Requests' : 'Completed Requests';
if (key === 'complete') {
return 'Complete';
}
return 'Other Requests';
return 'Failed';
};
const getVisibleCategoryOrder = (
tab: 'all' | 'downloads' | 'requests'
tab: ActivityTabKey
): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['downloads'];
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'requests') {
return ['pending_requests', 'fulfilled_requests', 'other_requests'];
return ['needs_review', 'in_progress', 'complete', 'failed'];
}
return ['downloads', 'pending_requests', 'fulfilled_requests', 'other_requests'];
if (tab === 'history') {
return [];
}
return ['needs_review', 'in_progress', 'complete', 'failed'];
};
const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (!item.requestId) {
return 'downloads';
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
if (item.requestRecord?.status === 'pending' || item.visualStatus === 'pending') {
return 'pending_requests';
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return 'needs_review';
}
if (item.requestRecord?.status === 'fulfilled' || item.visualStatus === 'fulfilled') {
return 'fulfilled_requests';
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
return 'other_requests';
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
const getLinkedDownloadIdFromRequestItem = (item: ActivityItem): string | null => {
@@ -138,6 +194,15 @@ const dedupeById = (items: ActivityItem[]): ActivityItem[] => {
return Array.from(byId.values());
};
const getItemUsername = (item: ActivityItem): string | null => {
const candidate = item.username || item.requestRecord?.username;
if (typeof candidate !== 'string') {
return null;
}
const normalized = candidate.trim();
return normalized || null;
};
const parsePinned = (value: string | null): boolean => {
if (!value) {
return false;
@@ -170,7 +235,15 @@ export const ActivitySidebar = ({
isAdmin,
onClearCompleted,
onCancel,
onDownloadDismiss,
requestItems,
dismissedItemKeys = [],
historyItems = [],
historyHasMore = false,
historyLoading = false,
onHistoryLoadMore,
onClearHistory,
onActiveTabChange,
pendingRequestCount,
showRequestsTab,
isRequestsLoading = false,
@@ -183,10 +256,15 @@ export const ActivitySidebar = ({
}: ActivitySidebarProps) => {
const [isPinned, setIsPinned] = useState<boolean>(() => getInitialPinnedPreference());
const [isDesktop, setIsDesktop] = useState<boolean>(() => getInitialDesktopState());
const [activeTab, setActiveTab] = useState<'all' | 'downloads' | 'requests'>('all');
const [activeTab, setActiveTab] = useState<ActivityTabKey>('all');
const [selectedUser, setSelectedUser] = useState<string>(ALL_USERS_FILTER);
const [rejectingRequest, setRejectingRequest] = useState<{ requestId: number; bookTitle: string } | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const dismissedKeySet = useMemo(
() => new Set(dismissedItemKeys),
[dismissedItemKeys]
);
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1024px)');
@@ -209,6 +287,10 @@ export const ActivitySidebar = ({
}
}, [showRequestsTab, activeTab]);
useEffect(() => {
onActiveTabChange?.(activeTab);
}, [activeTab, onActiveTabChange]);
useEffect(() => {
if (activeTab === 'downloads') {
setRejectingRequest(null);
@@ -245,12 +327,29 @@ export const ActivitySidebar = ({
return;
}
Object.values(bucket).forEach((book) => {
const itemKey = `download:${book.id}`;
const isTerminalStatus =
statusKey === 'complete' || statusKey === 'error' || statusKey === 'cancelled';
if (isTerminalStatus && dismissedKeySet.has(itemKey)) {
return;
}
items.push(downloadToActivityItem(book, statusKey));
});
});
return items.sort((left, right) => right.timestamp - left.timestamp);
}, [status]);
}, [dismissedKeySet, status]);
const visibleRequestItems = useMemo(
() =>
requestItems.filter((item) => {
if (!item.requestId) {
return true;
}
return !dismissedKeySet.has(`request:${item.requestId}`);
}),
[dismissedKeySet, requestItems]
);
const { mergedRequestItems, mergedDownloadItems } = useMemo(() => {
const downloadsById = new Map<string, ActivityItem>();
@@ -261,7 +360,7 @@ export const ActivitySidebar = ({
});
const mergedByDownloadId = new Map<string, ActivityItem>();
const nextRequestItems = requestItems.map((requestItem) => {
const nextRequestItems = visibleRequestItems.map((requestItem) => {
const linkedDownloadId = getLinkedDownloadIdFromRequestItem(requestItem);
if (!linkedDownloadId) {
return requestItem;
@@ -291,7 +390,7 @@ export const ActivitySidebar = ({
mergedRequestItems: nextRequestItems,
mergedDownloadItems: nextDownloadItems,
};
}, [downloadItems, requestItems]);
}, [downloadItems, visibleRequestItems]);
const hasTerminalDownloadItems = useMemo(
() =>
@@ -307,11 +406,84 @@ export const ActivitySidebar = ({
return combined.sort((a, b) => b.timestamp - a.timestamp);
}, [mergedDownloadItems, mergedRequestItems]);
const visibleItems = activeTab === 'all'
const baseVisibleItems = activeTab === 'all'
? allItems
: activeTab === 'requests'
? mergedRequestItems
: mergedDownloadItems;
? mergedRequestItems.filter((item) => {
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || requestStatus === 'rejected' || requestStatus === 'cancelled') {
return true;
}
return requestStatus === 'fulfilled' && item.kind === 'request';
})
: activeTab === 'history'
? historyItems
: mergedDownloadItems;
const availableUsers = useMemo(() => {
const userMap = new Map<string, string>();
baseVisibleItems.forEach((item) => {
const username = getItemUsername(item);
if (!username) {
return;
}
const lookupKey = username.toLowerCase();
if (!userMap.has(lookupKey)) {
userMap.set(lookupKey, username);
}
});
return Array.from(userMap.values()).sort((left, right) => left.localeCompare(right));
}, [baseVisibleItems]);
useEffect(() => {
if (selectedUser === ALL_USERS_FILTER) {
return;
}
if (!availableUsers.includes(selectedUser)) {
setSelectedUser(ALL_USERS_FILTER);
}
}, [availableUsers, selectedUser]);
const visibleItems = useMemo(() => {
if (selectedUser === ALL_USERS_FILTER) {
return baseVisibleItems;
}
return baseVisibleItems.filter((item) => getItemUsername(item) === selectedUser);
}, [baseVisibleItems, selectedUser]);
const hasUserFilter = isAdmin && availableUsers.length > 1;
const clearCompletedTargets = useMemo(() => {
const targets: ActivityDismissTarget[] = [];
const seen = new Set<string>();
visibleItems.forEach((item) => {
const isTerminalDownload =
item.kind === 'download' &&
(item.visualStatus === 'complete' || item.visualStatus === 'error' || item.visualStatus === 'cancelled');
if (!isTerminalDownload || !item.downloadBookId) {
return;
}
const downloadKey = `download:${item.downloadBookId}`;
if (!seen.has(downloadKey)) {
seen.add(downloadKey);
targets.push({ itemType: 'download', itemKey: downloadKey });
}
if (item.requestId) {
const requestKey = `request:${item.requestId}`;
if (!seen.has(requestKey)) {
seen.add(requestKey);
targets.push({ itemType: 'request', itemKey: requestKey });
}
}
});
return targets;
}, [visibleItems]);
const visibleCategoryOrder = useMemo(
() => getVisibleCategoryOrder(activeTab),
@@ -319,11 +491,15 @@ export const ActivitySidebar = ({
);
const groupedVisibleItems = useMemo(() => {
if (activeTab === 'history') {
return [];
}
const grouped = new Map<ActivityCategoryKey, ActivityItem[]>();
visibleCategoryOrder.forEach((key) => grouped.set(key, []));
visibleItems.forEach((item) => {
const category = activeTab === 'downloads' ? 'downloads' : getActivityCategory(item);
const category = getActivityCategory(item);
if (!grouped.has(category)) {
grouped.set(category, []);
}
@@ -355,31 +531,33 @@ export const ActivitySidebar = ({
useEffect(() => {
const activeButton = tabRefs.current[activeTab];
if (activeButton) {
const containerRect = activeButton.parentElement?.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
if (containerRect) {
setTabIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
if (!activeButton) {
setTabIndicatorStyle({ left: 0, width: 0 });
return;
}
const containerRect = activeButton.parentElement?.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
if (containerRect) {
setTabIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}, [activeTab, showRequestsTab]);
const panel = (
<>
<div
className={`px-4 pt-4 ${showRequestsTab ? 'pb-0' : 'pb-4 border-b'}`}
className="px-4 pt-4 pb-0"
style={{
borderColor: 'var(--border-muted)',
paddingTop: 'calc(1rem + env(safe-area-inset-top))',
}}
>
<div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Activity</h2>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{activeTab === 'history' ? 'History' : 'Activity'}</h2>
<button
type="button"
onClick={handleTogglePinned}
@@ -392,25 +570,96 @@ export const ActivitySidebar = ({
<path d="M15.804 2.276a.75.75 0 0 0-.336.195l-2 2a.75.75 0 0 0 0 1.062l.47.469-3.572 3.571c-.83-.534-1.773-.808-2.709-.691-1.183.148-2.32.72-3.187 1.587a.75.75 0 0 0 0 1.063L7.938 15l-5.467 5.467a.75.75 0 0 0 0 1.062.75.75 0 0 0 1.062 0L9 16.062l3.468 3.468a.75.75 0 0 0 1.062 0c.868-.868 1.44-2.004 1.588-3.187.117-.935-.158-1.879-.692-2.708L18 10.063l.469.469a.75.75 0 0 0 1.062 0l2-2a.75.75 0 0 0 0-1.062l-5-4.999a.75.75 0 0 0-.726-.195z" />
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m9 15-6 6M15 6l-1-1 2-2 5 5-2 2-1-1-4.5 4.5c1.5 1.5 1 4-.5 5.5l-8-8c1.5-1.5 4-2 5.5-.5z" />
</svg>
)}
</button>
</div>
<div className="flex items-center gap-1">
{hasUserFilter && (
<Dropdown
align="right"
widthClassName="w-auto"
panelClassName="min-w-[11rem]"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
isOpen || selectedUser !== ALL_USERS_FILTER ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={selectedUser === ALL_USERS_FILTER ? 'Filter by user' : `Filtered: ${selectedUser}`}
aria-label={selectedUser === ALL_USERS_FILTER ? 'Filter by user' : `Filtered by user ${selectedUser}`}
aria-expanded={isOpen}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" strokeWidth="1.75" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
</button>
)}
>
{({ close }) => (
<div role="listbox">
{[ALL_USERS_FILTER, ...availableUsers].map((value) => {
const isSelected = selectedUser === value;
const label = value === ALL_USERS_FILTER ? 'All users' : value;
return (
<button
type="button"
key={value}
className={`w-full px-3 py-2 text-left text-sm hover-surface flex items-center justify-between ${
isSelected ? 'text-sky-600 dark:text-sky-400' : ''
}`}
onClick={() => {
setSelectedUser(value);
close();
}}
>
<span>{label}</span>
{isSelected && (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="m5 13 4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
)}
<button
type="button"
onClick={() => setActiveTab((current) => (current === 'history' ? 'all' : 'history'))}
className={`relative h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
activeTab === 'history' ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={activeTab === 'history' ? 'Back to activity' : 'Open history'}
aria-label={activeTab === 'history' ? 'Back to activity' : 'Open history'}
aria-pressed={activeTab === 'history'}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6l3.75 2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v4.5h4.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12a8.25 8.25 0 1 0 3.37-6.63" />
</svg>
</button>
<button
type="button"
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors"
aria-label="Close activity sidebar"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{showRequestsTab && (
{activeTab !== 'history' && (
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
<div className="relative flex gap-1">
{/* Sliding indicator */}
@@ -452,24 +701,26 @@ export const ActivitySidebar = ({
</span>
)}
</button>
<button
type="button"
ref={(el) => { tabRefs.current.requests = el; }}
onClick={() => setActiveTab('requests')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === 'requests'
? 'text-sky-600 dark:text-sky-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
aria-current={activeTab === 'requests' ? 'page' : undefined}
>
Requests
{pendingRequestCount > 0 && (
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-amber-500/15 text-amber-700 dark:text-amber-300 inline-flex items-center justify-center leading-none">
{pendingRequestCount}
</span>
)}
</button>
{showRequestsTab && (
<button
type="button"
ref={(el) => { tabRefs.current.requests = el; }}
onClick={() => setActiveTab('requests')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === 'requests'
? 'text-sky-600 dark:text-sky-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
aria-current={activeTab === 'requests' ? 'page' : undefined}
>
Requests
{pendingRequestCount > 0 && (
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-amber-500/15 text-amber-700 dark:text-amber-300 inline-flex items-center justify-center leading-none">
{pendingRequestCount}
</span>
)}
</button>
)}
</div>
</div>
)}
@@ -484,11 +735,36 @@ export const ActivitySidebar = ({
<p className="text-center text-sm opacity-70 mt-8">
{activeTab === 'requests'
? isRequestsLoading ? 'Loading requests...' : 'No requests'
: activeTab === 'history'
? historyLoading ? 'Loading history...' : 'No history'
: activeTab === 'downloads'
? 'No downloads'
: 'No activity'}
</p>
) : (
activeTab === 'history' ? (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{visibleItems.map((item) => (
<ActivityCard
key={item.id}
item={item}
isAdmin={isAdmin}
/>
))}
{historyHasMore && (
<div className="pt-3 text-center">
<button
type="button"
onClick={onHistoryLoadMore}
disabled={historyLoading}
className="text-sm text-sky-600 dark:text-sky-400 hover:underline disabled:opacity-60"
>
{historyLoading ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</div>
) : (
groupedVisibleItems.map((group) => (
<section key={group.key} className="mb-4 last:mb-0">
{activeTab !== 'downloads' && (
@@ -527,6 +803,7 @@ export const ActivitySidebar = ({
item={item}
isAdmin={isAdmin}
onDownloadCancel={onCancel}
onDownloadDismiss={onDownloadDismiss}
onRequestCancel={onRequestCancel}
onRequestApprove={onRequestApprove}
onRequestDismiss={onRequestDismiss}
@@ -554,10 +831,11 @@ export const ActivitySidebar = ({
)}
</section>
))
)
)}
</div>
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && (
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
<div
className="p-3 border-t flex items-center justify-center"
style={{
@@ -567,13 +845,31 @@ export const ActivitySidebar = ({
>
<button
type="button"
onClick={onClearCompleted}
onClick={() => onClearCompleted(clearCompletedTargets)}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
Clear Completed
</button>
</div>
)}
{activeTab === 'history' && historyItems.length > 0 && (
<div
className="p-3 border-t flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
}}
>
<button
type="button"
onClick={onClearHistory}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
Clear History
</button>
</div>
)}
</>
);

View File

@@ -53,10 +53,10 @@ const toRequestVisualStatus = (status: RequestRecord['status']): ActivityVisualS
const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => {
if (!isAdmin) {
return 'Pending';
return 'Awaiting review';
}
const username = item.username?.trim() || item.requestRecord?.username?.trim();
return username ? `Requested by ${username}` : 'Requested';
return username ? `Needs review · ${username}` : 'Needs review';
};
const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => {
@@ -72,15 +72,15 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
let text = item.statusLabel;
if (hasInFlightLinkedDownload) {
text = isAdmin ? 'Request approved' : 'Approved';
text = 'Approved';
} else if (requestVisualStatus === 'pending') {
text = getPendingRequestText(item, isAdmin);
} else if (requestVisualStatus === 'fulfilled') {
text = isAdmin ? 'Request fulfilled' : 'Approved';
text = 'Approved';
} else if (requestVisualStatus === 'rejected') {
text = 'Rejected';
text = isAdmin ? 'Declined' : 'Not approved';
} else if (requestVisualStatus === 'cancelled') {
text = 'Cancelled';
text = isAdmin ? 'Cancelled by requester' : 'Cancelled';
}
return {
@@ -109,6 +109,10 @@ const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => {
};
const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[] => {
if (item.kind === 'download' && item.visualStatus === 'complete') {
return [getDownloadBadge(item)];
}
if (item.kind === 'download' && item.requestId && item.requestRecord) {
return [getRequestBadge(item, isAdmin), getDownloadBadge(item)];
}

View File

@@ -11,3 +11,4 @@ export {
} from './activityStyles';
export type { ActivityItem, ActivityKind, ActivityVisualStatus } from './activityTypes';
export { ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY } from './ActivitySidebar';
export type { ActivityDismissTarget } from './ActivitySidebar';

View File

@@ -0,0 +1,357 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AdminUser,
DeliveryPreferencesResponse,
getSelfUserEditContext,
updateSelfUser,
} from '../../services/api';
import { SelectField } from './fields';
import { FieldWrapper } from './shared';
import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard';
import { UserOverridesSection } from './users/UserOverridesSection';
import { buildUserSettingsPayload } from './users/settingsPayload';
import { PerUserSettings } from './users/types';
import { getStoredThemePreference, setThemePreference, THEME_FIELD } from '../../utils/themePreference';
interface SelfSettingsModalProps {
isOpen: boolean;
onClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const MIN_PASSWORD_LENGTH = 4;
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;
}
});
return normalized;
};
const getPasswordError = (password: string, passwordConfirm: string): string | null => {
if (!password && !passwordConfirm) {
return null;
}
if (!password) {
return 'Password is required';
}
if (password.length < MIN_PASSWORD_LENGTH) {
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
}
return password === passwordConfirm ? null : 'Passwords do not match';
};
const getErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
};
export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettingsModalProps) => {
const [isClosing, setIsClosing] = useState(false);
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 [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const [themeValue, setThemeValue] = useState<string>(getStoredThemePreference());
const loadEditContext = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const context = await getSelfUserEditContext();
const normalizedSettings = normalizeUserSettings((context.user.settings || {}) as PerUserSettings);
setEditingUser(context.user);
setOriginalUser(context.user);
setDeliveryPreferences(context.deliveryPreferences || null);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(context.userOverridableKeys || []));
setEditPassword('');
setEditPasswordConfirm('');
} catch (error) {
setLoadError(getErrorMessage(error, 'Failed to load account settings'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
setIsClosing(false);
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]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleClose]);
const isUserOverridable = useCallback(
(key: keyof PerUserSettings) => userOverridableSettings.has(String(key)),
[userOverridableSettings]
);
const currentSettingsPayload = useMemo(
() => buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences),
[deliveryPreferences, userOverridableSettings, userSettings]
);
const originalSettingsPayload = useMemo(
() => buildUserSettingsPayload(originalUserSettings, userOverridableSettings, deliveryPreferences),
[deliveryPreferences, originalUserSettings, userOverridableSettings]
);
const hasSettingsChanges =
JSON.stringify(currentSettingsPayload) !== JSON.stringify(originalSettingsPayload);
const hasProfileChanges = Boolean(
editingUser
&& originalUser
&& (
editingUser.email !== originalUser.email
|| editingUser.display_name !== originalUser.display_name
)
);
const hasPasswordChanges = editPassword.length > 0 || editPasswordConfirm.length > 0;
const passwordError = getPasswordError(editPassword, editPasswordConfirm);
const hasChanges = hasSettingsChanges || hasProfileChanges || hasPasswordChanges;
const handleSave = useCallback(async () => {
if (!editingUser || !originalUser) {
return;
}
if (passwordError) {
onShowToast?.(passwordError, 'error');
return;
}
const payload: {
email?: string | null;
display_name?: string | null;
password?: string;
settings?: Record<string, unknown>;
} = {};
if (
editingUser.edit_capabilities.canEditEmail
&& editingUser.email !== originalUser.email
) {
payload.email = editingUser.email;
}
if (
editingUser.edit_capabilities.canEditDisplayName
&& editingUser.display_name !== originalUser.display_name
) {
payload.display_name = editingUser.display_name;
}
if (editingUser.edit_capabilities.canSetPassword && editPassword) {
payload.password = editPassword;
}
if (hasSettingsChanges) {
payload.settings = currentSettingsPayload;
}
if (Object.keys(payload).length === 0) {
return;
}
setIsSaving(true);
try {
await updateSelfUser(payload);
onShowToast?.('Account updated', 'success');
await loadEditContext();
} catch (error) {
onShowToast?.(getErrorMessage(error, 'Failed to update account'), 'error');
} finally {
setIsSaving(false);
}
}, [
currentSettingsPayload,
editingUser,
hasSettingsChanges,
loadEditContext,
onShowToast,
originalUser,
passwordError,
editPassword,
]);
if (!isOpen && !isClosing) {
return null;
}
const titleId = 'self-settings-modal-title';
const hasCachedEditContext = Boolean(editingUser);
const showInitialLoadingState = isLoading && !hasCachedEditContext;
const showInitialLoadErrorState = Boolean(loadError) && !hasCachedEditContext;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-[var(--border-muted)] shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<h3 id={titleId} className="sr-only">My Account</h3>
{editingUser ? (
<UserIdentityHeader
user={editingUser}
showAuthSource
showInactiveState={false}
/>
) : (
<div className="text-sm font-medium">My Account</div>
)}
<div className="flex items-center">
<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"
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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</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-[var(--border-muted)] bg-[var(--bg-soft)] hover:bg-[var(--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>
<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: (
<div className="space-y-5">
<UserOverridesSection
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
/>
</div>
),
}}
/>
</div>
) : (
<div className="h-full flex 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-[var(--border-muted)] px-6 py-4">
<UserEditActions
variant="modalFooter"
onSave={() => {
void handleSave();
}}
saving={isSaving}
saveDisabled={!hasChanges || isSaving || isLoading}
onCancel={handleClose}
cancelDisabled={isSaving}
/>
</footer>
</div>
</div>
);
};

View File

@@ -5,7 +5,7 @@ interface HeadingFieldProps {
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-black/10 [&:not(:first-child)]:dark:border-white/10">
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-[var(--border-muted)]">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">

View File

@@ -1,4 +1,5 @@
export { SettingsModal } from './SettingsModal';
export { SelfSettingsModal } from './SelfSettingsModal';
export { SettingsHeader } from './SettingsHeader';
export { SettingsSidebar } from './SettingsSidebar';
export { SettingsContent } from './SettingsContent';

View File

@@ -45,10 +45,10 @@ const formatSourceLabel = (source: string): string => {
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
const modeDescriptions: Record<RequestPolicyMode, string> = {
download: 'Direct downloads allowed.',
request_release: 'Specific release requests only.',
request_book: 'Book-level requests only.',
blocked: 'Unavailable.',
download: 'Users can download directly.',
request_release: 'Users pick a release and request it.',
request_book: 'Users can request a book, admin picks the release.',
blocked: 'Downloads and requests are blocked.',
};
export const RequestPolicyGrid = ({
@@ -328,7 +328,7 @@ export const RequestPolicyGrid = ({
) : (
<div className="px-3 py-3">
<p className="text-xs opacity-60">
Per-source overrides are 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>
)}

View File

@@ -1,6 +1,9 @@
import { ReactNode } from 'react';
import { AdminUser } from '../../../services/api';
import { 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';
@@ -17,6 +20,11 @@ const CREATE_ROLE_OPTIONS: SelectOption[] = [
{ value: 'admin', label: 'Admin' },
];
const EDIT_ROLE_OPTIONS: SelectOption[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
];
const createTextField = (
key: string,
label: string,
@@ -89,6 +97,240 @@ const renderPasswordField = (
</FieldWrapper>
);
const getRoleLabel = (role: string) => role.charAt(0).toUpperCase() + role.slice(1);
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'}`
);
const getRoleDisabledReason = (user: AdminUser, oidcAdminGroup?: string): string => {
if (user.edit_capabilities.authSource === 'oidc') {
if (oidcAdminGroup) {
return `Role is managed by the ${oidcAdminGroup} group in your identity provider.`;
}
return 'Role is managed by OIDC group authorization.';
}
if (user.edit_capabilities.authSource === 'builtin') {
return 'Role can only be changed by admins.';
}
return 'Role is managed by the external authentication source.';
};
interface UserRoleControlProps {
user: AdminUser;
onUserChange?: (user: AdminUser) => void;
oidcAdminGroup?: string;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
}
export const UserRoleControl = ({
user,
onUserChange,
oidcAdminGroup,
tooltipPosition = 'bottom',
}: UserRoleControlProps) => {
const roleLabel = getRoleLabel(user.role);
const canEditRole = Boolean(onUserChange) && user.edit_capabilities.canEditRole;
const roleDisabledReason = !user.edit_capabilities.canEditRole
? getRoleDisabledReason(user, oidcAdminGroup)
: undefined;
if (canEditRole && onUserChange) {
return (
<DropdownList
options={EDIT_ROLE_OPTIONS}
value={user.role}
onChange={(value) => {
const nextRole = Array.isArray(value) ? value[0] ?? '' : value;
onUserChange({ ...user, role: nextRole });
}}
widthClassName="w-28"
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
user.role === 'admin'
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
: '!bg-zinc-500/10 !opacity-70'
}`}
/>
);
}
if (onUserChange && !user.edit_capabilities.canEditRole) {
return (
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position={tooltipPosition}>
<span className={getRoleBadgeClassName(user.role, true)}>
{roleLabel}
</span>
</Tooltip>
);
}
return (
<span className={getRoleBadgeClassName(user.role)}>
{roleLabel}
</span>
);
};
interface UserIdentityHeaderProps {
user: AdminUser;
showAuthSource?: boolean;
showInactiveState?: boolean;
}
export const UserIdentityHeader = ({
user,
showAuthSource = true,
showInactiveState = true,
}: UserIdentityHeaderProps) => {
const active = user.is_active !== false;
return (
<div className="flex items-center gap-3 min-w-0 flex-1">
<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'}`}
>
{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>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
)}
{showAuthSource && <UserAuthSourceBadge user={user} showInactive={false} />}
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
{showInactiveState && !active && (
<div className="text-[11px] opacity-60 truncate">
Inactive for current authentication mode
</div>
)}
</div>
</div>
);
};
interface UserEditActionsProps {
onSave: () => void;
saving: boolean;
saveDisabled?: boolean;
onCancel: () => void;
cancelDisabled?: boolean;
onDelete?: () => void;
onConfirmDelete?: () => void;
onCancelDelete?: () => void;
isDeletePending?: boolean;
deleting?: boolean;
variant?: 'card' | 'modalFooter';
}
export const UserEditActions = ({
onSave,
saving,
saveDisabled = false,
onCancel,
cancelDisabled = false,
onDelete,
onConfirmDelete,
onCancelDelete,
isDeletePending = false,
deleting = false,
variant = 'card',
}: UserEditActionsProps) => {
if (variant === 'modalFooter') {
return (
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
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"
>
{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>
Saving...
</>
) : (
'Save Changes'
)}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<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"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
{onDelete && (
<div className="flex flex-wrap gap-2 sm:ml-auto">
{isDeletePending ? (
<>
<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"
>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</>
) : (
<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"
>
Delete User
</button>
)}
</div>
)}
</div>
);
};
interface UserCreateCardProps {
form: CreateUserFormState;
onChange: (form: CreateUserFormState) => void;
@@ -168,6 +410,7 @@ interface UserEditFieldsProps {
onSave: () => void;
saving: boolean;
onCancel: () => void;
hideActions?: boolean;
editPassword: string;
onEditPasswordChange: (value: string) => void;
editPasswordConfirm: string;
@@ -185,6 +428,7 @@ export const UserEditFields = ({
onSave,
saving,
onCancel,
hideActions = false,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
@@ -241,55 +485,116 @@ export const UserEditFields = ({
</>
)}
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<button
onClick={onSave}
disabled={saving}
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"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
{onDelete && (
<div className="flex flex-wrap gap-2 sm:ml-auto">
{isDeletePending ? (
<>
<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"
>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</>
) : (
<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"
>
Delete User
</button>
)}
</div>
)}
</div>
{!hideActions && (
<UserEditActions
onSave={onSave}
saving={saving}
saveDisabled={saving}
onCancel={onCancel}
onDelete={onDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
isDeletePending={isDeletePending}
deleting={deleting}
/>
)}
</>
);
};
interface UserPreferencesPanelProps {
description?: string;
hideTitle?: boolean;
actionLabel?: string;
onAction?: () => void;
children?: ReactNode;
}
interface UserAccountCardContentProps extends Omit<UserEditFieldsProps, 'hideActions'> {
hideEditActions?: boolean;
preferencesPanel?: UserPreferencesPanelProps;
preferencesPlacement?: 'before' | 'after';
}
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 && panel.description && (
<p className="text-xs opacity-60 mt-0.5">{panel.description}</p>
)}
{panel.onAction && (
<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"
>
{panel.actionLabel || 'Open User Preferences'}
</button>
)}
</div>
)}
{panel.children}
</div>
);
export const UserAccountCardContent = ({
user,
onUserChange,
onSave,
saving,
onCancel,
hideEditActions = false,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
onEditPasswordConfirmChange,
onDelete,
onConfirmDelete,
onCancelDelete,
isDeletePending,
deleting,
preferencesPanel,
preferencesPlacement = 'before',
}: UserAccountCardContentProps) => {
const preferencesContent = preferencesPanel ? renderPreferencesPanel(preferencesPanel) : null;
return (
<div className="space-y-5">
{preferencesContent && preferencesPlacement === 'before' && (
<>
{preferencesContent}
<div className="border-t border-[var(--border-muted)]" />
</>
)}
<UserEditFields
user={user}
onUserChange={onUserChange}
onSave={onSave}
saving={saving}
onCancel={onCancel}
hideActions={hideEditActions}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
onDelete={onDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
isDeletePending={isDeletePending}
deleting={deleting}
/>
{preferencesContent && preferencesPlacement === 'after' && (
<>
<div className="border-t border-[var(--border-muted)]" />
{preferencesContent}
</>
)}
</div>
);
};

View File

@@ -1,18 +1,10 @@
import { useState } from 'react';
import { AdminUser, DownloadDefaults } from '../../../services/api';
import { DropdownList } from '../../DropdownList';
import { Tooltip } from '../../shared/Tooltip';
import {
canCreateLocalUsersForAuthMode,
CreateUserFormState,
} from './types';
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
import { UserCreateCard, UserEditFields } from './UserCard';
const EDIT_ROLE_OPTIONS = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
];
import { UserAccountCardContent, UserCreateCard, UserIdentityHeader, UserRoleControl } from './UserCard';
interface UserListViewProps {
authMode: string;
@@ -123,7 +115,6 @@ export const UserListView = ({
const active = user.is_active !== false;
const isEditingRow = showEditForm && activeEditUserId === user.id;
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
return (
<div
key={user.id}
@@ -132,82 +123,18 @@ export const UserListView = ({
<div
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<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'}`}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{user.display_name || user.username}
</span>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
)}
<UserAuthSourceBadge user={user} />
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
{!active && (
<div className="text-[11px] opacity-60 truncate">
Inactive for current authentication mode
</div>
)}
</div>
</div>
<UserIdentityHeader user={user} />
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
{hasLoadedEditUser && editingUser ? (() => {
const caps = editingUser.edit_capabilities;
const canEditRole = caps.canEditRole;
const roleDisabledReason = !canEditRole
? (caps.authSource === 'oidc'
? (downloadDefaults?.OIDC_ADMIN_GROUP
? `Role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Role is managed by OIDC group authorization.')
: 'Role is managed by the external authentication source.')
: undefined;
if (!canEditRole) {
return (
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position="bottom">
<span
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none cursor-not-allowed
${editingUser.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{editingUser.role.charAt(0).toUpperCase() + editingUser.role.slice(1)}
</span>
</Tooltip>
);
}
return (
<DropdownList
options={EDIT_ROLE_OPTIONS}
value={editingUser.role}
onChange={(value) => {
const val = Array.isArray(value) ? value[0] ?? '' : value;
onEditingUserChange({ ...editingUser, role: val });
}}
widthClassName="w-28"
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
editingUser.role === 'admin'
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
: '!bg-zinc-500/10 !opacity-70'
}`}
/>
);
})() : (
<span
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{roleLabel}
</span>
{hasLoadedEditUser && editingUser ? (
<UserRoleControl
user={editingUser}
onUserChange={onEditingUserChange}
oidcAdminGroup={downloadDefaults?.OIDC_ADMIN_GROUP}
tooltipPosition="bottom"
/>
) : (
<UserRoleControl user={user} />
)}
<button
@@ -244,38 +171,27 @@ export const UserListView = ({
{isEditingRow && (
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<>
<div>
<label className="text-sm font-medium">User Preferences</label>
<p className="text-xs opacity-60 mt-0.5">Override global delivery and request policy settings for this user.</p>
<button
onClick={onOpenOverrides}
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
bg-sky-600 hover:bg-sky-700 transition-colors"
>
Open User Preferences
</button>
</div>
<div className="border-t border-[var(--border-muted)]" />
<UserEditFields
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}
/>
</>
<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>
)}

View File

@@ -35,8 +35,8 @@ const REQUEST_POLICY_OVERRIDE_KEYS: Array<keyof PerUserSettings> = [
const requestPolicyHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'request_policy_overrides_heading',
title: 'Request Policy',
description: 'User-level request policy overrides. Reset to inherit global policy values.',
title: 'Requests',
description: 'Custom request settings for this user. Reset any to fall back to the global defaults.',
};
const hasOwnNonNull = (settings: PerUserSettings, key: keyof PerUserSettings): boolean => {

View File

@@ -1,5 +1,12 @@
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
export { UserCreateCard, UserEditFields } from './UserCard';
export {
UserAccountCardContent,
UserCreateCard,
UserEditActions,
UserEditFields,
UserIdentityHeader,
UserRoleControl,
} from './UserCard';
export { UserListView } from './UserListView';
export { RequestPolicyGrid } from './RequestPolicyGrid';
export { UserOverridesSection } from './UserOverridesSection';

View File

@@ -29,22 +29,22 @@ export const REQUEST_POLICY_DEFAULT_OPTIONS: Array<{
{
value: 'download',
label: 'Download',
description: 'Allow direct downloads.',
description: 'Everything can be downloaded directly.',
},
{
value: 'request_release',
label: 'Request Release',
description: 'Require requesting a specific release.',
description: 'Users must request a specific release.',
},
{
value: 'request_book',
label: 'Request Book',
description: 'Allow book-level requests only.',
description: 'Users request a book, admin picks the release.',
},
{
value: 'blocked',
label: 'Blocked',
description: 'Block downloads and requests.',
description: 'No downloads or requests allowed.',
},
];

View File

@@ -0,0 +1,417 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Socket } from 'socket.io-client';
import { Book, RequestRecord, StatusData } from '../types';
import {
ActivityHistoryItem,
ActivityDismissPayload,
clearActivityHistory,
dismissActivityItem,
dismissManyActivityItems,
getActivitySnapshot,
listActivityHistory,
} from '../services/api';
import {
ActivityDismissTarget,
ActivityItem,
downloadToActivityItem,
requestToActivityItem,
} from '../components/activity';
const HISTORY_PAGE_SIZE = 50;
const parseTimestamp = (value: string | null | undefined, fallback: number = 0): number => {
if (!value) {
return fallback;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const mapHistoryRowToActivityItem = (
row: ActivityHistoryItem,
viewerRole: 'user' | 'admin'
): ActivityItem => {
const dismissedAtTs = parseTimestamp(row.dismissed_at);
const snapshot = row.snapshot;
if (snapshot && typeof snapshot === 'object') {
const payload = snapshot as Record<string, unknown>;
if (payload.kind === 'download' && payload.download && typeof payload.download === 'object') {
const statusKey = row.final_status === 'error' || row.final_status === 'cancelled'
? row.final_status
: 'complete';
const downloadItem = downloadToActivityItem(payload.download as Book, statusKey);
const requestPayload = payload.request;
if (requestPayload && typeof requestPayload === 'object') {
const requestRecord = requestPayload as RequestRecord;
return {
...downloadItem,
id: `history-${row.id}`,
timestamp: dismissedAtTs || downloadItem.timestamp,
requestId: requestRecord.id,
requestLevel: requestRecord.request_level,
requestNote: requestRecord.note || undefined,
requestRecord,
adminNote: requestRecord.admin_note || undefined,
username: requestRecord.username || downloadItem.username,
};
}
return {
...downloadItem,
id: `history-${row.id}`,
timestamp: dismissedAtTs || downloadItem.timestamp,
};
}
if (payload.kind === 'request' && payload.request && typeof payload.request === 'object') {
const requestItem = requestToActivityItem(payload.request as RequestRecord, viewerRole);
return {
...requestItem,
id: `history-${row.id}`,
timestamp: dismissedAtTs || requestItem.timestamp,
};
}
}
const visualStatus: ActivityItem['visualStatus'] =
row.final_status === 'error'
? 'error'
: row.final_status === 'cancelled'
? 'cancelled'
: row.final_status === 'rejected'
? 'rejected'
: 'complete';
const statusLabel =
visualStatus === 'error'
? 'Failed'
: visualStatus === 'cancelled'
? 'Cancelled'
: visualStatus === 'rejected'
? viewerRole === 'admin'
? 'Declined'
: 'Not approved'
: 'Complete';
return {
id: `history-${row.id}`,
kind: row.item_type === 'request' ? 'request' : 'download',
visualStatus,
title: row.item_type === 'request' ? 'Request' : 'Download',
author: '',
metaLine: row.item_key,
statusLabel,
timestamp: dismissedAtTs,
};
};
interface UseActivityParams {
isAuthenticated: boolean;
isAdmin: boolean;
showToast: (
message: string,
type?: 'info' | 'success' | 'error',
persistent?: boolean
) => string;
socket: Socket | null;
}
interface UseActivityResult {
activityStatus: StatusData;
requestItems: ActivityItem[];
dismissedActivityKeys: string[];
historyItems: ActivityItem[];
pendingRequestCount: number;
isActivitySnapshotLoading: boolean;
activityHistoryLoading: boolean;
activityHistoryHasMore: boolean;
refreshActivitySnapshot: () => Promise<void>;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history') => void;
resetActivity: () => void;
handleActivityHistoryLoadMore: () => void;
handleRequestDismiss: (requestId: number) => void;
handleDownloadDismiss: (bookId: string, linkedRequestId?: number) => void;
handleClearCompleted: (items: ActivityDismissTarget[]) => void;
handleClearHistory: () => void;
}
export const useActivity = ({
isAuthenticated,
isAdmin,
showToast,
socket,
}: UseActivityParams): UseActivityResult => {
const [activityStatus, setActivityStatus] = useState<StatusData>({});
const [activityRequests, setActivityRequests] = useState<RequestRecord[]>([]);
const [dismissedActivityKeys, setDismissedActivityKeys] = useState<string[]>([]);
const [isActivitySnapshotLoading, setIsActivitySnapshotLoading] = useState(false);
const [activityHistoryRows, setActivityHistoryRows] = useState<ActivityHistoryItem[]>([]);
const [activityHistoryOffset, setActivityHistoryOffset] = useState(0);
const [activityHistoryHasMore, setActivityHistoryHasMore] = useState(false);
const [activityHistoryLoading, setActivityHistoryLoading] = useState(false);
const [activityHistoryLoaded, setActivityHistoryLoaded] = useState(false);
const resetActivityHistory = useCallback(() => {
setActivityHistoryRows([]);
setActivityHistoryOffset(0);
setActivityHistoryHasMore(false);
setActivityHistoryLoaded(false);
}, []);
const resetActivity = useCallback(() => {
setActivityStatus({});
setActivityRequests([]);
setDismissedActivityKeys([]);
resetActivityHistory();
}, [resetActivityHistory]);
const refreshActivitySnapshot = useCallback(async () => {
if (!isAuthenticated) {
resetActivity();
return;
}
setIsActivitySnapshotLoading(true);
try {
const snapshot = await getActivitySnapshot();
setActivityStatus(snapshot.status || {});
setActivityRequests(Array.isArray(snapshot.requests) ? snapshot.requests : []);
const keys = Array.isArray(snapshot.dismissed)
? snapshot.dismissed
.map((entry) => entry.item_key)
.filter((key): key is string => typeof key === 'string' && key.trim().length > 0)
: [];
setDismissedActivityKeys(Array.from(new Set(keys)));
} catch (error) {
console.warn('Failed to refresh activity snapshot:', error);
} finally {
setIsActivitySnapshotLoading(false);
}
}, [isAuthenticated, resetActivity]);
const refreshActivityHistory = useCallback(async () => {
if (!isAuthenticated) {
resetActivityHistory();
return;
}
setActivityHistoryLoading(true);
try {
const rows = await listActivityHistory(HISTORY_PAGE_SIZE, 0);
const normalizedRows = Array.isArray(rows) ? rows : [];
setActivityHistoryRows(normalizedRows);
setActivityHistoryOffset(normalizedRows.length);
setActivityHistoryHasMore(normalizedRows.length === HISTORY_PAGE_SIZE);
setActivityHistoryLoaded(true);
} catch (error) {
console.warn('Failed to refresh activity history:', error);
} finally {
setActivityHistoryLoading(false);
}
}, [isAuthenticated, resetActivityHistory]);
const handleActivityTabChange = useCallback((tab: 'all' | 'downloads' | 'requests' | 'history') => {
if (tab !== 'history' || activityHistoryLoaded || activityHistoryLoading) {
return;
}
void refreshActivityHistory();
}, [activityHistoryLoaded, activityHistoryLoading, refreshActivityHistory]);
const handleActivityHistoryLoadMore = useCallback(() => {
if (!isAuthenticated || activityHistoryLoading || !activityHistoryHasMore) {
return;
}
setActivityHistoryLoading(true);
void listActivityHistory(HISTORY_PAGE_SIZE, activityHistoryOffset)
.then((rows) => {
const normalizedRows = Array.isArray(rows) ? rows : [];
setActivityHistoryRows((current) => {
const existingIds = new Set(current.map((row) => row.id));
const nextRows = normalizedRows.filter((row) => !existingIds.has(row.id));
return [...current, ...nextRows];
});
setActivityHistoryOffset((current) => current + normalizedRows.length);
setActivityHistoryHasMore(normalizedRows.length === HISTORY_PAGE_SIZE);
})
.catch((error) => {
console.warn('Failed to load more activity history:', error);
})
.finally(() => {
setActivityHistoryLoading(false);
});
}, [activityHistoryHasMore, activityHistoryLoading, activityHistoryOffset, isAuthenticated]);
useEffect(() => {
void refreshActivitySnapshot();
}, [refreshActivitySnapshot]);
useEffect(() => {
if (!socket || !isAuthenticated) {
return;
}
const refreshFromSocketEvent = () => {
void refreshActivitySnapshot();
if (activityHistoryLoaded) {
void refreshActivityHistory();
}
};
socket.on('activity_update', refreshFromSocketEvent);
socket.on('request_update', refreshFromSocketEvent);
socket.on('new_request', refreshFromSocketEvent);
return () => {
socket.off('activity_update', refreshFromSocketEvent);
socket.off('request_update', refreshFromSocketEvent);
socket.off('new_request', refreshFromSocketEvent);
};
}, [activityHistoryLoaded, isAuthenticated, refreshActivitySnapshot, refreshActivityHistory, socket]);
const requestItems = useMemo(
() =>
activityRequests
.map((record) => requestToActivityItem(record, isAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp),
[activityRequests, isAdmin]
);
const historyItems = useMemo(
() => {
const mappedItems = activityHistoryRows
.map((row) => mapHistoryRowToActivityItem(row, isAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp);
// Download dismissals already carry linked request context; hide redundant
// fulfilled-request history rows that would otherwise appear as "Approved".
const requestIdsWithDownloadRows = new Set<number>();
mappedItems.forEach((item) => {
if (item.kind === 'download' && typeof item.requestId === 'number') {
requestIdsWithDownloadRows.add(item.requestId);
}
});
if (!requestIdsWithDownloadRows.size) {
return mappedItems;
}
return mappedItems.filter((item) => {
if (item.kind !== 'request' || typeof item.requestId !== 'number') {
return true;
}
if (!requestIdsWithDownloadRows.has(item.requestId)) {
return true;
}
const requestStatus = item.requestRecord?.status;
return requestStatus !== 'fulfilled' && item.visualStatus !== 'fulfilled';
});
},
[activityHistoryRows, isAdmin]
);
const pendingRequestCount = useMemo(
() => activityRequests.filter((record) => record.status === 'pending').length,
[activityRequests]
);
const refreshHistoryIfLoaded = useCallback(() => {
if (!activityHistoryLoaded) {
return;
}
void refreshActivityHistory();
}, [activityHistoryLoaded, refreshActivityHistory]);
const dismissItems = useCallback((items: ActivityDismissPayload[], optimisticKeys: string[], errorMessage: string) => {
setDismissedActivityKeys((current) => Array.from(new Set([...current, ...optimisticKeys])));
void dismissManyActivityItems(items)
.then(() => {
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
})
.catch((error) => {
console.error('Activity dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast(errorMessage, 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
const handleRequestDismiss = useCallback((requestId: number) => {
const requestKey = `request:${requestId}`;
setDismissedActivityKeys((current) =>
current.includes(requestKey) ? current : [...current, requestKey]
);
void dismissActivityItem({
item_type: 'request',
item_key: requestKey,
}).then(() => {
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
}).catch((error) => {
console.error('Request dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast('Failed to clear request', 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
const handleDownloadDismiss = useCallback((bookId: string, linkedRequestId?: number) => {
const items: ActivityDismissTarget[] = [{ itemType: 'download', itemKey: `download:${bookId}` }];
if (typeof linkedRequestId === 'number' && Number.isFinite(linkedRequestId)) {
items.push({ itemType: 'request', itemKey: `request:${linkedRequestId}` });
}
dismissItems(
items.map((item) => ({
item_type: item.itemType,
item_key: item.itemKey,
})),
items.map((item) => item.itemKey),
'Failed to clear item'
);
}, [dismissItems]);
const handleClearCompleted = useCallback((items: ActivityDismissTarget[]) => {
if (!items.length) {
return;
}
dismissItems(
items.map((item) => ({
item_type: item.itemType,
item_key: item.itemKey,
})),
Array.from(new Set(items.map((item) => item.itemKey))),
'Failed to clear finished downloads'
);
}, [dismissItems]);
const handleClearHistory = useCallback(() => {
resetActivityHistory();
void clearActivityHistory().catch((error) => {
console.error('Clear history failed:', error);
void refreshActivityHistory();
showToast('Failed to clear history', 'error');
});
}, [refreshActivityHistory, resetActivityHistory, showToast]);
return {
activityStatus,
requestItems,
dismissedActivityKeys,
historyItems,
pendingRequestCount,
isActivitySnapshotLoading,
activityHistoryLoading,
activityHistoryHasMore,
refreshActivitySnapshot,
handleActivityTabChange,
resetActivity,
handleActivityHistoryLoadMore,
handleRequestDismiss,
handleDownloadDismiss,
handleClearCompleted,
handleClearHistory,
};
};

View File

@@ -4,38 +4,20 @@ import {
SettingsTab,
SettingsGroup,
SettingsField,
SelectFieldConfig,
ActionResult,
UpdateResult,
} from '../types/settings';
import {
getStoredThemePreference,
setThemePreference,
THEME_FIELD,
} from '../utils/themePreference';
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
// Client-side only theme field that gets injected into the general tab
const THEME_FIELD: SelectFieldConfig = {
type: 'SelectField',
key: '_THEME',
label: 'Theme',
description: 'Choose your preferred color scheme.',
value: 'auto', // Placeholder, actual value comes from localStorage
options: [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto (System)' },
],
};
// Apply theme to document
function applyTheme(theme: string): void {
const effectiveTheme = theme === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
// Extract value from a field based on its type
function getFieldValue(field: SettingsField): unknown {
// These field types have no value property
@@ -138,7 +120,7 @@ export function useSettings(): UseSettingsReturn {
getValueBearingFields(tab.fields).forEach((field) => {
// Special handling for theme field - get from localStorage
if (field.key === '_THEME') {
initialValues[tab.name][field.key] = localStorage.getItem('preferred-theme') || 'auto';
initialValues[tab.name][field.key] = getStoredThemePreference();
} else {
initialValues[tab.name][field.key] = getFieldValue(field);
}
@@ -169,8 +151,7 @@ export function useSettings(): UseSettingsReturn {
const updateValue = useCallback((tabName: string, key: string, value: unknown) => {
// Apply theme immediately when changed (no save button needed)
if (key === '_THEME' && typeof value === 'string') {
localStorage.setItem('preferred-theme', value);
applyTheme(value);
setThemePreference(value);
// Also update original value so it doesn't show as pending change
setOriginalValues((prev) => ({
...prev,

View File

@@ -44,6 +44,10 @@ const API = {
requests: `${API_BASE}/requests`,
adminRequests: `${API_BASE}/admin/requests`,
adminRequestCounts: `${API_BASE}/admin/requests/count`,
activitySnapshot: `${API_BASE}/activity/snapshot`,
activityDismiss: `${API_BASE}/activity/dismiss`,
activityDismissMany: `${API_BASE}/activity/dismiss-many`,
activityHistory: `${API_BASE}/activity/history`,
};
// Custom error class for authentication failures
@@ -289,6 +293,38 @@ export const getStatus = async (): Promise<StatusData> => {
return fetchJSON<StatusData>(API.status);
};
export const getActivitySnapshot = async (): Promise<ActivitySnapshotResponse> => {
return fetchJSON<ActivitySnapshotResponse>(API.activitySnapshot);
};
export const dismissActivityItem = async (payload: ActivityDismissPayload): Promise<void> => {
await fetchJSON(API.activityDismiss, {
method: 'POST',
body: JSON.stringify(payload),
});
};
export const dismissManyActivityItems = async (items: ActivityDismissPayload[]): Promise<void> => {
await fetchJSON(API.activityDismissMany, {
method: 'POST',
body: JSON.stringify({ items }),
});
};
export const listActivityHistory = async (
limit: number = 50,
offset: number = 0
): Promise<ActivityHistoryItem[]> => {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
return fetchJSON<ActivityHistoryItem[]>(`${API.activityHistory}?${params.toString()}`);
};
export const clearActivityHistory = async (): Promise<void> => {
await fetchJSON(API.activityHistory, { method: 'DELETE' });
};
export const cancelDownload = async (id: string): Promise<void> => {
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
};
@@ -309,6 +345,38 @@ export interface AdminRequestCounts {
by_status: Record<string, number>;
}
export interface ActivityDismissedItem {
item_type: 'download' | 'request';
item_key: string;
}
export interface ActivitySnapshotResponse {
status: StatusData;
requests: RequestRecord[];
dismissed: ActivityDismissedItem[];
}
export interface ActivityDismissPayload {
item_type: 'download' | 'request';
item_key: string;
activity_log_id?: number;
}
export interface ActivityHistoryItem {
id: number;
user_id: number;
item_type: 'download' | 'request';
item_key: string;
activity_log_id: number | null;
dismissed_at: string;
snapshot: Record<string, unknown> | null;
origin: 'direct' | 'request' | 'requested' | null;
final_status: string | null;
terminal_at: string | null;
request_id: number | null;
source_id: string | null;
}
export const fetchRequestPolicy = async (): Promise<RequestPolicyResponse> => {
return fetchJSON<RequestPolicyResponse>(API.requestPolicy);
};
@@ -527,6 +595,12 @@ export interface AdminUser {
settings?: Record<string, unknown>;
}
export interface SelfUserEditContext {
user: AdminUser;
deliveryPreferences: DeliveryPreferencesResponse | null;
userOverridableKeys: string[];
}
export const getAdminUsers = async (): Promise<AdminUser[]> => {
return fetchJSON<AdminUser[]>(`${API_BASE}/admin/users`);
};
@@ -644,3 +718,19 @@ export const getAdminSettingsOverridesSummary = async (
): Promise<SettingsOverridesSummaryResponse> => {
return fetchJSON<SettingsOverridesSummaryResponse>(`${API_BASE}/admin/settings/overrides-summary?tab=${encodeURIComponent(tabName)}`);
};
export const getSelfUserEditContext = async (): Promise<SelfUserEditContext> => {
return fetchJSON<SelfUserEditContext>(`${API_BASE}/users/me/edit-context`);
};
export const updateSelfUser = async (
data: Partial<Pick<AdminUser, 'email' | 'display_name'>> & {
password?: string;
settings?: Record<string, unknown>;
}
): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/users/me`, {
method: 'PUT',
body: JSON.stringify(data),
});
};

View File

@@ -30,7 +30,7 @@ describe('activityCardModel', () => {
);
assert.equal(model.badges.length, 1);
assert.equal(model.badges[0]?.text, 'Requested by testuser');
assert.equal(model.badges[0]?.text, 'Needs review · testuser');
});
it('keeps pending label for requester-side pending requests', () => {
@@ -45,7 +45,7 @@ describe('activityCardModel', () => {
);
assert.equal(model.badges.length, 1);
assert.equal(model.badges[0]?.text, 'Pending');
assert.equal(model.badges[0]?.text, 'Awaiting review');
});
it('uses requester-friendly approved wording for fulfilled requests', () => {
@@ -98,7 +98,7 @@ describe('activityCardModel', () => {
assert.equal(model.badges[0]?.visualStatus, 'resolving');
});
it('shows request and download badges side-by-side for merged request downloads', () => {
it('shows a single download completion badge for completed merged request downloads', () => {
const model = buildActivityCardModel(
makeItem({
visualStatus: 'complete',
@@ -127,11 +127,42 @@ describe('activityCardModel', () => {
true
);
assert.equal(model.badges.length, 2);
assert.equal(model.badges[0]?.key, 'request');
assert.equal(model.badges[0]?.text, 'Request fulfilled');
assert.equal(model.badges[1]?.key, 'download');
assert.equal(model.badges[1]?.text, 'Sent to Kindle');
assert.equal(model.badges.length, 1);
assert.equal(model.badges[0]?.key, 'download');
assert.equal(model.badges[0]?.text, 'Sent to Kindle');
assert.equal(model.badges[0]?.visualStatus, 'complete');
});
it('does not render a special note for fulfilled requests with terminal delivery state', () => {
const model = buildActivityCardModel(
makeItem({
kind: 'request',
visualStatus: 'fulfilled',
requestId: 42,
requestRecord: {
id: 42,
user_id: 7,
status: 'fulfilled',
delivery_state: 'complete',
source_hint: 'prowlarr',
content_type: 'ebook',
request_level: 'release',
policy_mode: 'request_release',
book_data: { title: 'The Martian', author: 'Andy Weir' },
release_data: { source_id: 'book-1' },
note: null,
admin_note: null,
reviewed_by: null,
reviewed_at: null,
created_at: '2026-02-13T12:00:00Z',
updated_at: '2026-02-13T12:00:00Z',
username: 'testuser',
},
}),
false
);
assert.equal(model.noteLine, undefined);
});
it('builds pending admin request actions from one normalized source', () => {

View File

@@ -74,7 +74,7 @@ describe('activityMappers.downloadToActivityItem', () => {
assert.equal(item.kind, 'download');
assert.equal(item.visualStatus, 'queued');
assert.equal(item.statusLabel, 'Queued');
assert.equal(item.metaLine, 'EPUB | 3 MB | Direct Download | alice');
assert.equal(item.metaLine, 'EPUB · 3 MB · Direct Download · alice');
assert.equal(item.progress, 5);
assert.equal(item.progressAnimated, true);
assert.equal(item.timestamp, 123);
@@ -117,7 +117,7 @@ describe('activityMappers.requestToActivityItem', () => {
assert.equal(item.kind, 'request');
assert.equal(item.visualStatus, 'pending');
assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr | alice');
assert.equal(item.metaLine, 'EPUB · 2 MB · Prowlarr · alice');
assert.equal(item.requestId, 42);
assert.equal(item.requestLevel, 'release');
assert.equal(item.requestNote, 'please add this');
@@ -153,6 +153,6 @@ describe('activityMappers.requestToActivityItem', () => {
it('does not append username to meta line for user viewer role', () => {
const item = requestToActivityItem(makeRequest(), 'user');
assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr');
assert.equal(item.metaLine, 'EPUB · 2 MB · Prowlarr');
});
});

View File

@@ -197,6 +197,8 @@ export interface RequestRecord {
id: number;
user_id: number;
status: 'pending' | 'fulfilled' | 'rejected' | 'cancelled';
delivery_state?: 'none' | 'unknown' | 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error' | 'cancelled';
delivery_updated_at?: string | null;
source_hint: string | null;
content_type: ContentType;
request_level: 'book' | 'release';

View File

@@ -0,0 +1,41 @@
import { SelectFieldConfig } from '../types/settings';
export const THEME_PREFERENCE_KEY = 'preferred-theme';
export const DEFAULT_THEME_PREFERENCE = 'auto';
export const THEME_FIELD: SelectFieldConfig = {
type: 'SelectField',
key: '_THEME',
label: 'Theme',
description: 'Choose your preferred color scheme.',
value: DEFAULT_THEME_PREFERENCE,
options: [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto (System)' },
],
};
export function getStoredThemePreference(): string {
try {
return localStorage.getItem(THEME_PREFERENCE_KEY) || DEFAULT_THEME_PREFERENCE;
} catch {
return DEFAULT_THEME_PREFERENCE;
}
}
export function applyThemePreference(theme: string): void {
const effectiveTheme = theme === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
export function setThemePreference(theme: string): void {
try {
localStorage.setItem(THEME_PREFERENCE_KEY, theme);
} catch {
// localStorage may be unavailable in private browsing
}
applyThemePreference(theme);
}