mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-20 13:59:46 -04:00
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:
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
357
src/frontend/src/components/settings/SelfSettingsModal.tsx
Normal file
357
src/frontend/src/components/settings/SelfSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { SettingsModal } from './SettingsModal';
|
||||
export { SelfSettingsModal } from './SelfSettingsModal';
|
||||
export { SettingsHeader } from './SettingsHeader';
|
||||
export { SettingsSidebar } from './SettingsSidebar';
|
||||
export { SettingsContent } from './SettingsContent';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
417
src/frontend/src/hooks/useActivity.ts
Normal file
417
src/frontend/src/hooks/useActivity.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
41
src/frontend/src/utils/themePreference.ts
Normal file
41
src/frontend/src/utils/themePreference.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user