DB Backup and Restore
This commit is contained in:
@@ -56,7 +56,10 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins.split(",")));
|
||||
List<String> origins = Arrays.stream(allowedOrigins.split(","))
|
||||
.map(String::trim)
|
||||
.toList();
|
||||
configuration.setAllowedOriginPatterns(origins);
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.service.DatabaseManagementService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/database")
|
||||
public class DatabaseController {
|
||||
|
||||
@Autowired
|
||||
private DatabaseManagementService databaseManagementService;
|
||||
|
||||
@PostMapping("/backup")
|
||||
public ResponseEntity<Resource> backupDatabase() {
|
||||
try {
|
||||
Resource backup = databaseManagementService.createBackup();
|
||||
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
||||
String filename = "storycove_backup_" + timestamp + ".sql";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(backup);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to create database backup: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/restore")
|
||||
public ResponseEntity<Map<String, Object>> restoreDatabase(@RequestParam("file") MultipartFile file) {
|
||||
try {
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "No file uploaded"));
|
||||
}
|
||||
|
||||
if (!file.getOriginalFilename().endsWith(".sql")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "Invalid file type. Please upload a .sql file"));
|
||||
}
|
||||
|
||||
databaseManagementService.restoreFromBackup(file.getInputStream());
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Database restored successfully from " + file.getOriginalFilename()
|
||||
));
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "Failed to read backup file: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "Failed to restore database: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/clear")
|
||||
public ResponseEntity<Map<String, Object>> clearDatabase() {
|
||||
try {
|
||||
int deletedRecords = databaseManagementService.clearAllData();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Database cleared successfully",
|
||||
"deletedRecords", deletedRecords
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "Failed to clear database: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.storycove.entity.Author;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -52,4 +53,5 @@ public interface AuthorRepository extends JpaRepository<Author, UUID> {
|
||||
|
||||
@Query(value = "SELECT author_rating FROM authors WHERE id = :id", nativeQuery = true)
|
||||
Integer findAuthorRatingById(@Param("id") UUID id);
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.storycove.repository;
|
||||
|
||||
import com.storycove.entity.Collection;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -51,4 +52,5 @@ public interface CollectionRepository extends JpaRepository<Collection, UUID> {
|
||||
*/
|
||||
@Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags ORDER BY c.updatedAt DESC")
|
||||
List<Collection> findAllWithTags();
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.storycove.entity.Tag;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -117,4 +118,5 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
|
||||
|
||||
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
|
||||
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.entity.*;
|
||||
import com.storycove.repository.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.*;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class DatabaseManagementService {
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Autowired
|
||||
private StoryRepository storyRepository;
|
||||
|
||||
@Autowired
|
||||
private AuthorRepository authorRepository;
|
||||
|
||||
@Autowired
|
||||
private SeriesRepository seriesRepository;
|
||||
|
||||
@Autowired
|
||||
private TagRepository tagRepository;
|
||||
|
||||
@Autowired
|
||||
private CollectionRepository collectionRepository;
|
||||
|
||||
@Autowired
|
||||
private TypesenseService typesenseService;
|
||||
|
||||
public Resource createBackup() throws SQLException, IOException {
|
||||
StringBuilder sqlDump = new StringBuilder();
|
||||
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
// Add header
|
||||
sqlDump.append("-- StoryCove Database Backup\n");
|
||||
sqlDump.append("-- Generated at: ").append(new java.util.Date()).append("\n\n");
|
||||
|
||||
// Disable foreign key checks during restore (PostgreSQL syntax)
|
||||
sqlDump.append("SET session_replication_role = replica;\n\n");
|
||||
|
||||
// List of tables in dependency order (children first for deletion, parents first for insertion)
|
||||
List<String> tables = Arrays.asList(
|
||||
"story_tags", "author_urls", "collection_stories",
|
||||
"stories", "authors", "series", "tags", "collections"
|
||||
);
|
||||
|
||||
// Generate TRUNCATE statements for each table (assuming tables already exist)
|
||||
for (String tableName : tables) {
|
||||
sqlDump.append("-- Table: ").append(tableName).append("\n");
|
||||
sqlDump.append("TRUNCATE TABLE \"").append(tableName).append("\" CASCADE;\n");
|
||||
|
||||
// Get table data
|
||||
try (PreparedStatement stmt = connection.prepareStatement("SELECT * FROM \"" + tableName + "\"");
|
||||
ResultSet rs = stmt.executeQuery()) {
|
||||
|
||||
ResultSetMetaData metaData = rs.getMetaData();
|
||||
int columnCount = metaData.getColumnCount();
|
||||
|
||||
// Build column names for INSERT statement
|
||||
StringBuilder columnNames = new StringBuilder();
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
if (i > 1) columnNames.append(", ");
|
||||
columnNames.append("\"").append(metaData.getColumnName(i)).append("\"");
|
||||
}
|
||||
|
||||
while (rs.next()) {
|
||||
sqlDump.append("INSERT INTO \"").append(tableName).append("\" (")
|
||||
.append(columnNames).append(") VALUES (");
|
||||
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
if (i > 1) sqlDump.append(", ");
|
||||
|
||||
Object value = rs.getObject(i);
|
||||
if (value == null) {
|
||||
sqlDump.append("NULL");
|
||||
} else if (value instanceof String || value instanceof Timestamp ||
|
||||
value instanceof java.util.UUID) {
|
||||
// Escape single quotes and wrap in quotes
|
||||
String escapedValue = value.toString().replace("'", "''");
|
||||
sqlDump.append("'").append(escapedValue).append("'");
|
||||
} else if (value instanceof Boolean) {
|
||||
sqlDump.append(((Boolean) value) ? "true" : "false");
|
||||
} else {
|
||||
sqlDump.append(value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
sqlDump.append(");\n");
|
||||
}
|
||||
}
|
||||
|
||||
sqlDump.append("\n");
|
||||
}
|
||||
|
||||
// Re-enable foreign key checks (PostgreSQL syntax)
|
||||
sqlDump.append("SET session_replication_role = DEFAULT;\n");
|
||||
}
|
||||
|
||||
byte[] backupData = sqlDump.toString().getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteArrayResource(backupData);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void restoreFromBackup(InputStream backupStream) throws IOException, SQLException {
|
||||
// Read the SQL file
|
||||
StringBuilder sqlContent = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(backupStream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
// Skip comments and empty lines
|
||||
if (!line.trim().startsWith("--") && !line.trim().isEmpty()) {
|
||||
sqlContent.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the SQL statements
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
// Split by semicolon and execute each statement
|
||||
String[] statements = sqlContent.toString().split(";");
|
||||
|
||||
for (String statement : statements) {
|
||||
String trimmedStatement = statement.trim();
|
||||
if (!trimmedStatement.isEmpty()) {
|
||||
try (PreparedStatement stmt = connection.prepareStatement(trimmedStatement)) {
|
||||
stmt.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection.commit();
|
||||
|
||||
// Reindex search after successful restore
|
||||
try {
|
||||
// Note: Manual reindexing may be needed after restore
|
||||
// The search indices will need to be rebuilt through the settings page
|
||||
} catch (Exception e) {
|
||||
// Log the error but don't fail the restore
|
||||
System.err.println("Warning: Search indices may need manual reindexing after restore");
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
connection.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int clearAllData() {
|
||||
int totalDeleted = 0;
|
||||
|
||||
try {
|
||||
// Count entities before deletion
|
||||
int collectionCount = (int) collectionRepository.count();
|
||||
int storyCount = (int) storyRepository.count();
|
||||
int authorCount = (int) authorRepository.count();
|
||||
int seriesCount = (int) seriesRepository.count();
|
||||
int tagCount = (int) tagRepository.count();
|
||||
|
||||
// Delete main entities (cascade will handle junction tables)
|
||||
collectionRepository.deleteAll();
|
||||
storyRepository.deleteAll();
|
||||
authorRepository.deleteAll();
|
||||
seriesRepository.deleteAll();
|
||||
tagRepository.deleteAll();
|
||||
|
||||
totalDeleted = collectionCount + storyCount + authorCount + seriesCount + tagCount;
|
||||
|
||||
// Note: Search indexes will need to be manually recreated after clearing
|
||||
// Use the settings page to recreate Typesense collections after clearing the database
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to clear database: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
import Button from '../../components/ui/Button';
|
||||
import { storyApi, authorApi } from '../../lib/api';
|
||||
import { storyApi, authorApi, databaseApi } from '../../lib/api';
|
||||
|
||||
type FontFamily = 'serif' | 'sans' | 'mono';
|
||||
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||
@@ -39,6 +39,15 @@ export default function SettingsPage() {
|
||||
});
|
||||
const [authorsSchema, setAuthorsSchema] = useState<any>(null);
|
||||
const [showSchema, setShowSchema] = useState(false);
|
||||
const [databaseStatus, setDatabaseStatus] = useState<{
|
||||
backup: { loading: boolean; message: string; success?: boolean };
|
||||
restore: { loading: boolean; message: string; success?: boolean };
|
||||
clear: { loading: boolean; message: string; success?: boolean };
|
||||
}>({
|
||||
backup: { loading: false, message: '' },
|
||||
restore: { loading: false, message: '' },
|
||||
clear: { loading: false, message: '' }
|
||||
});
|
||||
|
||||
// Load settings from localStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -157,6 +166,146 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDatabaseBackup = async () => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
backup: { loading: true, message: 'Creating backup...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const backupBlob = await databaseApi.backup();
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(backupBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
link.download = `storycove_backup_${timestamp}.sql`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
backup: { loading: false, message: 'Backup downloaded successfully', success: true }
|
||||
}));
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
backup: { loading: false, message: error.message || 'Backup failed', success: false }
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
backup: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const handleDatabaseRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = '';
|
||||
|
||||
if (!file.name.endsWith('.sql')) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
restore: { loading: false, message: 'Please select a .sql file', success: false }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to restore the database? This will PERMANENTLY DELETE all current data and replace it with the backup data. This action cannot be undone!'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
restore: { loading: true, message: 'Restoring database...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await databaseApi.restore(file);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
restore: {
|
||||
loading: false,
|
||||
message: result.success ? result.message : result.message,
|
||||
success: result.success
|
||||
}
|
||||
}));
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
restore: { loading: false, message: error.message || 'Restore failed', success: false }
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear message after 10 seconds for restore (longer because it's important)
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
restore: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const handleDatabaseClear = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you ABSOLUTELY SURE you want to clear the entire database? This will PERMANENTLY DELETE ALL stories, authors, series, tags, and collections. This action cannot be undone!'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const doubleConfirmed = window.confirm(
|
||||
'This is your final warning! Clicking OK will DELETE EVERYTHING in your StoryCove database. Are you completely certain you want to proceed?'
|
||||
);
|
||||
|
||||
if (!doubleConfirmed) return;
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
clear: { loading: true, message: 'Clearing database...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await databaseApi.clear();
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
clear: {
|
||||
loading: false,
|
||||
message: result.success
|
||||
? `Database cleared successfully. Deleted ${result.deletedRecords} records.`
|
||||
: result.message,
|
||||
success: result.success
|
||||
}
|
||||
}));
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
clear: { loading: false, message: error.message || 'Clear operation failed', success: false }
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear message after 10 seconds for clear (longer because it's important)
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
clear: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
@@ -463,6 +612,110 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Management */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
|
||||
<p className="theme-text mb-6">
|
||||
Backup, restore, or clear your StoryCove database. These are powerful tools - use with caution!
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Backup Section */}
|
||||
<div className="border theme-border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">💾 Backup Database</h3>
|
||||
<p className="text-sm theme-text mb-3">
|
||||
Download a complete backup of your database as an SQL file. This includes all stories, authors, tags, series, and collections.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleDatabaseBackup}
|
||||
disabled={databaseStatus.backup.loading}
|
||||
loading={databaseStatus.backup.loading}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{databaseStatus.backup.loading ? 'Creating Backup...' : 'Download Backup'}
|
||||
</Button>
|
||||
{databaseStatus.backup.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.backup.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{databaseStatus.backup.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore Section */}
|
||||
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Database</h3>
|
||||
<p className="text-sm theme-text mb-3">
|
||||
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database with the backup file. All existing data will be permanently deleted.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".sql"
|
||||
onChange={handleDatabaseRestore}
|
||||
disabled={databaseStatus.restore.loading}
|
||||
className="flex-1 text-sm theme-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:theme-accent-bg file:text-white hover:file:bg-opacity-90 file:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{databaseStatus.restore.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.restore.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{databaseStatus.restore.message}
|
||||
</div>
|
||||
)}
|
||||
{databaseStatus.restore.loading && (
|
||||
<div className="text-sm theme-text mt-3 flex items-center gap-2">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
Restoring database...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear Section */}
|
||||
<div className="border theme-border rounded-lg p-4 border-red-200 dark:border-red-800">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">🗑️ Clear Database</h3>
|
||||
<p className="text-sm theme-text mb-3">
|
||||
<strong className="text-red-600 dark:text-red-400">⚠️ Danger Zone:</strong> This will permanently delete ALL data from your database. Stories, authors, tags, series, and collections will be completely removed. This action cannot be undone!
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleDatabaseClear}
|
||||
disabled={databaseStatus.clear.loading}
|
||||
loading={databaseStatus.clear.loading}
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white border-red-600"
|
||||
>
|
||||
{databaseStatus.clear.loading ? 'Clearing Database...' : 'Clear All Data'}
|
||||
</Button>
|
||||
{databaseStatus.clear.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.clear.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{databaseStatus.clear.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
<p className="font-medium mb-1">💡 Best Practices:</p>
|
||||
<ul className="text-xs space-y-1 ml-4">
|
||||
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
|
||||
<li>• <strong>Test restores</strong> in a development environment when possible</li>
|
||||
<li>• <strong>Store backups safely</strong> in multiple locations for important data</li>
|
||||
<li>• <strong>Verify backup files</strong> are complete before relying on them</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
|
||||
@@ -498,6 +498,30 @@ export const collectionApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Database management endpoints
|
||||
export const databaseApi = {
|
||||
backup: async (): Promise<Blob> => {
|
||||
const response = await api.post('/database/backup', {}, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
restore: async (file: File): Promise<{ success: boolean; message: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await api.post('/database/restore', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
clear: async (): Promise<{ success: boolean; message: string; deletedRecords?: number }> => {
|
||||
const response = await api.post('/database/clear');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Image utility
|
||||
export const getImageUrl = (path: string): string => {
|
||||
if (!path) return '';
|
||||
|
||||
Reference in New Issue
Block a user