864 lines
33 KiB
React
864 lines
33 KiB
React
import React, { useState, useEffect, useRef } from 'react';
|
|
import { MainUI } from './components/MainUI';
|
|
import { Sidebar } from './components/Sidebar';
|
|
import { SplitterModal } from './components/SplitterModal';
|
|
import { EditorModal } from './components/EditorModal';
|
|
import { NameAnalysisModal } from './components/NameAnalysisModal';
|
|
import { GuideModal } from './components/GuideModal';
|
|
import { LogModal } from './components/LogModal';
|
|
import { ConfirmationModal } from './components/ConfirmationModal';
|
|
import { aiService } from './services/aiService';
|
|
import { dbService } from './services/dbService';
|
|
import { epubService } from './services/epubService';
|
|
import { logger } from './services/logger';
|
|
import { parseFile, generateEpub } from './services/fileParsers';
|
|
import { formatBookStyle, optimizeDictionary, optimizeContext } from './services/textUtils';
|
|
import { AVAILABLE_GENRES, PROMPT_TEMPLATES } from './constants';
|
|
|
|
function App() {
|
|
// --- UI State ---
|
|
const abortControllerRef = useRef(null);
|
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [showLogs, setShowLogs] = useState(false);
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
|
|
|
// --- Database & Story State ---
|
|
const [stories, setStories] = useState([]);
|
|
const [storyToDelete, setStoryToDelete] = useState(null);
|
|
const [currentStoryId, setCurrentStoryId] = useState(null);
|
|
const autoSaveTimerRef = useRef(null);
|
|
|
|
// --- Data State (Current Story) ---
|
|
const [files, setFiles] = useState([]);
|
|
const [storyInfo, setStoryInfo] = useState({
|
|
title: '', author: '', summary: '',
|
|
genres: [], languages: ['Tiếng Trung'], mcPersonality: [],
|
|
worldSetting: [], sectFlow: [], contextNotes: '', image_prompt: ''
|
|
});
|
|
const [coverPreviewUrl, setCoverPreviewUrl] = useState(null);
|
|
const [isGeneratingCover, setIsGeneratingCover] = useState(false);
|
|
const [promptTemplate, setPromptTemplate] = useState('DEFAULT');
|
|
|
|
// --- Processing State ---
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [progressPercentage, setProgressPercentage] = useState(0);
|
|
const [startTime, setStartTime] = useState(null);
|
|
|
|
// --- Logic State for Tabs ---
|
|
const [quickInput, setQuickInput] = useState('');
|
|
const [isAutoAnalyzing, setIsAutoAnalyzing] = useState(false);
|
|
|
|
// Modals & Tools
|
|
const [showSplitter, setShowSplitter] = useState(false);
|
|
const [pendingSplitFile, setPendingSplitFile] = useState(null);
|
|
const [showEditor, setShowEditor] = useState(false);
|
|
const [editorFile, setEditorFile] = useState(null);
|
|
const [dictTab, setDictTab] = useState('default');
|
|
const [additionalDictionary, setAdditionalDictionary] = useState('');
|
|
const [viewOriginalPrompt, setViewOriginalPrompt] = useState(false);
|
|
const [selectedTemplateKey, setSelectedTemplateKey] = useState('default');
|
|
const [isOptimizingPrompt, setIsOptimizingPrompt] = useState(false);
|
|
const [showPromptDesigner, setShowPromptDesigner] = useState(false);
|
|
const [showGuideModal, setShowGuideModal] = useState(false);
|
|
const [showNameAnalysisModal, setShowNameAnalysisModal] = useState(false);
|
|
const [isAnalyzingNames, setIsAnalyzingNames] = useState(false);
|
|
const [analysisResults, setAnalysisResults] = useState(null);
|
|
|
|
// Workspace
|
|
const [selectedFiles, setSelectedFiles] = useState(new Set());
|
|
const [rangeStart, setRangeStart] = useState('');
|
|
const [rangeEnd, setRangeEnd] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(0);
|
|
const [showFilterPanel, setShowFilterPanel] = useState(false);
|
|
const [filterStatuses, setFilterStatuses] = useState(new Set());
|
|
const [showFindReplace, setShowFindReplace] = useState(false);
|
|
const [findText, setFindText] = useState('');
|
|
const [replaceText, setReplaceText] = useState('');
|
|
const [rangeMode, setRangeMode] = useState(true); // true = Select, false = Deselect
|
|
|
|
// --- Initialization ---
|
|
useEffect(() => {
|
|
logger.init();
|
|
logger.info("Application Started");
|
|
|
|
// Init DB
|
|
const initApp = async () => {
|
|
try {
|
|
await dbService.init();
|
|
const loadedStories = dbService.getStories();
|
|
setStories(loadedStories);
|
|
|
|
if (loadedStories.length > 0) {
|
|
// Auto-load most recent story
|
|
handleSelectStory(loadedStories[0].id);
|
|
}
|
|
} catch (e) {
|
|
console.error("DB Init Failed", e);
|
|
alert("Lỗi khởi tạo Database! Vui lòng refresh trang.");
|
|
}
|
|
};
|
|
initApp();
|
|
}, []);
|
|
|
|
// theme effect
|
|
useEffect(() => {
|
|
if (isDarkMode) document.documentElement.classList.add('dark');
|
|
else document.documentElement.classList.remove('dark');
|
|
}, [isDarkMode]);
|
|
|
|
// --- DB Operations & Auto Save ---
|
|
|
|
// Trigger Save whenever core data changes (Debounced)
|
|
useEffect(() => {
|
|
if (!currentStoryId) return;
|
|
|
|
// Clear existing timer
|
|
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
|
|
|
autoSaveTimerRef.current = setTimeout(async () => {
|
|
// 1. Update Story Info
|
|
await dbService.updateStory(currentStoryId, {
|
|
title: storyInfo.title,
|
|
author: storyInfo.author,
|
|
summary: storyInfo.summary,
|
|
genres: storyInfo.genres,
|
|
contextNotes: storyInfo.contextNotes,
|
|
image_prompt: storyInfo.image_prompt,
|
|
additionalDictionary: additionalDictionary,
|
|
customPrompt: promptTemplate,
|
|
cover_image: typeof coverPreviewUrl === 'string' && coverPreviewUrl.startsWith('data:') ? 'base64...' : storyInfo.cover_image
|
|
});
|
|
|
|
// 2. Save Files
|
|
await dbService.saveFilesBatch(currentStoryId, files);
|
|
|
|
console.log(`[AutoSave] Saved Story ${currentStoryId}`);
|
|
}, 2000); // 2s debounce
|
|
|
|
return () => clearTimeout(autoSaveTimerRef.current);
|
|
}, [files, storyInfo, additionalDictionary, promptTemplate, currentStoryId]);
|
|
|
|
const refreshStories = () => {
|
|
setStories(dbService.getStories());
|
|
};
|
|
|
|
const handleCreateStory = async () => {
|
|
console.log("[App] handleCreateStory Triggered");
|
|
|
|
// START FIX: Bypass blocking prompt
|
|
const timestamp = new Date().toLocaleString('vi-VN');
|
|
const defaultTitle = `Truyện Mới (${timestamp})`;
|
|
console.log("[App] Auto-creating story:", defaultTitle);
|
|
|
|
try {
|
|
const id = await dbService.createStory(defaultTitle);
|
|
console.log("[App] Story Created ID:", id);
|
|
refreshStories();
|
|
handleSelectStory(id);
|
|
} catch (e) {
|
|
console.error("[App] Create Story Failed:", e);
|
|
alert("Tạo truyện thất bại: " + e.message);
|
|
}
|
|
};
|
|
|
|
const handleDeleteStory = (id) => {
|
|
const story = stories.find(s => s.id === id);
|
|
if (story) setStoryToDelete(story);
|
|
};
|
|
|
|
const confirmDeleteStory = async () => {
|
|
if (!storyToDelete) return;
|
|
await dbService.deleteStory(storyToDelete.id);
|
|
refreshStories();
|
|
if (currentStoryId === storyToDelete.id) {
|
|
setFiles([]);
|
|
setStoryInfo({ title: '', author: '', summary: '', genres: [], languages: ['Tiếng Trung'], mcPersonality: [], worldSetting: [], sectFlow: [], contextNotes: '', image_prompt: '' });
|
|
setCurrentStoryId(null);
|
|
}
|
|
setStoryToDelete(null);
|
|
};
|
|
|
|
const handleUpdateStory = async (id, data) => {
|
|
await dbService.updateStory(id, data);
|
|
refreshStories();
|
|
if (id === currentStoryId) {
|
|
setStoryInfo(prev => ({ ...prev, ...data }));
|
|
}
|
|
};
|
|
|
|
const handleSelectStory = async (id) => {
|
|
// 1. Load Info
|
|
const story = dbService.getStory(id);
|
|
if (!story) return;
|
|
|
|
// 2. Load Files
|
|
const loadedFiles = dbService.getFiles(id);
|
|
|
|
// 3. Update State
|
|
setCurrentStoryId(id);
|
|
setStoryInfo(prev => ({
|
|
...prev,
|
|
title: story.title || '',
|
|
author: story.author || '',
|
|
summary: story.summary || '',
|
|
genres: story.genres || [],
|
|
contextNotes: story.contextNotes || '',
|
|
image_prompt: story.image_prompt || '',
|
|
}));
|
|
setAdditionalDictionary(story.additionalDictionary || '');
|
|
setPromptTemplate(story.customPrompt || PROMPT_TEMPLATES['DEFAULT']?.content || '');
|
|
setFiles(loadedFiles);
|
|
|
|
// Update cover
|
|
if (story.cover_image) setCoverPreviewUrl(story.cover_image);
|
|
else setCoverPreviewUrl(null);
|
|
|
|
// Refresh list (no need to re-sort or bump timestamp for view)
|
|
// await dbService.updateStory(id, {});
|
|
};
|
|
|
|
// --- Handlers (Existing) ---
|
|
const toggleTheme = () => setIsDarkMode(!isDarkMode);
|
|
|
|
const handleFileUpload = async (e) => {
|
|
if (!currentStoryId) {
|
|
alert("Vui lòng Chọn hoặc Tạo truyện mới trước khi upload file!");
|
|
return;
|
|
}
|
|
const uploadedFiles = Array.from(e.target.files);
|
|
logger.info(`User selected ${uploadedFiles.length} files`);
|
|
|
|
if (uploadedFiles.length === 1 && uploadedFiles[0].size > 10000) {
|
|
const parsed = await parseFile(uploadedFiles[0]);
|
|
if (parsed.length > 0) {
|
|
logger.info(`Large file detected (${uploadedFiles[0].size}B), triggering Splitter`);
|
|
setPendingSplitFile(parsed[0]);
|
|
setShowSplitter(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const newFiles = [];
|
|
for (const file of uploadedFiles) {
|
|
const parsed = await parseFile(file);
|
|
newFiles.push(...parsed);
|
|
}
|
|
setFiles(prev => [...prev, ...newFiles]);
|
|
};
|
|
|
|
const handleSplitConfirm = (splitFiles) => {
|
|
logger.info(`Split confirmed: ${splitFiles.length} new files created`);
|
|
setFiles(prev => [...prev, ...splitFiles]);
|
|
setShowSplitter(false);
|
|
setPendingSplitFile(null);
|
|
};
|
|
|
|
const handleQuickParse = () => {
|
|
if (!quickInput) return;
|
|
const parts = quickInput.split(/[,;]+/).map(s => s.trim()).filter(s => s);
|
|
const newGenres = [...storyInfo.genres];
|
|
parts.forEach(p => {
|
|
if (AVAILABLE_GENRES.includes(p) && !newGenres.includes(p)) newGenres.push(p);
|
|
});
|
|
setStoryInfo(prev => ({ ...prev, genres: newGenres }));
|
|
setQuickInput('');
|
|
};
|
|
|
|
// Helper
|
|
const blobToBase64 = (blob) => new Promise((resolve, _) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result);
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
|
|
const handleAutoAnalyze = async () => {
|
|
if (files.length === 0) {
|
|
alert("Bạn chưa tải file nào lên!");
|
|
return;
|
|
}
|
|
setIsAutoAnalyzing(true);
|
|
try {
|
|
const { info: analysis, cover } = await aiService.smartAnalyze(files);
|
|
setStoryInfo(prev => ({
|
|
...prev,
|
|
title: analysis.title || prev.title,
|
|
author: analysis.author || prev.author,
|
|
summary: analysis.summary || prev.summary,
|
|
genres: analysis.genres || prev.genres,
|
|
contextNotes: analysis.context_notes || prev.contextNotes,
|
|
image_prompt: analysis.image_prompt || prev.image_prompt,
|
|
mcPersonality: analysis.main_characters
|
|
? [...prev.mcPersonality, ...analysis.main_characters.map(c => typeof c === 'object' ? c.name : c)]
|
|
: prev.mcPersonality
|
|
}));
|
|
|
|
if (cover) {
|
|
if (coverPreviewUrl && !coverPreviewUrl.startsWith('http')) URL.revokeObjectURL(coverPreviewUrl);
|
|
const url = typeof cover === 'string' ? cover : URL.createObjectURL(cover);
|
|
setCoverPreviewUrl(url);
|
|
|
|
// Save to DB (Handle both String URL and Blob Base64)
|
|
const saveValue = typeof cover === 'string' ? cover : await blobToBase64(cover);
|
|
await dbService.updateStory(currentStoryId, { cover_image: saveValue });
|
|
}
|
|
alert("Phân tích hoàn tất!");
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Phân tích thất bại: " + e.message);
|
|
} finally {
|
|
setIsAutoAnalyzing(false);
|
|
}
|
|
};
|
|
|
|
const handleRegenerateCover = async () => {
|
|
if (!storyInfo.image_prompt) { alert("Chưa có Prompt vẽ ảnh."); return; }
|
|
setIsGeneratingCover(true);
|
|
try {
|
|
const cover = await aiService.generateCoverImage(storyInfo.image_prompt);
|
|
if (cover) {
|
|
const url = typeof cover === 'string' ? cover : URL.createObjectURL(cover);
|
|
setCoverPreviewUrl(url);
|
|
const saveValue = typeof cover === 'string' ? cover : await blobToBase64(cover);
|
|
await dbService.updateStory(currentStoryId, { cover_image: saveValue });
|
|
}
|
|
} catch (e) { alert("Vẽ lại thất bại: " + e.message); } finally { setIsGeneratingCover(false); }
|
|
};
|
|
|
|
const handleStartButton = async () => {
|
|
if (isProcessing) { handleStopProcessing(); return; }
|
|
|
|
// Logic: If files selected, process those. Else, process all pending.
|
|
let targets = [];
|
|
if (selectedFiles.size > 0) {
|
|
// STRICTLY SKIP COMPLETED files as requested
|
|
targets = files.filter(f => selectedFiles.has(f.id) && f.status !== 'PROCESSING' && f.status !== 'COMPLETED');
|
|
} else {
|
|
// Auto-detect targets: IDLE or ERROR or Raw Chars
|
|
targets = files.filter(f => {
|
|
if (f.status === 'IDLE' || f.status === 'ERROR' || !f.translatedContent) return true;
|
|
// Check for raw content in "COMPLETED" files -- DISABLED based on user request "Do not translate again"
|
|
// If user wants to fix raw chars, they should use "Manual Fix" or "Retranslate"
|
|
return false;
|
|
});
|
|
}
|
|
|
|
if (targets.length === 0) { alert("Không có file nào cần dịch (các file đã chọn đều đã hoàn thành hoặc đang xử lý)!"); return; }
|
|
|
|
setIsProcessing(true);
|
|
setStartTime(Date.now());
|
|
abortControllerRef.current = new AbortController();
|
|
const signal = abortControllerRef.current.signal;
|
|
|
|
// Batching Config
|
|
const BATCH_SIZE = 1; // DeepSeek Context Limit
|
|
let processedCount = 0;
|
|
const total = targets.length;
|
|
|
|
for (let i = 0; i < total; i += BATCH_SIZE) {
|
|
if (signal.aborted) break;
|
|
const batch = targets.slice(i, i + BATCH_SIZE);
|
|
|
|
// PERSIST: Mark batch as PROCESSING in DB
|
|
const processingUpdates = batch.map(f => ({ ...f, status: 'PROCESSING' }));
|
|
setFiles(prev => prev.map(f => processingUpdates.find(u => u.id === f.id) || f));
|
|
await dbService.saveFilesBatch(currentStoryId, processingUpdates); // Save to DB
|
|
|
|
try {
|
|
const { info: results } = await aiService.translateBatch(
|
|
batch,
|
|
promptTemplate,
|
|
additionalDictionary,
|
|
storyInfo.contextNotes,
|
|
signal
|
|
);
|
|
|
|
// Update Results & Validate
|
|
const updates = [];
|
|
for (const file of batch) {
|
|
let translatedText = results[file.id] || "";
|
|
|
|
// AUTO-CHECK & REPAIR
|
|
const chineseRegex = /[\u4e00-\u9fa5]/;
|
|
if (chineseRegex.test(translatedText)) {
|
|
logger.warn(`File ${file.name} contains raw chars. Attempting repair...`);
|
|
try {
|
|
// Quick repair attempt
|
|
translatedText = await aiService.repairTranslation(file.content, translatedText);
|
|
} catch (e) {
|
|
logger.error(`Repair failed for ${file.name}`, e);
|
|
}
|
|
}
|
|
|
|
if (!translatedText) throw new Error("Empty translation");
|
|
|
|
updates.push({ id: file.id, status: 'COMPLETED', content: translatedText });
|
|
}
|
|
|
|
// PERSIST: Save COMPLETED to DB immediately
|
|
await dbService.saveFilesBatch(currentStoryId, updates.map(u => ({
|
|
...batch.find(b => b.id === u.id), // merge with original to get name, etc
|
|
translatedContent: u.content,
|
|
status: u.status
|
|
})));
|
|
|
|
setFiles(prev => prev.map(f => {
|
|
const update = updates.find(u => u.id === f.id);
|
|
if (update) return { ...f, status: update.status, translatedContent: update.content };
|
|
return f;
|
|
}));
|
|
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
// Cleanup: Reset stuck processing files to IDLE in DB
|
|
const resetBatch = batch.map(b => ({ ...b, status: 'IDLE' }));
|
|
await dbService.saveFilesBatch(currentStoryId, resetBatch);
|
|
setFiles(prev => prev.map(f => resetBatch.find(b => b.id === f.id) || f));
|
|
break;
|
|
}
|
|
|
|
// Error handling
|
|
const errorBatch = batch.map(b => ({ ...b, status: 'ERROR', errorMessage: error.message }));
|
|
await dbService.saveFilesBatch(currentStoryId, errorBatch);
|
|
|
|
setFiles(prev => prev.map(f => batch.find(b => b.id === f.id) ? { ...f, status: 'ERROR', errorMessage: error.message } : f));
|
|
logger.error(`Batch failed`, error);
|
|
}
|
|
processedCount += batch.length;
|
|
setProgressPercentage((processedCount / total) * 100);
|
|
}
|
|
setIsProcessing(false);
|
|
setStartTime(null);
|
|
abortControllerRef.current = null;
|
|
logger.info("Batch translation finished");
|
|
};
|
|
|
|
const handleRetranslateMultiple = () => {
|
|
if (selectedFiles.size === 0) return;
|
|
setFiles(prev => prev.map(f => selectedFiles.has(f.id) ? { ...f, status: 'IDLE', translatedContent: null } : f));
|
|
};
|
|
|
|
const handleStopProcessing = () => {
|
|
if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; }
|
|
setIsProcessing(false);
|
|
// Force reset any stuck 'PROCESSING' files to 'IDLE'
|
|
setFiles(prev => prev.map(f => f.status === 'PROCESSING' ? { ...f, status: 'IDLE' } : f));
|
|
};
|
|
|
|
const handleOptimizePrompt = async () => {
|
|
setIsOptimizingPrompt(true);
|
|
try {
|
|
const currentTmplContent = PROMPT_TEMPLATES[selectedTemplateKey]?.content || PROMPT_TEMPLATES['DEFAULT'].content;
|
|
const optimized = await aiService.optimizePrompt(currentTmplContent, storyInfo, storyInfo.contextNotes);
|
|
setPromptTemplate(optimized);
|
|
alert("Optimized Prompt Applied!");
|
|
} catch (e) { alert("Tối ưu thất bại: " + e.message); } finally { setIsOptimizingPrompt(false); }
|
|
};
|
|
|
|
const handleTemplateChange = (key) => {
|
|
setSelectedTemplateKey(key);
|
|
if (PROMPT_TEMPLATES[key]) setPromptTemplate(PROMPT_TEMPLATES[key].content);
|
|
}; // End handleTemplateChange
|
|
|
|
const handleRetranslateSingle = async (e, id) => {
|
|
e.stopPropagation();
|
|
const file = files.find(f => f.id === id);
|
|
if (!file) return;
|
|
|
|
// Set Processing
|
|
setFiles(prev => prev.map(f => f.id === id ? { ...f, status: 'PROCESSING' } : f));
|
|
await dbService.saveFilesBatch(currentStoryId, [{ ...file, status: 'PROCESSING' }]);
|
|
|
|
try {
|
|
const { info: results } = await aiService.translateBatch(
|
|
[file],
|
|
promptTemplate,
|
|
additionalDictionary,
|
|
storyInfo.contextNotes
|
|
);
|
|
|
|
const translatedText = results[file.id];
|
|
if (!translatedText) throw new Error("Empty translation");
|
|
|
|
const updatedFile = { ...file, status: 'COMPLETED', translatedContent: translatedText };
|
|
|
|
// Update State & DB
|
|
setFiles(prev => prev.map(f => f.id === id ? updatedFile : f));
|
|
await dbService.saveFilesBatch(currentStoryId, [updatedFile]);
|
|
|
|
} catch (error) {
|
|
// Revert to Error/Idle
|
|
const errorFile = { ...file, status: 'ERROR', errorMessage: error.message };
|
|
setFiles(prev => prev.map(f => f.id === id ? errorFile : f));
|
|
await dbService.saveFilesBatch(currentStoryId, [errorFile]);
|
|
alert("Lỗi dịch lại: " + error.message);
|
|
}
|
|
};
|
|
|
|
const handleExportEpub = async () => {
|
|
if (files.length === 0) return;
|
|
try {
|
|
// Filter only selected if any, else all
|
|
const targets = selectedFiles.size > 0 ? files.filter(f => selectedFiles.has(f.id)) : files;
|
|
const blob = await epubService.generateEpub(storyInfo, targets);
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${storyInfo.title || 'ebook'}.epub`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
alert("Lỗi tạo EPUB: " + e.message);
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
|
|
const handleDeepAnalyze = async () => {
|
|
if (files.length === 0) { alert("Vui lòng tải file trước."); return; }
|
|
setIsAnalyzingNames(true);
|
|
setShowNameAnalysisModal(true);
|
|
try {
|
|
const { bible, entities } = await aiService.deepAnalyze(files, storyInfo);
|
|
if (bible) setStoryInfo(prev => ({ ...prev, contextNotes: bible }));
|
|
setAnalysisResults(entities || { characters: [], locations: [], terms: [] });
|
|
} catch (e) { alert("Phân tích thất bại: " + e.message); } finally { setIsAnalyzingNames(false); }
|
|
};
|
|
|
|
const handleAddTerms = (terms) => {
|
|
const newTerms = terms.join("\n");
|
|
setAdditionalDictionary(prev => prev ? prev + "\n" + newTerms : newTerms);
|
|
alert(`Đã thêm ${terms.length} thuật ngữ!`);
|
|
};
|
|
|
|
const handleFindReplace = async () => {
|
|
if (!findText) return;
|
|
if (!confirm(`Thay thế toàn bộ "${findText}" thành "${replaceText}" trong TẤT CẢ các file?`)) return;
|
|
|
|
setFiles(prev => prev.map(f => {
|
|
let content = f.content;
|
|
let translated = f.translatedContent;
|
|
// Replace in raw
|
|
if (content && content.includes(findText)) {
|
|
content = content.replaceAll(findText, replaceText);
|
|
}
|
|
// Replace in translation
|
|
if (translated && translated.includes(findText)) {
|
|
translated = translated.replaceAll(findText, replaceText);
|
|
}
|
|
return { ...f, content, translatedContent: translated };
|
|
}));
|
|
// Save to DB? Ideally yes, but auto-save handles it on next tick or we trigger it.
|
|
await dbService.updateStory(currentStoryId, { files: files });
|
|
alert("Đã thay thế xong!");
|
|
};
|
|
|
|
const handleManualCleanup = () => {
|
|
if (selectedFiles.size === 0) { alert("Vui lòng chọn file để Quét rác/Định dạng."); return; }
|
|
if (!confirm("Hệ thống sẽ xóa dòng rác, chuẩn hóa tiêu đề và thụt đầu dòng (Format EbookStandard). Tiếp tục?")) return;
|
|
|
|
setFiles(prev => prev.map(f => {
|
|
if (selectedFiles.has(f.id)) {
|
|
// Cleanup RAW content
|
|
const clean = formatBookStyle(f.content);
|
|
return { ...f, content: clean };
|
|
}
|
|
return f;
|
|
}));
|
|
alert("Đã định dạng xong!");
|
|
};
|
|
|
|
// --- Find & Replace Toggle ---
|
|
const toggleFindReplace = () => setShowFindReplace(prev => !prev);
|
|
|
|
// --- Range Logic ---
|
|
const handleRangeSelect = () => {
|
|
const start = parseInt(rangeStart);
|
|
const end = parseInt(rangeEnd);
|
|
if (isNaN(start) || isNaN(end)) { alert("Vui lòng nhập số chương hợp lệ!"); return; }
|
|
|
|
const isSelect = rangeMode; // true = Select, false = Deselect
|
|
const newSet = new Set(selectedFiles);
|
|
let count = 0;
|
|
|
|
files.forEach(f => {
|
|
// Extract number from filename (e.g., "Chương 10" -> 10, "Chapter 10" -> 10)
|
|
const match = f.name.match(/(\d+)/);
|
|
if (match) {
|
|
const num = parseInt(match[0]);
|
|
if (num >= start && num <= end) {
|
|
if (isSelect) {
|
|
if (!newSet.has(f.id)) { newSet.add(f.id); count++; }
|
|
} else {
|
|
if (newSet.has(f.id)) { newSet.delete(f.id); count++; }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
setSelectedFiles(newSet);
|
|
alert(`Đã ${isSelect ? 'chọn' : 'bỏ chọn'} ${count} file từ chương ${start} đến ${end}.`);
|
|
};
|
|
|
|
const handleRescueCopy = async (file) => {
|
|
if (!file) return;
|
|
try {
|
|
const localDict = optimizeDictionary(additionalDictionary, file.content);
|
|
const localCtx = optimizeContext(storyInfo.contextNotes, file.content);
|
|
|
|
let promptContent = "";
|
|
if (PROMPT_TEMPLATES[promptTemplate]) promptContent = PROMPT_TEMPLATES[promptTemplate].content;
|
|
else if (typeof promptTemplate === 'string') promptContent = promptTemplate;
|
|
|
|
const rescuePrompt = `*** HỆ THỐNG CỨU HỘ DỊCH THUẬT ***
|
|
Hãy dịch nội dung sau sang tiếng Việt.
|
|
[NGỮ CẢNH]:
|
|
${localCtx}
|
|
[TỪ ĐIỂN]:
|
|
${localDict}
|
|
[YÊU CẦU]: ${promptContent}
|
|
[NỘI DUNG RAW]:
|
|
${file.content}`;
|
|
|
|
await navigator.clipboard.writeText(rescuePrompt);
|
|
alert("Đã copy Prompt Cứu Hộ vào Clipboard! (Gồm Text + Dict + Context)");
|
|
} catch (e) {
|
|
alert("Lỗi copy: " + e.message);
|
|
}
|
|
};
|
|
|
|
// --- Filter Logic ---
|
|
const getVisibleFiles = () => {
|
|
let filtered = files;
|
|
if (filterStatuses.size > 0) {
|
|
if (filterStatuses.has('selected')) filtered = filtered.filter(f => selectedFiles.has(f.id));
|
|
else filtered = filtered.filter(f => filterStatuses.has(f.status));
|
|
}
|
|
if (currentPage > 0) {
|
|
const start = (currentPage - 1) * 50;
|
|
return filtered.slice(start, start + 50);
|
|
}
|
|
return filtered;
|
|
};
|
|
const stats = {
|
|
total: files.length,
|
|
completed: files.filter(f => f.status === 'COMPLETED').length,
|
|
processing: files.filter(f => f.status === 'PROCESSING').length,
|
|
failed: files.filter(f => f.status === 'ERROR').length,
|
|
pending: files.filter(f => f.status === 'IDLE').length
|
|
};
|
|
const visibleFiles = getVisibleFiles();
|
|
|
|
|
|
return (
|
|
<div className={`h-screen w-full overflow-hidden flex ${isDarkMode ? 'dark' : ''}`}>
|
|
|
|
{/* Sidebar */}
|
|
<Sidebar
|
|
stories={stories}
|
|
currentStoryId={currentStoryId}
|
|
onSelectStory={handleSelectStory}
|
|
onCreateStory={handleCreateStory}
|
|
onDeleteStory={handleDeleteStory}
|
|
onUpdateStory={handleUpdateStory}
|
|
isOpen={isSidebarOpen}
|
|
setIsOpen={setIsSidebarOpen}
|
|
/>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="flex-1 h-full min-w-0 flex flex-col relative transition-all duration-300">
|
|
|
|
{!currentStoryId && (
|
|
<div className="absolute inset-0 z-50 bg-slate-50 dark:bg-[#0d1117] flex flex-col items-center justify-center p-10 text-center animate-in fade-in">
|
|
<div className="bg-white dark:bg-[#161b22] p-8 rounded-3xl shadow-2xl max-w-md w-full border border-slate-100 dark:border-[#30363d]">
|
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-gray-100 mb-4">Chào mừng trở lại!</h2>
|
|
<p className="text-slate-500 mb-8">Vui lòng chọn một truyện từ Sidebar hoặc tạo mới để bắt đầu.</p>
|
|
<button onClick={handleCreateStory} className="w-full py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-200/50">
|
|
+ Tạo Truyện Mới
|
|
</button>
|
|
<div className="mt-8 pt-8 border-t border-slate-100 dark:border-[#30363d] text-xs text-slate-400">
|
|
AI Translate Dashboard v2.0 (SQLite Persistence)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<MainUI
|
|
isDarkMode={isDarkMode}
|
|
toggleTheme={toggleTheme}
|
|
files={files}
|
|
stats={stats}
|
|
progressPercentage={progressPercentage}
|
|
storyInfo={storyInfo}
|
|
setStoryInfo={setStoryInfo}
|
|
showSettings={showSettings}
|
|
setShowSettings={setShowSettings}
|
|
showLogs={showLogs}
|
|
setShowLogs={setShowLogs}
|
|
onShowChangelog={() => alert("Changelog/Guide")}
|
|
coverPreviewUrl={coverPreviewUrl}
|
|
handleCoverUpload={(e) => {
|
|
if (e.target.files[0]) setCoverPreviewUrl(URL.createObjectURL(e.target.files[0]));
|
|
}}
|
|
isGeneratingCover={isGeneratingCover}
|
|
handleRegenerateCover={handleRegenerateCover}
|
|
handleAutoAnalyze={handleAutoAnalyze}
|
|
isAutoAnalyzing={isAutoAnalyzing}
|
|
quickInput={quickInput}
|
|
setQuickInput={setQuickInput}
|
|
handleQuickParse={handleQuickParse}
|
|
handleBackup={() => alert("Dữ liệu tự động lưu vào SQLite!")}
|
|
handleRestore={() => alert("Dữ liệu tự động khôi phục từ SQLite!")}
|
|
requestResetApp={() => setFiles([])}
|
|
setShowGuide={() => setShowGuideModal(true)}
|
|
handleContextDownload={() => alert("Download Context chưa khả dụng")}
|
|
handleContextFileUpload={(e) => alert("Upload Context chưa khả dụng")}
|
|
setShowNameAnalysisModal={handleDeepAnalyze}
|
|
isAnalyzingNames={isAnalyzingNames}
|
|
setShowSmartStartModal={() => alert("Smart Start")}
|
|
dictTab={dictTab}
|
|
setDictTab={setDictTab}
|
|
handleDictionaryDownload={() => alert("Dict Download chưa khả dụng")}
|
|
handleDictionaryUpload={() => alert("Dict Upload chưa khả dụng")}
|
|
additionalDictionary={additionalDictionary}
|
|
setAdditionalDictionary={setAdditionalDictionary}
|
|
viewOriginalPrompt={viewOriginalPrompt}
|
|
setViewOriginalPrompt={setViewOriginalPrompt}
|
|
handlePromptUpload={() => alert("Prompt Upload chưa khả dụng")}
|
|
resetPrompt={() => setPromptTemplate('DEFAULT')}
|
|
promptTemplate={promptTemplate}
|
|
setPromptTemplate={setPromptTemplate}
|
|
selectedTemplateKey={selectedTemplateKey}
|
|
setSelectedTemplateKey={handleTemplateChange}
|
|
isOptimizingPrompt={isOptimizingPrompt}
|
|
handleOptimizePrompt={handleOptimizePrompt}
|
|
setShowPromptDesigner={setShowPromptDesigner}
|
|
currentPage={currentPage}
|
|
setCurrentPage={setCurrentPage}
|
|
totalPages={Math.ceil(files.length / 50) || 1}
|
|
visibleFiles={visibleFiles}
|
|
selectedFiles={selectedFiles}
|
|
handleSelectFile={(id, shift) => {
|
|
const newSet = new Set(selectedFiles);
|
|
if (newSet.has(id)) newSet.delete(id); else newSet.add(id);
|
|
setSelectedFiles(newSet);
|
|
}}
|
|
handleManualFixSingle={() => alert("Fix Single chưa khả dụng")}
|
|
requestRetranslateSingle={() => alert("Chức năng này đang phát triển")}
|
|
requestRetranslateMultiple={handleRetranslateMultiple}
|
|
openEditor={(file) => {
|
|
setEditorFile(file);
|
|
setShowEditor(true);
|
|
}}
|
|
handleRemoveFile={(id) => setFiles(prev => prev.filter(f => f.id !== id))}
|
|
handleFileUpload={handleFileUpload}
|
|
selectAll={() => {
|
|
if (selectedFiles.size === visibleFiles.length) setSelectedFiles(new Set());
|
|
else setSelectedFiles(new Set(visibleFiles.map(f => f.id)));
|
|
}}
|
|
rangeStart={rangeStart}
|
|
setRangeStart={setRangeStart}
|
|
rangeEnd={rangeEnd}
|
|
setRangeEnd={setRangeEnd}
|
|
|
|
isProcessing={isProcessing}
|
|
runSmartAutomation={() => alert("Smart Automation đang phát triển")}
|
|
showFilterPanel={showFilterPanel}
|
|
setShowFilterPanel={setShowFilterPanel}
|
|
filterStatuses={filterStatuses}
|
|
toggleFilterStatus={(status) => {
|
|
const newSet = new Set(filterStatuses);
|
|
if (newSet.has(status)) newSet.delete(status); else newSet.add(status);
|
|
setFilterStatuses(newSet);
|
|
}}
|
|
handleManualCleanup={handleManualCleanup}
|
|
handleSmartDelete={() => {
|
|
if (confirm(`Xóa ${selectedFiles.size} file đã chọn?`)) {
|
|
setFiles(prev => prev.filter(f => !selectedFiles.has(f.id)));
|
|
setSelectedFiles(new Set());
|
|
}
|
|
}}
|
|
requestDeleteAll={() => {
|
|
if (confirm("Xóa TẤT CẢ danh sách?")) setFiles([]);
|
|
}}
|
|
handleDownloadMerged={() => alert("Tính năng Gộp chưa khả dụng")}
|
|
handleStartButton={handleStartButton}
|
|
stopProcessing={handleStopProcessing}
|
|
// Find & Replace Props
|
|
showFindReplace={showFindReplace}
|
|
setShowFindReplace={setShowFindReplace}
|
|
toggleFindReplace={toggleFindReplace}
|
|
findText={findText}
|
|
setFindText={setFindText}
|
|
replaceText={replaceText}
|
|
setReplaceText={setReplaceText}
|
|
handleFindReplace={handleFindReplace}
|
|
// Range Props
|
|
handleRangeSelect={handleRangeSelect}
|
|
rangeStart={rangeStart}
|
|
setRangeStart={setRangeStart}
|
|
rangeEnd={rangeEnd}
|
|
setRangeEnd={setRangeEnd}
|
|
rangeMode={rangeMode}
|
|
setRangeMode={setRangeMode}
|
|
rangeMode={rangeMode}
|
|
setRangeMode={setRangeMode}
|
|
handleExportEpub={handleExportEpub}
|
|
requestRetranslateSingle={handleRetranslateSingle}
|
|
handleTestModel={() => { }}
|
|
enabledModels={[]}
|
|
modelConfigs={[]}
|
|
modelUsages={{}}
|
|
/>
|
|
</div>
|
|
|
|
{showSplitter && pendingSplitFile && (
|
|
<SplitterModal
|
|
isOpen={showSplitter}
|
|
fileName={pendingSplitFile.name}
|
|
fileContent={pendingSplitFile.content}
|
|
onCancel={() => { setShowSplitter(false); setPendingSplitFile(null); }}
|
|
onConfirmSplit={handleSplitConfirm}
|
|
/>
|
|
)}
|
|
|
|
{showEditor && editorFile && (
|
|
<EditorModal
|
|
isOpen={showEditor}
|
|
file={editorFile}
|
|
onClose={() => { setShowEditor(false); setEditorFile(null); }}
|
|
onSave={(id, raw, translated) => {
|
|
setFiles(prev => prev.map(f => f.id === id ? { ...f, content: raw, translatedContent: translated } : f));
|
|
}}
|
|
handleRescueCopy={() => handleRescueCopy(editorFile)}
|
|
/>
|
|
)}
|
|
|
|
{showGuideModal && <GuideModal isOpen={showGuideModal} onClose={() => setShowGuideModal(false)} />}
|
|
|
|
<ConfirmationModal
|
|
isOpen={!!storyToDelete}
|
|
onClose={() => setStoryToDelete(null)}
|
|
onConfirm={confirmDeleteStory}
|
|
title="Xóa Truyện?"
|
|
message={`Bạn có chắc muốn xóa truyện "${storyToDelete?.title || 'Chưa đặt tên'}" không? Hành động này không thể hoàn tác.`}
|
|
confirmText="Xóa Vĩnh Viễn"
|
|
/>
|
|
|
|
{showNameAnalysisModal && <NameAnalysisModal
|
|
isOpen={showNameAnalysisModal}
|
|
onClose={() => setShowNameAnalysisModal(false)}
|
|
isAnalyzing={isAnalyzingNames}
|
|
analysisResults={analysisResults}
|
|
onAddTerms={handleAddTerms}
|
|
/>}
|
|
|
|
{showLogs && <LogModal isOpen={showLogs} onClose={() => setShowLogs(false)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|