Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d91213e41 | ||
|
|
c86e2d6840 | ||
|
|
5f072f87bb | ||
|
|
eb3e57577c | ||
|
|
f29d85565a | ||
|
|
ae72344317 | ||
|
|
6565a6ce18 |
@@ -1,12 +0,0 @@
|
||||
# CI Configuration
|
||||
|
||||
## CI Lite Mode
|
||||
|
||||
Skip non-essential CI workflows by setting a repository variable:
|
||||
|
||||
**Settings → Secrets and variables → Actions → Variables → New repository variable**
|
||||
|
||||
- Name: `CI_PROFILE`
|
||||
- Value: `lite`
|
||||
|
||||
Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs.
|
||||
@@ -1,10 +1,14 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -25,6 +29,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
registry.addInterceptor(endpointInterceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// Cache hashed assets (JS/CSS with content hashes) for 1 year
|
||||
// These files have names like index-ChAS4tCC.js that change when content changes
|
||||
registry.addResourceHandler("/assets/**")
|
||||
.addResourceLocations("classpath:/static/assets/")
|
||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
|
||||
|
||||
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
|
||||
registry.addResourceHandler("/index.html")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
// Check if running in Tauri mode
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.1.2'
|
||||
version = '2.1.3'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
Generated
+33
@@ -105,6 +105,7 @@
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
@@ -11093,6 +11094,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
|
||||
@@ -14503,6 +14517,25 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz",
|
||||
"integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"p-map": "^7.0.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-tsconfig-paths": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
@@ -435,6 +435,24 @@ latestVersion = "Latest Version"
|
||||
checkForUpdates = "Check for Updates"
|
||||
viewDetails = "View Details"
|
||||
|
||||
[settings.security]
|
||||
title = "Security"
|
||||
description = "Update your password to keep your account secure."
|
||||
|
||||
[settings.security.password]
|
||||
subtitle = "Change your password. You will be logged out after updating."
|
||||
required = "All fields are required."
|
||||
mismatch = "New passwords do not match."
|
||||
error = "Unable to update password. Please verify your current password and try again."
|
||||
success = "Password updated successfully. Please sign in again."
|
||||
current = "Current password"
|
||||
currentPlaceholder = "Enter your current password"
|
||||
new = "New password"
|
||||
newPlaceholder = "Enter a new password"
|
||||
confirm = "Confirm new password"
|
||||
confirmPlaceholder = "Re-enter your new password"
|
||||
update = "Update password"
|
||||
|
||||
[settings.hotkeys]
|
||||
title = "Keyboard Shortcuts"
|
||||
description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel."
|
||||
@@ -493,6 +511,10 @@ oldPassword = "Current Password"
|
||||
newPassword = "New Password"
|
||||
confirmNewPassword = "Confirm New Password"
|
||||
submit = "Submit Changes"
|
||||
credsUpdated = "Account updated"
|
||||
description = "Changes saved. Please log in again."
|
||||
error = "Unable to update username. Please verify your password and try again."
|
||||
changeUsername = "Update your username. You will be logged out after updating."
|
||||
|
||||
[account]
|
||||
title = "Account Settings"
|
||||
@@ -5070,6 +5092,7 @@ loading = "Loading..."
|
||||
back = "Back"
|
||||
continue = "Continue"
|
||||
error = "Error"
|
||||
save = "Save"
|
||||
|
||||
[config.overview]
|
||||
title = "Application Configuration"
|
||||
|
||||
@@ -69,7 +69,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
}), []);
|
||||
|
||||
// Get isAdmin and runningEE from app config
|
||||
const isAdmin = true // config?.isAdmin ?? false;
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: `${name} (${code})`,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
// Hide the language selector if there's only one language option
|
||||
|
||||
@@ -3,6 +3,7 @@ export const VALID_NAV_KEYS = [
|
||||
'preferences',
|
||||
'notifications',
|
||||
'connections',
|
||||
'account',
|
||||
'general',
|
||||
'people',
|
||||
'teams',
|
||||
|
||||
@@ -44,8 +44,10 @@ export function CustomSearchLayer({
|
||||
}
|
||||
|
||||
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
|
||||
if (!state) return;
|
||||
|
||||
// Auto-scroll to active search result
|
||||
if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
|
||||
if (state.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
|
||||
const activeResult = state.results[state.activeResultIndex];
|
||||
if (activeResult) {
|
||||
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number
|
||||
|
||||
@@ -43,6 +43,8 @@ const EmbedPdfViewerContent = ({
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
isBookmarkSidebarVisible,
|
||||
isSearchInterfaceVisible,
|
||||
searchInterfaceActions,
|
||||
zoomActions,
|
||||
panActions: _panActions,
|
||||
rotationActions: _rotationActions,
|
||||
@@ -184,7 +186,7 @@ const EmbedPdfViewerContent = ({
|
||||
onZoomOut: zoomActions.zoomOut,
|
||||
});
|
||||
|
||||
// Handle keyboard zoom shortcuts
|
||||
// Handle keyboard shortcuts (zoom and search)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isViewerHovered) return;
|
||||
@@ -199,6 +201,16 @@ const EmbedPdfViewerContent = ({
|
||||
// Ctrl+- for zoom out
|
||||
event.preventDefault();
|
||||
zoomActions.zoomOut();
|
||||
} else if (event.key === 'f' || event.key === 'F') {
|
||||
// Ctrl+F for search
|
||||
event.preventDefault();
|
||||
if (isSearchInterfaceVisible) {
|
||||
// If already open, trigger refocus event
|
||||
window.dispatchEvent(new CustomEvent('refocus-search-input'));
|
||||
} else {
|
||||
// Open search interface
|
||||
searchInterfaceActions.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -207,7 +219,7 @@ const EmbedPdfViewerContent = ({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isViewerHovered]);
|
||||
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -46,6 +46,7 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||
import { isPdfFile } from '@app/utils/fileUtils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
@@ -167,8 +168,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
|
||||
// Initialize the engine with the React hook
|
||||
const { engine, isLoading, error } = usePdfiumEngine();
|
||||
// Initialize the engine with the React hook - use local WASM for offline support
|
||||
const { engine, isLoading, error } = usePdfiumEngine({
|
||||
wasmUrl: absoluteWithBasePath('/pdfium/pdfium.wasm'),
|
||||
});
|
||||
|
||||
|
||||
// Early return if no file or URL provided
|
||||
|
||||
@@ -28,11 +28,13 @@ export function SearchAPIBridge() {
|
||||
if (!search) return;
|
||||
|
||||
const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
|
||||
if (!state) return;
|
||||
|
||||
const newState = {
|
||||
results: state?.results || null,
|
||||
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index
|
||||
results: state.results || null,
|
||||
activeIndex: (state.activeResultIndex || 0) + 1 // Convert to 1-based index
|
||||
};
|
||||
|
||||
|
||||
setLocalState(prevState => {
|
||||
// Only update if state actually changed
|
||||
if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) {
|
||||
@@ -52,16 +54,42 @@ export function SearchAPIBridge() {
|
||||
state: localState,
|
||||
api: {
|
||||
search: async (query: string) => {
|
||||
search.startSearch();
|
||||
return search.searchAllPages(query);
|
||||
if (search?.startSearch && search?.searchAllPages) {
|
||||
search.startSearch();
|
||||
return search.searchAllPages(query);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
search.stopSearch();
|
||||
try {
|
||||
if (search?.stopSearch) {
|
||||
search.stopSearch();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error stopping search:', error);
|
||||
}
|
||||
setLocalState({ results: null, activeIndex: 0 });
|
||||
},
|
||||
next: () => search.nextResult(),
|
||||
previous: () => search.previousResult(),
|
||||
goToResult: (index: number) => search.goToResult(index),
|
||||
next: () => {
|
||||
try {
|
||||
search?.nextResult?.();
|
||||
} catch (error) {
|
||||
console.warn('Error navigating to next result:', error);
|
||||
}
|
||||
},
|
||||
previous: () => {
|
||||
try {
|
||||
search?.previousResult?.();
|
||||
} catch (error) {
|
||||
console.warn('Error navigating to previous result:', error);
|
||||
}
|
||||
},
|
||||
goToResult: (index: number) => {
|
||||
try {
|
||||
search?.goToResult?.(index);
|
||||
} catch (error) {
|
||||
console.warn('Error going to result:', error);
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
@@ -12,7 +12,9 @@ interface SearchInterfaceProps {
|
||||
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const searchState = viewerContext?.getSearchState();
|
||||
const searchResults = searchState?.results;
|
||||
const searchActiveIndex = searchState?.activeIndex;
|
||||
@@ -26,6 +28,61 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
} | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Auto-focus search input when visible
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Listen for refocus event (when Ctrl+F pressed while already open)
|
||||
useEffect(() => {
|
||||
const handleRefocus = () => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
|
||||
window.addEventListener('refocus-search-input', handleRefocus);
|
||||
return () => {
|
||||
window.removeEventListener('refocus-search-input', handleRefocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-search as user types (debounced)
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// If query is empty, clear search immediately
|
||||
if (!searchQuery.trim()) {
|
||||
searchActions?.clear();
|
||||
setResultInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search by 300ms
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
if (searchQuery.trim() && searchActions) {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
await searchActions.search(searchQuery.trim());
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, searchActions]);
|
||||
|
||||
// Monitor search state changes
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
@@ -59,30 +116,21 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, searchResults, searchActiveIndex, searchQuery]);
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
// If query is empty, clear the search
|
||||
handleClearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.trim() && searchActions) {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
await searchActions.search(query.trim());
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch(searchQuery);
|
||||
// Navigate to next result on Enter
|
||||
event.preventDefault();
|
||||
handleNext();
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
// Navigate to next result
|
||||
event.preventDefault();
|
||||
handleNext();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
// Navigate to previous result
|
||||
event.preventDefault();
|
||||
handlePrevious();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,17 +151,17 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
// No longer need to sync with external API on mount - removed
|
||||
|
||||
const handleJumpToResult = (index: number) => {
|
||||
// Use context actions instead of window API - functionality simplified for now
|
||||
if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
// Note: goToResult functionality would need to be implemented in SearchAPIBridge
|
||||
console.log('Jump to result:', index);
|
||||
// Convert to 0-based index for the API
|
||||
searchActions?.goToResult?.(index - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToSubmit = () => {
|
||||
const index = parseInt(jumpToValue);
|
||||
if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
const index = parseInt(jumpToValue, 10);
|
||||
if (!isNaN(index) && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
handleJumpToResult(index);
|
||||
setJumpToValue(''); // Clear the input after jumping
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,7 +171,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const _handleClose = () => {
|
||||
const handleInputBlur = () => {
|
||||
// Close popover on blur if no text is entered
|
||||
if (!searchQuery.trim()) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseClick = () => {
|
||||
handleClearSearch();
|
||||
onClose();
|
||||
};
|
||||
@@ -135,100 +190,99 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
padding: '0px'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
{/* Header with close button */}
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('search.title', 'Search PDF')}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleCloseClick}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Search input */}
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder={t('search.placeholder', 'Enter search term...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setSearchQuery(newValue);
|
||||
// If user clears the input, clear the search highlights
|
||||
if (!newValue.trim()) {
|
||||
handleClearSearch();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
style={{ flex: 1 }}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => handleSearch(searchQuery)}
|
||||
disabled={!searchQuery.trim() || isSearching}
|
||||
loading={isSearching}
|
||||
>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
searchQuery.trim() && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Results info and navigation */}
|
||||
{resultInfo && (
|
||||
<Group justify="space-between" align="center">
|
||||
{resultInfo.totalResults === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('search.noResults', 'No results found')}
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap="xs" align="center">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={jumpToValue}
|
||||
onChange={(e) => setJumpToValue(e.currentTarget.value)}
|
||||
onKeyDown={handleJumpToKeyDown}
|
||||
onBlur={handleJumpToSubmit}
|
||||
placeholder={resultInfo.currentIndex.toString()}
|
||||
style={{ width: '3rem' }}
|
||||
type="number"
|
||||
min="1"
|
||||
max={resultInfo.totalResults}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
of {resultInfo.totalResults}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{resultInfo.totalResults > 0 && (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={resultInfo.currentIndex <= 1}
|
||||
aria-label="Previous result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={resultInfo.currentIndex >= resultInfo.totalResults}
|
||||
aria-label="Next result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
{/* Results info and navigation - always show */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs" align="center">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={jumpToValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setJumpToValue(newValue);
|
||||
|
||||
// Jump immediately as user types
|
||||
const index = parseInt(newValue, 10);
|
||||
if (resultInfo && !isNaN(index) && index >= 1 && index <= resultInfo.totalResults) {
|
||||
handleJumpToResult(index);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleJumpToKeyDown}
|
||||
onBlur={() => setJumpToValue('')} // Clear on blur instead of submit
|
||||
placeholder={(resultInfo?.currentIndex || 0).toString()}
|
||||
style={{ width: '3rem' }}
|
||||
type="number"
|
||||
min="1"
|
||||
max={resultInfo?.totalResults || 0}
|
||||
disabled={!resultInfo || resultInfo.totalResults === 0}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
of {resultInfo?.totalResults || 0}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={!resultInfo || resultInfo.currentIndex <= 1}
|
||||
aria-label="Previous result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults}
|
||||
aria-label="Next result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
|
||||
@@ -36,7 +36,14 @@ export function useViewerRightRailButtons() {
|
||||
order: 10,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}>
|
||||
<Popover
|
||||
position={tooltipPosition}
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
opened={viewer.isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
@@ -45,6 +52,7 @@ export function useViewerRightRailButtons() {
|
||||
className="right-rail-icon"
|
||||
disabled={disabled}
|
||||
aria-label={searchLabel}
|
||||
onClick={viewer.searchInterfaceActions.toggle}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
@@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface visible={true} onClose={() => {}} />
|
||||
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -80,6 +80,14 @@ interface ViewerContextType {
|
||||
isBookmarkSidebarVisible: boolean;
|
||||
toggleBookmarkSidebar: () => void;
|
||||
|
||||
// Search interface visibility
|
||||
isSearchInterfaceVisible: boolean;
|
||||
searchInterfaceActions: {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
// Annotation visibility toggle
|
||||
isAnnotationsVisible: boolean;
|
||||
toggleAnnotationsVisibility: () => void;
|
||||
@@ -145,6 +153,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
|
||||
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
|
||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||
@@ -207,6 +216,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
setIsBookmarkSidebarVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const searchInterfaceActions = {
|
||||
open: () => setSearchInterfaceVisible(true),
|
||||
close: () => setSearchInterfaceVisible(false),
|
||||
toggle: () => setSearchInterfaceVisible(prev => !prev),
|
||||
};
|
||||
|
||||
const toggleAnnotationsVisibility = () => {
|
||||
setIsAnnotationsVisible(prev => !prev);
|
||||
};
|
||||
@@ -294,6 +309,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
isBookmarkSidebarVisible,
|
||||
toggleBookmarkSidebar,
|
||||
|
||||
// Search interface
|
||||
isSearchInterfaceVisible,
|
||||
searchInterfaceActions,
|
||||
|
||||
// Annotation controls
|
||||
isAnnotationsVisible,
|
||||
toggleAnnotationsVisibility,
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface SearchActions {
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
clear: () => void;
|
||||
goToResult: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface ExportActions {
|
||||
@@ -287,6 +288,12 @@ export function createViewerActions({
|
||||
api.clear();
|
||||
}
|
||||
},
|
||||
goToResult: (index: number) => {
|
||||
const api = registry.current.search?.api;
|
||||
if (api?.goToResult) {
|
||||
api.goToResult(index);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const exportActions: ExportActions = {
|
||||
|
||||
@@ -56,4 +56,14 @@ export const accountService = {
|
||||
formData.append('newPassword', newPassword);
|
||||
await apiClient.post('/api/v1/user/change-password-on-login', formData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Change username
|
||||
*/
|
||||
async changeUsername(newUsername: string, currentPassword: string): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('currentPasswordChangeUsername', currentPassword);
|
||||
formData.append('newUsername', newUsername);
|
||||
await apiClient.post('/api/v1/user/change-username', formData);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ import AdminEndpointsSection from '@app/components/shared/config/configSections/
|
||||
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
|
||||
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
|
||||
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
|
||||
import AccountSection from '@app/components/shared/config/configSections/AccountSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
|
||||
/**
|
||||
* Hook version of proprietary config nav sections with proper i18n support
|
||||
@@ -30,6 +32,23 @@ export const useConfigNavSections = (
|
||||
// Get the core sections (just Preferences)
|
||||
const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
|
||||
// Add account management under Preferences
|
||||
const preferencesSection = sections.find((section) => section.items.some((item) => item.key === 'general'));
|
||||
if (preferencesSection) {
|
||||
preferencesSection.items = preferencesSection.items.map((item) =>
|
||||
item.key === 'general' ? { ...item, component: <GeneralSection /> } : item
|
||||
);
|
||||
|
||||
if (loginEnabled) {
|
||||
preferencesSection.items.push({
|
||||
key: 'account',
|
||||
label: t('account.accountSettings', 'Account'),
|
||||
icon: 'person-rounded',
|
||||
component: <AccountSection />
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
|
||||
if (isAdmin || !loginEnabled) {
|
||||
const requiresLogin = !loginEnabled;
|
||||
@@ -220,6 +239,23 @@ export const createConfigNavSections = (
|
||||
// Get the core sections (just Preferences)
|
||||
const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
|
||||
// Add account management under Preferences
|
||||
const preferencesSection = sections.find((section) => section.items.some((item) => item.key === 'general'));
|
||||
if (preferencesSection) {
|
||||
preferencesSection.items = preferencesSection.items.map((item) =>
|
||||
item.key === 'general' ? { ...item, component: <GeneralSection /> } : item
|
||||
);
|
||||
|
||||
if (loginEnabled) {
|
||||
preferencesSection.items.push({
|
||||
key: 'account',
|
||||
label: 'Account',
|
||||
icon: 'person-rounded',
|
||||
component: <AccountSection />
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
|
||||
if (isAdmin || !loginEnabled) {
|
||||
const requiresLogin = !loginEnabled;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert as showToast } from '@app/components/toast';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, signOut } = useAuth();
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
||||
|
||||
const [currentPasswordForUsername, setCurrentPasswordForUsername] = useState('');
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
|
||||
|
||||
const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]);
|
||||
|
||||
const redirectToLogin = useCallback(() => {
|
||||
window.location.assign('/login');
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [redirectToLogin, signOut]);
|
||||
|
||||
const handlePasswordSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setPasswordError(t('settings.security.password.required', 'All fields are required.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError(t('settings.security.password.mismatch', 'New passwords do not match.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPasswordSubmitting(true);
|
||||
setPasswordError('');
|
||||
|
||||
await accountService.changePassword(currentPassword, newPassword);
|
||||
|
||||
showToast({
|
||||
alertType: 'success',
|
||||
title: t('settings.security.password.success', 'Password updated successfully. Please sign in again.'),
|
||||
});
|
||||
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordModalOpen(false);
|
||||
await handleLogout();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||
setPasswordError(
|
||||
axiosError.response?.data?.message ||
|
||||
t('settings.security.password.error', 'Unable to update password. Please verify your current password and try again.')
|
||||
);
|
||||
} finally {
|
||||
setPasswordSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentPasswordForUsername || !newUsername) {
|
||||
setUsernameError(t('settings.security.password.required', 'All fields are required.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUsernameSubmitting(true);
|
||||
setUsernameError('');
|
||||
|
||||
await accountService.changeUsername(newUsername, currentPasswordForUsername);
|
||||
|
||||
showToast({
|
||||
alertType: 'success',
|
||||
title: t('changeCreds.credsUpdated', 'Account updated'),
|
||||
body: t('changeCreds.description', 'Changes saved. Please log in again.'),
|
||||
});
|
||||
|
||||
setNewUsername('');
|
||||
setCurrentPasswordForUsername('');
|
||||
setUsernameModalOpen(false);
|
||||
await handleLogout();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||
setUsernameError(
|
||||
axiosError.response?.data?.message ||
|
||||
t('changeCreds.error', 'Unable to update username. Please verify your password and try again.')
|
||||
);
|
||||
} finally {
|
||||
setUsernameSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('account.accountSettings', 'Account')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('changeCreds.header', 'Update Your Account Details')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{userIdentifier
|
||||
? t('settings.general.user', 'User') + ': ' + userIdentifier
|
||||
: t('account.accountSettings', 'Account Settings')}
|
||||
</Text>
|
||||
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Button leftSection={<LocalIcon icon="key-rounded" />} onClick={() => setPasswordModalOpen(true)}>
|
||||
{t('settings.security.password.update', 'Update password')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<LocalIcon icon="edit-rounded" />}
|
||||
onClick={() => setUsernameModalOpen(true)}
|
||||
>
|
||||
{t('account.changeUsername', 'Change username')}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" color="red" leftSection={<LocalIcon icon="logout-rounded" />} onClick={handleLogout}>
|
||||
{t('settings.general.logout', 'Log out')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={passwordModalOpen}
|
||||
onClose={() => setPasswordModalOpen(false)}
|
||||
title={t('settings.security.title', 'Change password')}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.security.password.subtitle', 'Change your password. You will be logged out after updating.')}
|
||||
</Text>
|
||||
|
||||
{passwordError && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
{passwordError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
label={t('settings.security.password.current', 'Current password')}
|
||||
placeholder={t('settings.security.password.currentPlaceholder', 'Enter your current password')}
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('settings.security.password.new', 'New password')}
|
||||
placeholder={t('settings.security.password.newPlaceholder', 'Enter a new password')}
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('settings.security.password.confirm', 'Confirm new password')}
|
||||
placeholder={t('settings.security.password.confirmPlaceholder', 'Re-enter your new password')}
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setPasswordModalOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" loading={passwordSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
|
||||
{t('settings.security.password.update', 'Update password')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={usernameModalOpen}
|
||||
onClose={() => setUsernameModalOpen(false)}
|
||||
title={t('account.changeUsername', 'Change username')}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleUsernameSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('changeCreds.changeUsername', 'Update your username. You will be logged out after updating.')}
|
||||
</Text>
|
||||
|
||||
{usernameError && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
{usernameError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('changeCreds.newUsername', 'New Username')}
|
||||
placeholder={t('changeCreds.newUsername', 'New Username')}
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('changeCreds.oldPassword', 'Current Password')}
|
||||
placeholder={t('changeCreds.oldPassword', 'Current Password')}
|
||||
value={currentPasswordForUsername}
|
||||
onChange={(event) => setCurrentPasswordForUsername(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setUsernameModalOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" loading={usernameSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSection;
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
|
||||
|
||||
/**
|
||||
* Proprietary extension of GeneralSection that adds account management
|
||||
*/
|
||||
const GeneralSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { signOut, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.description', 'Configure general application preferences.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<Stack gap="xs" align="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.user', 'User')}: <strong>{user.email || user.username}</strong>
|
||||
</Text>
|
||||
<Button color="red" variant="outline" size="xs" onClick={handleLogout}>
|
||||
{t('settings.general.logout', 'Log out')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Render core general section preferences (without title since we show it above) */}
|
||||
<CoreGeneralSection hideTitle />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSection;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features
|
||||
@@ -20,6 +21,15 @@ export default defineConfig(({ mode }) => {
|
||||
tsconfigPaths({
|
||||
projects: [tsconfigProject],
|
||||
}),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
//provides static pdfium so embedpdf can run without cdn
|
||||
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
|
||||
dest: 'pdfium'
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
server: {
|
||||
host: true,
|
||||
|
||||
Reference in New Issue
Block a user