maintenance improvements
This commit is contained in:
@@ -3,27 +3,43 @@ package com.storycove.controller;
|
|||||||
import com.storycove.dto.HtmlSanitizationConfigDto;
|
import com.storycove.dto.HtmlSanitizationConfigDto;
|
||||||
import com.storycove.service.HtmlSanitizationService;
|
import com.storycove.service.HtmlSanitizationService;
|
||||||
import com.storycove.service.ImageService;
|
import com.storycove.service.ImageService;
|
||||||
|
import com.storycove.service.StoryService;
|
||||||
|
import com.storycove.entity.Story;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/config")
|
@RequestMapping("/api/config")
|
||||||
public class ConfigController {
|
public class ConfigController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ConfigController.class);
|
||||||
|
|
||||||
private final HtmlSanitizationService htmlSanitizationService;
|
private final HtmlSanitizationService htmlSanitizationService;
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
|
private final StoryService storyService;
|
||||||
|
|
||||||
@Value("${app.reading.speed.default:200}")
|
@Value("${app.reading.speed.default:200}")
|
||||||
private int defaultReadingSpeed;
|
private int defaultReadingSpeed;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public ConfigController(HtmlSanitizationService htmlSanitizationService, ImageService imageService) {
|
public ConfigController(HtmlSanitizationService htmlSanitizationService, ImageService imageService, StoryService storyService) {
|
||||||
this.htmlSanitizationService = htmlSanitizationService;
|
this.htmlSanitizationService = htmlSanitizationService;
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
|
this.storyService = storyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,27 +77,55 @@ public class ConfigController {
|
|||||||
@PostMapping("/cleanup/images/preview")
|
@PostMapping("/cleanup/images/preview")
|
||||||
public ResponseEntity<Map<String, Object>> previewImageCleanup() {
|
public ResponseEntity<Map<String, Object>> previewImageCleanup() {
|
||||||
try {
|
try {
|
||||||
|
logger.info("Starting image cleanup preview");
|
||||||
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(true);
|
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(true);
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
// Create detailed file information with story relationships
|
||||||
"success", true,
|
logger.info("Processing {} orphaned files for detailed information", result.getOrphanedImages().size());
|
||||||
"orphanedCount", result.getOrphanedImages().size(),
|
List<Map<String, Object>> orphanedFiles = result.getOrphanedImages().stream()
|
||||||
"totalSizeBytes", result.getTotalSizeBytes(),
|
.map(filePath -> {
|
||||||
"formattedSize", result.getFormattedSize(),
|
try {
|
||||||
"foldersToDelete", result.getFoldersToDelete(),
|
return createFileInfo(filePath);
|
||||||
"referencedImagesCount", result.getTotalReferencedImages(),
|
} catch (Exception e) {
|
||||||
"errors", result.getErrors(),
|
logger.error("Error processing file {}: {}", filePath, e.getMessage());
|
||||||
"hasErrors", result.hasErrors(),
|
// Return a basic error entry instead of failing completely
|
||||||
"dryRun", true
|
Map<String, Object> errorEntry = new HashMap<>();
|
||||||
);
|
errorEntry.put("filePath", filePath);
|
||||||
|
errorEntry.put("fileName", Paths.get(filePath).getFileName().toString());
|
||||||
|
errorEntry.put("fileSize", 0L);
|
||||||
|
errorEntry.put("formattedSize", "0 B");
|
||||||
|
errorEntry.put("storyId", "error");
|
||||||
|
errorEntry.put("storyTitle", null);
|
||||||
|
errorEntry.put("storyExists", false);
|
||||||
|
errorEntry.put("canAccessStory", false);
|
||||||
|
errorEntry.put("error", e.getMessage());
|
||||||
|
return errorEntry;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Use HashMap to avoid Map.of() null value issues
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("orphanedCount", result.getOrphanedImages().size());
|
||||||
|
response.put("totalSizeBytes", result.getTotalSizeBytes());
|
||||||
|
response.put("formattedSize", result.getFormattedSize());
|
||||||
|
response.put("foldersToDelete", result.getFoldersToDelete());
|
||||||
|
response.put("referencedImagesCount", result.getTotalReferencedImages());
|
||||||
|
response.put("errors", result.getErrors());
|
||||||
|
response.put("hasErrors", result.hasErrors());
|
||||||
|
response.put("dryRun", true);
|
||||||
|
response.put("orphanedFiles", orphanedFiles);
|
||||||
|
|
||||||
|
logger.info("Image cleanup preview completed successfully");
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(Map.of(
|
logger.error("Failed to preview image cleanup", e);
|
||||||
"success", false,
|
Map<String, Object> errorResponse = new HashMap<>();
|
||||||
"error", "Failed to preview image cleanup: " + e.getMessage()
|
errorResponse.put("success", false);
|
||||||
));
|
errorResponse.put("error", "Failed to preview image cleanup: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
|
||||||
|
return ResponseEntity.status(500).body(errorResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,4 +158,89 @@ public class ConfigController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create detailed file information for orphaned image including story relationship
|
||||||
|
*/
|
||||||
|
private Map<String, Object> createFileInfo(String filePath) {
|
||||||
|
try {
|
||||||
|
Path path = Paths.get(filePath);
|
||||||
|
String fileName = path.getFileName().toString();
|
||||||
|
long fileSize = Files.exists(path) ? Files.size(path) : 0;
|
||||||
|
|
||||||
|
// Extract story UUID from the path (content images are stored in /content/{storyId}/)
|
||||||
|
String storyId = extractStoryIdFromPath(filePath);
|
||||||
|
|
||||||
|
// Look up the story if we have a valid UUID
|
||||||
|
Story relatedStory = null;
|
||||||
|
if (storyId != null) {
|
||||||
|
try {
|
||||||
|
UUID storyUuid = UUID.fromString(storyId);
|
||||||
|
relatedStory = storyService.findById(storyUuid);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Could not find story with ID {}: {}", storyId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> fileInfo = new HashMap<>();
|
||||||
|
fileInfo.put("filePath", filePath);
|
||||||
|
fileInfo.put("fileName", fileName);
|
||||||
|
fileInfo.put("fileSize", fileSize);
|
||||||
|
fileInfo.put("formattedSize", formatBytes(fileSize));
|
||||||
|
fileInfo.put("storyId", storyId != null ? storyId : "unknown");
|
||||||
|
fileInfo.put("storyTitle", relatedStory != null ? relatedStory.getTitle() : null);
|
||||||
|
fileInfo.put("storyExists", relatedStory != null);
|
||||||
|
fileInfo.put("canAccessStory", relatedStory != null);
|
||||||
|
|
||||||
|
return fileInfo;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error creating file info for {}: {}", filePath, e.getMessage());
|
||||||
|
Map<String, Object> errorInfo = new HashMap<>();
|
||||||
|
errorInfo.put("filePath", filePath);
|
||||||
|
errorInfo.put("fileName", Paths.get(filePath).getFileName().toString());
|
||||||
|
errorInfo.put("fileSize", 0L);
|
||||||
|
errorInfo.put("formattedSize", "0 B");
|
||||||
|
errorInfo.put("storyId", "error");
|
||||||
|
errorInfo.put("storyTitle", null);
|
||||||
|
errorInfo.put("storyExists", false);
|
||||||
|
errorInfo.put("canAccessStory", false);
|
||||||
|
errorInfo.put("error", e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
|
||||||
|
return errorInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract story ID from content image file path
|
||||||
|
*/
|
||||||
|
private String extractStoryIdFromPath(String filePath) {
|
||||||
|
try {
|
||||||
|
// Content images are stored in: /path/to/uploads/content/{storyId}/filename.ext
|
||||||
|
Path path = Paths.get(filePath);
|
||||||
|
Path parent = path.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
String potentialUuid = parent.getFileName().toString();
|
||||||
|
// Basic UUID validation (36 characters with dashes in right places)
|
||||||
|
if (potentialUuid.length() == 36 &&
|
||||||
|
potentialUuid.charAt(8) == '-' &&
|
||||||
|
potentialUuid.charAt(13) == '-' &&
|
||||||
|
potentialUuid.charAt(18) == '-' &&
|
||||||
|
potentialUuid.charAt(23) == '-') {
|
||||||
|
return potentialUuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Invalid path or other error
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size in human readable format
|
||||||
|
*/
|
||||||
|
private String formatBytes(long bytes) {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
|
||||||
|
return String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +97,7 @@ public class DatabaseManagementService implements ApplicationContextAware {
|
|||||||
/**
|
/**
|
||||||
* Restore from complete backup (ZIP format)
|
* Restore from complete backup (ZIP format)
|
||||||
*/
|
*/
|
||||||
|
@Transactional(timeout = 1800) // 30 minutes timeout for large backup restores
|
||||||
public void restoreFromCompleteBackup(InputStream backupStream) throws IOException, SQLException {
|
public void restoreFromCompleteBackup(InputStream backupStream) throws IOException, SQLException {
|
||||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
System.err.println("Starting complete backup restore for library: " + currentLibraryId);
|
System.err.println("Starting complete backup restore for library: " + currentLibraryId);
|
||||||
@@ -193,10 +194,15 @@ public class DatabaseManagementService implements ApplicationContextAware {
|
|||||||
"stories", "collections", "tags", "series", "authors"
|
"stories", "collections", "tags", "series", "authors"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate TRUNCATE statements for each table (assuming tables already exist)
|
// Generate DELETE statements for each table (safer than TRUNCATE CASCADE)
|
||||||
for (String tableName : truncateTables) {
|
for (String tableName : truncateTables) {
|
||||||
sqlDump.append("-- Truncate Table: ").append(tableName).append("\n");
|
sqlDump.append("-- Clear Table: ").append(tableName).append("\n");
|
||||||
sqlDump.append("TRUNCATE TABLE \"").append(tableName).append("\" CASCADE;\n");
|
sqlDump.append("DELETE FROM \"").append(tableName).append("\";\n");
|
||||||
|
|
||||||
|
// Reset auto-increment sequences for tables with ID columns
|
||||||
|
if (Arrays.asList("authors", "series", "tags", "collections", "stories").contains(tableName)) {
|
||||||
|
sqlDump.append("SELECT setval(pg_get_serial_sequence('\"").append(tableName).append("\"', 'id'), 1, false);\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sqlDump.append("\n");
|
sqlDump.append("\n");
|
||||||
|
|
||||||
@@ -244,7 +250,7 @@ public class DatabaseManagementService implements ApplicationContextAware {
|
|||||||
return new ByteArrayResource(backupData);
|
return new ByteArrayResource(backupData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(timeout = 1800) // 30 minutes timeout for large backup restores
|
||||||
public void restoreFromBackup(InputStream backupStream) throws IOException, SQLException {
|
public void restoreFromBackup(InputStream backupStream) throws IOException, SQLException {
|
||||||
// Read the SQL file
|
// Read the SQL file
|
||||||
StringBuilder sqlContent = new StringBuilder();
|
StringBuilder sqlContent = new StringBuilder();
|
||||||
@@ -268,14 +274,27 @@ public class DatabaseManagementService implements ApplicationContextAware {
|
|||||||
|
|
||||||
// Parse SQL statements properly (handle semicolons inside string literals)
|
// Parse SQL statements properly (handle semicolons inside string literals)
|
||||||
List<String> statements = parseStatements(sqlContent.toString());
|
List<String> statements = parseStatements(sqlContent.toString());
|
||||||
|
System.err.println("Parsed " + statements.size() + " SQL statements. Starting execution...");
|
||||||
|
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
for (String statement : statements) {
|
for (String statement : statements) {
|
||||||
String trimmedStatement = statement.trim();
|
String trimmedStatement = statement.trim();
|
||||||
if (!trimmedStatement.isEmpty()) {
|
if (!trimmedStatement.isEmpty()) {
|
||||||
try (PreparedStatement stmt = connection.prepareStatement(trimmedStatement)) {
|
try (PreparedStatement stmt = connection.prepareStatement(trimmedStatement)) {
|
||||||
|
stmt.setQueryTimeout(300); // 5 minute timeout per statement
|
||||||
stmt.executeUpdate();
|
stmt.executeUpdate();
|
||||||
successCount++;
|
successCount++;
|
||||||
|
|
||||||
|
// Progress logging and batch commits for large restores
|
||||||
|
if (successCount % 100 == 0) {
|
||||||
|
System.err.println("Executed " + successCount + "/" + statements.size() + " statements...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit every 500 statements to avoid huge transactions
|
||||||
|
if (successCount % 500 == 0) {
|
||||||
|
connection.commit();
|
||||||
|
System.err.println("Committed batch at " + successCount + " statements");
|
||||||
|
}
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
// Log detailed error information for failed statements
|
// Log detailed error information for failed statements
|
||||||
System.err.println("ERROR: Failed to execute SQL statement #" + (successCount + 1));
|
System.err.println("ERROR: Failed to execute SQL statement #" + (successCount + 1));
|
||||||
@@ -449,7 +468,7 @@ public class DatabaseManagementService implements ApplicationContextAware {
|
|||||||
/**
|
/**
|
||||||
* Clear all data AND files (for complete restore)
|
* Clear all data AND files (for complete restore)
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional(timeout = 600) // 10 minutes timeout for clearing large datasets
|
||||||
public int clearAllDataAndFiles() {
|
public int clearAllDataAndFiles() {
|
||||||
// First clear the database
|
// First clear the database
|
||||||
int totalDeleted = clearAllData();
|
int totalDeleted = clearAllData();
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ spring:
|
|||||||
username: ${SPRING_DATASOURCE_USERNAME:storycove}
|
username: ${SPRING_DATASOURCE_USERNAME:storycove}
|
||||||
password: ${SPRING_DATASOURCE_PASSWORD:password}
|
password: ${SPRING_DATASOURCE_PASSWORD:password}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
|
hikari:
|
||||||
|
connection-timeout: 60000 # 60 seconds
|
||||||
|
idle-timeout: 300000 # 5 minutes
|
||||||
|
max-lifetime: 1800000 # 30 minutes
|
||||||
|
maximum-pool-size: 20
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
@@ -16,8 +21,8 @@ spring:
|
|||||||
|
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 256MB # Increased for backup restore
|
max-file-size: 600MB # Increased for large backup restore (425MB+)
|
||||||
max-request-size: 260MB # Slightly higher to account for form data
|
max-request-size: 610MB # Slightly higher to account for form data
|
||||||
|
|
||||||
jackson:
|
jackson:
|
||||||
serialization:
|
serialization:
|
||||||
@@ -27,6 +32,8 @@ spring:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
tomcat:
|
||||||
|
max-http-request-size: 650MB # Tomcat HTTP request size limit (separate from multipart)
|
||||||
|
|
||||||
storycove:
|
storycove:
|
||||||
app:
|
app:
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ configs:
|
|||||||
}
|
}
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
client_max_body_size 256M;
|
client_max_body_size 600M;
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend;
|
proxy_pass http://frontend;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -135,9 +135,13 @@ configs:
|
|||||||
proxy_set_header X-Real-IP $$remote_addr;
|
proxy_set_header X-Real-IP $$remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $$scheme;
|
proxy_set_header X-Forwarded-Proto $$scheme;
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 900s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 900s;
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 900s;
|
||||||
|
# Large upload settings
|
||||||
|
client_max_body_size 600M;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
}
|
}
|
||||||
location /images/ {
|
location /images/ {
|
||||||
alias /app/images/;
|
alias /app/images/;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
// Enable standalone output for optimized Docker builds
|
// Enable standalone output for optimized Docker builds
|
||||||
output: 'standalone',
|
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
|
// Removed Next.js rewrites since nginx handles all API routing
|
||||||
webpack: (config, { isServer }) => {
|
webpack: (config, { isServer }) => {
|
||||||
// Exclude cheerio and its dependencies from client-side bundling
|
// Exclude cheerio and its dependencies from client-side bundling
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ export default function AuthorsPage() {
|
|||||||
} else {
|
} else {
|
||||||
setSearchLoading(true);
|
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,
|
page: currentPage,
|
||||||
size: ITEMS_PER_PAGE,
|
size: ITEMS_PER_PAGE,
|
||||||
sortBy: sortBy,
|
sortBy: sortBy,
|
||||||
@@ -44,21 +47,19 @@ export default function AuthorsPage() {
|
|||||||
|
|
||||||
if (currentPage === 0) {
|
if (currentPage === 0) {
|
||||||
// First page - replace all results
|
// First page - replace all results
|
||||||
setAuthors(searchResults.content || []);
|
setAuthors(searchResults.results || []);
|
||||||
setFilteredAuthors(searchResults.content || []);
|
setFilteredAuthors(searchResults.results || []);
|
||||||
} else {
|
} else {
|
||||||
// Subsequent pages - append results
|
// Subsequent pages - append results
|
||||||
setAuthors(prev => [...prev, ...(searchResults.content || [])]);
|
setAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
||||||
setFilteredAuthors(prev => [...prev, ...(searchResults.content || [])]);
|
setFilteredAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTotalHits(searchResults.totalElements || 0);
|
setTotalHits(searchResults.totalHits || 0);
|
||||||
setHasMore(searchResults.content.length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < (searchResults.totalElements || 0));
|
setHasMore((searchResults.results || []).length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < (searchResults.totalHits || 0));
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load authors:', error);
|
console.error('Failed to search authors:', error);
|
||||||
// Error handling for API failures
|
|
||||||
console.error('Failed to load authors:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
@@ -84,17 +85,7 @@ export default function AuthorsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Client-side filtering for search query when using regular API
|
// No longer needed - Solr search handles filtering directly
|
||||||
useEffect(() => {
|
|
||||||
if (searchQuery) {
|
|
||||||
const filtered = authors.filter(author =>
|
|
||||||
author.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
setFilteredAuthors(filtered);
|
|
||||||
} else {
|
|
||||||
setFilteredAuthors(authors);
|
|
||||||
}
|
|
||||||
}, [authors, searchQuery]);
|
|
||||||
|
|
||||||
// Note: We no longer have individual story ratings in the author list
|
// Note: We no longer have individual story ratings in the author list
|
||||||
// Average rating would need to be calculated on backend if needed
|
// Average rating would need to be calculated on backend if needed
|
||||||
@@ -117,9 +108,8 @@ export default function AuthorsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold theme-header">Authors</h1>
|
<h1 className="text-3xl font-bold theme-header">Authors</h1>
|
||||||
<p className="theme-text mt-1">
|
<p className="theme-text mt-1">
|
||||||
{searchQuery ? `${filteredAuthors.length} of ${authors.length}` : filteredAuthors.length} {(searchQuery ? authors.length : filteredAuthors.length) === 1 ? 'author' : 'authors'}
|
{searchQuery ? `${totalHits} authors found` : `${totalHits} authors in your library`}
|
||||||
{searchQuery ? ` found` : ` in your library`}
|
{hasMore && ` (showing first ${filteredAuthors.length})`}
|
||||||
{!searchQuery && hasMore && ` (showing first ${filteredAuthors.length})`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -226,7 +216,7 @@ export default function AuthorsPage() {
|
|||||||
className="px-8 py-3"
|
className="px-8 py-3"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Loading...' : `Load More Authors (${totalHits - authors.length} remaining)`}
|
{loading ? 'Loading...' : `Load More Authors (${totalHits - filteredAuthors.length} remaining)`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,25 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
execute: { loading: false, message: '' }
|
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 () => {
|
const handleCompleteBackup = async () => {
|
||||||
@@ -231,13 +250,7 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear message after 10 seconds
|
// Note: Preview message no longer auto-clears to allow users to review file details
|
||||||
setTimeout(() => {
|
|
||||||
setCleanupStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
preview: { loading: false, message: '', success: undefined }
|
|
||||||
}));
|
|
||||||
}, 10000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageCleanupExecute = async () => {
|
const handleImageCleanupExecute = async () => {
|
||||||
@@ -614,6 +627,18 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
>
|
>
|
||||||
{cleanupStatus.execute.loading ? 'Cleaning...' : 'Execute Cleanup'}
|
{cleanupStatus.execute.loading ? 'Cleaning...' : 'Execute Cleanup'}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Preview Results */}
|
{/* Preview Results */}
|
||||||
@@ -667,6 +692,76 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
<span className="font-medium">Referenced Images:</span> {cleanupStatus.preview.data.referencedImagesCount}
|
<span className="font-medium">Referenced Images:</span> {cleanupStatus.preview.data.referencedImagesCount}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -787,6 +882,31 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -344,6 +344,33 @@ export const authorApi = {
|
|||||||
await api.delete(`/authors/${id}/avatar`);
|
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
|
// Tag endpoints
|
||||||
@@ -596,6 +623,17 @@ export const configApi = {
|
|||||||
hasErrors: boolean;
|
hasErrors: boolean;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
error?: string;
|
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');
|
const response = await api.post('/config/cleanup/images/preview');
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ http {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
client_max_body_size 256M;
|
client_max_body_size 600M;
|
||||||
|
|
||||||
# Frontend routes
|
# Frontend routes
|
||||||
location / {
|
location / {
|
||||||
@@ -55,6 +55,10 @@ http {
|
|||||||
proxy_connect_timeout 900s;
|
proxy_connect_timeout 900s;
|
||||||
proxy_send_timeout 900s;
|
proxy_send_timeout 900s;
|
||||||
proxy_read_timeout 900s;
|
proxy_read_timeout 900s;
|
||||||
|
# Large upload settings
|
||||||
|
client_max_body_size 600M;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static image serving
|
# Static image serving
|
||||||
|
|||||||
Reference in New Issue
Block a user