admin page oluşturuldu.
This commit is contained in:
@@ -15,3 +15,12 @@ CLIENT_ORIGIN="http://localhost:5173"
|
|||||||
ZAI_GLM_API_KEY="YOUR_ZAI_GLM_API_KEY"
|
ZAI_GLM_API_KEY="YOUR_ZAI_GLM_API_KEY"
|
||||||
ZAI_GLM_MODEL="glm-4.6"
|
ZAI_GLM_MODEL="glm-4.6"
|
||||||
ZAI_GLM_API_URL="https://api.z.ai/api/anthropic"
|
ZAI_GLM_API_URL="https://api.z.ai/api/anthropic"
|
||||||
|
|
||||||
|
# Diğer çeviri modelleri (opsiyonel)
|
||||||
|
OPENAI_API_KEY=""
|
||||||
|
OPENAI_MODEL="gpt-4o-mini"
|
||||||
|
DEEPSEEK_API_KEY=""
|
||||||
|
DEEPSEEK_MODEL="deepseek-chat"
|
||||||
|
|
||||||
|
# Varsayılan aktif çeviri modeli (glm | openai | deepseek)
|
||||||
|
TRANSLATION_ACTIVE_MODEL="glm"
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ services:
|
|||||||
- ZAI_GLM_API_KEY=${ZAI_GLM_API_KEY}
|
- ZAI_GLM_API_KEY=${ZAI_GLM_API_KEY}
|
||||||
- ZAI_GLM_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
|
- ZAI_GLM_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
|
||||||
- ZAI_GLM_API_URL=${ZAI_GLM_API_URL:-https://api.z.ai/api/anthropic}
|
- ZAI_GLM_API_URL=${ZAI_GLM_API_URL:-https://api.z.ai/api/anthropic}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
||||||
|
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
|
||||||
|
- DEEPSEEK_MODEL=${DEEPSEEK_MODEL:-deepseek-chat}
|
||||||
|
- TRANSLATION_ACTIVE_MODEL=${TRANSLATION_ACTIVE_MODEL:-glm}
|
||||||
|
- ADMIN_ALLOWED_EMAILS=${ADMIN_ALLOWED_EMAILS}
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-easy-crop": "^5.0.7",
|
"react-easy-crop": "^5.0.7",
|
||||||
"react-router-dom": "^7.0.2",
|
"react-router-dom": "^7.0.2",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"tesseract.js": "^5.1.1",
|
"tesseract.js": "^5.1.1",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
236
server/index.js
236
server/index.js
@@ -1,5 +1,6 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { createServer } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
@@ -9,6 +10,7 @@ import { promises as fs } from 'fs';
|
|||||||
import { v4 as uuidV4 } from 'uuid';
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
import Epub from 'epub-gen';
|
import Epub from 'epub-gen';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
|
|
||||||
const requiredEnv = [
|
const requiredEnv = [
|
||||||
'SUPABASE_URL',
|
'SUPABASE_URL',
|
||||||
@@ -24,10 +26,62 @@ requiredEnv.forEach((key) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 4000;
|
const PORT = process.env.PORT || 4000;
|
||||||
const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
||||||
const allowedOrigins = ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean);
|
const allowedOrigins = ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean);
|
||||||
|
const ADMIN_EMAILS = (process.env.ADMIN_ALLOWED_EMAILS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
const activeUserConnections = new Map();
|
||||||
|
const presenceTimers = new Map();
|
||||||
|
|
||||||
|
const incrementActiveUser = (userId) => {
|
||||||
|
if (!userId) return;
|
||||||
|
const current = activeUserConnections.get(userId) || 0;
|
||||||
|
activeUserConnections.set(userId, current + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrementActiveUser = (userId) => {
|
||||||
|
if (!userId) return;
|
||||||
|
const current = activeUserConnections.get(userId) || 0;
|
||||||
|
if (current <= 1) {
|
||||||
|
activeUserConnections.delete(userId);
|
||||||
|
} else {
|
||||||
|
activeUserConnections.set(userId, current - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
const server = createServer(app);
|
||||||
|
const io = new SocketIOServer(server, {
|
||||||
|
cors: {
|
||||||
|
origin: allowedOrigins,
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitAdminEvent = (event, payload = {}) => {
|
||||||
|
io.to('admins').emit(event, { ...payload, ts: Date.now() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitStats = async () => {
|
||||||
|
let totalUsers = 0;
|
||||||
|
try {
|
||||||
|
const { count, error } = await supabase
|
||||||
|
.from(USERS_TABLE)
|
||||||
|
.select('id', { count: 'exact', head: true });
|
||||||
|
if (!error && typeof count === 'number') {
|
||||||
|
totalUsers = count;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Kullanıcı sayısı alınamadı', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitAdminEvent('stats:update', {
|
||||||
|
activeUsers: activeUserConnections.size,
|
||||||
|
totalUsers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeOrigin = (value = '') => {
|
const normalizeOrigin = (value = '') => {
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +111,12 @@ const enforceClientOrigin = (req, res, next) => {
|
|||||||
const USERS_TABLE = process.env.SUPABASE_USERS_TABLE || 'users';
|
const USERS_TABLE = process.env.SUPABASE_USERS_TABLE || 'users';
|
||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
import { supabase } from './src/services/supabaseClient.js';
|
import { supabase } from './src/services/supabaseClient.js';
|
||||||
import { translateWithGlm } from './src/services/glmClient.js';
|
import {
|
||||||
|
getActiveTranslationModel,
|
||||||
|
listTranslationModels,
|
||||||
|
setActiveTranslationModel,
|
||||||
|
translateWithActiveModel,
|
||||||
|
} from './src/services/translationService.js';
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
@@ -82,6 +141,8 @@ const createToken = (user) =>
|
|||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role || 'user',
|
||||||
},
|
},
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '7d' },
|
{ expiresIn: '7d' },
|
||||||
@@ -92,8 +153,16 @@ const mapUserRecord = (record) => ({
|
|||||||
email: record.email,
|
email: record.email,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
username: record.username,
|
username: record.username,
|
||||||
|
role: record.role || 'user',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAdminAllowed = (user) => {
|
||||||
|
if (!user) return false;
|
||||||
|
const role = user.role || 'user';
|
||||||
|
const email = (user.email || '').toLowerCase();
|
||||||
|
return role === 'admin' && ADMIN_EMAILS.includes(email);
|
||||||
|
};
|
||||||
|
|
||||||
const authMiddleware = (req, res, next) => {
|
const authMiddleware = (req, res, next) => {
|
||||||
const header = req.headers.authorization || '';
|
const header = req.headers.authorization || '';
|
||||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
@@ -109,6 +178,85 @@ const authMiddleware = (req, res, next) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adminMiddleware = (req, res, next) => {
|
||||||
|
if (!req.user || !isAdminAllowed(req.user)) {
|
||||||
|
return res.status(403).json({ message: 'Bu işlem için yetkiniz yok.' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const token =
|
||||||
|
socket.handshake.auth?.token ||
|
||||||
|
(socket.handshake.headers?.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
||||||
|
if (!token) {
|
||||||
|
return next(new Error('Yetkisiz erişim'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
socket.user = payload;
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(new Error('Oturum doğrulanamadı'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const email = socket.user?.email || 'bilinmiyor';
|
||||||
|
const displayName =
|
||||||
|
socket.user?.name || socket.user?.username || (socket.user?.email || '').split('@')[0] || 'kullanıcı';
|
||||||
|
console.log(`[Socket] Bağlandı: ${socket.id} (${email})`);
|
||||||
|
|
||||||
|
const adminUser = isAdminAllowed(socket.user);
|
||||||
|
if (adminUser) {
|
||||||
|
socket.join('admins');
|
||||||
|
}
|
||||||
|
socket.emit('connection:ready', { message: 'Socket bağlantısı kuruldu.', admin: adminUser });
|
||||||
|
|
||||||
|
if (socket.user?.sub) {
|
||||||
|
incrementActiveUser(socket.user.sub);
|
||||||
|
const existingTimer = presenceTimers.get(socket.user.sub);
|
||||||
|
if (existingTimer) {
|
||||||
|
clearTimeout(existingTimer);
|
||||||
|
presenceTimers.delete(socket.user.sub);
|
||||||
|
}
|
||||||
|
emitAdminEvent('presence:update', {
|
||||||
|
userId: socket.user.sub,
|
||||||
|
email: socket.user.email,
|
||||||
|
name: displayName,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
emitStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('ping', () => {
|
||||||
|
socket.emit('pong');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
console.log(`[Socket] Koptu: ${socket.id} sebep=${reason}`);
|
||||||
|
if (socket.user?.sub) {
|
||||||
|
decrementActiveUser(socket.user.sub);
|
||||||
|
if (activeUserConnections.has(socket.user.sub)) {
|
||||||
|
emitStats();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reconnect sırasında çift log oluşmasını engellemek için kısa gecikme
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
presenceTimers.delete(socket.user.sub);
|
||||||
|
emitAdminEvent('presence:update', {
|
||||||
|
userId: socket.user.sub,
|
||||||
|
email: socket.user.email,
|
||||||
|
name: displayName,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
emitStats();
|
||||||
|
}, 1500);
|
||||||
|
presenceTimers.set(socket.user.sub, timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const authRouter = express.Router();
|
const authRouter = express.Router();
|
||||||
authRouter.use(enforceClientOrigin);
|
authRouter.use(enforceClientOrigin);
|
||||||
|
|
||||||
@@ -142,8 +290,9 @@ authRouter.post('/register', async (req, res) => {
|
|||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
username,
|
username,
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
|
role: 'user',
|
||||||
})
|
})
|
||||||
.select('id,email,name,username')
|
.select('id,email,name,username,role')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (insertError || !createdUser) {
|
if (insertError || !createdUser) {
|
||||||
@@ -151,6 +300,12 @@ authRouter.post('/register', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = createToken(createdUser);
|
const token = createToken(createdUser);
|
||||||
|
emitAdminEvent('user:registered', {
|
||||||
|
email: createdUser.email,
|
||||||
|
name: createdUser.name,
|
||||||
|
id: createdUser.id,
|
||||||
|
});
|
||||||
|
emitStats();
|
||||||
return res.status(201).json({ token, user: mapUserRecord(createdUser) });
|
return res.status(201).json({ token, user: mapUserRecord(createdUser) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,7 +318,7 @@ authRouter.post('/login', async (req, res) => {
|
|||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
const { data: userRecord, error: fetchError } = await supabase
|
const { data: userRecord, error: fetchError } = await supabase
|
||||||
.from(USERS_TABLE)
|
.from(USERS_TABLE)
|
||||||
.select('id,email,name,username,password_hash')
|
.select('id,email,name,username,password_hash,role')
|
||||||
.eq('email', normalizedEmail)
|
.eq('email', normalizedEmail)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -214,7 +369,7 @@ authRouter.post('/oauth', async (req, res) => {
|
|||||||
|
|
||||||
const { data: existingUser, error: fetchError } = await supabase
|
const { data: existingUser, error: fetchError } = await supabase
|
||||||
.from(USERS_TABLE)
|
.from(USERS_TABLE)
|
||||||
.select('id,email,name,username')
|
.select('id,email,name,username,role')
|
||||||
.eq('email', email)
|
.eq('email', email)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -226,13 +381,17 @@ authRouter.post('/oauth', async (req, res) => {
|
|||||||
if (!userRecord) {
|
if (!userRecord) {
|
||||||
const { data: insertedUser, error: insertError } = await supabase
|
const { data: insertedUser, error: insertError } = await supabase
|
||||||
.from(USERS_TABLE)
|
.from(USERS_TABLE)
|
||||||
.insert({
|
.upsert(
|
||||||
name,
|
{
|
||||||
email,
|
name,
|
||||||
username,
|
email,
|
||||||
password_hash: 'GOOGLE_OAUTH',
|
username,
|
||||||
})
|
password_hash: 'GOOGLE_OAUTH',
|
||||||
.select('id,email,name,username')
|
role: 'user',
|
||||||
|
},
|
||||||
|
{ onConflict: 'email' },
|
||||||
|
)
|
||||||
|
.select('id,email,name,username,role')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (insertError || !insertedUser) {
|
if (insertError || !insertedUser) {
|
||||||
@@ -242,6 +401,7 @@ authRouter.post('/oauth', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = createToken(userRecord);
|
const token = createToken(userRecord);
|
||||||
|
emitStats();
|
||||||
return res.json({ token, user: mapUserRecord(userRecord) });
|
return res.json({ token, user: mapUserRecord(userRecord) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Google OAuth hatası:', error);
|
console.error('Google OAuth hatası:', error);
|
||||||
@@ -252,7 +412,7 @@ authRouter.post('/oauth', async (req, res) => {
|
|||||||
authRouter.get('/me', authMiddleware, async (req, res) => {
|
authRouter.get('/me', authMiddleware, async (req, res) => {
|
||||||
const { data: userRecord, error } = await supabase
|
const { data: userRecord, error } = await supabase
|
||||||
.from(USERS_TABLE)
|
.from(USERS_TABLE)
|
||||||
.select('id,email,name,username')
|
.select('id,email,name,username,role')
|
||||||
.eq('id', req.user.sub)
|
.eq('id', req.user.sub)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -282,6 +442,24 @@ authRouter.post('/forgot-password', async (req, res) => {
|
|||||||
|
|
||||||
app.use('/auth', authRouter);
|
app.use('/auth', authRouter);
|
||||||
|
|
||||||
|
app.get('/admin/translation-models', authMiddleware, adminMiddleware, (_req, res) => {
|
||||||
|
return res.json({
|
||||||
|
active: getActiveTranslationModel(),
|
||||||
|
models: listTranslationModels(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/translation-models', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
|
const { model } = req.body || {};
|
||||||
|
try {
|
||||||
|
const newModel = setActiveTranslationModel(model);
|
||||||
|
emitAdminEvent('translate:model-changed', { active: newModel });
|
||||||
|
return res.json({ active: newModel, models: listTranslationModels() });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ message: error.message || 'Model değiştirilemedi.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/translate', authMiddleware, async (req, res) => {
|
app.post('/translate', authMiddleware, async (req, res) => {
|
||||||
const { text } = req.body || {};
|
const { text } = req.body || {};
|
||||||
if (!text || !text.trim()) {
|
if (!text || !text.trim()) {
|
||||||
@@ -290,11 +468,23 @@ app.post('/translate', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
console.log('[Translate] İstek alındı', { length: text.length, snippet: text.slice(0, 60) });
|
console.log('[Translate] İstek alındı', { length: text.length, snippet: text.slice(0, 60) });
|
||||||
try {
|
try {
|
||||||
const translated = await translateWithGlm(text);
|
const translated = await translateWithActiveModel(text);
|
||||||
console.log('[Translate] Çeviri başarıyla döndü');
|
const model = getActiveTranslationModel();
|
||||||
return res.json({ text: translated });
|
console.log('[Translate] Çeviri başarıyla döndü', { model });
|
||||||
|
emitAdminEvent('translate:completed', {
|
||||||
|
userId: req.user?.sub,
|
||||||
|
email: req.user?.email,
|
||||||
|
length: text.length,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
return res.json({ text: translated, model });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GLM çeviri hatası:', error);
|
console.error('GLM çeviri hatası:', error);
|
||||||
|
emitAdminEvent('translate:failed', {
|
||||||
|
userId: req.user?.sub,
|
||||||
|
email: req.user?.email,
|
||||||
|
message: error?.message,
|
||||||
|
});
|
||||||
return res.status(500).json({ message: error.message || 'Çeviri tamamlanamadı.' });
|
return res.status(500).json({ message: error.message || 'Çeviri tamamlanamadı.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -369,9 +559,21 @@ app.post('/generate-epub', authMiddleware, async (req, res) => {
|
|||||||
if (coverPath) {
|
if (coverPath) {
|
||||||
await fs.unlink(coverPath).catch(() => {});
|
await fs.unlink(coverPath).catch(() => {});
|
||||||
}
|
}
|
||||||
|
emitAdminEvent('epub:generated', {
|
||||||
|
userId: req.user?.sub,
|
||||||
|
email: req.user?.email,
|
||||||
|
filename,
|
||||||
|
byteLength: buffer.length,
|
||||||
|
language,
|
||||||
|
});
|
||||||
res.json({ filename, data: buffer.toString('base64') });
|
res.json({ filename, data: buffer.toString('base64') });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('EPUB generation failed:', error);
|
console.error('EPUB generation failed:', error);
|
||||||
|
emitAdminEvent('epub:failed', {
|
||||||
|
userId: req.user?.sub,
|
||||||
|
email: req.user?.email,
|
||||||
|
message: error?.message,
|
||||||
|
});
|
||||||
res.status(500).json({ message: 'EPUB generation failed' });
|
res.status(500).json({ message: 'EPUB generation failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -380,6 +582,6 @@ app.get('/', (_, res) => {
|
|||||||
res.json({ status: 'ok' });
|
res.json({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`imgPub EPUB server listening on port ${PORT}`);
|
console.log(`imgPub EPUB server listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"epub-gen": "^0.1.0",
|
"epub-gen": "^0.1.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
"nodemon": "^3.1.4",
|
"nodemon": "^3.1.4",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ const extractContent = (payload) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isGlmConfigured = () => Boolean(GLM_API_KEY);
|
||||||
|
|
||||||
export const translateWithGlm = async (text) => {
|
export const translateWithGlm = async (text) => {
|
||||||
if (!GLM_API_KEY) {
|
if (!isGlmConfigured()) {
|
||||||
throw new Error('ZAI_GLM_API_KEY veya ANTHROPIC_API_KEY tanımlı değil.');
|
throw new Error('ZAI_GLM_API_KEY veya ANTHROPIC_API_KEY tanımlı değil.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
server/src/services/translationService.js
Normal file
77
server/src/services/translationService.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { translateWithGlm, isGlmConfigured } from './glmClient.js';
|
||||||
|
|
||||||
|
const AVAILABLE_PROVIDERS = {
|
||||||
|
glm: {
|
||||||
|
id: 'glm',
|
||||||
|
label: 'GLM / Anthropic',
|
||||||
|
isConfigured: () => isGlmConfigured(),
|
||||||
|
translate: translateWithGlm,
|
||||||
|
envKeys: ['ZAI_GLM_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
id: 'openai',
|
||||||
|
label: 'OpenAI ChatGPT',
|
||||||
|
isConfigured: () => Boolean(process.env.OPENAI_API_KEY),
|
||||||
|
translate: async () => {
|
||||||
|
throw new Error('OpenAI çeviri modeli henüz yapılandırılmadı.');
|
||||||
|
},
|
||||||
|
envKeys: ['OPENAI_API_KEY'],
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
id: 'deepseek',
|
||||||
|
label: 'DeepSeek',
|
||||||
|
isConfigured: () => Boolean(process.env.DEEPSEEK_API_KEY),
|
||||||
|
translate: async () => {
|
||||||
|
throw new Error('DeepSeek çeviri modeli henüz yapılandırılmadı.');
|
||||||
|
},
|
||||||
|
envKeys: ['DEEPSEEK_API_KEY'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedModel = (value) => (value || '').toString().trim().toLowerCase();
|
||||||
|
|
||||||
|
const resolveInitialModel = () => {
|
||||||
|
const preferred = normalizedModel(process.env.TRANSLATION_ACTIVE_MODEL || 'glm');
|
||||||
|
const preferredProvider = AVAILABLE_PROVIDERS[preferred];
|
||||||
|
if (preferredProvider && preferredProvider.isConfigured()) {
|
||||||
|
return preferredProvider.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstConfigured = Object.values(AVAILABLE_PROVIDERS).find((provider) =>
|
||||||
|
provider.isConfigured(),
|
||||||
|
);
|
||||||
|
return firstConfigured ? firstConfigured.id : 'glm';
|
||||||
|
};
|
||||||
|
|
||||||
|
let activeModel = resolveInitialModel();
|
||||||
|
|
||||||
|
export const getActiveTranslationModel = () => activeModel;
|
||||||
|
|
||||||
|
export const setActiveTranslationModel = (modelId) => {
|
||||||
|
const provider = AVAILABLE_PROVIDERS[normalizedModel(modelId)];
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Geçersiz çeviri modeli.');
|
||||||
|
}
|
||||||
|
if (!provider.isConfigured()) {
|
||||||
|
throw new Error(`${provider.label} için API anahtarı tanımlı değil.`);
|
||||||
|
}
|
||||||
|
activeModel = provider.id;
|
||||||
|
return activeModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listTranslationModels = () =>
|
||||||
|
Object.values(AVAILABLE_PROVIDERS).map((provider) => ({
|
||||||
|
id: provider.id,
|
||||||
|
label: provider.label,
|
||||||
|
configured: provider.isConfigured(),
|
||||||
|
active: provider.id === activeModel,
|
||||||
|
requiredEnv: provider.envKeys,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const translateWithActiveModel = async (text) => {
|
||||||
|
const provider = AVAILABLE_PROVIDERS[activeModel];
|
||||||
|
if (!provider || !provider.isConfigured()) {
|
||||||
|
throw new Error('Aktif çeviri modeli yapılandırılmadı.');
|
||||||
|
}
|
||||||
|
return provider.translate(text);
|
||||||
|
};
|
||||||
111
src/App.jsx
111
src/App.jsx
@@ -15,10 +15,11 @@ import {
|
|||||||
Stepper,
|
Stepper,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAppStore } from './store/useAppStore';
|
import { useAppStore } from './store/useAppStore';
|
||||||
import { fetchCurrentUser, loginWithGoogle, logoutUser } from './utils/authApi';
|
import { fetchCurrentUser, loginWithGoogle, logoutUser } from './utils/authApi';
|
||||||
import { supabaseClient } from './lib/supabaseClient';
|
import { supabaseClient } from './lib/supabaseClient';
|
||||||
|
import { createSocketClient } from './lib/socketClient';
|
||||||
|
|
||||||
export const wizardSteps = [
|
export const wizardSteps = [
|
||||||
{ label: 'Yükle', path: '/' },
|
{ label: 'Yükle', path: '/' },
|
||||||
@@ -43,6 +44,9 @@ const App = () => {
|
|||||||
const currentUser = useAppStore((state) => state.currentUser);
|
const currentUser = useAppStore((state) => state.currentUser);
|
||||||
const authToken = useAppStore((state) => state.authToken);
|
const authToken = useAppStore((state) => state.authToken);
|
||||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
|
const isAdminPage = location.pathname === '/myadminpage';
|
||||||
|
const isAdmin = (currentUser?.role || 'user') === 'admin';
|
||||||
|
const presenceSocket = useRef(null);
|
||||||
|
|
||||||
const handleSnackbarClose = (_, reason) => {
|
const handleSnackbarClose = (_, reason) => {
|
||||||
if (reason === 'clickaway') return;
|
if (reason === 'clickaway') return;
|
||||||
@@ -53,6 +57,29 @@ const App = () => {
|
|||||||
initializeAuth();
|
initializeAuth();
|
||||||
}, [initializeAuth]);
|
}, [initializeAuth]);
|
||||||
|
|
||||||
|
// Pasif kullanıcılar için arka planda socket bağlantısı (aktif kullanıcı sayımı)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authToken) {
|
||||||
|
presenceSocket.current?.disconnect();
|
||||||
|
presenceSocket.current = null;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bağlantı varsa önce kapat
|
||||||
|
if (presenceSocket.current) {
|
||||||
|
presenceSocket.current.disconnect();
|
||||||
|
presenceSocket.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createSocketClient(authToken);
|
||||||
|
presenceSocket.current = client;
|
||||||
|
client.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client.disconnect();
|
||||||
|
};
|
||||||
|
}, [authToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncUser = async () => {
|
const syncUser = async () => {
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
@@ -184,6 +211,16 @@ const App = () => {
|
|||||||
<Box component="i" className="fa-solid fa-chevron-down" sx={{ fontSize: '0.85rem', color: 'inherit' }} />
|
<Box component="i" className="fa-solid fa-chevron-down" sx={{ fontSize: '0.85rem', color: 'inherit' }} />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={handleMenuClose}>
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={handleMenuClose}>
|
||||||
|
{isAdmin && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleMenuClose();
|
||||||
|
navigate('/myadminpage');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yönetim
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem disabled>Hesap Ayarlarım</MenuItem>
|
<MenuItem disabled>Hesap Ayarlarım</MenuItem>
|
||||||
<MenuItem disabled>EPUB'larım</MenuItem>
|
<MenuItem disabled>EPUB'larım</MenuItem>
|
||||||
<MenuItem onClick={handleLogout}>Çıkış</MenuItem>
|
<MenuItem onClick={handleLogout}>Çıkış</MenuItem>
|
||||||
@@ -220,40 +257,44 @@ const App = () => {
|
|||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
<Container maxWidth="lg" sx={{ pt: 14, pb: 6 }}>
|
<Container maxWidth="lg" sx={{ pt: 14, pb: 6 }}>
|
||||||
<Box mb={5} textAlign="center">
|
{!isAdminPage && (
|
||||||
<Typography
|
<>
|
||||||
variant="h3"
|
<Box mb={5} textAlign="center">
|
||||||
sx={{
|
<Typography
|
||||||
fontWeight: 700,
|
variant="h3"
|
||||||
color: '#1C1815',
|
sx={{
|
||||||
letterSpacing: -0.5,
|
fontWeight: 700,
|
||||||
fontSize: { xs: '1.9rem', md: '2.6rem' },
|
color: '#1C1815',
|
||||||
}}
|
letterSpacing: -0.5,
|
||||||
>
|
fontSize: { xs: '1.9rem', md: '2.6rem' },
|
||||||
Tek ekranda görselden EPUB'a kadar tüm sihirbaz adımları
|
}}
|
||||||
</Typography>
|
>
|
||||||
<Typography
|
Tek ekranda görselden EPUB'a kadar tüm sihirbaz adımları
|
||||||
sx={{
|
</Typography>
|
||||||
mt: 1.5,
|
<Typography
|
||||||
color: '#5A5751',
|
sx={{
|
||||||
fontSize: { xs: '1rem', md: '1.2rem' },
|
mt: 1.5,
|
||||||
lineHeight: 1.6,
|
color: '#5A5751',
|
||||||
maxWidth: 720,
|
fontSize: { xs: '1rem', md: '1.2rem' },
|
||||||
mx: 'auto',
|
lineHeight: 1.6,
|
||||||
}}
|
maxWidth: 720,
|
||||||
>
|
mx: 'auto',
|
||||||
Kapak seç, crop alanını belirle, OCR ile Türkçe metinleri koru ve sonucunu tek tıkla EPUB olarak indir. imgpub tüm işlemleri tek bir akışta toplar, hızlı ve modern bir deneyim sunar.
|
}}
|
||||||
</Typography>
|
>
|
||||||
</Box>
|
Kapak seç, crop alanını belirle, OCR ile Türkçe metinleri koru ve sonucunu tek tıkla EPUB olarak indir. imgpub tüm işlemleri tek bir akışta toplar, hızlı ve modern bir deneyim sunar.
|
||||||
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }} elevation={0}>
|
</Typography>
|
||||||
<Stepper activeStep={activeStep} alternativeLabel>
|
</Box>
|
||||||
{wizardSteps.map((step) => (
|
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }} elevation={0}>
|
||||||
<Step key={step.path} onClick={() => navigate(step.path)} sx={{ cursor: 'pointer' }}>
|
<Stepper activeStep={activeStep} alternativeLabel>
|
||||||
<StepLabel>{step.label}</StepLabel>
|
{wizardSteps.map((step) => (
|
||||||
</Step>
|
<Step key={step.path} onClick={() => navigate(step.path)} sx={{ cursor: 'pointer' }}>
|
||||||
))}
|
<StepLabel>{step.label}</StepLabel>
|
||||||
</Stepper>
|
</Step>
|
||||||
</Paper>
|
))}
|
||||||
|
</Stepper>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Paper sx={{ p: { xs: 2, md: 4 }, minHeight: 400 }} elevation={0}>
|
<Paper sx={{ p: { xs: 2, md: 4 }, minHeight: 400 }} elevation={0}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
87
src/lib/socketClient.js
Normal file
87
src/lib/socketClient.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
const SOCKET_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
export const createSocketClient = (token) => {
|
||||||
|
const socket = io(SOCKET_URL, {
|
||||||
|
autoConnect: false,
|
||||||
|
transports: ['websocket'],
|
||||||
|
withCredentials: true,
|
||||||
|
auth: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.warn('[Socket] Bağlantı hatası', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
console.info('[Socket] Bağlantı koptu', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connection:ready', (payload) => {
|
||||||
|
console.info('[Socket] Hazır', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (!socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
if (socket.connected) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeAdminEvents = (handlers = {}) => {
|
||||||
|
const offFns = [];
|
||||||
|
const register = (event, fn) => {
|
||||||
|
socket.on(event, fn);
|
||||||
|
offFns.push(() => socket.off(event, fn));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (handlers.onEpubGenerated) {
|
||||||
|
register('epub:generated', handlers.onEpubGenerated);
|
||||||
|
}
|
||||||
|
if (handlers.onEpubFailed) {
|
||||||
|
register('epub:failed', handlers.onEpubFailed);
|
||||||
|
}
|
||||||
|
if (handlers.onTranslateCompleted) {
|
||||||
|
register('translate:completed', handlers.onTranslateCompleted);
|
||||||
|
}
|
||||||
|
if (handlers.onTranslateFailed) {
|
||||||
|
register('translate:failed', handlers.onTranslateFailed);
|
||||||
|
}
|
||||||
|
if (handlers.onStatsUpdate) {
|
||||||
|
register('stats:update', handlers.onStatsUpdate);
|
||||||
|
}
|
||||||
|
if (handlers.onModelChanged) {
|
||||||
|
register('translate:model-changed', handlers.onModelChanged);
|
||||||
|
}
|
||||||
|
if (handlers.onUserRegistered) {
|
||||||
|
register('user:registered', handlers.onUserRegistered);
|
||||||
|
}
|
||||||
|
if (handlers.onPresenceUpdate) {
|
||||||
|
register('presence:update', handlers.onPresenceUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
offFns.forEach((off) => off());
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
subscribeAdminEvents,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureSocket = (existingSocket, token) => {
|
||||||
|
if (existingSocket) {
|
||||||
|
return existingSocket;
|
||||||
|
}
|
||||||
|
return createSocketClient(token);
|
||||||
|
};
|
||||||
21
src/main.jsx
21
src/main.jsx
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
|
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
|
||||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||||
import App, { wizardSteps } from './App';
|
import App, { wizardSteps } from './App';
|
||||||
import UploadStep from './components/UploadStep';
|
import UploadStep from './components/UploadStep';
|
||||||
import CropStep from './components/CropStep';
|
import CropStep from './components/CropStep';
|
||||||
@@ -13,6 +13,8 @@ import DownloadStep from './components/DownloadStep';
|
|||||||
import Login from './pages/auth/Login';
|
import Login from './pages/auth/Login';
|
||||||
import Register from './pages/auth/Register';
|
import Register from './pages/auth/Register';
|
||||||
import ForgotPassword from './pages/auth/ForgotPassword';
|
import ForgotPassword from './pages/auth/ForgotPassword';
|
||||||
|
import AdminPage from './pages/AdminPage';
|
||||||
|
import { useAppStore } from './store/useAppStore';
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@@ -126,6 +128,22 @@ const theme = createTheme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AdminGate = () => {
|
||||||
|
const currentUser = useAppStore((state) => state.currentUser);
|
||||||
|
const authReady = useAppStore((state) => state.authReady);
|
||||||
|
|
||||||
|
if (!authReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!currentUser) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
if ((currentUser.role || 'user') !== 'admin') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return <AdminPage />;
|
||||||
|
};
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -138,6 +156,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: wizardSteps[4].path, element: <TranslationStep /> },
|
{ path: wizardSteps[4].path, element: <TranslationStep /> },
|
||||||
{ path: wizardSteps[5].path, element: <EpubStep /> },
|
{ path: wizardSteps[5].path, element: <EpubStep /> },
|
||||||
{ path: wizardSteps[6].path, element: <DownloadStep /> },
|
{ path: wizardSteps[6].path, element: <DownloadStep /> },
|
||||||
|
{ path: '/myadminpage', element: <AdminGate /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/login', element: <Login /> },
|
{ path: '/login', element: <Login /> },
|
||||||
|
|||||||
386
src/pages/AdminPage.jsx
Normal file
386
src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box, Button, Chip, Grid, Paper, Stack, Typography } from '@mui/material';
|
||||||
|
import { createSocketClient } from '../lib/socketClient';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
import { fetchTranslationModelsAdmin, setActiveTranslationModelAdmin } from '../utils/adminApi';
|
||||||
|
|
||||||
|
const AdminPage = () => {
|
||||||
|
const authToken = useAppStore((state) => state.authToken);
|
||||||
|
const currentUser = useAppStore((state) => state.currentUser);
|
||||||
|
const [stats, setStats] = useState({ activeUsers: 0, totalUsers: 0 });
|
||||||
|
const [models, setModels] = useState([]);
|
||||||
|
const [loadingModel, setLoadingModel] = useState(false);
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
|
||||||
|
const activeModel = useMemo(() => models.find((m) => m.active)?.id || null, [models]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authToken) return undefined;
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchTranslationModelsAdmin(authToken);
|
||||||
|
setModels(response.models || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Modeller alınamadı:', error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadModels();
|
||||||
|
}, [authToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authToken) return undefined;
|
||||||
|
const client = createSocketClient(authToken);
|
||||||
|
client.connect();
|
||||||
|
const off = client.subscribeAdminEvents({
|
||||||
|
onStatsUpdate: (payload) => {
|
||||||
|
setStats({
|
||||||
|
activeUsers: payload?.activeUsers ?? 0,
|
||||||
|
totalUsers: payload?.totalUsers ?? 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onModelChanged: (payload) => {
|
||||||
|
setModels((prev) =>
|
||||||
|
prev.map((model) => ({
|
||||||
|
...model,
|
||||||
|
active: model.id === payload?.active,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onEpubGenerated: (payload) => {
|
||||||
|
pushLog('EPUB', `EPUB üretildi: ${payload?.filename || '-'} (${payload?.email || 'kullanıcı'})`);
|
||||||
|
},
|
||||||
|
onEpubFailed: (payload) => {
|
||||||
|
pushLog('EPUB', `EPUB başarısız: ${payload?.message || 'bilinmiyor'} (${payload?.email || 'kullanıcı'})`, true);
|
||||||
|
},
|
||||||
|
onTranslateCompleted: (payload) => {
|
||||||
|
pushLog('ÇEVİRİ', `Çeviri tamamlandı (${payload?.model || 'aktif'}) - ${payload?.email || 'kullanıcı'}`);
|
||||||
|
},
|
||||||
|
onTranslateFailed: (payload) => {
|
||||||
|
pushLog('ÇEVİRİ', `Çeviri hatası: ${payload?.message || 'bilinmiyor'} (${payload?.email || 'kullanıcı'})`, true);
|
||||||
|
},
|
||||||
|
onUserRegistered: (payload) => {
|
||||||
|
const name = payload?.name || '';
|
||||||
|
const info = name ? `${payload?.email || 'bilinmiyor'} | ${name}` : payload?.email || 'bilinmiyor';
|
||||||
|
pushLog('KAYIT', `Yeni kullanıcı: ${info}`);
|
||||||
|
},
|
||||||
|
onPresenceUpdate: (payload) => {
|
||||||
|
if (payload?.email && currentUser?.email && payload.email === currentUser.email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = payload?.active ? 'Aktif oldu' : 'Çıkış yaptı';
|
||||||
|
const namePart = payload?.name ? ` | ${payload.name}` : '';
|
||||||
|
pushLog('OTURUM', `${action}: ${payload?.email || 'kullanıcı'}${namePart}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
off?.();
|
||||||
|
client.disconnect();
|
||||||
|
};
|
||||||
|
}, [authToken, currentUser]);
|
||||||
|
|
||||||
|
const pushLog = (tag, message, isError = false) => {
|
||||||
|
setLogs((prev) => [
|
||||||
|
{
|
||||||
|
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
ts: new Date(),
|
||||||
|
tag,
|
||||||
|
message,
|
||||||
|
isError,
|
||||||
|
},
|
||||||
|
...prev.slice(0, 49),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSwitch = async (modelId) => {
|
||||||
|
setLoadingModel(true);
|
||||||
|
try {
|
||||||
|
const response = await setActiveTranslationModelAdmin(modelId, authToken);
|
||||||
|
setModels(response.models || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Model değiştirilemedi:', error.message);
|
||||||
|
} finally {
|
||||||
|
setLoadingModel(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayLogs = useMemo(() => {
|
||||||
|
if (logs.length >= 5) return logs;
|
||||||
|
const base = [...logs];
|
||||||
|
while (base.length < 5) {
|
||||||
|
const idx = base.length;
|
||||||
|
base.push({
|
||||||
|
id: `placeholder-${idx}`,
|
||||||
|
ts: null,
|
||||||
|
tag: '',
|
||||||
|
message: idx === 0 ? 'Log bekleniyor...' : '',
|
||||||
|
isError: false,
|
||||||
|
placeholder: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 10, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
minWidth: 360,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip label="Admin Yetkisinde" color="secondary" size="small" />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||||
|
Yönetim Alanı
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" align="center">
|
||||||
|
Canlı veriler soket bağlantısı üzerinden bu alanda gösterilecek.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
Şimdilik altyapı hazır, ilerleyen adımda canlı akışı ekleyeceğiz.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderColor: '#E7C179',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Toplam Kullanıcı
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 700, mt: 0.5 }}>
|
||||||
|
{stats.totalUsers}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderColor: '#29615D',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Aktif Kullanıcı (canlı)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 700, mt: 0.5 }}>
|
||||||
|
{stats.activeUsers}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
mt: 3,
|
||||||
|
p: 2,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||||
|
Çeviri Modelleri
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{models.map((model) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={model.id}>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
borderColor: model.active ? '#29615D' : '#E0DFDC',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{model.label}
|
||||||
|
</Typography>
|
||||||
|
{model.active && <Chip label="Aktif" color="success" size="small" />}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{model.configured ? 'API anahtarı tanımlı' : 'API anahtarı eksik (.env)'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Env: {model.requiredEnv?.join(', ') || '-'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
disabled={!model.configured || model.active || loadingModel}
|
||||||
|
onClick={() => handleModelSwitch(model.id)}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
Bu modeli kullan
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
mt: 3,
|
||||||
|
p: 0,
|
||||||
|
width: '100%',
|
||||||
|
bgcolor: '#0c0d10',
|
||||||
|
color: '#d1d5db',
|
||||||
|
borderRadius: '1.2rem',
|
||||||
|
borderColor: '#1f2937',
|
||||||
|
boxShadow: '0 12px 24px rgba(0,0,0,0.28)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
px: 2,
|
||||||
|
py: 1.1,
|
||||||
|
borderBottom: '1px solid #1f2937',
|
||||||
|
bgcolor: '#111318',
|
||||||
|
borderTopLeftRadius: '1.2rem',
|
||||||
|
borderTopRightRadius: '1.2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="i"
|
||||||
|
className="fa-solid fa-terminal"
|
||||||
|
sx={{ color: '#10b981', fontSize: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#e5e7eb' }}>
|
||||||
|
Canlı Log
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`Aktif: ${stats.activeUsers}`}
|
||||||
|
size="small"
|
||||||
|
icon={<Box component="i" className="fa-solid fa-plug" sx={{ fontSize: '0.75rem', color: '#10b981' }} />}
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#112019',
|
||||||
|
color: '#a7f3d0',
|
||||||
|
'& .MuiChip-icon': { ml: 0.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`Toplam: ${stats.totalUsers}`}
|
||||||
|
size="small"
|
||||||
|
icon={<Box component="i" className="fa-solid fa-users" sx={{ fontSize: '0.75rem', color: '#93c5fd' }} />}
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#111827',
|
||||||
|
color: '#dbeafe',
|
||||||
|
'& .MuiChip-icon': { ml: 0.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxHeight: 200,
|
||||||
|
minHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
fontFamily: 'SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
|
px: 2,
|
||||||
|
py: 1.2,
|
||||||
|
my: '3px',
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: 8,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'rgba(17,19,24,0.5)',
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
background: 'rgba(0,0,0,0.15)',
|
||||||
|
marginTop: 3,
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayLogs.map((log) => {
|
||||||
|
const isPlaceholder = Boolean(log.placeholder);
|
||||||
|
const chipStyles = (() => {
|
||||||
|
if (log.tag === 'KAYIT') {
|
||||||
|
return { bg: '#0f3d2e', color: '#b9f6ca' };
|
||||||
|
}
|
||||||
|
if (log.tag === 'OTURUM') {
|
||||||
|
if (log.message?.toLowerCase().includes('aktif oldu')) {
|
||||||
|
return { bg: '#5a3200', color: '#ffd7a3' };
|
||||||
|
}
|
||||||
|
if (log.message?.toLowerCase().includes('çıkış yaptı')) {
|
||||||
|
return { bg: '#4a0f0f', color: '#fecdd3' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bg: log.isError ? '#7f1d1d' : '#1f2937', color: log.isError ? '#fecaca' : '#9ca3af' };
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={log.id}
|
||||||
|
direction="row"
|
||||||
|
spacing={isPlaceholder ? 0.25 : 0.5}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
color: log.isError ? '#fca5a5' : '#d1d5db',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
py: 0.5,
|
||||||
|
borderBottom: '1px solid #111318',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: '#6b7280', minWidth: isPlaceholder ? 0 : 60 }}>
|
||||||
|
{log.ts ? log.ts.toLocaleTimeString() : ''}
|
||||||
|
</Typography>
|
||||||
|
{log.tag ? (
|
||||||
|
<Chip
|
||||||
|
label={log.tag}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: chipStyles.bg,
|
||||||
|
color: chipStyles.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: isPlaceholder ? 0 : 4,
|
||||||
|
height: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: log.placeholder ? '#6b7280' : 'inherit',
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log.message}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminPage;
|
||||||
@@ -3,6 +3,12 @@ import { create } from 'zustand';
|
|||||||
const TOKEN_STORAGE_KEY = 'imgpub_token';
|
const TOKEN_STORAGE_KEY = 'imgpub_token';
|
||||||
const USER_STORAGE_KEY = 'imgpub_user';
|
const USER_STORAGE_KEY = 'imgpub_user';
|
||||||
|
|
||||||
|
const normalizeUser = (user) => {
|
||||||
|
if (!user) return null;
|
||||||
|
const roleValue = (user.role || 'user').toString().toLowerCase();
|
||||||
|
return { ...user, role: roleValue };
|
||||||
|
};
|
||||||
|
|
||||||
const readStoredAuth = () => {
|
const readStoredAuth = () => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { token: null, user: null };
|
return { token: null, user: null };
|
||||||
@@ -10,7 +16,7 @@ const readStoredAuth = () => {
|
|||||||
try {
|
try {
|
||||||
const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
const userRaw = window.localStorage.getItem(USER_STORAGE_KEY);
|
const userRaw = window.localStorage.getItem(USER_STORAGE_KEY);
|
||||||
const user = userRaw ? JSON.parse(userRaw) : null;
|
const user = userRaw ? normalizeUser(JSON.parse(userRaw)) : null;
|
||||||
return { token, user };
|
return { token, user };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Stored auth okunamadı', error);
|
console.warn('Stored auth okunamadı', error);
|
||||||
@@ -55,6 +61,7 @@ export const useAppStore = create((set) => ({
|
|||||||
generatedEpub: null,
|
generatedEpub: null,
|
||||||
authToken: null,
|
authToken: null,
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
|
authReady: false,
|
||||||
error: null,
|
error: null,
|
||||||
setError: (message) => set({ error: message }),
|
setError: (message) => set({ error: message }),
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
@@ -101,22 +108,25 @@ export const useAppStore = create((set) => ({
|
|||||||
initializeAuth: () => {
|
initializeAuth: () => {
|
||||||
const { token, user } = readStoredAuth();
|
const { token, user } = readStoredAuth();
|
||||||
if (token && user) {
|
if (token && user) {
|
||||||
set({ authToken: token, currentUser: user });
|
set({ authToken: token, currentUser: user, authReady: true });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
set({ authReady: true });
|
||||||
},
|
},
|
||||||
setAuthSession: ({ token, user }) => {
|
setAuthSession: ({ token, user }) => {
|
||||||
|
const normalizedUser = normalizeUser(user);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||||
window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
|
window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(normalizedUser));
|
||||||
}
|
}
|
||||||
set({ authToken: token, currentUser: user });
|
set({ authToken: token, currentUser: normalizedUser });
|
||||||
},
|
},
|
||||||
updateCurrentUser: (user) =>
|
updateCurrentUser: (user) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (typeof window !== 'undefined' && state.authToken) {
|
if (typeof window !== 'undefined' && state.authToken) {
|
||||||
window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
|
window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(normalizeUser(user)));
|
||||||
}
|
}
|
||||||
return { currentUser: user };
|
return { currentUser: normalizeUser(user) };
|
||||||
}),
|
}),
|
||||||
clearAuthSession: () => {
|
clearAuthSession: () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
14
src/utils/adminApi.js
Normal file
14
src/utils/adminApi.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { apiClient } from './apiClient';
|
||||||
|
|
||||||
|
export const fetchTranslationModelsAdmin = (token) =>
|
||||||
|
apiClient('/admin/translation-models', {
|
||||||
|
method: 'GET',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setActiveTranslationModelAdmin = (model, token) =>
|
||||||
|
apiClient('/admin/translation-models', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { model },
|
||||||
|
token,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user