maintenance improvements

This commit is contained in:
Stefan Hardegger
2025-09-26 21:41:33 +02:00
parent 74cdd5dc57
commit 5325169495
10 changed files with 377 additions and 65 deletions

View File

@@ -2,6 +2,7 @@
const nextConfig = {
// Enable standalone output for optimized Docker builds
output: 'standalone',
// Note: Body size limits are handled by nginx and backend, not Next.js frontend
// Removed Next.js rewrites since nginx handles all API routing
webpack: (config, { isServer }) => {
// Exclude cheerio and its dependencies from client-side bundling

View File

@@ -35,30 +35,31 @@ export default function AuthorsPage() {
} else {
setSearchLoading(true);
}
const searchResults = await authorApi.getAuthors({
// Use Solr search for all queries (including empty search)
const searchResults = await authorApi.searchAuthors({
query: searchQuery || '*', // Use '*' for all authors when no search query
page: currentPage,
size: ITEMS_PER_PAGE,
sortBy: sortBy,
sortDir: sortOrder
});
if (currentPage === 0) {
// First page - replace all results
setAuthors(searchResults.content || []);
setFilteredAuthors(searchResults.content || []);
setAuthors(searchResults.results || []);
setFilteredAuthors(searchResults.results || []);
} else {
// Subsequent pages - append results
setAuthors(prev => [...prev, ...(searchResults.content || [])]);
setFilteredAuthors(prev => [...prev, ...(searchResults.content || [])]);
setAuthors(prev => [...prev, ...(searchResults.results || [])]);
setFilteredAuthors(prev => [...prev, ...(searchResults.results || [])]);
}
setTotalHits(searchResults.totalElements || 0);
setHasMore(searchResults.content.length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < (searchResults.totalElements || 0));
setTotalHits(searchResults.totalHits || 0);
setHasMore((searchResults.results || []).length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < (searchResults.totalHits || 0));
} catch (error) {
console.error('Failed to load authors:', error);
// Error handling for API failures
console.error('Failed to load authors:', error);
console.error('Failed to search authors:', error);
} finally {
setLoading(false);
setSearchLoading(false);
@@ -84,17 +85,7 @@ export default function AuthorsPage() {
}
};
// Client-side filtering for search query when using regular API
useEffect(() => {
if (searchQuery) {
const filtered = authors.filter(author =>
author.name.toLowerCase().includes(searchQuery.toLowerCase())
);
setFilteredAuthors(filtered);
} else {
setFilteredAuthors(authors);
}
}, [authors, searchQuery]);
// No longer needed - Solr search handles filtering directly
// Note: We no longer have individual story ratings in the author list
// Average rating would need to be calculated on backend if needed
@@ -117,9 +108,8 @@ export default function AuthorsPage() {
<div>
<h1 className="text-3xl font-bold theme-header">Authors</h1>
<p className="theme-text mt-1">
{searchQuery ? `${filteredAuthors.length} of ${authors.length}` : filteredAuthors.length} {(searchQuery ? authors.length : filteredAuthors.length) === 1 ? 'author' : 'authors'}
{searchQuery ? ` found` : ` in your library`}
{!searchQuery && hasMore && ` (showing first ${filteredAuthors.length})`}
{searchQuery ? `${totalHits} authors found` : `${totalHits} authors in your library`}
{hasMore && ` (showing first ${filteredAuthors.length})`}
</p>
</div>
@@ -226,7 +216,7 @@ export default function AuthorsPage() {
className="px-8 py-3"
loading={loading}
>
{loading ? 'Loading...' : `Load More Authors (${totalHits - authors.length} remaining)`}
{loading ? 'Loading...' : `Load More Authors (${totalHits - filteredAuthors.length} remaining)`}
</Button>
</div>
)}

View File

@@ -49,6 +49,25 @@ export default function SystemSettings({}: SystemSettingsProps) {
execute: { loading: false, message: '' }
});
const [hoveredImage, setHoveredImage] = useState<{ src: string; alt: string } | null>(null);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const handleImageHover = (filePath: string, fileName: string, event: React.MouseEvent) => {
// Convert backend file path to frontend image URL
const imageUrl = filePath.replace(/^.*\/images\//, '/images/');
setHoveredImage({ src: imageUrl, alt: fileName });
setMousePosition({ x: event.clientX, y: event.clientY });
};
const handleImageLeave = () => {
setHoveredImage(null);
};
const isImageFile = (fileName: string): boolean => {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
return imageExtensions.some(ext => fileName.toLowerCase().endsWith(ext));
};
const handleCompleteBackup = async () => {
@@ -231,13 +250,7 @@ export default function SystemSettings({}: SystemSettingsProps) {
}));
}
// Clear message after 10 seconds
setTimeout(() => {
setCleanupStatus(prev => ({
...prev,
preview: { loading: false, message: '', success: undefined }
}));
}, 10000);
// Note: Preview message no longer auto-clears to allow users to review file details
};
const handleImageCleanupExecute = async () => {
@@ -614,6 +627,18 @@ export default function SystemSettings({}: SystemSettingsProps) {
>
{cleanupStatus.execute.loading ? 'Cleaning...' : 'Execute Cleanup'}
</Button>
{cleanupStatus.preview.message && (
<Button
onClick={() => setCleanupStatus(prev => ({
...prev,
preview: { loading: false, message: '', success: undefined, data: undefined }
}))}
variant="ghost"
className="px-4 py-2 text-sm"
>
Clear Preview
</Button>
)}
</div>
{/* Preview Results */}
@@ -667,6 +692,76 @@ export default function SystemSettings({}: SystemSettingsProps) {
<span className="font-medium">Referenced Images:</span> {cleanupStatus.preview.data.referencedImagesCount}
</div>
</div>
{/* Detailed File List */}
{cleanupStatus.preview.data.orphanedFiles && cleanupStatus.preview.data.orphanedFiles.length > 0 && (
<div className="mt-4">
<details className="cursor-pointer">
<summary className="font-medium text-sm theme-header mb-2">
📁 View Files to be Deleted ({cleanupStatus.preview.data.orphanedFiles.length})
</summary>
<div className="mt-3 max-h-96 overflow-y-auto border theme-border rounded">
<table className="w-full text-xs">
<thead className="bg-gray-100 dark:bg-gray-800 sticky top-0">
<tr>
<th className="text-left p-2 font-medium">File Name</th>
<th className="text-left p-2 font-medium">Size</th>
<th className="text-left p-2 font-medium">Story</th>
<th className="text-left p-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{cleanupStatus.preview.data.orphanedFiles.map((file: any, index: number) => (
<tr key={index} className="border-t theme-border hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="p-2">
<div
className={`truncate max-w-xs ${isImageFile(file.fileName) ? 'cursor-pointer text-blue-600 dark:text-blue-400' : ''}`}
title={file.fileName}
onMouseEnter={isImageFile(file.fileName) ? (e) => handleImageHover(file.filePath, file.fileName, e) : undefined}
onMouseMove={isImageFile(file.fileName) ? (e) => setMousePosition({ x: e.clientX, y: e.clientY }) : undefined}
onMouseLeave={isImageFile(file.fileName) ? handleImageLeave : undefined}
>
{isImageFile(file.fileName) && '🖼️ '}{file.fileName}
</div>
<div className="text-xs text-gray-500 truncate max-w-xs" title={file.filePath}>
{file.filePath}
</div>
</td>
<td className="p-2">{file.formattedSize}</td>
<td className="p-2">
{file.storyExists && file.storyTitle ? (
<a
href={`/stories/${file.storyId}`}
className="text-blue-600 dark:text-blue-400 hover:underline truncate max-w-xs block"
title={file.storyTitle}
>
{file.storyTitle}
</a>
) : file.storyId !== 'unknown' && file.storyId !== 'error' ? (
<span className="text-gray-500" title={`Story ID: ${file.storyId}`}>
Deleted Story
</span>
) : (
<span className="text-gray-400">Unknown</span>
)}
</td>
<td className="p-2">
{file.storyExists ? (
<span className="text-orange-600 dark:text-orange-400 text-xs">Orphaned</span>
) : file.storyId !== 'unknown' && file.storyId !== 'error' ? (
<span className="text-red-600 dark:text-red-400 text-xs">Story Deleted</span>
) : (
<span className="text-gray-500 text-xs">Unknown Folder</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
)}
</div>
)}
</div>
@@ -787,6 +882,31 @@ export default function SystemSettings({}: SystemSettingsProps) {
</div>
</div>
</div>
{/* Image Preview Overlay */}
{hoveredImage && (
<div
className="fixed pointer-events-none z-50 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl p-2 max-w-sm"
style={{
left: mousePosition.x + 10,
top: mousePosition.y - 100,
transform: mousePosition.x > window.innerWidth - 300 ? 'translateX(-100%)' : 'none'
}}
>
<img
src={hoveredImage.src}
alt={hoveredImage.alt}
className="max-w-full max-h-64 object-contain rounded"
onError={() => {
// Hide preview if image fails to load
setHoveredImage(null);
}}
/>
<div className="text-xs theme-text mt-1 truncate">
{hoveredImage.alt}
</div>
</div>
)}
</div>
);
}

View File

@@ -343,7 +343,34 @@ export const authorApi = {
removeAvatar: async (id: string): Promise<void> => {
await api.delete(`/authors/${id}/avatar`);
},
searchAuthors: async (params: {
query?: string;
page?: number;
size?: number;
sortBy?: string;
sortDir?: string;
}): Promise<{
results: Author[];
totalHits: number;
page: number;
perPage: number;
query: string;
searchTimeMs: number;
}> => {
const searchParams = new URLSearchParams();
// Add query parameter
searchParams.append('q', params.query || '*');
if (params.page !== undefined) searchParams.append('page', params.page.toString());
if (params.size !== undefined) searchParams.append('size', params.size.toString());
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
if (params.sortDir) searchParams.append('sortOrder', params.sortDir);
const response = await api.get(`/authors/search-typesense?${searchParams.toString()}`);
return response.data;
},
};
// Tag endpoints
@@ -596,6 +623,17 @@ export const configApi = {
hasErrors: boolean;
dryRun: boolean;
error?: string;
orphanedFiles?: Array<{
filePath: string;
fileName: string;
fileSize: number;
formattedSize: string;
storyId: string;
storyTitle: string | null;
storyExists: boolean;
canAccessStory: boolean;
error?: string;
}>;
}> => {
const response = await api.post('/config/cleanup/images/preview');
return response.data;

File diff suppressed because one or more lines are too long