initial commit
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+7963
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"localforage": "^1.10.0",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"sql.js": "^1.13.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
+863
@@ -0,0 +1,863 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
export const ConfirmationModal = ({ isOpen, onClose, onConfirm, title, message, confirmText = "Xóa", cancelText = "Hủy" }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white dark:bg-[#161b22] rounded-2xl shadow-xl w-full max-w-sm border border-slate-200 dark:border-[#30363d] overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-slate-100 dark:border-[#30363d] flex justify-between items-center bg-rose-50 dark:bg-rose-900/10">
|
||||||
|
<h3 className="font-bold text-rose-600 dark:text-rose-400 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-black/5 rounded-full transition-colors text-slate-500">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 bg-slate-50 dark:bg-[#0d1117] flex justify-end gap-3 border-t border-slate-100 dark:border-[#30363d]">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-bold text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-[#21262d] border border-transparent hover:border-slate-200 dark:hover:border-[#30363d] rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 text-sm font-bold text-white bg-rose-600 hover:bg-rose-500 shadow-lg shadow-rose-200/50 dark:shadow-none rounded-lg transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ImageIcon, Upload, RefreshCw, Download, Sparkles, Loader2,
|
||||||
|
Archive, ArchiveRestore, Trash2, Globe, Tags, Users, Palette, Sword,
|
||||||
|
Wand2, FileUp, GraduationCap
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { TagInput } from './TagInput';
|
||||||
|
import { AVAILABLE_GENRES, AVAILABLE_PERSONALITIES } from '../constants';
|
||||||
|
|
||||||
|
const AVAILABLE_LANGUAGES = ['Tiếng Trung', 'Tiếng Anh', 'Tiếng Hàn', 'Tiếng Nhật', 'Convert'];
|
||||||
|
const AVAILABLE_SETTINGS = ['Đô Thị', 'Tiên Hiệp', 'Huyền Huyễn', 'Khoa Huyễn', 'Võng Du', 'Mạt Thế', 'Dị Giới', 'Thanh Xuân', 'Cổ Đại', 'Phương Tây'];
|
||||||
|
const AVAILABLE_FLOWS = ['Vô Địch Lưu', 'Phàm Nhân Lưu', 'Hệ Thống Lưu', 'Vô Hạn Lưu', 'Trọng Sinh', 'Xuyên Không', 'Điền Văn', 'Sảng Văn', 'Ngược Tâm'];
|
||||||
|
|
||||||
|
export const DashboardPage = (props) => {
|
||||||
|
|
||||||
|
const handleDownloadCover = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (props.coverPreviewUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = props.coverPreviewUrl;
|
||||||
|
link.download = `Cover_${props.storyInfo.title || 'AI_Art'}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 max-w-7xl mx-auto space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<h2 className="text-xl md:text-2xl font-bold text-slate-800 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<FileUp className="w-6 h-6 text-indigo-500" /> Thông Tin Tác Phẩm
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => props.setShowGuide(true)} className="px-4 py-2 bg-white dark:bg-[#161b22] border border-indigo-200 dark:border-indigo-900 text-indigo-600 dark:text-indigo-400 rounded-xl font-bold text-sm hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors shadow-sm flex items-center gap-2">
|
||||||
|
<GraduationCap className="w-4 h-4" /> Hướng Dẫn Sử Dụng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Compact Card */}
|
||||||
|
<div className="bg-white dark:bg-[#161b22] rounded-3xl p-4 md:p-6 shadow-sm border border-slate-200 dark:border-[#30363d]">
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
|
||||||
|
{/* Compact Cover Section */}
|
||||||
|
<div className="w-full md:w-48 shrink-0 flex flex-col gap-3">
|
||||||
|
<div className="aspect-[2/3] w-full bg-slate-50 dark:bg-[#0d1117] rounded-2xl border-2 border-dashed border-slate-200 dark:border-[#30363d] relative overflow-hidden group hover:border-indigo-300 dark:hover:border-indigo-700 transition-colors shadow-inner">
|
||||||
|
{props.isGeneratingCover && (
|
||||||
|
<div className="absolute inset-0 z-20 bg-white/80 dark:bg-black/80 flex flex-col items-center justify-center gap-2 backdrop-blur-sm">
|
||||||
|
<Loader2 className="w-6 h-6 text-indigo-600 animate-spin" />
|
||||||
|
<span className="text-[10px] font-bold text-indigo-600">Đang vẽ...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.coverPreviewUrl ? (
|
||||||
|
<img src={props.coverPreviewUrl} alt="Cover" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center text-slate-300 dark:text-slate-600">
|
||||||
|
<ImageIcon className="w-8 h-8 mb-2" />
|
||||||
|
<span className="text-[10px] font-bold uppercase text-center px-2">Chưa có ảnh</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover Actions */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2 z-10 p-2">
|
||||||
|
<label className="w-full py-1.5 bg-white/20 hover:bg-white text-white hover:text-slate-900 rounded-lg cursor-pointer text-[10px] font-bold backdrop-blur-md transition-all flex items-center justify-center gap-1">
|
||||||
|
<Upload className="w-3 h-3" /> Tải Lên
|
||||||
|
<input type="file" accept="image/*" className="hidden" onChange={props.handleCoverUpload} />
|
||||||
|
</label>
|
||||||
|
<button onClick={props.handleRegenerateCover} className="w-full py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg cursor-pointer text-[10px] font-bold shadow-lg transition-all flex items-center justify-center gap-1">
|
||||||
|
<RefreshCw className="w-3 h-3" /> Vẽ Lại
|
||||||
|
</button>
|
||||||
|
{props.coverPreviewUrl && (
|
||||||
|
<button onClick={handleDownloadCover} className="w-full py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg cursor-pointer text-[10px] font-bold shadow-lg transition-all flex items-center justify-center gap-1">
|
||||||
|
<Download className="w-3 h-3" /> Lưu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={props.handleAutoAnalyze} disabled={props.isAutoAnalyzing} className="w-full py-2.5 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-400 hover:to-purple-500 text-white rounded-xl text-xs font-bold shadow-md shadow-indigo-200/50 flex items-center justify-center gap-2 transition-all disabled:opacity-50">
|
||||||
|
{props.isAutoAnalyzing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||||
|
{props.isAutoAnalyzing ? "Đang xử lý..." : "Auto Phân Tích"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Inputs Section */}
|
||||||
|
<div className="flex-1 flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase mb-1.5 block ml-1">Tên Truyện</label>
|
||||||
|
<input type="text" className="w-full px-4 py-2.5 bg-slate-50 dark:bg-[#0d1117] border border-slate-200 dark:border-[#30363d] rounded-xl text-base font-bold text-slate-800 dark:text-gray-100 outline-none focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-900 focus:bg-white dark:focus:bg-[#161b22] transition-all shadow-sm" value={props.storyInfo.title} onChange={e => props.setStoryInfo({ ...props.storyInfo, title: e.target.value })} placeholder="Nhập tên truyện..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase mb-1.5 block ml-1">Tác Giả</label>
|
||||||
|
<input type="text" className="w-full px-4 py-2.5 bg-slate-50 dark:bg-[#0d1117] border border-slate-200 dark:border-[#30363d] rounded-xl text-base font-medium text-slate-700 dark:text-gray-200 outline-none focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-900 focus:bg-white dark:focus:bg-[#161b22] transition-all shadow-sm" value={props.storyInfo.author} onChange={e => props.setStoryInfo({ ...props.storyInfo, author: e.target.value })} placeholder="Tên tác giả..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase mb-1.5 block ml-1">Tóm Tắt (Summary)</label>
|
||||||
|
<textarea className="w-full h-20 px-4 py-3 bg-slate-50 dark:bg-[#0d1117] border border-slate-200 dark:border-[#30363d] rounded-xl text-sm text-slate-600 dark:text-gray-300 outline-none focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-900 focus:bg-white dark:focus:bg-[#161b22] transition-all resize-y custom-scrollbar" placeholder="Nội dung tóm tắt (Dùng cho tạo bìa và giới thiệu Ebook)..." value={props.storyInfo.summary || ''} onChange={e => props.setStoryInfo({ ...props.storyInfo, summary: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Detailed Classification */}
|
||||||
|
<div className="bg-white dark:bg-[#161b22] rounded-3xl p-4 md:p-6 shadow-sm border border-slate-200 dark:border-[#30363d]">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-gray-100 mb-4 flex items-center gap-2">
|
||||||
|
<Tags className="w-5 h-5 text-emerald-500" /> Phân Loại Chi Tiết
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4">
|
||||||
|
<TagInput label="Ngôn ngữ gốc" icon={<Globe className="w-3.5 h-3.5" />} options={AVAILABLE_LANGUAGES} selected={props.storyInfo.languages || []} onChange={v => props.setStoryInfo({ ...props.storyInfo, languages: v })} placeholder="Chọn..." />
|
||||||
|
<TagInput label="Thể loại" icon={<Tags className="w-3.5 h-3.5" />} options={AVAILABLE_GENRES} selected={props.storyInfo.genres || []} onChange={v => props.setStoryInfo({ ...props.storyInfo, genres: v })} placeholder="+ Thể loại" />
|
||||||
|
<TagInput label="Tính cách Main" icon={<Users className="w-3.5 h-3.5" />} options={AVAILABLE_PERSONALITIES} selected={props.storyInfo.mcPersonality || []} onChange={v => props.setStoryInfo({ ...props.storyInfo, mcPersonality: v })} placeholder="+ Tính cách" />
|
||||||
|
<TagInput label="Bối cảnh" icon={<Palette className="w-3.5 h-3.5" />} options={AVAILABLE_SETTINGS} selected={props.storyInfo.worldSetting || []} onChange={v => props.setStoryInfo({ ...props.storyInfo, worldSetting: v })} placeholder="+ Bối cảnh" />
|
||||||
|
<TagInput label="Lưu phái" icon={<Sword className="w-3.5 h-3.5" />} options={AVAILABLE_FLOWS} selected={props.storyInfo.sectFlow || []} onChange={v => props.setStoryInfo({ ...props.storyInfo, sectFlow: v })} placeholder="+ Lưu phái" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Tools */}
|
||||||
|
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Save, ArrowRightLeft, Copy, Check, LifeBuoy, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export const EditorModal = ({ isOpen, file, onClose, onSave, handleRescueCopy }) => {
|
||||||
|
const [rawContent, setRawContent] = useState('');
|
||||||
|
const [translatedContent, setTranslatedContent] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('both'); // 'raw', 'translated', 'both'
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (file) {
|
||||||
|
setRawContent(file.content || '');
|
||||||
|
setTranslatedContent(file.translatedContent || '');
|
||||||
|
}
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
// Auto-save Effect
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (rawContent !== (file.content || '') || translatedContent !== (file.translatedContent || '')) {
|
||||||
|
setIsSaving(true);
|
||||||
|
onSave(file.id, rawContent, translatedContent);
|
||||||
|
setTimeout(() => setIsSaving(false), 1000);
|
||||||
|
}
|
||||||
|
}, 2000); // Save after 2s of inactivity
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [rawContent, translatedContent, file.id, onSave]); // Note: file.content logic above prevents loops if parent doesn't update 'file' prop immediately
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(file.id, rawContent, translatedContent);
|
||||||
|
setIsSaving(true);
|
||||||
|
setTimeout(() => { setIsSaving(false); onClose(); }, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !file) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white dark:bg-[#161b22] w-full max-w-6xl h-[90vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-slate-200 dark:border-[#30363d] animate-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 dark:border-[#30363d] flex justify-between items-center bg-slate-50 dark:bg-[#0d1117]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-gray-100 max-w-md truncate" title={file.name}>
|
||||||
|
Hiệu chỉnh: {file.name}
|
||||||
|
</h3>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase border
|
||||||
|
${file.status === 'COMPLETED' ? 'bg-emerald-100 text-emerald-700 border-emerald-200' :
|
||||||
|
file.status === 'IDLE' ? 'bg-slate-100 text-slate-600 border-slate-200' :
|
||||||
|
'bg-sky-100 text-sky-700 border-sky-200'}`}>
|
||||||
|
{file.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="hidden md:flex bg-slate-200 dark:bg-[#21262d] p-1 rounded-lg mr-4">
|
||||||
|
<button onClick={() => setActiveTab('raw')} className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${activeTab === 'raw' ? 'bg-white dark:bg-[#30363d] text-indigo-600 dark:text-indigo-400 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}>Raw</button>
|
||||||
|
<button onClick={() => setActiveTab('both')} className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${activeTab === 'both' ? 'bg-white dark:bg-[#30363d] text-indigo-600 dark:text-indigo-400 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}>Song Ngữ</button>
|
||||||
|
<button onClick={() => setActiveTab('translated')} className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${activeTab === 'translated' ? 'bg-white dark:bg-[#30363d] text-indigo-600 dark:text-indigo-400 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}>Bản Dịch</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleSave} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-200/50 transition-all active:scale-95">
|
||||||
|
<Save className="w-4 h-4" /> Lưu
|
||||||
|
</button>
|
||||||
|
<button onClick={handleRescueCopy} className="p-2 hover:bg-amber-100 dark:hover:bg-amber-900/30 text-amber-500 rounded-xl transition-colors" title="Copy Gói Cứu Hộ (Dùng cho ChatGPT)">
|
||||||
|
<LifeBuoy className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-slate-200 dark:hover:bg-[#30363d] rounded-xl text-slate-500 transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Raw Column */}
|
||||||
|
{(activeTab === 'raw' || activeTab === 'both') && (
|
||||||
|
<div className={`flex-1 flex flex-col border-r border-slate-200 dark:border-[#30363d] ${activeTab === 'both' ? 'w-1/2' : 'w-full'}`}>
|
||||||
|
<div className="px-4 py-2 bg-slate-100 dark:bg-[#0d1117] border-b border-slate-200 dark:border-[#30363d] flex justify-between items-center">
|
||||||
|
<span className="text-xs font-bold text-slate-500 uppercase">Văn bản gốc</span>
|
||||||
|
<span className="text-xs font-mono text-slate-400">{rawContent.length} chars</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="flex-1 w-full p-4 resize-none outline-none focus:bg-indigo-50/10 dark:focus:bg-[#1f2428] font-mono text-sm leading-relaxed text-slate-700 dark:text-slate-300 bg-white dark:bg-[#0d1117] custom-scrollbar"
|
||||||
|
value={rawContent}
|
||||||
|
onChange={(e) => setRawContent(e.target.value)}
|
||||||
|
placeholder="Nội dung gốc..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Translated Column */}
|
||||||
|
{(activeTab === 'translated' || activeTab === 'both') && (
|
||||||
|
<div className={`flex-1 flex flex-col ${activeTab === 'both' ? 'w-1/2' : 'w-full'}`}>
|
||||||
|
<div className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/10 border-b border-indigo-100 dark:border-indigo-900/20 flex justify-between items-center">
|
||||||
|
<span className="text-xs font-bold text-indigo-600 dark:text-indigo-400 uppercase">Bản dịch AI</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="text-[10px] flex items-center gap-1 text-indigo-500 hover:text-indigo-700" onClick={() => navigator.clipboard.writeText(translatedContent)}>
|
||||||
|
<Copy className="w-3 h-3" /> Copy
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono text-indigo-400">{translatedContent.length} chars</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="flex-1 w-full p-4 resize-none outline-none focus:bg-indigo-50/20 dark:focus:bg-[#1f2428] font-sans text-base leading-relaxed text-slate-800 dark:text-slate-200 bg-white dark:bg-[#0d1117] custom-scrollbar"
|
||||||
|
value={translatedContent}
|
||||||
|
onChange={(e) => setTranslatedContent(e.target.value)}
|
||||||
|
placeholder="Bản dịch sẽ xuất hiện ở đây..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
class ErrorBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null, errorInfo: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, errorInfo) {
|
||||||
|
console.error("Uncaught error:", error, errorInfo);
|
||||||
|
this.setState({ errorInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full flex flex-col items-center justify-center bg-slate-50 dark:bg-[#0d1117] p-8 text-center">
|
||||||
|
<div className="bg-white dark:bg-[#161b22] p-8 rounded-3xl shadow-xl max-w-2xl border border-rose-200 dark:border-rose-900/30">
|
||||||
|
<h1 className="text-2xl font-bold text-rose-600 mb-4">Đã có lỗi xảy ra! (Application Crash)</h1>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300 mb-6">Ứng dụng gặp sự cố không mong muốn. Vui lòng tải lại trang hoặc báo lỗi cho Developer.</p>
|
||||||
|
|
||||||
|
<div className="bg-slate-100 dark:bg-[#0d1117] p-4 rounded-xl text-left overflow-auto max-h-60 mb-6 border border-slate-200 dark:border-[#30363d]">
|
||||||
|
<p className="font-mono text-xs text-rose-500 font-bold mb-2">{this.state.error && this.state.error.toString()}</p>
|
||||||
|
<pre className="font-mono text-[10px] text-slate-500 whitespace-pre-wrap">{this.state.errorInfo && this.state.errorInfo.componentStack}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold shadow-lg shadow-indigo-200/50 transition-all"
|
||||||
|
>
|
||||||
|
Tải Lại Trang
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FileText, Check, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
export const FileItem = ({ file, onSelect, isSelected }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"group flex items-center justify-between p-3 rounded-md cursor-pointer transition-all border",
|
||||||
|
isSelected
|
||||||
|
? "bg-[#1f2937] border-blue-500/50 shadow-md"
|
||||||
|
: "bg-[#0d1117] border-transparent hover:bg-[#161b22] hover:border-[#30363d]"
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(file.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={clsx(
|
||||||
|
"p-2 rounded-md",
|
||||||
|
isSelected ? "bg-blue-500/10 text-blue-400" : "bg-[#21262d] text-gray-400"
|
||||||
|
)}>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className={clsx(
|
||||||
|
"text-sm font-medium truncate transition-colors",
|
||||||
|
isSelected ? "text-blue-100" : "text-gray-300 group-hover:text-white"
|
||||||
|
)}>
|
||||||
|
{file.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5 flex items-center gap-1">
|
||||||
|
{(file.content.length / 1000).toFixed(1)}k chars
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center pl-2">
|
||||||
|
{file.status === 'COMPLETED' && <Check className="w-4 h-4 text-green-500" />}
|
||||||
|
{file.status === 'PROCESSING' && <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />}
|
||||||
|
{file.status === 'ERROR' && <AlertCircle className="w-4 h-4 text-red-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FileItem } from './FileItem';
|
||||||
|
|
||||||
|
export const FileList = ({ files, selectedId, onSelectFile }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 pb-4">
|
||||||
|
{files.map(file => (
|
||||||
|
<FileItem
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
isSelected={selectedId === file.id}
|
||||||
|
onSelect={onSelectFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { X, Book, FileText, Settings, HelpCircle, Lightbulb, Zap, Command } from 'lucide-react';
|
||||||
|
|
||||||
|
export const GuideModal = ({ isOpen, onClose }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white dark:bg-[#161b22] w-full max-w-4xl max-h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-slate-200 dark:border-[#30363d] animate-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 dark:border-[#30363d] flex justify-between items-center bg-slate-50 dark:bg-[#0d1117]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg text-indigo-600 dark:text-indigo-400">
|
||||||
|
<Book className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-gray-100">Hướng Dẫn Sử Dụng</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-slate-200 dark:hover:bg-[#30363d] rounded-xl text-slate-500 transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar bg-white dark:bg-[#161b22]">
|
||||||
|
<div className="space-y-8 max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
{/* Section 1: Workflow */}
|
||||||
|
<section>
|
||||||
|
<h4 className="text-xl font-bold text-slate-800 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-amber-500" /> Quy Trình Cơ Bản
|
||||||
|
</h4>
|
||||||
|
<div className="bg-slate-50 dark:bg-[#0d1117] rounded-xl p-5 border border-slate-100 dark:border-[#30363d] space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-none flex flex-col items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-sm shadow-md shadow-indigo-200/50">1</div>
|
||||||
|
<div className="w-0.5 h-full bg-indigo-100 dark:bg-indigo-900/30 my-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-700 dark:text-slate-200">Tải File Truyện</h5>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Hỗ trợ các định dạng .txt, .docx, .epub. Nếu file quá lớn (>10KB), hệ thống sẽ tự động gợi ý <strong>Tách Chương</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-none flex flex-col items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-sm shadow-md shadow-indigo-200/50">2</div>
|
||||||
|
<div className="w-0.5 h-full bg-indigo-100 dark:bg-indigo-900/30 my-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-700 dark:text-slate-200">Chuẩn Bị Thông Tin</h5>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Tại tab <strong>Thông Tin</strong>, nhập Tên truyện, Tác giả và Thể loại. Bấm <strong className="text-indigo-600 dark:text-indigo-400">Auto Phân Tích</strong> để AI tự động trích xuất thông tin từ file.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-none flex flex-col items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-sm shadow-md shadow-indigo-200/50">3</div>
|
||||||
|
<div className="w-0.5 h-full bg-indigo-100 dark:bg-indigo-900/30 my-1"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-700 dark:text-slate-200">Cấu Hình Dịch</h5>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Chuyển sang tab <strong>Tri Thức</strong> để thêm Từ điển (Vietphrase/Names) và tùy chỉnh Prompt nếu cần.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-none flex flex-col items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-sm shadow-md shadow-indigo-200/50">4</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-700 dark:text-slate-200">Bắt Đầu Dịch</h5>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Tại tab <strong>Biên Tập</strong>, bấm nút <strong>BẮT ĐẦU</strong> để dịch hàng loạt. Bạn có thể tạm dừng bất cứ lúc nào.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2: Hotkeys / Tips */}
|
||||||
|
<section className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Command className="w-5 h-5 text-indigo-500" /> Tính Năng Nổi Bật
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li className="flex items-start gap-3 p-3 rounded-xl bg-indigo-50 dark:bg-indigo-900/10 border border-indigo-100 dark:border-indigo-900/20">
|
||||||
|
<FileText className="w-5 h-5 text-indigo-600 dark:text-indigo-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<strong className="block text-sm text-indigo-700 dark:text-indigo-300">Tách Chương Thông Minh</strong>
|
||||||
|
<span className="text-xs text-indigo-600/80 dark:text-indigo-400/80">Regex đa năng nhận diện chính xác "Chương X", "Hồi Y", định dạng EPUB và convert Trung/Việt.</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/10 border border-emerald-100 dark:border-emerald-900/20">
|
||||||
|
<Settings className="w-5 h-5 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<strong className="block text-sm text-emerald-700 dark:text-emerald-300">Editor Song Ngữ</strong>
|
||||||
|
<span className="text-xs text-emerald-600/80 dark:text-emerald-400/80">Cho phép sửa trực tiếp nội dung Raw và Dịch song song, tự động lưu và cập nhật thống kê.</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-bold text-slate-800 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Lightbulb className="w-5 h-5 text-yellow-500" /> Mẹo Nhỏ
|
||||||
|
</h4>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/10 p-4 rounded-xl border border-yellow-100 dark:border-yellow-900/20 text-sm text-slate-600 dark:text-slate-300 space-y-2">
|
||||||
|
<p>• Dùng <strong>Shift + Click</strong> để chọn nhiều file trong danh sách.</p>
|
||||||
|
<p>• Bạn có thể nhập nhanh thể loại bằng cách paste text (VD: "Tiên Hiệp, Hệ Thống") vào ô nhập nhanh.</p>
|
||||||
|
<p>• Nếu API bị lỗi, hãy thử dùng chế độ <strong>MOCK</strong> bằng cách nhập API Key là "MOCK" trong Cài đặt.</p>
|
||||||
|
<p>• Dùng tính năng <strong>Smart AI</strong> để tự động sửa lỗi chính tả và format lại văn bản sau khi dịch.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="text-center pt-4 border-t border-slate-100 dark:border-[#30363d]">
|
||||||
|
<p className="text-xs text-slate-400">Phiên bản 2.0.1 (Pro) - Xây dựng bởi AntiGravity Agent</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Cpu, RefreshCw, Zap, Clock, Timer, CheckCircle, HelpCircle,
|
||||||
|
Terminal, Settings, Activity, FileText, Loader2, AlertCircle, Sun, Moon
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const Header = (props) => {
|
||||||
|
const formatNumber = (num) => new Intl.NumberFormat('vi-VN').format(num);
|
||||||
|
|
||||||
|
// Fallback for stats if undefined
|
||||||
|
const stats = props.stats || { total: 0, completed: 0, processing: 0, failed: 0, pending: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white/90 dark:bg-[#161b22]/90 backdrop-blur-md border-b border-slate-200 dark:border-[#30363d] sticky top-0 z-50 shrink-0 shadow-sm flex flex-col transition-colors duration-200">
|
||||||
|
{/* Top Bar: Title & Global Tools */}
|
||||||
|
<div className="px-4 py-2 flex items-center justify-between border-b border-slate-100 dark:border-[#30363d]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-gradient-to-tr from-indigo-600 to-violet-600 p-1.5 rounded-lg text-white shadow-md">
|
||||||
|
<Cpu className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-sm font-bold text-slate-800 dark:text-gray-100 leading-none">Dịch & Biên Tập Truyện AI Edition</h1>
|
||||||
|
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium mt-0.5">Dev: Nguyễn Trí Hiếu</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={props.onShowChangelog} className="ml-2 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">
|
||||||
|
<HelpCircle className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={props.toggleTheme}
|
||||||
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-[#21262d] text-slate-400 dark:text-slate-400 hover:text-amber-500 dark:hover:text-amber-400 transition-all"
|
||||||
|
title={props.isDarkMode ? "Chuyển sang Giao diện Sáng" : "Chuyển sang Giao diện Tối"}
|
||||||
|
>
|
||||||
|
{props.isDarkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{props.startTime && (
|
||||||
|
<div className="hidden md:flex items-center gap-3 px-3 py-1.5 bg-slate-50 dark:bg-[#0d1117] rounded-full border border-slate-200 dark:border-[#30363d] text-xs font-mono font-medium text-slate-600 dark:text-slate-400 shadow-sm">
|
||||||
|
<div className="flex items-center gap-1.5"><Clock className="w-3.5 h-3.5 text-sky-500" /> {new Date(props.startTime).toLocaleTimeString()}</div>
|
||||||
|
<div className="w-px h-3 bg-slate-300 dark:bg-slate-600"></div>
|
||||||
|
<div className="flex items-center gap-1.5 text-indigo-600 dark:text-indigo-400 font-bold">
|
||||||
|
<Timer className="w-3.5 h-3.5 animate-pulse" />
|
||||||
|
{(() => {
|
||||||
|
const end = props.endTime || Date.now();
|
||||||
|
const diff = Math.floor((end - props.startTime) / 1000);
|
||||||
|
const h = Math.floor(diff / 3600).toString().padStart(2, '0');
|
||||||
|
const m = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
|
||||||
|
const s = (diff % 60).toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={() => props.setShowLogs(true)} className={`relative p-2 rounded-lg transition-all ${props.showLogs ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400' : 'hover:bg-slate-100 dark:hover:bg-[#21262d] text-slate-400'}`}>
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
{props.hasLogErrors && <span className="absolute top-1.5 right-1.5 w-2.5 h-2.5 bg-rose-500 rounded-full animate-ping" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => props.setShowSettings(true)} className={`p-2 rounded-lg transition-all ${props.showSettings ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400' : 'hover:bg-slate-100 dark:hover:bg-[#21262d] text-slate-400'}`}>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Bar: Stats (Scrollable) */}
|
||||||
|
<div className="px-4 py-3 overflow-x-auto no-scrollbar flex items-center gap-6 border-b border-slate-100 dark:border-[#30363d] bg-white dark:bg-[#161b22]">
|
||||||
|
{/* Compact Stats */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-slate-50 dark:bg-[#0d1117] rounded-lg border border-slate-100 dark:border-[#30363d] min-w-[130px]">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<div className="flex flex-col leading-none">
|
||||||
|
<span className="text-[9px] font-bold text-slate-400 uppercase">Tổng File</span>
|
||||||
|
<span className="text-sm font-bold text-slate-700 dark:text-slate-300">{formatNumber(stats.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-50 dark:bg-emerald-900/10 rounded-lg border border-emerald-100 dark:border-emerald-900/20 min-w-[130px]">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
<div className="flex flex-col leading-none">
|
||||||
|
<span className="text-[9px] font-bold text-emerald-600 dark:text-emerald-500 uppercase">Hoàn thành</span>
|
||||||
|
<span className="text-sm font-bold text-emerald-700 dark:text-emerald-400">{formatNumber(stats.completed)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-sky-50 dark:bg-sky-900/10 rounded-lg border border-sky-100 dark:border-sky-900/20 min-w-[130px]">
|
||||||
|
<Activity className="w-3.5 h-3.5 text-sky-500" />
|
||||||
|
<div className="flex flex-col leading-none">
|
||||||
|
<span className="text-[9px] font-bold text-sky-600 dark:text-sky-500 uppercase">Xử lý</span>
|
||||||
|
<span className="text-sm font-bold text-sky-700 dark:text-sky-400">{formatNumber(stats.processing)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-rose-50 dark:bg-rose-900/10 rounded-lg border border-rose-100 dark:border-rose-900/20 min-w-[130px]">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 text-rose-500" />
|
||||||
|
<div className="flex flex-col leading-none">
|
||||||
|
<span className="text-[9px] font-bold text-rose-600 dark:text-rose-500 uppercase">Lỗi / Chờ</span>
|
||||||
|
<span className="text-sm font-bold text-rose-700 dark:text-rose-400">{stats.failed} <span className="text-[10px] text-rose-400 font-normal">/ {stats.pending}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-12 bg-slate-200 dark:bg-[#30363d] shrink-0 hidden lg:block"></div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="flex flex-col gap-2 shrink-0">
|
||||||
|
<button onClick={props.handleManualResetQuota} className="px-3 py-2 text-xs font-bold text-slate-500 dark:text-slate-400 bg-white dark:bg-[#0d1117] border border-slate-200 dark:border-[#30363d] rounded-lg hover:border-sky-300 dark:hover:border-sky-700 hover:text-sky-600 dark:hover:text-sky-400 transition-colors flex items-center gap-2 whitespace-nowrap shadow-sm">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" /> Reset Quota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Progress Bar */}
|
||||||
|
<div className="w-full h-1 bg-slate-100 dark:bg-[#0d1117] relative">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-sky-400 via-indigo-500 to-purple-500 transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${props.progressPercentage || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
BookA, BookOpen, Download, Upload, Microscope, Sparkles, Loader2,
|
||||||
|
Zap, Eye, EyeOff, LayoutTemplate, Wrench, RefreshCw
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { PROMPT_TEMPLATES, DEFAULT_DICTIONARY, DEFAULT_PROMPT } from '../constants';
|
||||||
|
|
||||||
|
export const KnowledgePage = (props) => {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6 h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Left Column: Context & Dictionary */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* 1. Context Section */}
|
||||||
|
<div className="bg-white dark:bg-[#161b22] rounded-3xl p-6 shadow-sm border border-slate-200 dark:border-[#30363d] flex-1 flex flex-col min-h-[400px]">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-amber-500" /> Ngữ Cảnh (Context)
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={props.handleContextDownload} className="p-2 hover:bg-amber-50 dark:hover:bg-amber-900/20 text-slate-400 hover:text-amber-600 transition-colors" title="Tải về"><Download className="w-4 h-4" /></button>
|
||||||
|
<label className="p-2 hover:bg-amber-50 dark:hover:bg-amber-900/20 text-slate-400 hover:text-amber-600 transition-colors cursor-pointer" title="Tải lên">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<input type="file" accept=".txt" className="hidden" onChange={props.handleContextFileUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="flex-1 w-full bg-amber-50/30 dark:bg-amber-900/10 border border-amber-100 dark:border-amber-900/30 rounded-xl p-4 text-sm font-mono text-slate-700 dark:text-amber-100/80 resize-none outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 transition-all custom-scrollbar leading-relaxed"
|
||||||
|
placeholder="Nhập thông tin bối cảnh, tóm tắt cốt truyện, quan hệ nhân vật..."
|
||||||
|
value={props.storyInfo.contextNotes || ''}
|
||||||
|
onChange={e => props.setStoryInfo({ ...props.storyInfo, contextNotes: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<button onClick={() => props.setShowNameAnalysisModal(true)} disabled={props.isAnalyzingNames} className="w-full py-3 bg-white dark:bg-[#0d1117] border border-amber-200 dark:border-amber-900/50 text-amber-600 dark:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition-all">
|
||||||
|
{props.isAnalyzingNames ? <Loader2 className="w-4 h-4 animate-spin" /> : <Microscope className="w-4 h-4" />} Phân Tích Sâu (AI)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Dictionary Section */}
|
||||||
|
<div className="bg-white dark:bg-[#161b22] rounded-3xl p-6 shadow-sm border border-slate-200 dark:border-[#30363d] flex-1 flex flex-col min-h-[400px]">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<BookA className="w-5 h-5 text-emerald-500" /> Từ Điển (Glossary)
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button onClick={props.handleDictionaryDownload} className="p-2 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-slate-400 hover:text-emerald-600 transition-colors"><Download className="w-4 h-4" /></button>
|
||||||
|
<label className="p-2 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-slate-400 hover:text-emerald-600 transition-colors cursor-pointer">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<input type="file" multiple accept=".txt" className="hidden" onChange={props.handleDictionaryUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="flex-1 w-full bg-emerald-50/30 dark:bg-emerald-900/10 border border-emerald-100 dark:border-emerald-900/30 rounded-xl p-4 text-sm font-mono text-slate-700 dark:text-emerald-100/80 resize-none outline-none focus:ring-2 focus:ring-emerald-200 dark:focus:ring-emerald-800 transition-all custom-scrollbar whitespace-pre-wrap leading-relaxed"
|
||||||
|
placeholder="Mỗi dòng một từ: Trung=Việt"
|
||||||
|
value={props.additionalDictionary}
|
||||||
|
onChange={e => props.setAdditionalDictionary(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Prompt Engineering */}
|
||||||
|
<div className="bg-white dark:bg-[#161b22] rounded-3xl p-6 shadow-sm border border-slate-200 dark:border-[#30363d] flex flex-col h-full min-h-[800px]">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-indigo-500" /> Prompt Engineering
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-slate-400 hover:text-indigo-600 transition-colors cursor-pointer" title="Load Prompt">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<input type="file" accept=".txt" className="hidden" onChange={props.handlePromptUpload} />
|
||||||
|
</label>
|
||||||
|
<button onClick={props.resetPrompt} className="p-2 hover:bg-rose-50 dark:hover:bg-rose-900/20 text-slate-400 hover:text-rose-600 transition-colors" title="Reset"><RefreshCw className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4 bg-slate-50 dark:bg-[#0d1117] p-2 rounded-xl border border-slate-100 dark:border-[#30363d]">
|
||||||
|
<div className="p-2 bg-white dark:bg-[#161b22] rounded-lg shadow-sm text-indigo-500"><LayoutTemplate className="w-4 h-4" /></div>
|
||||||
|
<select
|
||||||
|
className="flex-1 bg-transparent border-0 text-sm font-bold text-slate-700 dark:text-gray-200 outline-none focus:ring-0 cursor-pointer"
|
||||||
|
value={props.selectedTemplateKey}
|
||||||
|
onChange={(e) => props.setSelectedTemplateKey(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(PROMPT_TEMPLATES).map(([key, template]) => (
|
||||||
|
<option key={key} value={key} className="dark:bg-[#161b22]">{template.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor Area - Simplified */}
|
||||||
|
<div className="flex-1 relative mb-4 min-h-[500px] rounded-2xl border-2 border-slate-200 dark:border-[#30363d] overflow-hidden bg-[#f8fafc] dark:bg-[#0d1117] focus-within:border-indigo-400 dark:focus-within:border-indigo-600 focus-within:ring-4 focus-within:ring-indigo-100 dark:focus-within:ring-indigo-900/30 transition-all group">
|
||||||
|
<textarea
|
||||||
|
className="absolute inset-0 w-full h-full bg-transparent p-5 text-sm font-mono text-slate-800 dark:text-gray-300 resize-none outline-none custom-scrollbar whitespace-pre-wrap leading-7"
|
||||||
|
placeholder="Nội dung Prompt..."
|
||||||
|
value={props.promptTemplate}
|
||||||
|
onChange={e => props.setPromptTemplate(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={props.handleOptimizePrompt} disabled={props.isOptimizingPrompt} className="w-full py-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl text-sm font-bold shadow-lg shadow-indigo-200/50 transition-all flex items-center justify-center gap-2">
|
||||||
|
{props.isOptimizingPrompt ? <Loader2 className="w-5 h-5 animate-spin" /> : <Wrench className="w-5 h-5" />}
|
||||||
|
{props.isOptimizingPrompt ? "Đang Tối Ưu..." : "Tối Ưu Hóa Prompt (AI Architect)"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Terminal, Trash2, StopCircle, PlayCircle, AlertTriangle, CheckCircle, Info } from 'lucide-react';
|
||||||
|
import { logger } from '../services/logger';
|
||||||
|
|
||||||
|
export const LogModal = ({ isOpen, onClose }) => {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [isAutoScroll, setIsAutoScroll] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const unsubscribe = logger.subscribe((newLogs) => {
|
||||||
|
setLogs([...newLogs]); // Create copy to force render
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error': return <AlertTriangle className="w-3.5 h-3.5 text-rose-500" />;
|
||||||
|
case 'warn': return <AlertTriangle className="w-3.5 h-3.5 text-amber-500" />;
|
||||||
|
default: return <Info className="w-3.5 h-3.5 text-sky-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColor = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error': return 'bg-rose-50 dark:bg-rose-900/10 text-rose-800 dark:text-rose-300 border-rose-100 dark:border-rose-900/30';
|
||||||
|
case 'warn': return 'bg-amber-50 dark:bg-amber-900/10 text-amber-800 dark:text-amber-300 border-amber-100 dark:border-amber-900/30';
|
||||||
|
default: return 'bg-white dark:bg-[#161b22] text-slate-600 dark:text-slate-300 border-slate-100 dark:border-[#30363d]';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[250] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white dark:bg-[#0d1117] w-full max-w-4xl h-[80vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-slate-200 dark:border-[#30363d] animate-in zoom-in-95 duration-200 font-mono">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 dark:border-[#30363d] flex justify-between items-center bg-slate-50 dark:bg-[#161b22]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-slate-200 dark:bg-[#21262d] rounded-lg text-slate-600 dark:text-slate-400">
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-800 dark:text-gray-100 uppercase tracking-wider">System Logs</h3>
|
||||||
|
<p className="text-[10px] text-slate-400 font-bold">{logs.length} events recorded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => logger.clear()} className="p-2 hover:bg-rose-50 dark:hover:bg-rose-900/20 text-slate-400 hover:text-rose-500 rounded-lg transition-colors" title="Xóa logs">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-slate-200 dark:hover:bg-[#30363d] rounded-xl text-slate-500 transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar bg-slate-100 dark:bg-[#0d1117] space-y-1.5 flex flex-col">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-slate-400 dark:text-slate-600">
|
||||||
|
<CheckCircle className="w-8 h-8 opacity-50 mb-2" />
|
||||||
|
<span className="text-xs font-bold uppercase opacity-50">Hệ thống hoạt động bình thường</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<div key={log.id} className={`flex gap-3 p-3 rounded-lg border text-xs leading-relaxed break-all shadow-sm ${getColor(log.type)}`}>
|
||||||
|
<span className="shrink-0 mt-0.5 opacity-70">{getIcon(log.type)}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1 opacity-50 text-[10px]">
|
||||||
|
<span>{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="uppercase font-bold">{log.type}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap font-mono">{log.message}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { LayoutDashboard, BookOpen, PenTool } from 'lucide-react';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { DashboardPage } from './DashboardPage';
|
||||||
|
import { KnowledgePage } from './KnowledgePage';
|
||||||
|
import { WorkspacePage } from './WorkspacePage';
|
||||||
|
|
||||||
|
export const MainUI = (props) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('workspace');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full font-sans transition-colors duration-200 ${props.isDarkMode ? 'bg-[#0d1117] text-gray-100' : 'bg-slate-50 text-slate-800'}`}>
|
||||||
|
{/* 1. Universal Header */}
|
||||||
|
<div className="flex-none z-50">
|
||||||
|
<Header {...props} />
|
||||||
|
|
||||||
|
{/* 2. Navigation Tabs */}
|
||||||
|
<div className="bg-white dark:bg-[#161b22] border-b border-slate-200 dark:border-[#30363d] shadow-sm transition-colors duration-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 flex gap-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('dashboard')}
|
||||||
|
className={`py-4 text-sm font-bold flex items-center gap-2 border-b-2 transition-all ${activeTab === 'dashboard' ? 'text-indigo-600 dark:text-indigo-400 border-indigo-600 dark:border-indigo-400' : 'text-slate-500 dark:text-slate-400 border-transparent hover:text-indigo-600 dark:hover:text-indigo-400'}`}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4" /> Thông Tin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('knowledge')}
|
||||||
|
className={`py-4 text-sm font-bold flex items-center gap-2 border-b-2 transition-all ${activeTab === 'knowledge' ? 'text-amber-600 dark:text-amber-400 border-amber-600 dark:border-amber-400' : 'text-slate-500 dark:text-slate-400 border-transparent hover:text-amber-600 dark:hover:text-amber-400'}`}
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" /> Tri Thức
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('workspace')}
|
||||||
|
className={`py-4 text-sm font-bold flex items-center gap-2 border-b-2 transition-all ${activeTab === 'workspace' ? 'text-sky-600 dark:text-sky-400 border-sky-600 dark:border-sky-400' : 'text-slate-500 dark:text-slate-400 border-transparent hover:text-sky-600 dark:hover:text-sky-400'}`}
|
||||||
|
>
|
||||||
|
<PenTool className="w-4 h-4" /> Biên Tập
|
||||||
|
<span className="bg-slate-100 dark:bg-[#0d1117] text-slate-600 dark:text-slate-300 px-2 py-0.5 rounded-full text-[10px]">{props.files.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Page Content */}
|
||||||
|
<main className="flex-1 bg-slate-50/50 dark:bg-[#0d1117] relative min-h-0 overflow-hidden transition-colors duration-200">
|
||||||
|
{activeTab === 'dashboard' && (
|
||||||
|
<div className="h-full overflow-y-auto custom-scrollbar">
|
||||||
|
<DashboardPage {...props} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'knowledge' && (
|
||||||
|
<div className="h-full overflow-y-auto custom-scrollbar">
|
||||||
|
<KnowledgePage {...props} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'workspace' && <WorkspacePage {...props} />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Check, Loader2, BookOpen, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export const NameAnalysisModal = ({ isOpen, onClose, isAnalyzing, analysisResults, onAddTerms }) => {
|
||||||
|
const [selectedItems, setSelectedItems] = useState(new Set());
|
||||||
|
const [activeTab, setActiveTab] = useState('characters');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Auto-select all new items by default
|
||||||
|
useEffect(() => {
|
||||||
|
if (analysisResults) {
|
||||||
|
const allIds = new Set();
|
||||||
|
['characters', 'locations', 'terms'].forEach(key => {
|
||||||
|
if (analysisResults[key]) {
|
||||||
|
analysisResults[key].forEach((item, idx) => allIds.add(`${key}-${idx}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSelectedItems(allIds);
|
||||||
|
}
|
||||||
|
}, [analysisResults]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleToggle = (id) => {
|
||||||
|
const newSet = new Set(selectedItems);
|
||||||
|
if (newSet.has(id)) newSet.delete(id); else newSet.add(id);
|
||||||
|
setSelectedItems(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSelected = () => {
|
||||||
|
const termsToAdd = [];
|
||||||
|
['characters', 'locations', 'terms'].forEach(key => {
|
||||||
|
if (analysisResults[key]) {
|
||||||
|
analysisResults[key].forEach((item, idx) => {
|
||||||
|
if (selectedItems.has(`${key}-${idx}`)) {
|
||||||
|
// Format: Original=Vietnamese
|
||||||
|
const original = item.original || item.name;
|
||||||
|
const vietnamese = item.vietnamese || item.name;
|
||||||
|
termsToAdd.push(`${original}=${vietnamese}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onAddTerms(termsToAdd);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'characters', label: 'Nhân Vật', count: analysisResults?.characters?.length || 0 },
|
||||||
|
{ id: 'locations', label: 'Địa Danh', count: analysisResults?.locations?.length || 0 },
|
||||||
|
{ id: 'terms', label: 'Thuật Ngữ', count: analysisResults?.terms?.length || 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white dark:bg-[#161b22] w-full max-w-4xl max-h-[85vh] rounded-2xl shadow-2xl flex flex-col border border-slate-200 dark:border-[#30363d] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center p-5 border-b border-slate-100 dark:border-[#30363d] bg-white dark:bg-[#161b22]">
|
||||||
|
<h3 className="text-xl font-bold text-slate-800 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<BookOpen className="w-6 h-6 text-indigo-500" />
|
||||||
|
Phân Tích Thực Thể (Deep Analysis)
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Search Box */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm kiếm..."
|
||||||
|
className="pl-3 pr-8 py-1.5 text-xs rounded-lg border border-slate-200 dark:border-[#30363d] bg-slate-50 dark:bg-[#0d1117] focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-slate-100 dark:hover:bg-[#21262d] rounded-full text-slate-400 hover:text-slate-600 transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-20">
|
||||||
|
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 font-medium animate-pulse">Đang đọc hiểu văn bản & trích xuất dữ liệu...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-slate-100 dark:border-[#30363d]">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex-1 py-4 text-sm font-bold border-b-2 transition-all flex items-center justify-center gap-2 ${activeTab === tab.id
|
||||||
|
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400 bg-indigo-50/50 dark:bg-indigo-900/10'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-700 hover:bg-slate-50 dark:hover:bg-[#21262d]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label} <span className="bg-slate-100 dark:bg-[#30363d] px-2 py-0.5 rounded-full text-xs text-slate-600 dark:text-gray-400">{tab.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Controls */}
|
||||||
|
<div className="flex justify-between items-center px-4 py-2 border-b border-slate-100 dark:border-[#30363d] bg-white dark:bg-[#161b22] sticky top-0 z-10">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase">
|
||||||
|
Kết quả: {analysisResults && analysisResults[activeTab]?.filter(item =>
|
||||||
|
(item.vietnamese || item.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(item.original || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
).length}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newSet = new Set(selectedItems);
|
||||||
|
analysisResults[activeTab]?.forEach((_, idx) => newSet.add(`${activeTab}-${idx}`));
|
||||||
|
setSelectedItems(newSet);
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold text-indigo-600 hover:text-indigo-700 dark:text-indigo-400"
|
||||||
|
>
|
||||||
|
Chọn Tất Cả
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newSet = new Set(selectedItems);
|
||||||
|
analysisResults[activeTab]?.forEach((_, idx) => newSet.delete(`${activeTab}-${idx}`));
|
||||||
|
setSelectedItems(newSet);
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
Bỏ Chọn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar bg-slate-50 dark:bg-[#0d1117]">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{analysisResults && analysisResults[activeTab]?.filter(item =>
|
||||||
|
(item.vietnamese || item.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(item.original || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
).map((item, idx) => {
|
||||||
|
const id = `${activeTab}-${idx}`;
|
||||||
|
const isSelected = selectedItems.has(id);
|
||||||
|
const original = item.original || item.name || "Unknown";
|
||||||
|
const vietnamese = item.vietnamese || item.name || "Unknown";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
onClick={() => handleToggle(id)}
|
||||||
|
className={`p-3 rounded-xl border cursor-pointer transition-all flex items-start gap-3 group relative ${isSelected
|
||||||
|
? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-800 ring-1 ring-indigo-200 dark:ring-indigo-900'
|
||||||
|
: 'bg-white dark:bg-[#161b22] border-slate-200 dark:border-[#30363d] hover:border-indigo-300 dark:hover:border-indigo-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`mt-0.5 w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${isSelected
|
||||||
|
? 'bg-indigo-500 border-indigo-500 text-white'
|
||||||
|
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-[#0d1117]'
|
||||||
|
}`}>
|
||||||
|
{isSelected && <Check className="w-3.5 h-3.5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-slate-800 dark:text-gray-200 text-sm flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="text-indigo-600 dark:text-indigo-400">{original}</span>
|
||||||
|
{original !== vietnamese && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-300">→</span>
|
||||||
|
<span>{vietnamese}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 line-clamp-2">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{(!analysisResults || analysisResults[activeTab]?.length === 0) && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 opacity-50">
|
||||||
|
<BookOpen className="w-12 h-12 text-slate-300 mb-2" />
|
||||||
|
<p className="text-sm">Không tìm thấy dữ liệu.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 bg-white dark:bg-[#161b22] border-t border-slate-100 dark:border-[#30363d] flex justify-between items-center">
|
||||||
|
<span className="text-xs font-bold text-slate-500">Đã chọn: {selectedItems.size}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleAddSelected}
|
||||||
|
disabled={isAnalyzing || selectedItems.size === 0}
|
||||||
|
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-300 text-white font-bold rounded-xl shadow-lg shadow-indigo-200/50 transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Thêm Vào Từ Điển
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Check } from 'lucide-react';
|
||||||
|
import { PROMPT_TEMPLATES, AVAILABLE_GENRES, AVAILABLE_PERSONALITIES } from '../constants';
|
||||||
|
|
||||||
|
export const PromptDesigner = ({ isOpen, onClose, storyInfo, onUpdateInfo, onApplyPrompt }) => {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState('DEFAULT');
|
||||||
|
const [localInfo, setLocalInfo] = useState({ ...storyInfo });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalInfo({ ...storyInfo });
|
||||||
|
}, [storyInfo, isOpen]);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onUpdateInfo(localInfo);
|
||||||
|
onApplyPrompt(selectedTemplate); // Tell parent to optimize prompt with this template
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-[#161b22] rounded-xl w-full max-w-2xl border border-[#30363d] flex flex-col max-h-[90vh]">
|
||||||
|
<div className="p-4 border-b border-[#30363d] flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-white">Thiết Kế Prompt & Thông Tin</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
|
||||||
|
{/* 1. Template Selection */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-bold text-blue-400 uppercase mb-3">1. Chọn Chế Độ Dịch (Template)</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{Object.entries(PROMPT_TEMPLATES).map(([key, tpl]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => setSelectedTemplate(key)}
|
||||||
|
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedTemplate === key
|
||||||
|
? 'bg-blue-600/20 border-blue-500 text-white'
|
||||||
|
: 'bg-[#0d1117] border-[#30363d] text-gray-400 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedTemplate === key && <Check className="w-4 h-4 text-blue-400" />}
|
||||||
|
<span className="font-medium">{tpl.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. Story Metadata */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-bold text-purple-400 uppercase mb-3">2. Thông Tin Truyện (Metadata)</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Tên Truyện</label>
|
||||||
|
<input
|
||||||
|
className="w-full bg-[#0d1117] border border-[#30363d] rounded p-2 text-white text-sm focus:border-blue-500 outline-none"
|
||||||
|
value={localInfo.title || ''}
|
||||||
|
onChange={e => setLocalInfo({ ...localInfo, title: e.target.value })}
|
||||||
|
placeholder="Nhập tên truyện..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Tác Giả</label>
|
||||||
|
<input
|
||||||
|
className="w-full bg-[#0d1117] border border-[#30363d] rounded p-2 text-white text-sm focus:border-blue-500 outline-none"
|
||||||
|
value={localInfo.author || ''}
|
||||||
|
onChange={e => setLocalInfo({ ...localInfo, author: e.target.value })}
|
||||||
|
placeholder="Tên tác giả..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-2">Thể Loại Chính</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AVAILABLE_GENRES.map(g => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() => {
|
||||||
|
const current = localInfo.genres || [];
|
||||||
|
const newGenres = current.includes(g)
|
||||||
|
? current.filter(x => x !== g)
|
||||||
|
: [...current, g];
|
||||||
|
setLocalInfo({ ...localInfo, genres: newGenres });
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 text-xs rounded border transition-all ${(localInfo.genres || []).includes(g)
|
||||||
|
? 'bg-purple-600/30 border-purple-500 text-purple-200'
|
||||||
|
: 'bg-[#0d1117] border-[#30363d] text-gray-500 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-2">Tính Cách Main</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AVAILABLE_PERSONALITIES.map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => {
|
||||||
|
const current = localInfo.mcPersonality || [];
|
||||||
|
const newP = current.includes(p) ? current.filter(x => x !== p) : [...current, p];
|
||||||
|
setLocalInfo({ ...localInfo, mcPersonality: newP });
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 text-xs rounded border transition-all ${(localInfo.mcPersonality || []).includes(p)
|
||||||
|
? 'bg-green-600/30 border-green-500 text-green-200'
|
||||||
|
: 'bg-[#0d1117] border-[#30363d] text-gray-500 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-[#30363d] bg-[#161b22] flex justify-end gap-3 rounded-b-xl">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-400 hover:text-white">Đóng</button>
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded shadow-lg shadow-blue-900/20"
|
||||||
|
>
|
||||||
|
Áp dụng & Tối ưu Prompt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { aiService } from '../services/aiService';
|
||||||
|
|
||||||
|
export const SettingsModal = ({ isOpen, onClose }) => {
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setApiKey(localStorage.getItem('DEEPSEEK_API_KEY') || '');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (apiKey.trim()) {
|
||||||
|
aiService.setApiKey(apiKey.trim());
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
alert("Please enter a valid API Key");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||||
|
<div className="bg-gray-800 rounded-xl p-6 w-full max-w-md border border-gray-700 shadow-2xl">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">API Settings</h2>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-400 text-sm mb-2">DeepSeek API Key</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={isVisible ? "text" : "password"}
|
||||||
|
className="w-full bg-gray-900 border border-gray-700 rounded p-2 text-white focus:border-blue-500 focus:outline-none"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-2 text-gray-500 hover:text-white"
|
||||||
|
onClick={() => setIsVisible(!isVisible)}
|
||||||
|
>
|
||||||
|
{isVisible ? '🙈' : '👁️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Your key is stored locally in your browser. We do not see it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded text-gray-400 hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded font-medium transition"
|
||||||
|
>
|
||||||
|
Save Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Book, Plus, Trash2, ChevronLeft, ChevronRight, Database, Edit2, GripVertical } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Sidebar = ({
|
||||||
|
stories,
|
||||||
|
currentStoryId,
|
||||||
|
onSelectStory,
|
||||||
|
onCreateStory,
|
||||||
|
onDeleteStory,
|
||||||
|
onUpdateStory,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen
|
||||||
|
}) => {
|
||||||
|
// --- State ---
|
||||||
|
const [width, setWidth] = useState(256); // Default 256px
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
// --- Resize Logic ---
|
||||||
|
const startResizing = (mouseDownEvent) => {
|
||||||
|
mouseDownEvent.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (mouseMoveEvent) => {
|
||||||
|
if (isResizing) {
|
||||||
|
// Ensure min 60px, max 600px
|
||||||
|
const newWidth = Math.max(64, Math.min(600, mouseMoveEvent.clientX));
|
||||||
|
setWidth(newWidth);
|
||||||
|
if (newWidth < 100 && isOpen) setIsOpen(false);
|
||||||
|
if (newWidth > 100 && !isOpen) setIsOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (isResizing) setIsResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isResizing, isOpen, setIsOpen]);
|
||||||
|
|
||||||
|
// --- Rename Logic ---
|
||||||
|
const handleStartEdit = (e, story) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingId(story.id);
|
||||||
|
setEditName(story.title || "");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (editingId && onUpdateStory) {
|
||||||
|
onUpdateStory(editingId, { title: editName });
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveEdit();
|
||||||
|
if (e.key === 'Escape') setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const formatDate = (ts) => {
|
||||||
|
if (!ts) return '';
|
||||||
|
return new Date(ts).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full border-r border-slate-200 dark:border-[#30363d] bg-white dark:bg-[#0d1117] relative flex flex-col group/sidebar"
|
||||||
|
style={{ width: isOpen ? width : 64, transition: isResizing ? 'none' : 'width 0.3s ease' }}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500 z-50 transition-colors opacity-0 group-hover/sidebar:opacity-100"
|
||||||
|
onMouseDown={startResizing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toggle Button (Only when NOT resizing to avoid conflict?) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="absolute -right-3 top-6 bg-white dark:bg-[#21262d] border border-slate-200 dark:border-[#30363d] rounded-full p-1 shadow-sm text-slate-500 hover:text-indigo-600 z-50 transform hover:scale-110 transition-all"
|
||||||
|
>
|
||||||
|
{isOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-4 border-b border-slate-100 dark:border-[#30363d] flex items-center ${isOpen ? 'justify-between' : 'justify-center'}`}>
|
||||||
|
{isOpen && <h2 className="font-bold text-slate-700 dark:text-gray-200 flex items-center gap-2 truncate"><Database className="w-4 h-4 text-indigo-500" /> Thư Viện</h2>}
|
||||||
|
<button
|
||||||
|
onClick={onCreateStory}
|
||||||
|
className={`p-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg shadow-md transition-all active:scale-95 ${isOpen ? '' : 'w-full'}`}
|
||||||
|
title="Tạo truyện mới"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mx-auto" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story List */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2 select-none">
|
||||||
|
{stories.map(story => {
|
||||||
|
const isActive = story.id === currentStoryId;
|
||||||
|
const isEditing = editingId === story.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={story.id}
|
||||||
|
onClick={() => !isEditing && onSelectStory(story.id)}
|
||||||
|
onDoubleClick={(e) => isOpen && handleStartEdit(e, story)}
|
||||||
|
className={`group relative rounded-xl transition-all cursor-pointer border ${isActive
|
||||||
|
? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-800'
|
||||||
|
: 'bg-transparent border-transparent hover:bg-slate-50 dark:hover:bg-[#161b22] hover:border-slate-200 dark:hover:border-[#30363d]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-3 p-2 ${isOpen ? '' : 'justify-center'}`}>
|
||||||
|
{/* Cover Icon/Image */}
|
||||||
|
<div className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden border ${isActive ? 'border-indigo-200 dark:border-indigo-700' : 'bg-slate-100 dark:bg-[#21262d] border-slate-200 dark:border-[#30363d]'}`}>
|
||||||
|
{story.cover_image ? (
|
||||||
|
<img src={story.cover_image} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Book className={`w-5 h-5 ${isActive ? 'text-indigo-600' : 'text-slate-400'}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full text-sm font-bold bg-white dark:bg-[#0d1117] border border-indigo-500 rounded px-1 outline-none"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onBlur={handleSaveEdit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h4 className={`font-bold text-sm truncate leading-tight ${isActive ? 'text-indigo-700 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-200'}`} title={story.title}>
|
||||||
|
{story.title || "Chưa đặt tên"}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="flex justify-between items-center mt-1">
|
||||||
|
<span className="text-[10px] text-slate-400 truncate max-w-[60%]">{story.author || "Tác giả?"}</span>
|
||||||
|
<span className="text-[9px] text-slate-300">{formatDate(story.last_accessed)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions (Delete/Edit) */}
|
||||||
|
{isOpen && !isEditing && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all bg-white/80 dark:bg-[#161b22]/90 rounded-lg p-0.5 shadow-sm backdrop-blur-sm">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleStartEdit(e, story)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-md"
|
||||||
|
title="Đổi tên"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDeleteStory(story.id); }}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/30 rounded-md"
|
||||||
|
title="Xóa"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Info */}
|
||||||
|
<div className="p-3 border-t border-slate-100 dark:border-[#30363d] text-center">
|
||||||
|
<span className="text-[10px] text-slate-300 font-mono">Synced • v2.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const SmartAnalysisModal = ({ isOpen, onClose, onConfirm, isAnalyzing }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-800 rounded-xl p-6 w-full max-w-md border border-gray-700 shadow-2xl">
|
||||||
|
<h2 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent mb-4">
|
||||||
|
Smart Story Analysis
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-300 text-sm mb-6 leading-relaxed">
|
||||||
|
AI will read samples from your files (Start, Middle, End) to identify:
|
||||||
|
<br />
|
||||||
|
• Story Title & Author
|
||||||
|
<br />
|
||||||
|
• Main Characters & Sects
|
||||||
|
<br />
|
||||||
|
• Cultivation Levels & Terms
|
||||||
|
<br />
|
||||||
|
• Specialized Prompt
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
className="px-4 py-2 rounded text-gray-400 hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
className={`px-4 py-2 rounded font-medium text-white transition flex items-center gap-2
|
||||||
|
${isAnalyzing ? 'bg-blue-800 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-500'}`}
|
||||||
|
>
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Reading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Start Analysis'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { padNumber, sanitizeFilename } from '../services/textHelpers';
|
||||||
|
|
||||||
|
// Regex presets for splitting logic
|
||||||
|
const REGEX_PRESETS = [
|
||||||
|
{ label: "Thông Minh (Đa năng)", value: "" },
|
||||||
|
{ label: "Cấu Trúc EPUB Gốc", value: "^###EPUB_CHAPTER_SPLIT###\\s+.*$" },
|
||||||
|
{ label: "Tiếng Anh (Roman/Word)", value: "^(?:(?:Chapter|Part|Book|Vol|Episode)\\s+(?:[IVXLCDM]+|\\d+|One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve|Thirteen|Fourteen|Fifteen|Twenty)|(?:[IVXLCDM]+|(?:One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve|Thirteen|Fourteen|Fifteen|Twenty))\\s*[:.])\\s*.*$" },
|
||||||
|
{ label: "Truyện Nhật (Syosetu)", value: "^(?:[##][0-90-9]+|第\\s*[0-90-9]+\\s*[話章幕節]|[0-90-9]+\\s*[..])\\s*.*$" },
|
||||||
|
{ label: "Truyện Trung (Đa Năng)", value: "^(?:第\\s*[0-90-9零一二三四五六七八九十百千]+\\s*[章話節回幕卷]|卷\\s*[0-90-9]+|[0-90-9]+\\s+[\\u4e00-\\u9fa5]).*$" },
|
||||||
|
{ label: "Việt/Anh (Chương X)", value: "^(?:Chapter|Chương|Hồi|Tiết|Quyển|Tập)\\s*\\d+.*$" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const splitContentByRegex = (content, regexStr) => {
|
||||||
|
// Enhanced Regex Logic (Ported from Demo)
|
||||||
|
const defaultRegex = /^(?:第\s*[0-90-9零一二三四五六七八九十百千]+\s*[章話節回幕卷].*|(?:Chapter|Chương|Hồi|Tiết|Quyển|Tập|Episode|Vol\.?|Book)\s*\d+.*|[##][0-90-9]+.*|[0-90-9]+\s*[..]\s*.*|[0-90-9]+\s+[\u4e00-\u9fa5].*|Episode\s+\d+|Màn\s+\d+|Phần\s+\d+)$/i;
|
||||||
|
|
||||||
|
// Create Regex Object
|
||||||
|
const regex = (regexStr && regexStr.trim() !== '')
|
||||||
|
? new RegExp(regexStr, 'i')
|
||||||
|
: defaultRegex;
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const files = [];
|
||||||
|
let currentBuffer = [];
|
||||||
|
let currentTitle = "Mở đầu / Giới thiệu";
|
||||||
|
let chapterIndex = 1;
|
||||||
|
|
||||||
|
// Helper to finalize a chapter
|
||||||
|
const finalizeChapter = (title, buffer, chIndex) => {
|
||||||
|
if (buffer.length === 0) return;
|
||||||
|
|
||||||
|
const rawContent = buffer.join('\n').trim();
|
||||||
|
|
||||||
|
// --- GHOST CHAPTER FILTER ---
|
||||||
|
// Ignore extremely short chapters (likely TOC or noise)
|
||||||
|
if (rawContent.length < 80) return;
|
||||||
|
|
||||||
|
let safeTitle = sanitizeFilename(title);
|
||||||
|
// Clean up common clutter
|
||||||
|
safeTitle = safeTitle.replace(/^###EPUB_CHAPTER_SPLIT###\s*/, '');
|
||||||
|
safeTitle = safeTitle.replace(/^[\*\->=\s]+/, '').trim(); // Remove leading * > - =
|
||||||
|
|
||||||
|
if (safeTitle.length > 80) safeTitle = safeTitle.substring(0, 80) + "...";
|
||||||
|
if (!safeTitle) safeTitle = `Chương ${chIndex}`;
|
||||||
|
|
||||||
|
// Uses sequential index for sorting prefix
|
||||||
|
const finalName = `${padNumber(chIndex)} ${safeTitle}`;
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: finalName,
|
||||||
|
content: rawContent,
|
||||||
|
translatedContent: null,
|
||||||
|
status: 'IDLE',
|
||||||
|
retryCount: 0,
|
||||||
|
originalCharCount: rawContent.length,
|
||||||
|
remainingRawCharCount: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main Loop
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (!trimmedLine) {
|
||||||
|
currentBuffer.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. CLEAN NOISE: Strip leading special chars (*, >, -, =, space) just for regex checking
|
||||||
|
const cleanLine = trimmedLine.replace(/^[\s\*\->=\+]+/, '');
|
||||||
|
|
||||||
|
if (regex.test(cleanLine)) {
|
||||||
|
if (currentBuffer.length > 0) {
|
||||||
|
finalizeChapter(currentTitle, currentBuffer, chapterIndex);
|
||||||
|
chapterIndex++;
|
||||||
|
}
|
||||||
|
// Use the cleaned line as the title
|
||||||
|
currentTitle = cleanLine;
|
||||||
|
currentBuffer = [line];
|
||||||
|
} else {
|
||||||
|
currentBuffer.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize last chapter
|
||||||
|
finalizeChapter(currentTitle, currentBuffer, chapterIndex);
|
||||||
|
|
||||||
|
// SORTING
|
||||||
|
files.sort((a, b) => {
|
||||||
|
const numA = parseInt(a.name.substring(0, 5), 10);
|
||||||
|
const numB = parseInt(b.name.substring(0, 5), 10);
|
||||||
|
return numA - numB;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback if no split happened (return original as single file)
|
||||||
|
if (files.length === 0 && content.trim().length > 0) {
|
||||||
|
return [{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'Tệp gốc',
|
||||||
|
content: content,
|
||||||
|
status: 'IDLE',
|
||||||
|
retryCount: 0,
|
||||||
|
originalCharCount: content.length
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const splitContentByLength = (content, limit, mode) => {
|
||||||
|
let chapters = [];
|
||||||
|
const totalLen = content.length;
|
||||||
|
let currentIndex = 0;
|
||||||
|
let partCount = 1;
|
||||||
|
|
||||||
|
while (currentIndex < totalLen) {
|
||||||
|
let endIndex = Math.min(currentIndex + limit, totalLen);
|
||||||
|
|
||||||
|
// Smart break: Try to find a safe break point (newline)
|
||||||
|
if (endIndex < totalLen) {
|
||||||
|
const nextNewline = content.indexOf('\n', endIndex);
|
||||||
|
if (nextNewline !== -1 && (nextNewline - endIndex) < 500) {
|
||||||
|
endIndex = nextNewline + 1;
|
||||||
|
} else {
|
||||||
|
const prevNewline = content.lastIndexOf('\n', endIndex);
|
||||||
|
if (prevNewline > currentIndex) {
|
||||||
|
endIndex = prevNewline + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunkText = content.substring(currentIndex, endIndex).trim();
|
||||||
|
|
||||||
|
if (chunkText.length > 0) {
|
||||||
|
let finalContent = chunkText;
|
||||||
|
let finalName = "";
|
||||||
|
|
||||||
|
if (mode === 'reindex') {
|
||||||
|
const header = `Chương ${partCount}`;
|
||||||
|
finalName = `${padNumber(partCount)} ${header}`;
|
||||||
|
finalContent = `${header}\n\n${chunkText}`;
|
||||||
|
} else {
|
||||||
|
finalName = `${padNumber(partCount)} Part ${partCount} (Split)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: finalName,
|
||||||
|
content: finalContent,
|
||||||
|
translatedContent: null,
|
||||||
|
status: 'IDLE',
|
||||||
|
retryCount: 0,
|
||||||
|
originalCharCount: chunkText.length,
|
||||||
|
});
|
||||||
|
partCount++;
|
||||||
|
}
|
||||||
|
currentIndex = endIndex;
|
||||||
|
}
|
||||||
|
return chapters;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SplitterModal = ({ isOpen, fileContent, fileName, onConfirmSplit, onCancel }) => {
|
||||||
|
const [mode, setMode] = useState('regex');
|
||||||
|
const [charLimit, setCharLimit] = useState(6000);
|
||||||
|
const [customRegex, setCustomRegex] = useState('');
|
||||||
|
const [previewFiles, setPreviewFiles] = useState([]);
|
||||||
|
const [isCalculating, setIsCalculating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setIsCalculating(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
let resultFiles = [];
|
||||||
|
if (mode === 'regex') {
|
||||||
|
resultFiles = splitContentByRegex(fileContent, customRegex);
|
||||||
|
} else {
|
||||||
|
resultFiles = splitContentByLength(fileContent, charLimit, mode);
|
||||||
|
}
|
||||||
|
setPreviewFiles(resultFiles);
|
||||||
|
setIsCalculating(false);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen, mode, charLimit, customRegex, fileContent]);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirmSplit(previewFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeepAsIs = () => {
|
||||||
|
const file = {
|
||||||
|
id: crypto.randomUUID(), name: fileName, content: fileContent,
|
||||||
|
translatedContent: null, status: 'IDLE', retryCount: 0,
|
||||||
|
originalCharCount: fileContent.length, remainingRawCharCount: 0
|
||||||
|
};
|
||||||
|
onConfirmSplit([file]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white dark:bg-[#161b22] rounded-3xl shadow-2xl w-full max-w-3xl h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200 border border-slate-200 dark:border-[#30363d]">
|
||||||
|
<div className="p-6 border-b border-slate-100 dark:border-[#30363d] shrink-0">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-gray-100">Bộ Tách Chương (Splitter v2.3 - Modular)</h3>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex bg-slate-100 dark:bg-[#0d1117] p-1 rounded-xl gap-1">
|
||||||
|
<button onClick={() => setMode('regex')} className={`flex-1 py-2 rounded-lg text-[10px] font-bold transition-all ${mode === 'regex' ? 'bg-white dark:bg-[#21262d] text-sky-600 dark:text-sky-400 shadow-sm' : 'text-slate-500 dark:text-slate-400'}`}>Regex (Smart)</button>
|
||||||
|
<button onClick={() => setMode('preserve')} className={`flex-1 py-2 rounded-lg text-[10px] font-bold transition-all ${mode === 'preserve' ? 'bg-white dark:bg-[#21262d] text-indigo-600 dark:text-indigo-400 shadow-sm' : 'text-slate-500 dark:text-slate-400'}`}>Cắt Đoạn</button>
|
||||||
|
<button onClick={() => setMode('reindex')} className={`flex-1 py-2 rounded-lg text-[10px] font-bold transition-all ${mode === 'reindex' ? 'bg-white dark:bg-[#21262d] text-purple-600 dark:text-purple-400 shadow-sm' : 'text-slate-500 dark:text-slate-400'}`}>Cắt & Đánh Số</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'regex' ? (
|
||||||
|
<div className="space-y-3 p-3 bg-slate-50 dark:bg-[#0d1117] rounded-xl border border-slate-100 dark:border-[#30363d]">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{REGEX_PRESETS.map((preset, idx) => (
|
||||||
|
<button key={idx} onClick={() => setCustomRegex(preset.value)} className={`px-2 py-1 text-[10px] font-bold rounded border transition-colors ${customRegex === preset.value ? 'bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-400 border-sky-300 dark:border-sky-800' : 'bg-white dark:bg-[#21262d] text-slate-500 dark:text-slate-400 border-slate-200 dark:border-[#30363d] hover:border-sky-300 dark:hover:border-sky-800 hover:text-sky-600 dark:hover:text-sky-400'}`} > {preset.label} </button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input type="text" className="w-full px-3 py-2 bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#30363d] rounded-lg text-xs font-mono text-slate-700 dark:text-slate-200 focus:ring-2 focus:ring-sky-200 dark:focus:ring-sky-900 outline-none" placeholder="Regex tùy chỉnh..." value={customRegex} onChange={e => setCustomRegex(e.target.value)} />
|
||||||
|
<p className="text-[10px] text-slate-400">Mẹo: Tự động lọc bỏ ký tự đặc biệt (*, >, -) ở đầu tiêu đề.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 bg-slate-50 dark:bg-[#0d1117] rounded-xl border border-slate-100 dark:border-[#30363d]">
|
||||||
|
<p className="text-[10px] text-slate-500 dark:text-slate-400 mb-2">{mode === 'preserve' ? 'Cắt nhỏ file theo ký tự, GIỮ NGUYÊN nội dung.' : 'Cắt nhỏ, XÓA tiêu đề cũ và tự động thêm "Chương 1, 2..."'}</p>
|
||||||
|
<input type="number" className="w-full px-3 py-2 bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#30363d] rounded-lg text-xs text-slate-700 dark:text-slate-200" value={charLimit} onChange={e => setCharLimit(parseInt(e.target.value) || 6000)} step={500} min={1000} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center px-4 py-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl border border-indigo-100 dark:border-indigo-900/30">
|
||||||
|
<span className="text-xs font-bold text-indigo-700 dark:text-indigo-400">Dự kiến:</span>
|
||||||
|
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-300">{isCalculating ? <Loader2 className="w-4 h-4 animate-spin" /> : `${previewFiles.length} phần`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PREVIEW LIST */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-slate-50 dark:bg-[#0d1117] p-4 border-b border-slate-100 dark:border-[#30363d] custom-scrollbar">
|
||||||
|
<h4 className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Xem trước ({previewFiles.length})</h4>
|
||||||
|
{previewFiles.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{previewFiles.slice(0, 100).map((file, idx) => (
|
||||||
|
<div key={idx} className="bg-white dark:bg-[#161b22] px-3 py-2 rounded-lg border border-slate-200 dark:border-[#30363d] text-xs text-slate-600 dark:text-slate-400 truncate shadow-sm">
|
||||||
|
<span className="font-mono font-bold text-indigo-500 dark:text-indigo-400 mr-2">{String(idx + 1).padStart(3, '0')}</span>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{previewFiles.length > 100 && (
|
||||||
|
<div className="text-center text-xs text-slate-400 italic py-2">
|
||||||
|
...và {previewFiles.length - 100} chương khác
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center text-slate-400 text-xs italic">
|
||||||
|
Chưa tìm thấy chương nào phù hợp...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 shrink-0 flex flex-col gap-3 bg-white dark:bg-[#161b22]">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button onClick={onCancel} className="py-3 text-sm font-bold text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-[#21262d] rounded-xl border border-slate-200 dark:border-[#30363d]">Hủy</button>
|
||||||
|
<button onClick={handleConfirm} disabled={previewFiles.length === 0} className="py-3 bg-sky-500 hover:bg-sky-600 text-white rounded-xl text-sm font-bold shadow-lg shadow-sky-200/50"> Thực Hiện </button>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleKeepAsIs} className="w-full py-2 text-xs font-bold text-indigo-500 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded-xl border border-dashed border-indigo-200 dark:border-indigo-900/40">Nhập Nguyên Bản (Không Tách)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
export const TagInput = ({ label, icon, options, selected = [], onChange, placeholder }) => {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
const handleAdd = (val) => {
|
||||||
|
if (!val) return;
|
||||||
|
const values = val.split(/[,;]+/).map(v => v.trim()).filter(v => v);
|
||||||
|
let newSelected = [...selected];
|
||||||
|
values.forEach(v => {
|
||||||
|
// Avoid duplicates
|
||||||
|
if (v && !newSelected.includes(v)) newSelected.push(v);
|
||||||
|
});
|
||||||
|
if (newSelected.length !== selected.length) onChange(newSelected);
|
||||||
|
setInputValue('');
|
||||||
|
setShowOptions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (val) => { onChange(selected.filter(i => i !== val)); };
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd(inputValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target)) setShowOptions(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredOptions = (options || []).filter(opt => !selected.includes(opt) && opt.toLowerCase().includes(inputValue.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 relative" ref={containerRef}>
|
||||||
|
<label className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
{icon} {label}
|
||||||
|
</label>
|
||||||
|
<div className="min-h-[38px] px-2 py-1.5 bg-white dark:bg-[#0d1117] border border-slate-200 dark:border-[#30363d] rounded-xl focus-within:ring-2 focus-within:ring-sky-200 dark:focus-within:ring-blue-900 focus-within:border-sky-300 dark:focus-within:border-blue-700 transition-all flex flex-wrap gap-1.5">
|
||||||
|
{selected.map(tag => (
|
||||||
|
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 bg-slate-100 dark:bg-[#161b22] text-slate-700 dark:text-slate-300 rounded-md text-[10px] font-bold border border-slate-200 dark:border-[#30363d]">
|
||||||
|
{tag}
|
||||||
|
<button onClick={() => handleRemove(tag)} className="hover:text-rose-500 dark:hover:text-rose-400"><X className="w-3 h-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 min-w-[60px] bg-transparent outline-none text-xs py-0.5 text-slate-700 dark:text-slate-200 placeholder-slate-400"
|
||||||
|
placeholder={selected.length === 0 ? placeholder : ""}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={e => { setInputValue(e.target.value); setShowOptions(true); }}
|
||||||
|
onFocus={() => setShowOptions(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showOptions && (inputValue || filteredOptions.length > 0) && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#30363d] rounded-xl shadow-xl max-h-48 overflow-y-auto custom-scrollbar p-1">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map(opt => (
|
||||||
|
<button key={opt} onClick={() => handleAdd(opt)} className="w-full text-left px-3 py-1.5 text-xs text-slate-600 dark:text-slate-300 hover:bg-sky-50 dark:hover:bg-blue-900/30 hover:text-sky-700 dark:hover:text-blue-300 rounded-lg transition-colors">
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
inputValue && (
|
||||||
|
<button onClick={() => handleAdd(inputValue)} className="w-full text-left px-3 py-1.5 text-xs text-sky-600 dark:text-blue-400 hover:bg-sky-50 dark:hover:bg-blue-900/30 rounded-lg font-bold">
|
||||||
|
Thêm mới "{inputValue}"
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Play, Pause, Square, Trash2, Book, FileText, Download, Upload, Search, CheckSquare, ArrowRight, X, RefreshCw, FileArchive, FileUp, CheckCircle, AlertCircle, Loader2, Clock, Edit3, AlignLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export const WorkspacePage = (props) => {
|
||||||
|
const formatNumber = (num) => new Intl.NumberFormat('vi-VN').format(num);
|
||||||
|
|
||||||
|
const ACTION_BTN_CLASS = "flex flex-col items-center justify-center w-14 h-14 rounded-xl hover:bg-slate-50 dark:hover:bg-[#161b22] transition-all group active:scale-95 border border-transparent hover:border-slate-200 dark:hover:border-[#30363d]";
|
||||||
|
|
||||||
|
const renderFilterBadge = (label, active, onClick, colorClass) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${active ? colorClass : 'bg-white dark:bg-[#161b22] border-slate-200 dark:border-[#30363d] text-slate-500 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-500'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full relative animate-in fade-in slide-in-from-bottom-4 duration-500 overflow-hidden bg-slate-50 dark:bg-[#0d1117]">
|
||||||
|
{/* 1. Toolbar & Pagination */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 dark:border-[#30363d] flex justify-between items-center bg-white dark:bg-[#161b22] shadow-sm flex-wrap gap-2 shrink-0 z-20">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar max-w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => props.setCurrentPage(0)}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all shrink-0 ${props.currentPage === 0 ? 'bg-indigo-600 text-white shadow-md' : 'bg-slate-50 dark:bg-[#0d1117] text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-[#21262d]'}`}
|
||||||
|
>
|
||||||
|
Tất cả ({props.files.length})
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: props.totalPages }).map((_, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => props.setCurrentPage(idx + 1)}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all shrink-0 ${props.currentPage === idx + 1 ? 'bg-indigo-600 text-white shadow-md' : 'bg-slate-50 dark:bg-[#0d1117] text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-[#21262d]'}`}
|
||||||
|
>
|
||||||
|
Trang {idx + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1.5 Filter Panel */}
|
||||||
|
{props.showFilterPanel && (
|
||||||
|
<div className="bg-slate-50 dark:bg-[#0d1117] border-b border-slate-200 dark:border-[#30363d] p-4 animate-in slide-in-from-top-2 shrink-0 z-10 w-full">
|
||||||
|
<div className="max-w-7xl mx-auto flex flex-col gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-24 text-xs font-bold text-slate-400 uppercase mt-2">Trạng thái:</div>
|
||||||
|
<div className="flex flex-wrap gap-2 flex-1">
|
||||||
|
{renderFilterBadge("Đã chọn", props.filterStatuses.has('selected'), () => props.toggleFilterStatus('selected'), 'bg-indigo-100 text-indigo-700 border-indigo-200')}
|
||||||
|
{renderFilterBadge("Hoàn thành", props.filterStatuses.has('COMPLETED'), () => props.toggleFilterStatus('COMPLETED'), 'bg-emerald-100 text-emerald-700 border-emerald-200')}
|
||||||
|
{renderFilterBadge("Còn Raw", props.filterStatuses.has('IDLE'), () => props.toggleFilterStatus('IDLE'), 'bg-orange-100 text-orange-700 border-orange-200')}
|
||||||
|
{renderFilterBadge("Lỗi", props.filterStatuses.has('ERROR'), () => props.toggleFilterStatus('ERROR'), 'bg-rose-100 text-rose-700 border-rose-200')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2. File Grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 bg-slate-50/50 dark:bg-[#0d1117]">
|
||||||
|
{props.files.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-slate-400 dark:text-slate-600 border-2 border-dashed border-slate-300/50 dark:border-[#30363d] rounded-3xl bg-white/50 dark:bg-[#161b22]/50">
|
||||||
|
<div className="p-8 bg-white dark:bg-[#161b22] rounded-full shadow-xl shadow-indigo-100 dark:shadow-none mb-6 animate-bounce"><FileArchive className="w-16 h-16 text-indigo-200 dark:text-indigo-900" /></div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-600 dark:text-slate-300 mb-2">Chưa có file nào</h3>
|
||||||
|
<p className="text-sm text-slate-400 mb-8 max-w-xs text-center">Kéo thả file .txt, .epub vào đây</p>
|
||||||
|
<label className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold shadow-lg hover:bg-indigo-500 cursor-pointer transition-all flex items-center gap-2">
|
||||||
|
<FileUp className="w-4 h-4" /> Tải Truyện Lên
|
||||||
|
<input type="file" multiple accept=".txt,.zip,.epub,.docx" className="hidden" onChange={props.handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
{props.visibleFiles.map(file => {
|
||||||
|
const isSelected = props.selectedFiles.has(file.id);
|
||||||
|
const isProcessing = file.status === 'PROCESSING';
|
||||||
|
|
||||||
|
// Ratio Calculation
|
||||||
|
const ratio = file.content.length > 0 && file.translatedContent ? (file.translatedContent.length / file.content.length) : 0;
|
||||||
|
const ratioPercent = Math.round(ratio * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={file.id} onClick={(e) => { if (!e.target.closest('button')) props.handleSelectFile(file.id, e.shiftKey) }}
|
||||||
|
className={`group relative bg-white dark:bg-[#161b22] border rounded-2xl p-4 transition-all duration-200 cursor-pointer shadow-sm
|
||||||
|
${isSelected ? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50/30 dark:bg-indigo-900/20' : 'border-slate-200 dark:border-[#30363d] hover:border-indigo-300 dark:hover:border-indigo-700 hover:shadow-md'}
|
||||||
|
${isProcessing ? 'ring-1 ring-sky-300 bg-sky-50/10' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 shadow-sm transition-transform group-hover:scale-105
|
||||||
|
${file.status === 'COMPLETED' ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400' :
|
||||||
|
file.status === 'ERROR' ? 'bg-rose-100 text-rose-500 dark:bg-rose-900/30 dark:text-rose-400' :
|
||||||
|
isProcessing ? 'bg-sky-100 text-sky-600 dark:bg-sky-900/30 dark:text-sky-400' :
|
||||||
|
'bg-slate-100 text-slate-400 dark:bg-[#0d1117] dark:text-slate-500'}`}>
|
||||||
|
{file.status === 'COMPLETED' ? <CheckCircle className="w-5 h-5" /> :
|
||||||
|
file.status === 'ERROR' ? <AlertCircle className="w-5 h-5" /> :
|
||||||
|
isProcessing ? <Loader2 className="w-5 h-5 animate-spin" /> :
|
||||||
|
<FileText className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-bold text-slate-700 dark:text-gray-200 text-base truncate leading-tight mb-1" title={file.name}>{file.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-medium text-slate-400">
|
||||||
|
<span className="flex items-center gap-1 bg-slate-50 dark:bg-[#0d1117] px-1.5 py-0.5 rounded border border-slate-100 dark:border-[#30363d] opacity-50">
|
||||||
|
<Clock className="w-3 h-3 text-slate-300" /> --s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded uppercase tracking-wider border
|
||||||
|
${file.status === 'COMPLETED' ? 'bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-900/30' :
|
||||||
|
file.status === 'ERROR' ? 'bg-rose-50 text-rose-600 border-rose-100 dark:bg-rose-900/20 dark:text-rose-400 dark:border-rose-900/30' :
|
||||||
|
isProcessing ? 'bg-sky-50 text-sky-600 border-sky-100 dark:bg-sky-900/20 dark:text-sky-400 dark:border-sky-900/30' :
|
||||||
|
'bg-slate-50 text-slate-500 border-slate-100 dark:bg-[#0d1117] dark:text-slate-400 dark:border-[#30363d]'}`}>
|
||||||
|
{file.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="grid grid-cols-5 items-center gap-1 text-[10px] font-mono p-1.5 rounded-lg border bg-slate-50 border-slate-100 dark:bg-[#0d1117] dark:border-[#30363d]">
|
||||||
|
<div className="col-span-2 flex items-center gap-1 text-slate-500"><AlignLeft className="w-3 h-3" /> {formatNumber(file.content.length)}</div>
|
||||||
|
<div className="col-span-1 flex justify-center">
|
||||||
|
{file.status === 'COMPLETED' ? (
|
||||||
|
<div className="px-1.5 py-0.5 rounded text-[9px] font-bold flex items-center justify-center min-w-[36px] bg-sky-100 text-sky-600 dark:bg-sky-900/30 dark:text-sky-400">
|
||||||
|
{ratioPercent}%
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-600"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center justify-end gap-1 font-bold text-slate-700 dark:text-gray-300">
|
||||||
|
<span className={`${file.translatedContent ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-300 dark:text-slate-600'}`}>
|
||||||
|
{file.translatedContent ? formatNumber(file.translatedContent.length) : '---'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10 bg-white/90 dark:bg-[#161b22]/90 p-1 rounded-lg backdrop-blur-sm border border-slate-100 dark:border-[#30363d] shadow-sm">
|
||||||
|
<button onClick={(e) => props.requestRetranslateSingle(e, file.id)} className="p-1.5 text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg" title="Dịch lại"><RefreshCw className="w-3.5 h-3.5" /></button>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); props.openEditor(file) }} className="p-1.5 text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-900/30 rounded-lg" title="Sửa"><Edit3 className="w-3.5 h-3.5" /></button>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); props.handleRemoveFile(file.id) }} className="p-1.5 text-slate-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/30 rounded-lg" title="Xóa"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Bottom Action Bar */}
|
||||||
|
<div className="shrink-0 bg-white dark:bg-[#161b22] border-t border-slate-200 dark:border-[#30363d] p-4 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] z-30">
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4 overflow-x-auto no-scrollbar">
|
||||||
|
|
||||||
|
{/* LEFT: Range Selection */}
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 dark:bg-[#0d1117] p-1.5 rounded-xl border border-slate-100 dark:border-[#30363d]">
|
||||||
|
<button
|
||||||
|
onClick={() => props.setRangeMode(!props.rangeMode)}
|
||||||
|
className={`p-1.5 rounded-lg text-[10px] font-bold uppercase transition-colors ${props.rangeMode ? 'text-indigo-600 bg-indigo-50 dark:bg-indigo-900/20' : 'text-rose-600 bg-rose-50 dark:bg-rose-900/20'}`}
|
||||||
|
title={props.rangeMode ? "Chế độ: Chọn" : "Chế độ: Bỏ chọn"}
|
||||||
|
>
|
||||||
|
{props.rangeMode ? "Select" : "Unselect"}
|
||||||
|
</button>
|
||||||
|
<input className="w-12 px-2 py-1.5 bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#30363d] rounded-lg text-xs font-bold text-center outline-none focus:ring-1 focus:ring-indigo-500" placeholder="Từ" value={props.rangeStart} onChange={e => props.setRangeStart(e.target.value)} />
|
||||||
|
<ArrowRight className="w-3 h-3 text-slate-300" />
|
||||||
|
<input className="w-12 px-2 py-1.5 bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#30363d] rounded-lg text-xs font-bold text-center outline-none focus:ring-1 focus:ring-indigo-500" placeholder="Đến" value={props.rangeEnd} onChange={e => props.setRangeEnd(e.target.value)} />
|
||||||
|
<button onClick={props.handleRangeSelect} className={`p-2 rounded-lg transition-colors ${props.rangeMode ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/20 dark:text-indigo-400 hover:bg-indigo-100' : 'bg-rose-50 text-rose-600 dark:bg-rose-900/20 dark:text-rose-400 hover:bg-rose-100'}`} title="Thực hiện">
|
||||||
|
<CheckSquare className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-8 bg-slate-200 dark:bg-[#30363d]"></div>
|
||||||
|
|
||||||
|
{/* FIND & REPLACE */}
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 dark:bg-[#0d1117] p-1.5 rounded-xl border border-slate-100 dark:border-[#30363d]">
|
||||||
|
<button
|
||||||
|
onClick={props.toggleFindReplace}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-bold transition-all ${props.showFindReplace ? 'bg-indigo-500 text-white shadow-sm' : 'hover:bg-white dark:hover:bg-[#161b22] text-slate-500 dark:text-slate-400'}`}
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Tìm & Thay thế</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MIDDLE: Find/Replace Inputs (Conditional) */}
|
||||||
|
{props.showFindReplace && (
|
||||||
|
<div className="flex items-center gap-2 animate-in slide-in-from-bottom-2 fade-in">
|
||||||
|
<div className="relative group">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm gì..."
|
||||||
|
className="pl-9 pr-3 py-2 bg-slate-100 dark:bg-[#0d1117] border-slate-200 dark:border-[#30363d] rounded-lg text-xs font-medium focus:ring-2 focus:ring-indigo-500 w-32 focus:w-48 transition-all outline-none dark:text-gray-200"
|
||||||
|
value={props.findText}
|
||||||
|
onChange={(e) => props.setFindText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-4 h-4 text-slate-300" />
|
||||||
|
<div className="relative group">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Thay thành..."
|
||||||
|
className="pl-9 pr-3 py-2 bg-slate-100 dark:bg-[#0d1117] border-slate-200 dark:border-[#30363d] rounded-lg text-xs font-medium focus:ring-2 focus:ring-emerald-500 w-32 focus:w-48 transition-all outline-none dark:text-gray-200"
|
||||||
|
value={props.replaceText}
|
||||||
|
onChange={(e) => props.setReplaceText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={props.handleFindReplace}
|
||||||
|
className="px-3 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg text-xs font-bold shadow-sm transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Thay Thế
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
|
||||||
|
{/* RIGHT: Export & Start */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
{/* Export Epub */}
|
||||||
|
<button
|
||||||
|
onClick={props.handleExportEpub}
|
||||||
|
disabled={props.files.length === 0}
|
||||||
|
className="flex items-col flex-col justify-center items-center w-12 h-12 rounded-xl text-slate-400 hover:text-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-all active:scale-95 border border-transparent hover:border-orange-200"
|
||||||
|
title="Xuất eBook (EPUB)"
|
||||||
|
>
|
||||||
|
<Book className="w-5 h-5 mb-0.5" />
|
||||||
|
<span className="text-[9px] font-bold uppercase">EPUB</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-10 bg-slate-200 dark:bg-[#30363d]"></div>
|
||||||
|
|
||||||
|
{/* Remove All */}
|
||||||
|
<button
|
||||||
|
onClick={props.requestDeleteAll}
|
||||||
|
className="flex flex-col items-center justify-center w-12 h-12 rounded-xl text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-900/20 transition-all active:scale-95 border border-transparent hover:border-rose-200"
|
||||||
|
title="Xóa Tất Cả"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5 mb-0.5" />
|
||||||
|
<span className="text-[9px] font-bold uppercase">XÓA</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-10 bg-slate-200 dark:bg-[#30363d]"></div>
|
||||||
|
|
||||||
|
{/* Stop All Button */}
|
||||||
|
<button
|
||||||
|
onClick={props.stopProcessing}
|
||||||
|
className="flex items-center gap-2 px-4 h-12 rounded-2xl bg-rose-100 hover:bg-rose-200 text-rose-600 dark:bg-rose-900/20 dark:hover:bg-rose-900/40 dark:text-rose-400 font-bold transition-all active:scale-95"
|
||||||
|
title="Dừng và Reset trạng thái"
|
||||||
|
>
|
||||||
|
<Square className="w-5 h-5 fill-current" />
|
||||||
|
<span className="hidden lg:inline">Dừng Tất Cả</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Start Button */}
|
||||||
|
<button
|
||||||
|
onClick={props.isProcessing ? props.stopProcessing : props.handleStartButton}
|
||||||
|
className={`flex items-center gap-2 px-6 h-12 rounded-2xl shadow-lg transition-all active:scale-95 font-bold text-white ${props.isProcessing ? 'bg-rose-500 hover:bg-rose-600 shadow-rose-200/50' : 'bg-gradient-to-r from-sky-500 to-indigo-600 hover:from-sky-400 hover:to-indigo-500 shadow-indigo-200/50'}`}
|
||||||
|
>
|
||||||
|
{props.isProcessing ? <Loader2 className="w-5 h-5 animate-spin" /> : <Play className="w-5 h-5 fill-current" />}
|
||||||
|
<span className="hidden sm:inline">{props.isProcessing ? "DỪNG LẠI" : "BẮT ĐẦU"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
|
||||||
|
// --- PHẦN 1: QUY TẮC BẢO MẬT CỐT LÕI (OMNI-SECURITY PROTOCOL V7.0 - CHECKLIST INTEGRATED) ---
|
||||||
|
|
||||||
|
const USER_TRANSLATION_PROMPT_CONTENT = `*** GIAO THỨC BẢO MẬT VÀ BIÊN TẬP ĐA NGUYÊN NÂNG CẤP (OMNI-SECURITY PROTOCOL V7.0 - PERFECTIONIST MODE, SUBLIMATION & VIETNAMESE MARTIAL LAW) ***
|
||||||
|
|
||||||
|
### 0. MỆNH LỆNH THIẾT QUÂN LUẬT (MARTIAL LAW - ABSOLUTE ZERO TOLERANCE)
|
||||||
|
**MỤC TIÊU DUY NHẤT: TRẢ VỀ VĂN BẢN TIẾNG VIỆT (VIETNAMESE ONLY).**
|
||||||
|
1. **CẤM TUYỆT ĐỐI TIẾNG ANH TRONG LỜI VĂN:** Bất kể văn bản gốc là gì, kết quả đầu ra phải là văn xuôi tiếng Việt thuần túy.
|
||||||
|
- **NGOẠI LỆ DUY NHẤT:** Thuật ngữ game (Skill, Class, System notification), tên riêng phương Tây (Harry, Alice, Peter), câu thần chú (Avada Kedavra) được phép giữ nguyên nếu ngữ cảnh yêu cầu. Không phiên âm Hán Việt cho tên phương Tây (ví dụ: Harry không thành Cáp Lợi).
|
||||||
|
2. **CƠ CHẾ TỰ SỬA:** Nếu phát hiện xu hướng viết tiếng Anh cho đoạn văn tả cảnh hoặc hội thoại thông thường, lập tức dừng lại và dịch sang tiếng Việt.
|
||||||
|
3. **XỬ LÝ CONVERT/RAW:** Nếu đầu vào là convert (tiếng Việt thô, sai ngữ pháp) hoặc raw (Trung, Hàn, Nhật, Anh, v.v.), nhiệm vụ là biên tập (rewrite/edit) lại thành tiếng Việt chuẩn văn học, mượt mà.
|
||||||
|
4. **ĐỊNH DẠNG ĐẦU RA:** Giữ nguyên cấu trúc dòng, hội thoại trong ngoặc kép “...”. Không thêm lời dẫn, ghi chú, emoji hoặc ký tự trang trí không cần thiết.
|
||||||
|
5. **ĐỒNG BỘ ID (ID CHECK):** Đảm bảo nội dung dịch khớp chính xác với ID đầu vào. KHÔNG ĐƯỢC gộp chương, KHÔNG ĐƯỢC bỏ qua chương.
|
||||||
|
6. **BẢO VỆ NGOẶC (BRACKET PROTECTION):** Giữ nguyên các loại ngoặc đặc biệt: 【 】 《 》 [ ]. KHÔNG ĐƯỢC tự ý chuyển đổi thành in đậm Markdown (**...**).
|
||||||
|
*** CRITICAL WARNING: ***
|
||||||
|
Input content MAY be in English, Chinese, or Japanese.
|
||||||
|
Regardless of the input language, the **OUTPUT MUST BE VIETNAMESE**.
|
||||||
|
- IF input is English: TRANSLATE IT TO VIETNAMESE.
|
||||||
|
- DO NOT summarize in English.
|
||||||
|
- DO NOT reply in English.
|
||||||
|
- DO NOT output the original English text.
|
||||||
|
- JUST TRANSLATE TO VIETNAMESE.
|
||||||
|
|
||||||
|
### I. ĐỊNH DANH VÀ VAI TRÒ (SYSTEM PERSONA)
|
||||||
|
**Kích hoạt Nhân Cách:** [OMNI-EDITOR: HÀN THIÊN TÔN - Phiên Bản Nâng Cấp V7.0]
|
||||||
|
Bạn là một thực thể biên tập và dịch thuật văn học tối thượng. Bạn sở hữu một "Checklist Nội Tại" (Internal Checklist) để tự giám sát chất lượng từng câu chữ mình viết ra.
|
||||||
|
Bạn là một thực thể biên tập và dịch thuật văn học tối thượng, kết hợp sự bay bổng của đại văn hào, độ chính xác của giáo sư y khoa và nhà khoa học, sự linh hoạt của nhà ngôn ngữ học đa văn hóa, tinh thần trẻ trung của otaku và thâm trầm của đạo sĩ. Thông thạo 108 ngôn ngữ (bao gồm Trung cổ/hiện đại, Anh Mỹ/Anh, Nhật, Hàn, Ba Lan, Nga, Pháp, Đức, Tây Ban Nha, Cyrillic, Thái, Việt cổ). Chuyên xử lý đa thể loại và lĩnh vực: Tiên hiệp, Huyền huyễn, Võng du/Hệ thống, Ngự thú, Vô hạn lưu, Đồng nhân, Dị giới, Khoa huyễn, Mạt thế, Linh dị, Thơ ca, Đông/Tây phương, Đô thị, Hiện đại, Tương lai, Ma pháp/Phép thuật, Hài hước, Kiếm hiệp/Võ hiệp/Võ thuật, Mỹ thực, Khoa học (vật lý/sinh học), Y tế (bệnh lý/chẩn đoán), Sức khỏe (dinh dưỡng/tâm lý), Light Novel (Anh/Hàn/Nhật/Manhwa).
|
||||||
|
Nếu ngôn ngữ gốc là Tiếng Trung: Bạn là một dịch giả Hán Nôm lão luyện, ưu tiên từ Hán Việt đắt giá.
|
||||||
|
Nếu ngôn ngữ gốc là Anh/Nhật/Hàn: Bạn là một dịch giả hiện đại, linh hoạt, am hiểu văn hóa Pop-culture.
|
||||||
|
Nếu ngôn ngữ gốc không xác định, tự động nhận diện dựa trên từ khóa, ký tự đặc trưng.
|
||||||
|
|
||||||
|
**Mục Tiêu Tối Thượng:**
|
||||||
|
1. Độ Sạch Tuyệt Đối: Bản dịch/biên tập cuối cùng phải là văn bản 100% tiếng Việt thuần khiết.
|
||||||
|
2. Chất Lượng Dịch Thuật: Dịch nguyên tác hoặc biên tập bản thô sang tiếng Việt mượt mà, tái hiện chính xác không khí, cảm xúc.
|
||||||
|
3. Sửa Lỗi Ngữ Pháp & Cấu Trúc: Chỉnh sửa theo chuẩn Chủ Ngữ - Vị Ngữ - Tân Ngữ tiếng Việt.
|
||||||
|
4. Tính Nhất Quán: Đảm bảo tên gọi, thuật ngữ, xưng hô xuyên suốt toàn bộ văn bản.
|
||||||
|
5. Tối Ưu Hóa Nội Dung: Tích hợp xử lý chuyên sâu cho đa ngôn ngữ và lĩnh vực, tránh mô tả thừa về bạo lực/tình dục (nghệ thuật hóa).
|
||||||
|
6. Trung Thành: Tuyệt đối không bịa đặt, thêm thắt tình tiết không có trong nguyên tác (No Hallucination).
|
||||||
|
|
||||||
|
**Triết Lý:**
|
||||||
|
- Tự Nhiên: Ngôn ngữ trôi chảy, đúng ngữ pháp.
|
||||||
|
- Tinh Tế: Từ ngữ chính xác, gợi hình.
|
||||||
|
- Đặc Sắc: Tái hiện không khí thể loại.
|
||||||
|
- Trung Thành: Tuyệt đối không bịa đặt.
|
||||||
|
|
||||||
|
### II. THỨ BẬC ƯU TIÊN XỬ LÝ (PROCESSING HIERARCHY) - CRITICAL
|
||||||
|
Để tránh xung đột logic, tuân thủ thứ tự ưu tiên sau (từ cao xuống thấp):
|
||||||
|
|
||||||
|
1. **Xử Lý Văn Bản Ngắn & Thông Báo (Short Content Handling - STRICT):**
|
||||||
|
- **NẾU** nội dung đầu vào ngắn (dưới 200 từ) hoặc là Thông báo nghỉ, Lời tác giả, Cầu phiếu:
|
||||||
|
- **HÀNH ĐỘNG:** DỊCH SÁT NGHĨA (Literal Translation).
|
||||||
|
- **CẤM:** Không được phóng đại, không được "văn vẻ hóa".
|
||||||
|
|
||||||
|
2. **An Toàn & Nghệ Thuật Hóa (Safety by Sublimation):**
|
||||||
|
- Đối với nội dung nhạy cảm (bạo lực cực đoan, tình dục chi tiết), **KHÔNG TỪ CHỐI DỊCH**. Thay vào đó, áp dụng kỹ thuật "Nghệ Thuật Hóa" – sử dụng ẩn dụ, hoán dụ.
|
||||||
|
- Ví dụ Bạo Lực: Tập trung tác động lực/âm thanh/cảm giác.
|
||||||
|
- Ví dụ Tình Dục: Tả cảnh ngụ tình, xúc giác/cảm xúc.
|
||||||
|
|
||||||
|
3. **Trung Thành Tuyệt Đối (No Hallucination & No Omission):**
|
||||||
|
- Giữ nguyên 100% lượng thông tin cốt lõi.
|
||||||
|
|
||||||
|
4. **Dữ Liệu Người Dùng (User Context/Glossary):**
|
||||||
|
- TUYỆT ĐỐI tuân thủ [GLOSSARY/DICTIONARY] và [SERIES BIBLE] nếu được cung cấp.
|
||||||
|
|
||||||
|
4. **Bản Địa Hóa Văn Phong (Localization):**
|
||||||
|
- Ưu tiên cấu trúc câu tiếng Việt tự nhiên.
|
||||||
|
|
||||||
|
5. **Quy Tắc Đặc Thù Thể Loại (Genre Specifics):**
|
||||||
|
- **Tiên Hiệp/Cổ Trang:** Dùng Hán Việt triệt để, văn phong cổ trang.
|
||||||
|
- **Đô Thị/Hiện Đại:** Dùng thuần Việt, văn phong tự nhiên.
|
||||||
|
- **Game/Võng Du/System:** Giữ nguyên thuật ngữ tiếng Anh thông dụng (Level, Skill) nếu cần.
|
||||||
|
- **Fanfic/Western/Light Novel:**
|
||||||
|
+ Tên nhân vật phương Tây → Giữ nguyên tiếng Anh/Latin (Harry, Alice).
|
||||||
|
+ Tên nhân vật Nhật/Hàn → Giữ nguyên Romaji hoặc Hán Việt tùy ngữ cảnh.
|
||||||
|
- **Khoa Huyễn/Khoa Học:** Logic, trung tính, hiện đại.
|
||||||
|
- **Y Tế/Sức Khỏe:** Chuyên nghiệp, khách quan, khích lệ.
|
||||||
|
|
||||||
|
### III. DỮ LIỆU ĐẦU VÀO & TỰ ĐỘNG PHÂN TÍCH (INPUT METADATA & AUTO-PILOT)
|
||||||
|
*[A] Thông Tin Bắt Buộc (Mandatory):*
|
||||||
|
- Tên Truyện: [{{TITLE}}]
|
||||||
|
- Tác Giả: [{{AUTHOR}}]
|
||||||
|
- Ngôn Ngữ Gốc: [{{LANGUAGE}}]
|
||||||
|
- Thể Loại Chính: [{{GENRE}}]
|
||||||
|
|
||||||
|
*[B] Thông Tin Bổ Sung:*
|
||||||
|
- Tính Cách Main: [{{PERSONALITY}}]
|
||||||
|
- Bối Cảnh/Thế Giới: [{{SETTING}}]
|
||||||
|
- Lưu Phái/Hệ Thống: [{{FLOW}}]
|
||||||
|
- Đối Tượng Độc Giả: [{{TARGET_AUDIENCE}}]
|
||||||
|
|
||||||
|
### IV. HƯỚNG DẪN VĂN PHONG CHUYÊN SÂU (STYLE GUIDELINES)
|
||||||
|
1. **Nhận Diện Thể Loại & Ngôn Ngữ:** Ưu tiên từ dữ liệu đầu vào.
|
||||||
|
2. **Áp Dụng Văn Phong Phù Hợp:** Tiên Hiệp (Hoa mỹ), Võng Du (Hiện đại/Slang), Ngự Thú (Cảm xúc), Vô Hạn Lưu (Dồn dập)...
|
||||||
|
3. **Chuẩn Hóa Tên Gọi Và Thuật Ngữ:** Nhất quán. "Vấn" trong tên riêng giữ Hán Việt.
|
||||||
|
4. **Xử Lý Hội Thoại:** Theo thể loại.
|
||||||
|
|
||||||
|
### V. QUY TẮC ĐẶT TIÊU ĐỀ (CRITICAL)
|
||||||
|
1. Định Dạng Bắt Buộc: "Chương [Số]: [Tên Tiêu Đề]"
|
||||||
|
2. Tên Tiêu Đề Phải: Viết Hoa Chữ Cái Đầu (Title Case). Độ dài 5-12 từ. Phong cách hoa mỹ/gợi hình.
|
||||||
|
|
||||||
|
### VI. QUY TRÌNH THỰC HIỆN (BẮT BUỘC TUÂN THỦ)
|
||||||
|
1. Phân Tích & Nhận Diện.
|
||||||
|
2. Thiết Lập Glossary.
|
||||||
|
3. Dọn Dẹp Sơ Bộ.
|
||||||
|
4. Dịch & Biên Tập.
|
||||||
|
5. Tinh Chỉnh.
|
||||||
|
6. Rà Soát.
|
||||||
|
7. Hoàn Thiện.
|
||||||
|
|
||||||
|
### VII. BỘ LỌC CHẤT LƯỢNG CUỐI CÙNG (BẮT BUỘC)
|
||||||
|
TUYỆT ĐỐI CẤM: Ký tự Trung/Hàn/Cyrillic/Thái, Pinyin có dấu, emoji, từ ghép lai, từ thừa.
|
||||||
|
NGOẠI LỆ: Tên riêng (Kirito, John), thuật ngữ (HP, DNA, AI).
|
||||||
|
|
||||||
|
### VIII. QUY TRÌNH TỰ KIỂM TRA (INTERNAL CHECKLIST)
|
||||||
|
Trước khi xuất ra bất kỳ dòng nào, hãy tự chạy Checklist sau trong tư duy:
|
||||||
|
1. **Check Độ Dài:** Không Hallucination.
|
||||||
|
2. **Check Cấu Trúc:** Không gộp đoạn.
|
||||||
|
3. **Check Ngôn Ngữ:** Không sót tiếng Anh.
|
||||||
|
4. **Check Xưng Hô:** Đúng tính cách Main.
|
||||||
|
5. **Check ID:** Đúng ID chương.
|
||||||
|
|
||||||
|
**CHECKLIST CUỐI CÙNG TRƯỚC KHI XUẤT:**
|
||||||
|
- [ ] Tiêu đề cực kêu?
|
||||||
|
- [ ] Văn bản sạch 100% tiếng Việt?
|
||||||
|
- [ ] Xưng hô chuẩn thể loại?
|
||||||
|
- [ ] Nội dung nhạy cảm nghệ thuật hóa?
|
||||||
|
|
||||||
|
### IX. ĐỊNH DẠNG TRẢ VỀ (CLEAN OUTPUT ONLY)
|
||||||
|
- Không Bình Luận.
|
||||||
|
- Không Rác.
|
||||||
|
- Cấu Trúc:
|
||||||
|
**Chương [Số]: [Tiêu Đề CỰC KÊU, Title Case]**
|
||||||
|
[Nội dung truyện đã được biên tập kỹ lưỡng...]
|
||||||
|
|
||||||
|
**(Hết Phần Hướng Dẫn - Sẵn Sàng Nhận Dữ Liệu)**
|
||||||
|
[USER_INSTRUCTION_PLACEHOLDER]
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEFAULT_PROMPT = USER_TRANSLATION_PROMPT_CONTENT;
|
||||||
|
|
||||||
|
export const PROMPT_TEMPLATES = {
|
||||||
|
DEFAULT: {
|
||||||
|
name: "Mặc định (Omni-Security Protocol V7.0)",
|
||||||
|
content: DEFAULT_PROMPT
|
||||||
|
},
|
||||||
|
TIEN_HIEP: {
|
||||||
|
name: "Tiên Hiệp (Cổ Trang, Hán Việt)",
|
||||||
|
content: DEFAULT_PROMPT
|
||||||
|
},
|
||||||
|
HUYEN_HUYEN: {
|
||||||
|
name: "Huyền Huyễn (Dị Giới, Phép Thuật)",
|
||||||
|
content: DEFAULT_PROMPT.replace("### I. ĐỊNH DANH VÀ VAI TRÒ",
|
||||||
|
`### I. ĐỊNH DANH VÀ VAI TRÒ (MODE: HUYỀN HUYỄN & DỊ GIỚI)
|
||||||
|
**Phong Cách:** Kết hợp giữa cổ trang và phương tây (nếu có yếu tố Magic/Kỵ sĩ). Dùng từ ngữ gợi hình, kỳ ảo.
|
||||||
|
**Quy Tắc:**
|
||||||
|
- Hệ thống phép thuật: Dịch sang Hán Việt hoa mỹ (Hỏa Cầu Thuật, Băng Tiễn) hoặc giữ nguyên nếu tên riêng đặc biệt.
|
||||||
|
- Quái vật/Chủng tộc: Dùng tên Hán Việt quen thuộc (Cự Long, Tinh Linh, Người Lùn) thay vì để Dragon, Elf, Dwarf.
|
||||||
|
- Địa danh: Dịch nghĩa nếu có thể (Rừng Hắc Ám, Thung Lũng Chết).
|
||||||
|
` + "\n\n### I. ĐỊNH DANH VÀ VAI TRÒ")
|
||||||
|
},
|
||||||
|
LIGHT_NOVEL: {
|
||||||
|
name: "Light Novel (Nhật/Hàn/Anh - Isekai/RomCom)",
|
||||||
|
content: DEFAULT_PROMPT.replace("### I. ĐỊNH DANH VÀ VAI TRÒ",
|
||||||
|
`### I. ĐỊNH DANH VÀ VAI TRÒ (MODE: LIGHT NOVEL & ISEKAI)
|
||||||
|
**Phong Cách:** Trẻ trung, năng động, sử dụng văn phong Light Novel đặc trưng.
|
||||||
|
**Quy Tắc Đặc Thù:**
|
||||||
|
1. **Tên Riêng & Địa Danh (Katakana/Hangul):** BẮT BUỘC khôi phục về tên gốc Latin.
|
||||||
|
- Alice (không phải Arisu), Excalibur (không phải Ekusukariba).
|
||||||
|
- Tên Nhật: Romaji (Kirito, Asuna).
|
||||||
|
- Tên Hàn: Romanized (Jin-Woo, Dokja).
|
||||||
|
2. **Honorifics (Kính ngữ):**
|
||||||
|
- Nhật: Giữ nguyên (San, Kun, Chan, Senpai, Sensei) nếu bối cảnh học đường/đời thường.
|
||||||
|
- Hàn: Oppa/Hyung/Noona/Unnie -> Anh/Chị.
|
||||||
|
3. **Thuật ngữ Game/Isekai:**
|
||||||
|
- Status, Skill, Level, Rank -> Giữ nguyên Tiếng Anh hoặc dịch theo chuẩn game thủ Việt.
|
||||||
|
4. **Hiệu ứng âm thanh (SFX):** Dịch sang từ tượng thanh tiếng Việt (Rầm, Vụt, Keng).
|
||||||
|
` + "\n\n### I. ĐỊNH DANH VÀ VAI TRÒ")
|
||||||
|
},
|
||||||
|
SAC_HIEP: {
|
||||||
|
name: "Sắc Hiệp / Ngôn Tình (Nghệ Thuật Hóa)",
|
||||||
|
content: DEFAULT_PROMPT.replace("### 0. MỆNH LỆNH THIẾT QUÂN LUẬT",
|
||||||
|
`### 0. MỆNH LỆNH THIẾT QUÂN LUẬT (MODE: SẮC HIỆP & CẢM XÚC)
|
||||||
|
**ĐẶC BIỆT CHÚ Ý:**
|
||||||
|
1. **Nghệ Thuật Hóa Cảnh Nóng:** Tuyệt đối không dùng từ ngữ thô tục, trần trụi. Sử dụng thủ pháp ẩn dụ, hoán dụ, tả ý không tả thực. Tập trung vào cảm xúc.
|
||||||
|
2. **Văn Phong Lãng Mạn:** Sử dụng từ ngữ hoa mỹ, gợi cảm nhưng không gợi dục.
|
||||||
|
3. **CẤM KIỂM DUYỆT SAI:** Không được từ chối dịch. Biên tập lại cho phù hợp.
|
||||||
|
` + "\n\n### 0. MỆNH LỆNH THIẾT QUÂN LUẬT")
|
||||||
|
},
|
||||||
|
DO_THI: {
|
||||||
|
name: "Đô Thị / Hiện Đại (Slang, Đời thường)",
|
||||||
|
content: DEFAULT_PROMPT.replace("### I. ĐỊNH DANH VÀ VAI TRÒ",
|
||||||
|
`### I. ĐỊNH DANH VÀ VAI TRÒ (MODE: ĐÔ THỊ HIỆN ĐẠI)
|
||||||
|
**Phong Cách:** Hiện đại, trẻ trung, đời thường, sử dụng ngôn ngữ mạng (Slang) tinh tế.
|
||||||
|
**Quy Tắc:**
|
||||||
|
- Xưng hô: Anh-em, tôi-cậu, mày-tao (nếu thân/thù). Tránh dùng từ Hán Việt cổ.
|
||||||
|
- Tiền tệ: Quy đổi hoặc giữ nguyên.
|
||||||
|
- Công nghệ: Giữ nguyên thuật ngữ tiếng Anh phổ biến (Livestream, App, Smartphone, Wifi).
|
||||||
|
` + "\n\n### I. ĐỊNH DANH VÀ VAI TRÒ")
|
||||||
|
},
|
||||||
|
DONG_NHAN: {
|
||||||
|
name: "Đồng Nhân / Fanfic (Harry Potter, Marvel...)",
|
||||||
|
content: DEFAULT_PROMPT.replace("### II. THỨ BẬC ƯU TIÊN XỬ LÝ",
|
||||||
|
`### II. THỨ BẬC ƯU TIÊN XỬ LÝ (MODE: FANFICTION & ORIGIN RESTORATION)
|
||||||
|
**CRITICAL - PHỤC HỒI NGUYÊN TÁC:**
|
||||||
|
1. **Tên Riêng & Địa Danh:** BẮT BUỘC phải trả về tên gốc tiếng Anh/Latin của vũ trụ đó. CẤM dùng phiên âm Hán Việt (trừ khi là truyện TQ).
|
||||||
|
- Harry Potter: Hogwarts, Gryffindor.
|
||||||
|
- Marvel/DC: Tony Stark, SHIELD, Gotham.
|
||||||
|
- Anime: Naruto, Konoha, Luffy.
|
||||||
|
2. **Thuật Ngữ:** Dùng từ chuẩn của fandom Việt Nam (Chakra, Jutsu, Haki).
|
||||||
|
` + "\n\n### II. THỨ BẬC ƯU TIÊN XỬ LÝ")
|
||||||
|
},
|
||||||
|
QUAN_SU: {
|
||||||
|
name: "Quân Sự / Trinh Thám / Lịch Sử",
|
||||||
|
content: DEFAULT_PROMPT.replace("### I. ĐỊNH DANH VÀ VAI TRÒ",
|
||||||
|
`### I. ĐỊNH DANH VÀ VAI TRÒ (MODE: HARDCORE MILITARY & MYSTERY)
|
||||||
|
**Phong Cách:** Đanh thép, chính xác, logic, lạnh lùng.
|
||||||
|
**Quy Tắc:**
|
||||||
|
- Vũ khí/Khí tài: Dùng tên chuẩn quân sự (AK-47, F-22).
|
||||||
|
- Cấp bậc: Dùng hệ thống quân hàm tương ứng.
|
||||||
|
- Trinh thám: Câu văn gãy gọn.
|
||||||
|
` + "\n\n### I. ĐỊNH DANH VÀ VAI TRÒ")
|
||||||
|
},
|
||||||
|
VO_NGU: {
|
||||||
|
name: "Võng Du / Game / Hệ Thống",
|
||||||
|
content: DEFAULT_PROMPT.replace("### I. ĐỊNH DANH VÀ VAI TRÒ",
|
||||||
|
`### I. ĐỊNH DANH VÀ VAI TRÒ (MODE: GAMING & SYSTEM)
|
||||||
|
**Phong Cách:** Số liệu chính xác, thuật ngữ game thủ.
|
||||||
|
**Quy Tắc:**
|
||||||
|
- Stat/Chỉ số: Giữ nguyên hoặc dịch chuẩn (HP, MP).
|
||||||
|
- Skill/Item: Có thể giữ tiếng Anh hoặc Hán Việt.
|
||||||
|
- Hệ thống thông báo: In đậm hoặc để trong khung [ ].
|
||||||
|
` + "\n\n### I. ĐỊNH DANH VÀ VAI TRÒ")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AVAILABLE_GENRES = [
|
||||||
|
'Tiên Hiệp', 'Huyền Huyễn', 'Đô Thị', 'Khoa Huyễn', 'Võng Du',
|
||||||
|
'Đồng Nhân', 'Kiếm Hiệp', 'Ngôn Tình', 'Dị Giới', 'Mạt Thế',
|
||||||
|
'Ngự Thú', 'Linh Dị', 'Hệ Thống', 'Xuyên Nhanh', 'Hài Hước',
|
||||||
|
'Fantasy', 'Action', 'Adventure', 'Harem', 'Romance', 'Web Novel',
|
||||||
|
'Slice of Life', 'Isekai', 'LitRPG', 'Magic', 'School Life', 'Horror', 'Mystery'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AVAILABLE_PERSONALITIES = [
|
||||||
|
'Vô sỉ/Cợt nhả', 'Lạnh lùng/Sát phạt', 'Cẩn trọng/Vững vàng',
|
||||||
|
'Thông minh/Đa mưu', 'Nhiệt huyết/Trẻ trâu', 'Trầm ổn/Già dặn',
|
||||||
|
'Hài hước/Bựa', 'Tàn nhẫn/Hắc ám', 'Chính nghĩa/Thánh mẫu'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AVAILABLE_SETTINGS = [
|
||||||
|
'Trung Cổ/Cổ Đại', 'Hiện đại/Đô thị', 'Tương lai/Sci-fi',
|
||||||
|
'Mạt thế/Zombie', 'Hồng Hoang/Thần Thoại', 'Võng Du/Game',
|
||||||
|
'Phương Tây/Magic', 'Thanh Xuân/Vườn Trường', 'Showbiz/Giải Trí'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AVAILABLE_FLOWS = [
|
||||||
|
'Phàm nhân lưu', 'Vô địch lưu', 'Phế vật lưu',
|
||||||
|
'Hệ thống lưu', 'Xuyên không lưu', 'Trọng sinh lưu',
|
||||||
|
'Điền văn lưu', 'Vô hạn lưu', 'G苟 Đạo (Cẩu đạo)'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_DICTIONARY = `# TỪ ĐIỂN MẶC ĐỊNH
|
||||||
|
[System] = Hệ Thống
|
||||||
|
[Level] = Cấp độ
|
||||||
|
[Skill] = Kỹ năng
|
||||||
|
[Item] = Vật phẩm
|
||||||
|
[Quest] = Nhiệm vụ
|
||||||
|
[Dungeon] = Hầm ngục
|
||||||
|
[Boss] = Boss
|
||||||
|
[NPC] = NPC
|
||||||
|
[Main Character] = Nhân vật chính
|
||||||
|
大家伙儿 = mọi người / tất cả mọi người
|
||||||
|
同学们 = các bạn học
|
||||||
|
cm = cm
|
||||||
|
`;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-[#0d1117] text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar for Dark Theme */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
@apply bg-[#0d1117];
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-[#30363d] rounded-full hover:bg-[#484f58];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility for text selection */
|
||||||
|
::selection {
|
||||||
|
@apply bg-blue-500/30 text-white;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
|
||||||
|
import { optimizeDictionary, optimizeContext } from './textUtils';
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.deepseek.com/v1";
|
||||||
|
|
||||||
|
export const aiService = {
|
||||||
|
checkApiKey: () => {
|
||||||
|
const key = localStorage.getItem("DEEPSEEK_API_KEY");
|
||||||
|
return !!key;
|
||||||
|
},
|
||||||
|
|
||||||
|
setApiKey: (key) => {
|
||||||
|
localStorage.setItem("DEEPSEEK_API_KEY", key);
|
||||||
|
},
|
||||||
|
|
||||||
|
translateBatch: async (files, prompt, dictionary, globalContext, signal) => {
|
||||||
|
const apiKey = localStorage.getItem("DEEPSEEK_API_KEY");
|
||||||
|
if (!apiKey) throw new Error("Missing API Key");
|
||||||
|
|
||||||
|
const combinedContent = files.map(f => f.content).join("\n");
|
||||||
|
const relevantDict = optimizeDictionary(dictionary, combinedContent);
|
||||||
|
const relevantCtx = optimizeContext(globalContext, combinedContent);
|
||||||
|
|
||||||
|
let inputText = "";
|
||||||
|
const idMap = {};
|
||||||
|
files.forEach((f, idx) => {
|
||||||
|
const localId = `FILE_${idx}`;
|
||||||
|
idMap[localId] = f.id;
|
||||||
|
inputText += `\n>>>ID:${localId}\n${f.content}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const systemInstruction = `You are a professional translator and editor.
|
||||||
|
TASK: Translate MULTIPLE chapters from Chinese/Foreign to Vietnamese.
|
||||||
|
|
||||||
|
*** BATCH PROTOCOL (STRICT): ***
|
||||||
|
1. Start each file translation with the marker: >>>ID:FILE_{number}
|
||||||
|
2. Follow immediately with the translated content.
|
||||||
|
3. Do NOT close tags. Just start the next ID when done.
|
||||||
|
4. Translate ALL files provided in the exact order.
|
||||||
|
5. **CRITICAL:** COPY THE >>>ID TAG EXACTLY AS GIVEN IN INPUT. DO NOT INVENT IDS.
|
||||||
|
|
||||||
|
*** STRICT NEGATIVE CONSTRAINTS (MARTIAL LAW): ***
|
||||||
|
1. NO MISSING CONTENT: Translate every sentence.
|
||||||
|
2. NO HALLUCINATION: Do not invent content.
|
||||||
|
3. NO COMMENTARY: Output ONLY the marker and the translation.
|
||||||
|
4. NO ENGLISH: The output MUST BE VIETNAMESE. If you see text like "Martial Law", it means "Thiết Quân Luật" regarding the output language.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const userMessage = `
|
||||||
|
[RELEVANT DICTIONARY]
|
||||||
|
${relevantDict}
|
||||||
|
|
||||||
|
[CONTEXT / GLOSSARY]
|
||||||
|
${relevantCtx}
|
||||||
|
|
||||||
|
[USER INSTRUCTION]
|
||||||
|
${prompt || ""}
|
||||||
|
|
||||||
|
[INPUT BATCH]
|
||||||
|
${inputText}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemInstruction },
|
||||||
|
{ role: "user", content: userMessage }
|
||||||
|
],
|
||||||
|
temperature: 0.2, // Reduced from 1.3 for stability
|
||||||
|
stream: false
|
||||||
|
}),
|
||||||
|
signal: signal // Support Abort
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error?.message || "API Request Failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const rawOutput = data.choices[0].message.content;
|
||||||
|
|
||||||
|
// Clean Markdown Code Blocks if any
|
||||||
|
const cleanOutput = rawOutput.replace(/```\w*\n/g, "").replace(/```/g, "");
|
||||||
|
|
||||||
|
// Parse Output with Regex for robustness
|
||||||
|
const results = {};
|
||||||
|
const parts = cleanOutput.split(/>>>ID:\s*/);
|
||||||
|
|
||||||
|
parts.forEach(part => {
|
||||||
|
if (!part.trim()) return;
|
||||||
|
const match = part.match(/^(FILE_\d+)([\s\S]*)$/);
|
||||||
|
if (match) {
|
||||||
|
const localId = match[1].trim();
|
||||||
|
const content = match[2].trim();
|
||||||
|
const realId = idMap[localId];
|
||||||
|
if (realId) {
|
||||||
|
results[realId] = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { info: results };
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
throw new Error("Translation stopped by user");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
smartAnalyze: async (files) => {
|
||||||
|
const apiKey = localStorage.getItem("DEEPSEEK_API_KEY");
|
||||||
|
if (!apiKey) throw new Error("Missing API Key");
|
||||||
|
|
||||||
|
const SAMPLE_SIZE = 50000;
|
||||||
|
const totalFiles = files.length;
|
||||||
|
const samples = [];
|
||||||
|
|
||||||
|
samples.push(files[0].content.slice(0, SAMPLE_SIZE));
|
||||||
|
if (totalFiles > 2) {
|
||||||
|
const mid = Math.floor(totalFiles / 2);
|
||||||
|
samples.push(files[mid].content.slice(0, SAMPLE_SIZE));
|
||||||
|
}
|
||||||
|
if (totalFiles > 1) {
|
||||||
|
samples.push(files[files.length - 1].content.slice(-SAMPLE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
*** SYSTEM: OMNI-INTELLIGENCE ANALYZER v6.0 (JSON MODE) ***
|
||||||
|
*** PERSONA: HÀN THIÊN TÔN (KIẾN TRÚC SƯ CỐT TRUYỆN) ***
|
||||||
|
|
||||||
|
NHIỆM VỤ: Phân tích văn bản đầu vào và trích xuất thông tin cốt lõi để xây dựng hồ sơ truyện.
|
||||||
|
ĐẦU RA: Định dạng JSON Tuyệt đối (Không Markdown, không giải thích).
|
||||||
|
|
||||||
|
QUY TẮC PHÂN TÍCH (THEO OMNI-PROTOCOL V6.0):
|
||||||
|
1. **Origin Restoration (Phục Hồi Nguyên Tác):**
|
||||||
|
- Nếu bối cảnh là Phương Tây, Fanfic (Harry Potter, Marvel, DC), Anime/Manga (Naruto, One Piece):
|
||||||
|
- BẮT BUỘC nhận diện tên gốc (Ví dụ: "Cáp Lợi" -> "Harry Potter", "Lộ Phi" -> "Luffy").
|
||||||
|
2. **Game/System:** Nhận diện thuật ngữ Game (Level, Skill, Class) để giữ nguyên tiếng Anh nếu cần.
|
||||||
|
3. **Tiên Hiệp/Đông Phương:** Nhận diện tên Hán Việt chuẩn xác.
|
||||||
|
4. **Chuẩn Hóa Tên Truyện:** Tiêu đề phải là Tiếng Việt tự nhiên hoặc Hán Việt chuẩn (Title Case).
|
||||||
|
|
||||||
|
HÃY TRẢ VỀ JSON VỚI CẤU TRÚC SAU:
|
||||||
|
{
|
||||||
|
"title": "Tên truyện (Ưu tiên tiếng Việt hoặc Hán Việt chuẩn - Title Case)",
|
||||||
|
"author": "Tên tác giả",
|
||||||
|
"genres": ["Thể loại 1", "Thể loại 2", "Fanfic (nếu có)", "Hệ thống (nếu có)"],
|
||||||
|
"language_source": "Ngôn ngữ gốc dự đoán (Trung/Nhật/Hàn/Anh)",
|
||||||
|
"language_written": "Ngôn ngữ của văn bản đầu vào (Việt/Trung/Anh...)",
|
||||||
|
"personality": ["Tính cách Main 1", "Tính cách Main 2"],
|
||||||
|
"setting": ["Bối cảnh (Đô thị/Tu tiên/Hogwarts/One Piece...)", "Thời đại"],
|
||||||
|
"flow": ["Lưu phái (Vô địch lưu/Phàm nhân lưu...)", "Nhịp độ"],
|
||||||
|
"summary": "Tóm tắt ngắn gọn cốt truyện (dưới 300 từ) bằng TIẾNG VIỆT, nêu rõ Main là ai, làm gì, mục tiêu là gì.",
|
||||||
|
"context_notes": "Ghi chú quan trọng về thế giới, cảnh giới, thuật ngữ đặc thù.",
|
||||||
|
"main_characters": [{"name": "Tên nhân vật", "role": "Vai trò", "description": "Mô tả ngắn"}],
|
||||||
|
"image_prompt": "BẮT BUỘC: Một prompt tiếng Anh chi tiết để vẽ ảnh bìa. Describe main character appearance, background scenery, atmosphere (Dark/Bright/Epic), art style (Anime/Realistic/Digital Art)."
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a professional literary analyst." },
|
||||||
|
{ role: "user", content: `${prompt}\n\n[SAMPLES]\n${samples.join("\n\n=== NEXT SAMPLE ===\n\n")}` }
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
response_format: { type: "json_object" }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error?.message || "Analysis Failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let info = {};
|
||||||
|
try {
|
||||||
|
info = JSON.parse(data.choices[0].message.content);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON Parse Error", e);
|
||||||
|
info = { title: "Error parsing JSON" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Cover if prompt exists
|
||||||
|
let cover = null;
|
||||||
|
if (info.image_prompt) {
|
||||||
|
cover = await aiService.generateCoverImage(info.image_prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { info, cover };
|
||||||
|
},
|
||||||
|
|
||||||
|
repairTranslation: async (original, translated) => {
|
||||||
|
// Detect if valid translation
|
||||||
|
const chineseRegex = /[\u4e00-\u9fa5]/;
|
||||||
|
if (!chineseRegex.test(translated)) return translated; // No repair needed
|
||||||
|
|
||||||
|
const apiKey = localStorage.getItem("DEEPSEEK_API_KEY");
|
||||||
|
if (!apiKey) return translated;
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You are a post-processing repair engine.
|
||||||
|
TASK: Fix the provided text which contains untranslated Chinese/Foreign characters.
|
||||||
|
RULES:
|
||||||
|
1. Remove all foreign characters and replace with valid Vietnamese translation.
|
||||||
|
2. Use valid Vietnamese grammar.
|
||||||
|
3. Do NOT add notes or explanations.
|
||||||
|
4. Output ONLY the repaired text.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `[SOURCE]\n${original}\n\n[DRAFT WITH ERRORS]\n${translated}\n\n[TASK]\nRepair the DRAFT to remove all Chinese characters (${chineseRegex}) and ensure smooth Vietnamese.`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0.1
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return translated;
|
||||||
|
const data = await response.json();
|
||||||
|
return data.choices[0].message.content;
|
||||||
|
},
|
||||||
|
|
||||||
|
optimizePrompt: async (promptTemplate, storyInfo, additionalRules = "") => {
|
||||||
|
const apiKey = localStorage.getItem("DEEPSEEK_API_KEY");
|
||||||
|
if (!apiKey) throw new Error("Missing API Key");
|
||||||
|
|
||||||
|
const instruction = `Bạn là một Chuyên gia Prompt Engineering (Kỹ sư Prompt) và Nhà Phê Bình Văn Học.
|
||||||
|
NHIỆM VỤ: "KIẾN TRÚC" LẠI Prompt dịch truyện để nó trở nên **chuyên biệt và tối ưu nhất** cho bộ truyện cụ thể này (Đo ni đóng giày).
|
||||||
|
|
||||||
|
YÊU CẦU:
|
||||||
|
1. Giữ nguyên cấu trúc kỹ thuật của Prompt gốc (các thẻ XML/Markdown quan trọng, các quy tắc cấm tiếng Anh).
|
||||||
|
2. Dựa vào [THÔNG TIN TRUYỆN] để tinh chỉnh phần "ĐỊNH DANH VÀ VAI TRÒ" và "HƯỚNG DẪN VĂN PHONG".
|
||||||
|
3. Thêm các chỉ dẫn cụ thể về giọng văn (Tone), cách xưng hô (Addressing), và thuật ngữ đặc thù.
|
||||||
|
4. Tích hợp [QUY TẮC BỔ SUNG] nếu có.
|
||||||
|
5. Trả về Prompt hoàn chỉnh đã được viết lại.`;
|
||||||
|
|
||||||
|
const userContent = `
|
||||||
|
[THÔNG TIN TRUYỆN]
|
||||||
|
- Tên: ${storyInfo.title}
|
||||||
|
- Tác giả: ${storyInfo.author}
|
||||||
|
- Thể loại: ${storyInfo.genres.join(', ')}
|
||||||
|
- Tính cách Main: ${storyInfo.mcPersonality.join(', ')}
|
||||||
|
- Bối cảnh: ${storyInfo.worldSetting.join(', ')}
|
||||||
|
- Lưu phái: ${storyInfo.sectFlow.join(', ')}
|
||||||
|
|
||||||
|
[QUY TẮC BỔ SUNG TỪ NGƯỜI DÙNG]
|
||||||
|
${additionalRules}
|
||||||
|
|
||||||
|
[PROMPT MẪU (BASE)]
|
||||||
|
${promptTemplate}
|
||||||
|
|
||||||
|
---
|
||||||
|
HÃY VIẾT LẠI PROMPT TRÊN ĐỂ TỐI ƯU HÓA CHO TRUYỆN NÀY:`;
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: instruction },
|
||||||
|
{ role: "user", content: userContent }
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Optimization Failed");
|
||||||
|
const data = await response.json();
|
||||||
|
return data.choices[0].message.content;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helper: Clean Markdown Code Blocks
|
||||||
|
_cleanJson: (text) => {
|
||||||
|
if (!text) return null;
|
||||||
|
const cleaned = text.replace(/```json\n?|```/g, '').trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON Parse Failed:", e, "\nInput:", text);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateCoverImage: async (prompt) => {
|
||||||
|
if (!prompt) return null;
|
||||||
|
try {
|
||||||
|
const encodedPrompt = encodeURIComponent(prompt + ", high quality, detailed, masterpiece, 8k");
|
||||||
|
// Add seed to prevent caching
|
||||||
|
const seed = Math.floor(Math.random() * 1000);
|
||||||
|
const imageUrl = `https://pollinations.ai/p/${encodedPrompt}?width=800&height=1200&model=flux&seed=${seed}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[AutoCover] Fetching:", imageUrl);
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) throw new Error("Fetch failed");
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], "generated_cover.jpg", { type: "image/jpeg" });
|
||||||
|
} catch (fetchError) {
|
||||||
|
console.warn("[AutoCover] Blob fetch failed, returning direct URL:", fetchError);
|
||||||
|
return imageUrl; // Fallback to URL string
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Cover generation failed:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deepAnalyze: async (files, storyInfo) => {
|
||||||
|
const apiKey = localStorage.getItem("DEEPSEEK_API_KEY");
|
||||||
|
if (!apiKey) throw new Error("Missing API Key");
|
||||||
|
|
||||||
|
console.log(`[DeepAnalyze] Input: ${files.length} files. Story: ${storyInfo?.title}`);
|
||||||
|
|
||||||
|
// 1. Prepare Content (Chunking) - Max 5 samples for better coverage
|
||||||
|
const sortedFiles = [...files].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
|
let samples = [];
|
||||||
|
if (sortedFiles.length <= 5) {
|
||||||
|
samples = sortedFiles.map(f => f.content);
|
||||||
|
} else {
|
||||||
|
// Start, 3 Middle, End
|
||||||
|
samples.push(sortedFiles[0].content);
|
||||||
|
const step = Math.floor(sortedFiles.length / 4);
|
||||||
|
samples.push(sortedFiles[step].content);
|
||||||
|
samples.push(sortedFiles[step * 2].content);
|
||||||
|
samples.push(sortedFiles[step * 3].content);
|
||||||
|
samples.push(sortedFiles[sortedFiles.length - 1].content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedContent = samples.join("\n\n=== NEXT PART ===\n\n");
|
||||||
|
const CHUNK_SIZE = 40000; // Increased chunk size for DeepSeek
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < combinedContent.length; i += CHUNK_SIZE) {
|
||||||
|
chunks.push(combinedContent.substring(i, i + CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
console.log(`[DeepAnalyze] Created ${chunks.length} chunks.`);
|
||||||
|
|
||||||
|
// 2. Parallel Extraction (Map Phase)
|
||||||
|
const partialResults = await Promise.all(chunks.map(async (chunk, idx) => {
|
||||||
|
try {
|
||||||
|
console.log(`[DeepAnalyze] Analyzing chunk ${idx}...`);
|
||||||
|
const response = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `*** ENTITY EXTRACTOR (Chinese/English -> Vietnamese) ***
|
||||||
|
Nhiệm vụ: Phân tích văn bản nguồn (Trung/Anh/Việt) và trích xuất thực thể.
|
||||||
|
Yêu cầu:
|
||||||
|
1. Đọc văn bản đầu vào.
|
||||||
|
2. Trích xuất:
|
||||||
|
- characters: Tên nhân vật (Hán Việt nếu là Trung), Vai trò.
|
||||||
|
- locations: Địa danh.
|
||||||
|
- terms: Cảnh giới, Vật phẩm, Công pháp.
|
||||||
|
3. Với mỗi thực thể, cung cấp:
|
||||||
|
- "original": Tên gốc trong văn bản (Trung/Anh).
|
||||||
|
- "vietnamese": Tên dịch Hán Việt/Việt chuẩn.
|
||||||
|
- "description": Mô tả ngắn gọn vai trò/đặc điểm (Tiếng Việt).
|
||||||
|
4. Output JSON Strict: { "characters": [{"original": "...", "vietnamese": "...", "description": "..."}], "locations": [...], "terms": [...] }`
|
||||||
|
},
|
||||||
|
{ role: "user", content: `[CONTENT START]\n${chunk.slice(0, 30000)}\n[CONTENT END]` }
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
response_format: { type: "json_object" }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[DeepAnalyze] Chunk ${idx} Error:`, response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const cleanData = aiService._cleanJson(data.choices[0].message.content);
|
||||||
|
console.log(`[DeepAnalyze] Chunk ${idx} Result:`, cleanData ? "Success" : "Failed Clean");
|
||||||
|
return cleanData;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[DeepAnalyze] Chunk ${idx} Exception:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Flatten Results
|
||||||
|
const rawEntities = { characters: [], locations: [], terms: [] };
|
||||||
|
partialResults.forEach(res => {
|
||||||
|
if (res) {
|
||||||
|
if (res.characters) rawEntities.characters.push(...res.characters);
|
||||||
|
if (res.locations) rawEntities.locations.push(...res.locations);
|
||||||
|
if (res.terms) rawEntities.terms.push(...res.terms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Synthesis Phase (Reduce Phase - Master Bible)
|
||||||
|
// Optimize context: Take top unique items to avoid breaking JSON structure
|
||||||
|
const optimizeList = (list) => {
|
||||||
|
const unique = new Map();
|
||||||
|
list.forEach(item => {
|
||||||
|
if (!item) return;
|
||||||
|
// Normalize keys (Handle new and legacy formats)
|
||||||
|
const viet = item.vietnamese || item.name || item.Name || "Unknown";
|
||||||
|
const orig = item.original || viet; // Fallback to viet if no original
|
||||||
|
const desc = item.description || item.Description || "";
|
||||||
|
|
||||||
|
if (viet !== "Unknown" && !unique.has(viet)) {
|
||||||
|
unique.set(viet, { original: orig, vietnamese: viet, description: desc });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Return top 150 items
|
||||||
|
return Array.from(unique.values()).slice(0, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const optimizedEntities = {
|
||||||
|
characters: optimizeList(rawEntities.characters || []),
|
||||||
|
locations: optimizeList(rawEntities.locations || []),
|
||||||
|
terms: optimizeList(rawEntities.terms || [])
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAW_DATA_JSON = JSON.stringify(optimizedEntities);
|
||||||
|
|
||||||
|
// 3. Synthesis Phase: Generate Bible Only (Avoids massive JSON response)
|
||||||
|
// 3. Synthesis Phase: Generate Bible Only (Avoids massive JSON response)
|
||||||
|
const BIBLE_PROMPT = `
|
||||||
|
*** SYSTEM: MASTER SERIES BIBLE CREATOR (OMNI-PROTOCOL) ***
|
||||||
|
Nhiệm vụ: Dựa trên dữ liệu thực thể đã cung cấp, hãy viết một "HỒ SƠ CỐT TRUYỆN" (Master Bible) cực kỳ chi tiết và chuyên sâu để hỗ trợ dịch giả và AI dịch thuật.
|
||||||
|
Ngôn ngữ: Tiếng Việt.
|
||||||
|
|
||||||
|
DỮ LIỆU ĐẦU VÀO:
|
||||||
|
- Tiêu đề: ${storyInfo?.title}
|
||||||
|
- Tác giả: ${storyInfo?.author}
|
||||||
|
- Thực thể chính: ${RAW_DATA_JSON}
|
||||||
|
|
||||||
|
YÊU CẦU OUTPUT (MARKDOWN):
|
||||||
|
Hãy trình bày kết quả dưới dạng Markdown chuyên nghiệp, rõ ràng:
|
||||||
|
|
||||||
|
# 1. BỐI CẢNH & THẾ GIỚI (World Building)
|
||||||
|
- Mô tả chi tiết về thế giới, thời đại, địa lý.
|
||||||
|
- Hệ thống tu luyện/sức mạnh (Cảnh giới, cấp bậc).
|
||||||
|
- Các thế lực chính/tà.
|
||||||
|
|
||||||
|
# 2. HỒ SƠ NHÂN VẬT (Characters)
|
||||||
|
- Phân tích sâu về Main Character (Tính cách, ngoại hình, năng lực).
|
||||||
|
- Nhân vật phụ quan trọng và vai trò của họ.
|
||||||
|
|
||||||
|
# 3. QUY TẮC DỊCH THUẬT (Translation Guidelines)
|
||||||
|
- Từ khóa/Thuật ngữ đặc biệt cần lưu ý.
|
||||||
|
- Văn phong chủ đạo (Hán Việt 70%, Thuần Việt 30% v.v.).
|
||||||
|
|
||||||
|
# 4. MỐI QUAN HỆ (Relationships)
|
||||||
|
- Sơ đồ quan hệ (Bạn bè, kẻ thù, sư đồ).
|
||||||
|
|
||||||
|
# 5. TÓM TẮT DIỄN BIẾN (Plot Summary)
|
||||||
|
- Tóm tắt logic các sự kiện chính đã nhận diện được.
|
||||||
|
|
||||||
|
LƯU Ý: Phải viết chi tiết, đầy đủ, không viết tắt, không hời hợt. Dùng bullet points để dễ đọc.
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let bibleText = "";
|
||||||
|
try {
|
||||||
|
console.log("[DeepAnalyze] Generating Bible Markdown...");
|
||||||
|
const bibleResponse = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a specialized Series Bible Synthesizer. Output TEXT only." },
|
||||||
|
{ role: "user", content: BIBLE_PROMPT }
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bibleResponse.ok) {
|
||||||
|
const bibleData = await bibleResponse.json();
|
||||||
|
bibleText = bibleData.choices[0].message.content;
|
||||||
|
} else {
|
||||||
|
console.error("Bible Gen Failed:", bibleResponse.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Bible Gen Exception:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bible: bibleText,
|
||||||
|
entities: optimizedEntities
|
||||||
|
};
|
||||||
|
},
|
||||||
|
optimizePrompt: async (promptTemplate, storyInfo, additionalRules = "") => {
|
||||||
|
const apiKey = localStorage.getItem("DEEPSEEK_API_KEY");
|
||||||
|
if (!apiKey) throw new Error("Missing API Key");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "You are an Expert Prompt Engineer (AI Architect). Your goal is to optimize system prompts for AI Translation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `
|
||||||
|
OPTIMIZE THIS PROMPT FOR TRANSLATING A NOVEL (Chinese -> Vietnamese).
|
||||||
|
Original Prompt:
|
||||||
|
"${promptTemplate}"
|
||||||
|
|
||||||
|
Context Info:
|
||||||
|
- Title: ${storyInfo?.title}
|
||||||
|
- Author: ${storyInfo?.author}
|
||||||
|
- Genre: ${storyInfo?.genres?.join(', ')}
|
||||||
|
- Notes: ${additionalRules}
|
||||||
|
|
||||||
|
REQUIREMENTS:
|
||||||
|
1. Make it strict but creative.
|
||||||
|
2. Emphasize "Hán Việt" handling.
|
||||||
|
3. Keep the core logic but enhance clarity and instructions.
|
||||||
|
4. Output ONLY the optimized prompt content (no explanations).
|
||||||
|
`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0.7
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Optimization Failed");
|
||||||
|
const data = await response.json();
|
||||||
|
return data.choices[0].message.content;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Optimize Prompt Error:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import initSqlJs from 'sql.js';
|
||||||
|
import localforage from 'localforage';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const DB_NAME = 'ai_translate_db_v1';
|
||||||
|
const STORE_KEY = 'sqlite_binary';
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
let SQL = null;
|
||||||
|
|
||||||
|
export const dbService = {
|
||||||
|
init: async () => {
|
||||||
|
if (db) return db;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load WASM
|
||||||
|
SQL = await initSqlJs({
|
||||||
|
// Locate the WASM file in public folder
|
||||||
|
locateFile: file => `/${file}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to load saved binary from IndexedDB
|
||||||
|
const savedData = await localforage.getItem(STORE_KEY);
|
||||||
|
|
||||||
|
if (savedData) {
|
||||||
|
// Load existing DB
|
||||||
|
db = new SQL.Database(new Uint8Array(savedData));
|
||||||
|
console.log("[DB] Loaded existing database.");
|
||||||
|
} else {
|
||||||
|
// Create new DB
|
||||||
|
db = new SQL.Database();
|
||||||
|
console.log("[DB] Created new database.");
|
||||||
|
dbService._createTables();
|
||||||
|
await dbService.save(); // Initial save
|
||||||
|
}
|
||||||
|
|
||||||
|
return db;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[DB] Initialization Failed:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_createTables: () => {
|
||||||
|
// Stories Table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
author TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
cover_image TEXT, -- Base64 or URL
|
||||||
|
created_at INTEGER,
|
||||||
|
last_accessed INTEGER,
|
||||||
|
metadata TEXT -- JSON: { genres, languages, personality, world... }
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Files Table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id TEXT PRIMARY KEY, -- Use existing file IDs if possible, or new UUIDs
|
||||||
|
story_id TEXT,
|
||||||
|
name TEXT,
|
||||||
|
content TEXT,
|
||||||
|
translated_content TEXT,
|
||||||
|
status TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
last_modified INTEGER,
|
||||||
|
FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Dictionary Table (Global or per story? Let's make it simple first: Per Story stored in metadata or separate?)
|
||||||
|
// For now, Dictionary is just text in App.jsx. We can store it in metadata if needed.
|
||||||
|
},
|
||||||
|
|
||||||
|
save: async () => {
|
||||||
|
if (!db) return;
|
||||||
|
try {
|
||||||
|
const data = db.export();
|
||||||
|
await localforage.setItem(STORE_KEY, data);
|
||||||
|
// console.log("[DB] Saved to IndexedDB");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[DB] Save Failed:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Story Operations ---
|
||||||
|
|
||||||
|
createStory: async (title = "Truyện Mới") => {
|
||||||
|
if (!db) {
|
||||||
|
console.error("[DB] Database is null!");
|
||||||
|
throw new Error("Database not initialized");
|
||||||
|
}
|
||||||
|
console.log("[DB] Executing createStory for:", title);
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = Date.now();
|
||||||
|
const metadata = JSON.stringify({
|
||||||
|
genres: [], languages: ['Tiếng Trung'], mcPersonality: [], worldSetting: [], contextNotes: '', additionalDictionary: '', customPrompt: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.run(`INSERT INTO stories (id, title, created_at, last_accessed, metadata) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[id, title, now, now, metadata]);
|
||||||
|
await dbService.save();
|
||||||
|
console.log("[DB] Insert Successful, ID:", id);
|
||||||
|
return id;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[DB] Insert Failed:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getStories: () => {
|
||||||
|
if (!db) return [];
|
||||||
|
const result = db.exec("SELECT id, title, author, cover_image, last_accessed FROM stories ORDER BY created_at DESC");
|
||||||
|
if (result.length === 0) return [];
|
||||||
|
|
||||||
|
// Transform result [columns, values] to objects
|
||||||
|
const columns = result[0].columns;
|
||||||
|
return result[0].values.map(row => {
|
||||||
|
const obj = {};
|
||||||
|
columns.forEach((col, i) => obj[col] = row[i]);
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getStory: (id) => {
|
||||||
|
if (!db) return null;
|
||||||
|
const result = db.exec("SELECT * FROM stories WHERE id = ?", [id]);
|
||||||
|
if (result.length === 0) return null;
|
||||||
|
|
||||||
|
const columns = result[0].columns;
|
||||||
|
const row = result[0].values[0];
|
||||||
|
const story = {};
|
||||||
|
columns.forEach((col, i) => story[col] = row[i]);
|
||||||
|
|
||||||
|
// Parse metadata
|
||||||
|
try {
|
||||||
|
if (story.metadata) Object.assign(story, JSON.parse(story.metadata));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse metadata", e);
|
||||||
|
}
|
||||||
|
return story;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStory: async (id, data) => {
|
||||||
|
if (!db) return;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Separate columns vs metadata
|
||||||
|
const directCols = ['title', 'author', 'summary', 'cover_image'];
|
||||||
|
const metadataKeys = ['genres', 'languages', 'mcPersonality', 'worldSetting', 'contextNotes', 'image_prompt', 'additionalDictionary', 'customPrompt'];
|
||||||
|
|
||||||
|
// 1. Get current metadata
|
||||||
|
const current = dbService.getStory(id);
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
// 2. Prepare Updates
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
directCols.forEach(col => {
|
||||||
|
if (data[col] !== undefined) {
|
||||||
|
updates.push(`${col} = ?`);
|
||||||
|
values.push(data[col]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Update Metadata
|
||||||
|
const newMetadata = {};
|
||||||
|
metadataKeys.forEach(k => {
|
||||||
|
if (data[k] !== undefined) newMetadata[k] = data[k];
|
||||||
|
else if (current[k] !== undefined) newMetadata[k] = current[k];
|
||||||
|
});
|
||||||
|
|
||||||
|
updates.push(`metadata = ?`);
|
||||||
|
values.push(JSON.stringify(newMetadata));
|
||||||
|
|
||||||
|
updates.push(`last_accessed = ?`);
|
||||||
|
values.push(now);
|
||||||
|
|
||||||
|
// Where ID
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const query = `UPDATE stories SET ${updates.join(', ')} WHERE id = ?`;
|
||||||
|
db.run(query, values);
|
||||||
|
await dbService.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteStory: async (id) => {
|
||||||
|
db.run("DELETE FROM stories WHERE id = ?", [id]);
|
||||||
|
// Files cascade delete handled by schema? sql.js usually doesn't enforce FK by default unless enabled
|
||||||
|
// Manually delete files to be safe
|
||||||
|
db.run("DELETE FROM files WHERE story_id = ?", [id]);
|
||||||
|
await dbService.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- File Operations ---
|
||||||
|
|
||||||
|
getFiles: (storyId) => {
|
||||||
|
const res = db.exec("SELECT * FROM files WHERE story_id = ?", [storyId]);
|
||||||
|
if (res.length === 0) return [];
|
||||||
|
|
||||||
|
const columns = res[0].columns;
|
||||||
|
return res[0].values.map(row => {
|
||||||
|
const f = {};
|
||||||
|
columns.forEach((col, i) => {
|
||||||
|
// Map snake_case to camelCase
|
||||||
|
if (col === 'translated_content') f['translatedContent'] = row[i];
|
||||||
|
else if (col === 'last_modified') f['lastModified'] = row[i];
|
||||||
|
else f[col] = row[i];
|
||||||
|
});
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveFilesBatch: async (storyId, files) => {
|
||||||
|
// Use transaction
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
const stmt = db.prepare("INSERT OR REPLACE INTO files (id, story_id, name, content, translated_content, status, size, last_modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||||
|
|
||||||
|
files.forEach(f => {
|
||||||
|
stmt.run([f.id, storyId, f.name, f.content, f.translatedContent || null, f.status || 'IDLE', f.content.length, Date.now()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
stmt.free();
|
||||||
|
db.run("COMMIT");
|
||||||
|
await dbService.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFile: async (id) => {
|
||||||
|
db.run("DELETE FROM files WHERE id = ?", [id]);
|
||||||
|
await dbService.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAllFiles: async (storyId) => {
|
||||||
|
db.run("DELETE FROM files WHERE story_id = ?", [storyId]);
|
||||||
|
await dbService.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { formatBookStyle } from './textUtils';
|
||||||
|
|
||||||
|
// Helper: Pad number with leading zeros (e.g. 1 -> 00001)
|
||||||
|
const padNumber = (num, size = 5) => {
|
||||||
|
let s = String(num);
|
||||||
|
while (s.length < size) s = "0" + s;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: Clean filename for display
|
||||||
|
const sanitizeFilename = (name) => {
|
||||||
|
return name.replace(/[:/\\?%*|"<>]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeXml = (unsafe) => {
|
||||||
|
if (!unsafe) return "";
|
||||||
|
return unsafe.replace(/[<>&'"]/g, (c) => {
|
||||||
|
switch (c) {
|
||||||
|
case '<': return '<';
|
||||||
|
case '>': return '>';
|
||||||
|
case '&': return '&';
|
||||||
|
case '\'': return ''';
|
||||||
|
case '"': return '"';
|
||||||
|
default: return c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start Helper: Parse filename metadata
|
||||||
|
const parseFilenameMetadata = (filename) => {
|
||||||
|
const cleanName = filename.replace(/\.(epub|zip|docx|doc|txt|rar)$/i, '').replace(/\s*\(\d+\)$/, '').trim();
|
||||||
|
let title = cleanName;
|
||||||
|
let author = "";
|
||||||
|
|
||||||
|
if (cleanName.includes('_')) {
|
||||||
|
const parts = cleanName.split('_');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
author = parts.pop()?.trim() || "";
|
||||||
|
title = parts.join('_').trim();
|
||||||
|
}
|
||||||
|
} else if (cleanName.includes(' - ')) {
|
||||||
|
const parts = cleanName.split(' - ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
author = parts.pop()?.trim() || "";
|
||||||
|
title = parts.join(' - ').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { title, author };
|
||||||
|
};
|
||||||
|
// End Helper
|
||||||
|
|
||||||
|
const cleanContentArtifacts = (content) => {
|
||||||
|
if (!content) return "";
|
||||||
|
let clean = content;
|
||||||
|
// 1. Remove internal Splitter markers
|
||||||
|
clean = clean.replace(/^###EPUB_CHAPTER_SPLIT###[^\n]*\n?/gm, '');
|
||||||
|
// 2. Standardize Line Endings
|
||||||
|
clean = clean.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
// 3. Apply Book Formatting
|
||||||
|
clean = formatBookStyle(clean);
|
||||||
|
return clean.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const epubService = {
|
||||||
|
generateEpub: async (storyInfo, files, coverImage = null) => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
// Sort files naturally
|
||||||
|
const sortedFiles = [...files].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sortedFiles.length === 0) throw new Error("Không có chương nào để tạo EPUB");
|
||||||
|
|
||||||
|
// VALIDATION: Mimetype must be first and uncompressed
|
||||||
|
zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
|
||||||
|
|
||||||
|
const title = storyInfo.title || "Unknown Title";
|
||||||
|
const author = storyInfo.author || "Unknown Author";
|
||||||
|
const uuidVal = "urn:uuid:" + crypto.randomUUID();
|
||||||
|
const description = storyInfo.description || "";
|
||||||
|
|
||||||
|
const oebps = zip.folder("OEBPS");
|
||||||
|
const metaInf = zip.folder("META-INF");
|
||||||
|
metaInf.file("container.xml", `<?xml version="1.0" encoding="UTF-8"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`);
|
||||||
|
|
||||||
|
// --- IMAGES & CSS ---
|
||||||
|
let coverManifest = "";
|
||||||
|
let coverMeta = "";
|
||||||
|
let coverImgFilename = "";
|
||||||
|
|
||||||
|
if (coverImage) {
|
||||||
|
// Assume coverImage is a File/Blob object
|
||||||
|
// TODO: Handle cover passed from App (currently App doesn't pass cover in generateEpub call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS
|
||||||
|
oebps.file("Styles/style.css", `body{font-family:serif;line-height:1.6;margin:0;padding:5%;text-align:justify}h1,h2{text-align:center;font-weight:bold;margin:1.5em 0 1em;page-break-after:avoid;page-break-before:always}p{text-indent:1.5em;margin:0.5em 0;margin-bottom:1em}img{max-width:100%;height:auto;display:block;margin:1em auto}.intro-container{text-align:center;margin-top:2em}.intro-title{font-size:2em;font-weight:bold;margin-bottom:0.5em}.intro-author{font-size:1.2em;color:#555;margin-bottom:2em}.tag{display:inline-block;background:#eee;padding:2px 8px;border-radius:4px;font-size:0.8em;margin:5px}`);
|
||||||
|
|
||||||
|
// --- CONTENT GENERATION ---
|
||||||
|
const manifestItems = [];
|
||||||
|
const spineItems = [];
|
||||||
|
const navPoints = []; // For NCX
|
||||||
|
const navLinks = []; // For HTML Nav
|
||||||
|
|
||||||
|
// 1. Intro Page
|
||||||
|
const introFilename = "Text/intro.xhtml";
|
||||||
|
let coverHtml = ""; // Placeholder
|
||||||
|
const tagsHtml = (storyInfo.genres || []).map(g => `<span class="tag">${escapeXml(g)}</span>`).join('');
|
||||||
|
|
||||||
|
// Add CSS link
|
||||||
|
const introXhtml = `<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" lang="vi"><head><title>Giới Thiệu</title><link href="../Styles/style.css" rel="stylesheet" type="text/css"/></head><body><section class="intro-container">${coverHtml}<h1 class="intro-title">${escapeXml(title)}</h1><div class="intro-author">${escapeXml(author)}</div><div class="intro-info"><div>${tagsHtml}</div><hr/><h3>Giới Thiệu</h3><div>${escapeXml(description).replace(/\n/g, '<br/>')}</div></div></section></body></html>`;
|
||||||
|
|
||||||
|
oebps.file(introFilename, introXhtml);
|
||||||
|
manifestItems.push(`<item id="intro" href="${introFilename}" media-type="application/xhtml+xml"/>`);
|
||||||
|
spineItems.push(`<itemref idref="intro"/>`);
|
||||||
|
navPoints.push(`<navPoint id="nav-intro" playOrder="1"><navLabel><text>Giới Thiệu</text></navLabel><content src="${introFilename}"/></navPoint>`);
|
||||||
|
navLinks.push(`<li><a href="intro.xhtml">Giới Thiệu</a></li>`);
|
||||||
|
|
||||||
|
// 2. Chapters
|
||||||
|
const totalFiles = sortedFiles.length;
|
||||||
|
for (let i = 0; i < totalFiles; i++) {
|
||||||
|
const file = sortedFiles[i];
|
||||||
|
const rawContent = file.translatedContent || file.content || "";
|
||||||
|
const content = cleanContentArtifacts(rawContent);
|
||||||
|
|
||||||
|
if (!content.trim()) continue;
|
||||||
|
|
||||||
|
const chapterId = `ch${i + 1}`;
|
||||||
|
const filename = `Text/${chapterId}.xhtml`;
|
||||||
|
|
||||||
|
// Clean title
|
||||||
|
let displayTitle = file.name.replace(/\.(txt|zip|docx)$/i, '').replace(/^\d{5}[\s_]/, '');
|
||||||
|
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
|
||||||
|
let bodyLines = lines;
|
||||||
|
|
||||||
|
if (lines.length > 0 && lines[0].length < 150) {
|
||||||
|
// Check if first line is title-like (Already handled by formatBookStyle mostly, but safe to check)
|
||||||
|
// We'll trust formatBookStyle's output. If the first line is "Chương X: ...", take it as title?
|
||||||
|
// Actually, formatBookStyle puts the header as the first line.
|
||||||
|
const firstLine = lines[0];
|
||||||
|
if (firstLine.toLowerCase().startsWith('chương')) {
|
||||||
|
displayTitle = firstLine;
|
||||||
|
bodyLines = lines.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlBody = bodyLines.map(l => `<p>${escapeXml(l)}</p>`).join('');
|
||||||
|
const xhtml = `<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" lang="vi"><head><title>${escapeXml(displayTitle)}</title><link href="../Styles/style.css" rel="stylesheet" type="text/css"/></head><body><h2>${escapeXml(displayTitle)}</h2>${htmlBody}</body></html>`;
|
||||||
|
|
||||||
|
oebps.file(filename, xhtml);
|
||||||
|
manifestItems.push(`<item id="${chapterId}" href="${filename}" media-type="application/xhtml+xml"/>`);
|
||||||
|
spineItems.push(`<itemref idref="${chapterId}"/>`);
|
||||||
|
|
||||||
|
const order = i + 2;
|
||||||
|
const navPointId = `nav-point-${i + 1}`;
|
||||||
|
navPoints.push(`<navPoint id="${navPointId}" playOrder="${order}"><navLabel><text>${escapeXml(displayTitle)}</text></navLabel><content src="${filename}"/></navPoint>`);
|
||||||
|
const relativePath = `${chapterId}.xhtml`;
|
||||||
|
navLinks.push(`<li><a href="${relativePath}">${escapeXml(displayTitle)}</a></li>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Navigation File
|
||||||
|
const navFilename = "Text/nav.xhtml";
|
||||||
|
const navXhtml = `<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="vi"><head><title>Mục Lục</title><link href="../Styles/style.css" rel="stylesheet" type="text/css"/></head><body><nav epub:type="toc" id="toc"><h1>Mục Lục</h1><ol>${navLinks.join('')}</ol></nav></body></html>`;
|
||||||
|
oebps.file(navFilename, navXhtml);
|
||||||
|
manifestItems.push(`<item id="nav" href="${navFilename}" media-type="application/xhtml+xml" properties="nav"/>`);
|
||||||
|
|
||||||
|
// 4. NCX
|
||||||
|
const ncxFilename = "toc.ncx";
|
||||||
|
const tocNcx = `<?xml version="1.0" encoding="UTF-8"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><head><meta name="dtb:uid" content="${uuidVal}"/><meta name="dtb:depth" content="1"/><meta name="dtb:totalPageCount" content="0"/><meta name="dtb:maxPageNumber" content="0"/></head><docTitle><text>${escapeXml(title)}</text></docTitle><navMap>${navPoints.join('')}</navMap></ncx>`;
|
||||||
|
oebps.file(ncxFilename, tocNcx);
|
||||||
|
|
||||||
|
// 5. OPF
|
||||||
|
const opf = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="3.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||||
|
<dc:title>${escapeXml(title)}</dc:title>
|
||||||
|
<dc:creator>${escapeXml(author)}</dc:creator>
|
||||||
|
<dc:language>vi</dc:language>
|
||||||
|
<dc:identifier id="BookId">${uuidVal}</dc:identifier>
|
||||||
|
<dc:description>${escapeXml(description)}</dc:description>
|
||||||
|
<meta property="dcterms:modified">${new Date().toISOString().split('.')[0] + 'Z'}</meta>
|
||||||
|
${coverMeta}
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml" />
|
||||||
|
<item id="style" href="Styles/style.css" media-type="text/css" />
|
||||||
|
${coverManifest}
|
||||||
|
${manifestItems.join('\n ')}
|
||||||
|
</manifest>
|
||||||
|
<spine toc="ncx">
|
||||||
|
<itemref idref="nav" linear="no"/>
|
||||||
|
${spineItems.join('\n ')}
|
||||||
|
</spine>
|
||||||
|
</package>`;
|
||||||
|
|
||||||
|
oebps.file("content.opf", opf);
|
||||||
|
|
||||||
|
return await zip.generateAsync({ type: "blob" });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import mammoth from 'mammoth';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
// Standard FileItem structure
|
||||||
|
const createFileItem = (name, content) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: name,
|
||||||
|
content: content,
|
||||||
|
translatedContent: null,
|
||||||
|
status: 'IDLE',
|
||||||
|
retryCount: 0,
|
||||||
|
originalCharCount: content.length,
|
||||||
|
remainingRawCharCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const parseFile = async (file) => {
|
||||||
|
logger.info(`Parsing file: ${file.name} (${file.size} bytes)`);
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
let content = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (name.endsWith('.txt')) {
|
||||||
|
content = await file.text();
|
||||||
|
if (typeof content !== 'string') throw new Error("File content is not text");
|
||||||
|
const items = [createFileItem(file.name, content)];
|
||||||
|
logger.info(`Parsed TXT: ${items.length} items created`);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.endsWith('.docx')) {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||||
|
content = result.value;
|
||||||
|
return [createFileItem(file.name, content)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.endsWith('.epub')) {
|
||||||
|
// For EPUB, we might want to split by chapters if possible,
|
||||||
|
// but for now let's return a single merged file or simple split.
|
||||||
|
// The SplitterModal can handle splitting later.
|
||||||
|
content = await parseEpub(file);
|
||||||
|
logger.info(`Parsed EPUB: ${content.length} chars extracted`);
|
||||||
|
return [createFileItem(file.name, content)];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Định dạng file không hỗ trợ (chỉ hỗ trợ .txt, .docx, .epub)");
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Parse error for ${file.name}`, e);
|
||||||
|
// Return error object or empty array?
|
||||||
|
// Better to throw so UI handles it, or return valid empty
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseEpub = async (file) => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
await zip.loadAsync(file);
|
||||||
|
|
||||||
|
const container = await zip.file("META-INF/container.xml")?.async("text");
|
||||||
|
if (!container) throw new Error("Invalid EPUB");
|
||||||
|
|
||||||
|
const opfPathMatch = container.match(/full-path="([^"]+)"/);
|
||||||
|
if (!opfPathMatch) throw new Error("OPF file not found");
|
||||||
|
|
||||||
|
const opfPath = opfPathMatch[1];
|
||||||
|
// We could parse OPF to get correct spine order,
|
||||||
|
// but for simple text extraction we can just iterate HTMLs.
|
||||||
|
// However, order matters for a story.
|
||||||
|
|
||||||
|
// Simple Heuristic: Extract all HTMLs and sort by name (often 001.html, 002.html)
|
||||||
|
// or try to follow OPF spine if we can easily parse it.
|
||||||
|
|
||||||
|
// Let's try to get simple text first.
|
||||||
|
let fullText = "";
|
||||||
|
|
||||||
|
// Get all files ending in html/xhtml
|
||||||
|
const files = [];
|
||||||
|
zip.forEach((relativePath, zipEntry) => {
|
||||||
|
if (/\.(html|xhtml|htm)$/i.test(relativePath)) {
|
||||||
|
files.push(relativePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort files to approximate reading order
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
for (const path of files) {
|
||||||
|
const html = await zip.file(path).async("text");
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
// Remove style/script
|
||||||
|
doc.querySelectorAll('style, script').forEach(el => el.remove());
|
||||||
|
const text = doc.body.innerText || "";
|
||||||
|
if (text.trim()) fullText += text.trim() + "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullText;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateEpub = async (metadata) => {
|
||||||
|
// metadata: { title, author, files: [{title, content}] }
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
// mimetype
|
||||||
|
zip.file("mimetype", "application/epub+zip");
|
||||||
|
|
||||||
|
// META-INF/container.xml
|
||||||
|
zip.file("META-INF/container.xml", `<?xml version="1.0"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>`);
|
||||||
|
|
||||||
|
// content.opf
|
||||||
|
let manifest = '';
|
||||||
|
let spine = '';
|
||||||
|
|
||||||
|
// Add Cover if exists (Not implemented yet, skipping)
|
||||||
|
|
||||||
|
// Add Chapters
|
||||||
|
metadata.files.forEach((file, idx) => {
|
||||||
|
const id = `chapter_${idx + 1}`;
|
||||||
|
const href = `${id}.xhtml`;
|
||||||
|
manifest += `<item id="${id}" href="${href}" media-type="application/xhtml+xml"/>\n`;
|
||||||
|
spine += `<itemref idref="${id}"/>\n`;
|
||||||
|
|
||||||
|
const htmlContent = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<title>${file.title}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: serif; line-height: 1.6; margin: 2em; }
|
||||||
|
h1 { text-align: center; color: #333; page-break-after: avoid; }
|
||||||
|
p { text-indent: 1.5em; margin-bottom: 0.5em; text-align: justify; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${file.title}</h1>
|
||||||
|
${file.content.split('\n').filter(l => l.trim()).map(l => `<p>${l.trim()}</p>`).join('\n')}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
zip.file(href, htmlContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add NCX (Navigation) - Required for older readers
|
||||||
|
const ncxContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||||
|
<head><meta name="dtb:uid" content="urn:uuid:${crypto.randomUUID()}"/></head>
|
||||||
|
<docTitle><text>${metadata.title}</text></docTitle>
|
||||||
|
<navMap>
|
||||||
|
${metadata.files.map((f, i) => `
|
||||||
|
<navPoint id="navPoint-${i + 1}" playOrder="${i + 1}">
|
||||||
|
<navLabel><text>${f.title}</text></navLabel>
|
||||||
|
<content src="chapter_${i + 1}.xhtml"/>
|
||||||
|
</navPoint>`).join('\n')}
|
||||||
|
</navMap>
|
||||||
|
</ncx>`;
|
||||||
|
zip.file("toc.ncx", ncxContent);
|
||||||
|
manifest += `<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>\n`;
|
||||||
|
|
||||||
|
const opfContent = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="2.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||||
|
<dc:title>${metadata.title || "Untitled"}</dc:title>
|
||||||
|
<dc:creator>${metadata.author || "Unknown"}</dc:creator>
|
||||||
|
<dc:language>vi</dc:language>
|
||||||
|
<dc:identifier id="BookId">urn:uuid:${crypto.randomUUID()}</dc:identifier>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
${manifest}
|
||||||
|
</manifest>
|
||||||
|
<spine toc="ncx">
|
||||||
|
${spine}
|
||||||
|
</spine>
|
||||||
|
</package>`;
|
||||||
|
|
||||||
|
zip.file("content.opf", opfContent);
|
||||||
|
|
||||||
|
return await zip.generateAsync({ type: "blob" });
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
|
||||||
|
// This is a simple in-memory logger service for the client side.
|
||||||
|
// It proxies console.log/warn/error to capture logs for the UI.
|
||||||
|
|
||||||
|
const MAX_LOGS = 500;
|
||||||
|
let subscribers = [];
|
||||||
|
|
||||||
|
// Store logs in memory
|
||||||
|
let logBuffer = [];
|
||||||
|
|
||||||
|
// Capture original methods immediately when module loads
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
const notify = () => {
|
||||||
|
subscribers.forEach(cb => cb(logBuffer));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLog = (type, args) => {
|
||||||
|
const message = args.map(arg => {
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
return '[Object]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
logBuffer.unshift(entry); // Newest first
|
||||||
|
if (logBuffer.length > MAX_LOGS) {
|
||||||
|
logBuffer.pop();
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
subscribe: (callback) => {
|
||||||
|
subscribers.push(callback);
|
||||||
|
callback(logBuffer);
|
||||||
|
return () => {
|
||||||
|
subscribers = subscribers.filter(cb => cb !== callback);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => {
|
||||||
|
logBuffer = [];
|
||||||
|
notify();
|
||||||
|
originalLog.call(console, "[Logger] Cleared logs");
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (...args) => {
|
||||||
|
addLog('info', args);
|
||||||
|
originalLog.apply(console, args);
|
||||||
|
},
|
||||||
|
warn: (...args) => {
|
||||||
|
addLog('warn', args);
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
},
|
||||||
|
error: (...args) => {
|
||||||
|
addLog('error', args);
|
||||||
|
originalError.apply(console, args);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Call this once at app start to hijack console
|
||||||
|
init: () => {
|
||||||
|
// Avoid double wrapping if called multiple times
|
||||||
|
if (console.log === originalLog) {
|
||||||
|
// We only hijack if it hasn't been hijacked by US yet.
|
||||||
|
// But actually, we don't strictly need to hijack if we just use logger.info everywhere.
|
||||||
|
// However, to catch other libraries' logs, we do need to hijack.
|
||||||
|
|
||||||
|
// BUT, if we hijack, we must make sure we don't create loop if the hijack calls logger.info.
|
||||||
|
// Our hijack below calls addLog + originalLog. It does NOT call logger.info. SAFE.
|
||||||
|
} else {
|
||||||
|
// Already hijacked or modified by someone else?
|
||||||
|
// Let's assume we want to overwrite it to ensure we capture.
|
||||||
|
// But we should use the 'originalLog' we captured at module load time to be safe.
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log = (...args) => {
|
||||||
|
addLog('info', args);
|
||||||
|
originalLog.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args) => {
|
||||||
|
addLog('warn', args);
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args) => {
|
||||||
|
addLog('error', args);
|
||||||
|
originalError.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
originalLog.call(console, "[Logger] System Initialized (Console + UI)");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
|
||||||
|
// textHelpers.js - Utilities for text processing
|
||||||
|
|
||||||
|
export const FOREIGN_CHARS_REGEX = /[\u4e00-\u9fa5\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af\u0400-\u04ff]/;
|
||||||
|
|
||||||
|
const JUNK_KEYWORDS_REGEX = /thông báo|xin nghỉ|cầu phiếu|đề cử|tác giả|phiếu|nghỉ phép|lời nói đầu|cảm nghĩ|đôi lời|chúc mừng|convert|converter|cầu donate|ps:|p\/s:|chương chống trộm/i;
|
||||||
|
|
||||||
|
export const padNumber = (num, size = 5) => {
|
||||||
|
let s = String(num);
|
||||||
|
while (s.length < size) s = "0" + s;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeFilename = (name) => {
|
||||||
|
return name.replace(/[:/\\?%*|"<>]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toTitleCase = (str) => {
|
||||||
|
return str.toLowerCase().replace(/(?:^|\s|['"({[])\S/g, function (a) {
|
||||||
|
return a.toUpperCase();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatBookStyle = (content) => {
|
||||||
|
if (!content) return "";
|
||||||
|
let text = content;
|
||||||
|
|
||||||
|
// 1. Filter Garbage Lines (*, #, --, ==, __)
|
||||||
|
text = text.replace(/^[ \t]*(?:[\*\#\=\-\_][ \t]*){2,}[ \t]*$/gm, '');
|
||||||
|
|
||||||
|
// 2. Filter Inline Garbage
|
||||||
|
text = text.replace(/(?:[\*\#\=\-\_][ \t]*){3,}/g, '');
|
||||||
|
|
||||||
|
// 3. Standardize Header
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const formattedLines = lines
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(l => l.length > 0)
|
||||||
|
.map(l => {
|
||||||
|
const headerMatch = l.match(/^[\s\*\-\=\#\_]*(Chương\s+\d+)(.*)$/i);
|
||||||
|
|
||||||
|
if (headerMatch) {
|
||||||
|
const prefix = headerMatch[1]; // "Chương 1"
|
||||||
|
let suffix = headerMatch[2]; // ": Tiêu đề..."
|
||||||
|
suffix = suffix.replace(/^[\s:\.\-\_]+/, '').trim();
|
||||||
|
|
||||||
|
if (suffix) {
|
||||||
|
suffix = toTitleCase(suffix);
|
||||||
|
return `${prefix}: ${suffix}`;
|
||||||
|
} else {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Indent (4 spaces)
|
||||||
|
return ` ${l}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Double Line Spacing for readability
|
||||||
|
return formattedLines.join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectJunkChapter = (title, content) => {
|
||||||
|
const len = content.length;
|
||||||
|
|
||||||
|
// 1. Too Short (< 1200 chars)
|
||||||
|
if (len < 1200) return true;
|
||||||
|
|
||||||
|
// 2. Title Keywords
|
||||||
|
if (JUNK_KEYWORDS_REGEX.test(title)) return true;
|
||||||
|
|
||||||
|
// 3. First 200 chars keywords
|
||||||
|
const sample = content.substring(0, 200);
|
||||||
|
if (JUNK_KEYWORDS_REGEX.test(sample)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replacePromptVariables = (template, info) => {
|
||||||
|
if (!template) return "";
|
||||||
|
let result = template;
|
||||||
|
const val = (v) => {
|
||||||
|
if (Array.isArray(v)) return v.join(', ');
|
||||||
|
return v ? String(v) : 'Chưa rõ';
|
||||||
|
};
|
||||||
|
|
||||||
|
result = result.replace(/\{\{TITLE\}\}/g, val(info.title));
|
||||||
|
result = result.replace(/\{\{AUTHOR\}\}/g, val(info.author));
|
||||||
|
result = result.replace(/\{\{LANGUAGE\}\}/g, val(info.languages || ['Trung Quốc']));
|
||||||
|
result = result.replace(/\{\{GENRE\}\}/g, val(info.genres));
|
||||||
|
result = result.replace(/\{\{PERSONALITY\}\}/g, val(info.mcPersonality));
|
||||||
|
result = result.replace(/\{\{SETTING\}\}/g, val(info.worldSetting));
|
||||||
|
result = result.replace(/\{\{FLOW\}\}/g, val(info.sectFlow));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countForeignChars = (text) => {
|
||||||
|
if (!text) return 0;
|
||||||
|
const globalRegex = new RegExp(FOREIGN_CHARS_REGEX, 'g');
|
||||||
|
const matches = text.match(globalRegex);
|
||||||
|
return matches ? matches.length : 0;
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
|
||||||
|
export const optimizeDictionary = (dictionary, content) => {
|
||||||
|
if (!content || !dictionary) return "";
|
||||||
|
|
||||||
|
const lines = dictionary.split('\n');
|
||||||
|
const uniqueMap = {};
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return;
|
||||||
|
|
||||||
|
let rawKey = "";
|
||||||
|
if (trimmed.includes('=')) {
|
||||||
|
rawKey = trimmed.split('=')[0].trim();
|
||||||
|
} else if (trimmed.includes(':')) {
|
||||||
|
rawKey = trimmed.split(':')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawKey) {
|
||||||
|
uniqueMap[rawKey] = trimmed;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedLines = [];
|
||||||
|
Object.keys(uniqueMap).forEach(key => {
|
||||||
|
const normalizedKey = key.replace(/^\[|\]$/g, '');
|
||||||
|
if (content.includes(normalizedKey)) {
|
||||||
|
usedLines.push(uniqueMap[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return usedLines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optimizeContext = (context, content) => {
|
||||||
|
if (!context || !content) return "";
|
||||||
|
|
||||||
|
const blocks = context.split(/\n\s*\n/);
|
||||||
|
const relevantBlocks = [];
|
||||||
|
|
||||||
|
const criticalKeywords = ['văn phong', 'quy tắc', 'lưu ý', 'tông giọng', 'ma trận xưng hô', 'hồ sơ nhân vật'];
|
||||||
|
|
||||||
|
blocks.forEach(block => {
|
||||||
|
const trimmed = block.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
// Rule 1: Metadata
|
||||||
|
if (trimmed.startsWith('#') || trimmed.startsWith('===') || trimmed.startsWith('[METADATA]')) {
|
||||||
|
relevantBlocks.push(block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: Force keep
|
||||||
|
if (trimmed.startsWith('!')) {
|
||||||
|
relevantBlocks.push(block.replace('!', ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: Critical keywords
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (criticalKeywords.some(k => lower.includes(k))) {
|
||||||
|
relevantBlocks.push(block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 4: Smart filter by subject
|
||||||
|
let subject = trimmed.split('\n')[0];
|
||||||
|
if (subject.includes('->')) subject = subject.split('->')[0];
|
||||||
|
else if (subject.includes(':')) subject = subject.split(':')[0];
|
||||||
|
|
||||||
|
subject = subject.replace(/^[\s\-\*\[\]]+|[\s\[\]]+$/g, '');
|
||||||
|
|
||||||
|
if (subject.length > 1) {
|
||||||
|
if (content.includes(subject)) {
|
||||||
|
relevantBlocks.push(block);
|
||||||
|
} else if (trimmed.length < 50) {
|
||||||
|
relevantBlocks.push(block);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
relevantBlocks.push(block);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return relevantBlocks.join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatBookStyle = (content) => {
|
||||||
|
if (!content) return "";
|
||||||
|
let text = content;
|
||||||
|
|
||||||
|
// 1. Garbage Line Removal
|
||||||
|
text = text.replace(/^[ \t]*(?:[\*\#\=\-\_][ \t]*){2,}[ \t]*$/gm, '');
|
||||||
|
|
||||||
|
// 2. Head Cleanup (Chương \d+)
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const formattedLines = lines.map(l => {
|
||||||
|
const trimmed = l.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
|
||||||
|
const headerMatch = trimmed.match(/^[\s\*\-\=\#\_]*(Chương\s+\d+)(.*)$/i);
|
||||||
|
if (headerMatch) {
|
||||||
|
const prefix = headerMatch[1];
|
||||||
|
let suffix = headerMatch[2].replace(/^[\s:\.\-\_]+/, '').trim();
|
||||||
|
if (suffix) {
|
||||||
|
// Title Case Suffix
|
||||||
|
suffix = suffix.toLowerCase().replace(/(?:^|\s)\S/g, a => a.toUpperCase());
|
||||||
|
return `${prefix}: ${suffix}`;
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
return ` ${trimmed}`; // Indent normal paragraphs
|
||||||
|
});
|
||||||
|
|
||||||
|
return formattedLines.filter(l => l).join('\n\n');
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'AI Translate',
|
||||||
|
short_name: 'AI Translate',
|
||||||
|
description: 'AI Translation Tool',
|
||||||
|
theme_color: '#ffffff',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user