diff --git a/.env.example b/.env.example
index a5b2d1e..4abf643 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,14 @@
VITE_API_BASE_URL=http://localhost:4000
VITE_SUPABASE_URL=""
VITE_SUPABASE_ANON_KEY=""
+
+# Backend
+SUPABASE_URL=""
+SUPABASE_SERVICE_ROLE_KEY=""
+SUPABASE_USERS_TABLE="users"
+JWT_SECRET="change-me"
+
+# GLM / Anthropic çeviri servisi
+ZAI_GLM_API_KEY="YOUR_ZAI_GLM_API_KEY"
+ZAI_GLM_MODEL="glm-4.6"
+ZAI_GLM_API_URL="https://api.z.ai/api/anthropic"
diff --git a/docker-compose.yml b/docker-compose.yml
index 786e02c..4d85562 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -23,6 +23,13 @@ services:
environment:
- PORT=4000
- CLIENT_ORIGIN=http://localhost:5173
+ - SUPABASE_URL=${SUPABASE_URL}
+ - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
+ - SUPABASE_USERS_TABLE=${SUPABASE_USERS_TABLE:-users}
+ - JWT_SECRET=${JWT_SECRET}
+ - ZAI_GLM_API_KEY=${ZAI_GLM_API_KEY}
+ - ZAI_GLM_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
+ - ZAI_GLM_API_URL=${ZAI_GLM_API_URL:-https://api.z.ai/api/anthropic}
ports:
- "4000:4000"
volumes:
@@ -53,6 +60,13 @@ services:
environment:
- PORT=4000
- CLIENT_ORIGIN=http://localhost:4173
+ - SUPABASE_URL=${SUPABASE_URL}
+ - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
+ - SUPABASE_USERS_TABLE=${SUPABASE_USERS_TABLE:-users}
+ - JWT_SECRET=${JWT_SECRET}
+ - ZAI_GLM_API_KEY=${ZAI_GLM_API_KEY}
+ - ZAI_GLM_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
+ - ZAI_GLM_API_URL=${ZAI_GLM_API_URL:-https://api.z.ai/api/anthropic}
ports:
- "4000:4000"
profiles:
diff --git a/server/index.js b/server/index.js
index d88340b..5238788 100644
--- a/server/index.js
+++ b/server/index.js
@@ -9,7 +9,12 @@ import { promises as fs } from 'fs';
import { v4 as uuidV4 } from 'uuid';
import Epub from 'epub-gen';
-const requiredEnv = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'JWT_SECRET'];
+const requiredEnv = [
+ 'SUPABASE_URL',
+ 'SUPABASE_SERVICE_ROLE_KEY',
+ 'JWT_SECRET',
+ 'ZAI_GLM_API_KEY',
+];
requiredEnv.forEach((key) => {
if (!process.env[key]) {
console.error(`Missing required environment variable: ${key}`);
@@ -25,6 +30,7 @@ const allowedOrigins = ORIGIN.split(',').map((origin) => origin.trim());
const USERS_TABLE = process.env.SUPABASE_USERS_TABLE || 'users';
const JWT_SECRET = process.env.JWT_SECRET;
import { supabase } from './src/services/supabaseClient.js';
+import { translateWithGlm } from './src/services/glmClient.js';
app.use(
cors({
@@ -248,6 +254,21 @@ authRouter.post('/forgot-password', async (req, res) => {
app.use('/auth', authRouter);
+app.post('/translate', async (req, res) => {
+ const { text } = req.body || {};
+ if (!text || !text.trim()) {
+ return res.status(400).json({ message: 'Çevrilecek metin bulunamadı.' });
+ }
+
+ try {
+ const translated = await translateWithGlm(text);
+ return res.json({ text: translated });
+ } catch (error) {
+ console.error('GLM çeviri hatası:', error);
+ return res.status(500).json({ message: error.message || 'Çeviri tamamlanamadı.' });
+ }
+});
+
app.post('/generate-epub', async (req, res) => {
const { text, meta, cover } = req.body || {};
if (!text || !text.trim()) {
diff --git a/server/src/services/glmClient.js b/server/src/services/glmClient.js
new file mode 100644
index 0000000..8919a4a
--- /dev/null
+++ b/server/src/services/glmClient.js
@@ -0,0 +1,142 @@
+const GLM_API_KEY = process.env.ZAI_GLM_API_KEY || process.env.ANTHROPIC_API_KEY;
+const GLM_MODEL = process.env.ANTHROPIC_MODEL || process.env.ZAI_GLM_MODEL || 'glm-4.6';
+const resolveEndpoint = () => {
+ const base =
+ process.env.ANTHROPIC_BASE_URL || process.env.ZAI_GLM_API_URL || 'https://api.z.ai/api/anthropic';
+ const normalized = base.replace(/\/$/, '');
+ if (/\/messages$/.test(normalized)) {
+ return normalized;
+ }
+ if (/\/v\d+$/.test(normalized)) {
+ return `${normalized}/messages`;
+ }
+ if (/\/v\d+\/.+/.test(normalized)) {
+ return normalized;
+ }
+ return `${normalized}/v1/messages`;
+};
+
+const GLM_API_URL = resolveEndpoint();
+const IS_ANTHROPIC_STYLE = /anthropic/.test(GLM_API_URL);
+
+const SYSTEM_PROMPT =
+ 'You are a professional localization editor. Translate any given English or mixed-language text into fluent, publication-ready Turkish. Keep the original meaning, respect formatting, and avoid adding explanations.';
+
+const extractContent = (payload) => {
+ if (!payload) return '';
+ if (Array.isArray(payload.content)) {
+ return payload.content
+ .map((item) => {
+ if (!item) return '';
+ if (typeof item === 'string') return item;
+ if (Array.isArray(item.text)) {
+ return item.text.map((inner) => inner?.text || inner || '').join('');
+ }
+ if (typeof item.text === 'string') return item.text;
+ if (Array.isArray(item.content)) {
+ return item.content
+ .map((inner) => (typeof inner === 'string' ? inner : inner?.text || ''))
+ .join('');
+ }
+ return '';
+ })
+ .filter(Boolean)
+ .join('\n')
+ .trim();
+ }
+ if (Array.isArray(payload.output)) {
+ return payload.output
+ .map((item) => item.content || item.text || '')
+ .filter(Boolean)
+ .join('')
+ .trim();
+ }
+ if (Array.isArray(payload.choices) && payload.choices.length > 0) {
+ const choice = payload.choices[0];
+ if (choice.message?.content) {
+ if (Array.isArray(choice.message.content)) {
+ return choice.message.content
+ .map((c) => (typeof c === 'string' ? c : c.text || ''))
+ .join('')
+ .trim();
+ }
+ return `${choice.message.content}`.trim();
+ }
+ if (choice.text) {
+ return `${choice.text}`.trim();
+ }
+ }
+ if (payload.data?.output_text?.length) {
+ return payload.data.output_text.join('\n').trim();
+ }
+ if (typeof payload.content === 'string') {
+ return payload.content.trim();
+ }
+ if (typeof payload.text === 'string') {
+ return payload.text.trim();
+ }
+ return '';
+};
+
+export const translateWithGlm = async (text) => {
+ if (!GLM_API_KEY) {
+ throw new Error('ZAI_GLM_API_KEY veya ANTHROPIC_API_KEY tanımlı değil.');
+ }
+
+ const prompt =
+ `Aşağıdaki metni Türkçe'ye çevir. Yalnızca çeviriyi döndür:\n\n"""${text}"""`;
+
+ let body;
+ if (IS_ANTHROPIC_STYLE) {
+ body = {
+ model: GLM_MODEL,
+ max_tokens: 1024,
+ temperature: 0.1,
+ system: SYSTEM_PROMPT,
+ messages: [
+ {
+ role: 'user',
+ content: [{ type: 'text', text: prompt }],
+ },
+ ],
+ };
+ } else {
+ body = {
+ model: GLM_MODEL,
+ max_tokens: 1024,
+ temperature: 0.1,
+ messages: [
+ { role: 'system', content: SYSTEM_PROMPT },
+ { role: 'user', content: prompt },
+ ],
+ };
+ }
+
+ const response = await fetch(GLM_API_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${GLM_API_KEY}`,
+ ...(IS_ANTHROPIC_STYLE && {
+ 'x-api-key': GLM_API_KEY,
+ 'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
+ }),
+ },
+ body: JSON.stringify(body),
+ });
+
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ const message =
+ payload?.error?.message ||
+ payload?.msg ||
+ `GLM isteği başarısız oldu (status: ${response.status})`;
+ throw new Error(message);
+ }
+
+ const translated = extractContent(payload);
+ if (!translated) {
+ throw new Error('GLM çıktısı boş döndü.');
+ }
+ return translated;
+};
diff --git a/src/components/EpubStep.jsx b/src/components/EpubStep.jsx
index c37e6c5..a210657 100644
--- a/src/components/EpubStep.jsx
+++ b/src/components/EpubStep.jsx
@@ -14,6 +14,9 @@ import { createEpubFromOcr } from '../utils/epubUtils';
const EpubStep = () => {
const navigate = useNavigate();
const ocrText = useAppStore((state) => state.ocrText);
+ const translatedText = useAppStore((state) => state.translatedText);
+ const translationStatus = useAppStore((state) => state.translationStatus);
+ const translationError = useAppStore((state) => state.translationError);
const generatedEpub = useAppStore((state) => state.generatedEpub);
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
const setError = useAppStore((state) => state.setError);
@@ -21,14 +24,16 @@ const EpubStep = () => {
const croppedCoverImage = useAppStore((state) => state.croppedCoverImage);
const [processing, setProcessing] = useState(false);
const needsCoverCrop = Boolean(coverImageId && !croppedCoverImage);
+ const translationBlocking = translationStatus === 'running';
+ const exportText = translatedText?.trim() || ocrText;
useEffect(() => {
let cancelled = false;
const run = async () => {
- if (!ocrText?.trim() || generatedEpub || needsCoverCrop) return;
+ if (!exportText?.trim() || generatedEpub || needsCoverCrop || translationBlocking) return;
setProcessing(true);
try {
- const epub = await createEpubFromOcr(ocrText, croppedCoverImage);
+ const epub = await createEpubFromOcr(exportText, croppedCoverImage);
if (!cancelled) {
setGeneratedEpub(epub);
}
@@ -46,7 +51,15 @@ const EpubStep = () => {
return () => {
cancelled = true;
};
- }, [croppedCoverImage, generatedEpub, needsCoverCrop, ocrText, setError, setGeneratedEpub]);
+ }, [
+ croppedCoverImage,
+ exportText,
+ generatedEpub,
+ needsCoverCrop,
+ setError,
+ setGeneratedEpub,
+ translationBlocking,
+ ]);
if (!ocrText?.trim()) {
return (
@@ -67,6 +80,23 @@ const EpubStep = () => {
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
+ {translationBlocking && (
+
+ Türkçe çeviri tamamlanana kadar EPUB üretimi bekletiliyor. Lütfen çeviri ekranındaki işlemin bitmesini
+ bekle.
+
+ )}
+ {translationStatus === 'done' && translatedText && (
+
+ Çeviri tamamlandı. EPUB, Türkçe metinle oluşturuluyor.
+
+ )}
+ {translationStatus === 'error' && (
+
+ Çeviri sırasında bir sorun oluştu. EPUB üretiminde orijinal OCR metni kullanılacak.
+ {translationError ? ` (${translationError})` : ''}
+
+ )}
{needsCoverCrop && (
Kapak olarak işaretlediğin görseli croplamalısın. Crop adımında kapak görselini kaydet ve tekrar dene.
@@ -108,7 +138,7 @@ const EpubStep = () => {