admin page oluşturuldu.

This commit is contained in:
2025-11-21 01:21:37 +03:00
parent 26acaccacd
commit cf42825705
13 changed files with 915 additions and 60 deletions

View File

@@ -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"

View File

@@ -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:

View File

@@ -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"
}, },

View File

@@ -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, name,
email, email,
username, username,
password_hash: 'GOOGLE_OAUTH', password_hash: 'GOOGLE_OAUTH',
}) role: 'user',
.select('id,email,name,username') },
{ 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}`);
}); });

View File

@@ -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"
} }

View File

@@ -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.');
} }

View 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);
};

View File

@@ -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,6 +257,8 @@ const App = () => {
</Container> </Container>
</Box> </Box>
<Container maxWidth="lg" sx={{ pt: 14, pb: 6 }}> <Container maxWidth="lg" sx={{ pt: 14, pb: 6 }}>
{!isAdminPage && (
<>
<Box mb={5} textAlign="center"> <Box mb={5} textAlign="center">
<Typography <Typography
variant="h3" variant="h3"
@@ -254,6 +293,8 @@ const App = () => {
))} ))}
</Stepper> </Stepper>
</Paper> </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
View 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);
};

View File

@@ -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
View 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;

View File

@@ -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
View 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,
});