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_MODEL="glm-4.6"
|
||||
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_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
|
||||
- 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:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-easy-crop": "^5.0.7",
|
||||
"react-router-dom": "^7.0.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
|
||||
236
server/index.js
236
server/index.js
@@ -1,5 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import cors from 'cors';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
@@ -9,6 +10,7 @@ import { promises as fs } from 'fs';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import Epub from 'epub-gen';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
|
||||
const requiredEnv = [
|
||||
'SUPABASE_URL',
|
||||
@@ -24,10 +26,62 @@ requiredEnv.forEach((key) => {
|
||||
});
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
||||
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 = '') => {
|
||||
try {
|
||||
@@ -57,7 +111,12 @@ const enforceClientOrigin = (req, res, next) => {
|
||||
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';
|
||||
import {
|
||||
getActiveTranslationModel,
|
||||
listTranslationModels,
|
||||
setActiveTranslationModel,
|
||||
translateWithActiveModel,
|
||||
} from './src/services/translationService.js';
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
@@ -82,6 +141,8 @@ const createToken = (user) =>
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role || 'user',
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' },
|
||||
@@ -92,8 +153,16 @@ const mapUserRecord = (record) => ({
|
||||
email: record.email,
|
||||
name: record.name,
|
||||
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 header = req.headers.authorization || '';
|
||||
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();
|
||||
authRouter.use(enforceClientOrigin);
|
||||
|
||||
@@ -142,8 +290,9 @@ authRouter.post('/register', async (req, res) => {
|
||||
email: normalizedEmail,
|
||||
username,
|
||||
password_hash: passwordHash,
|
||||
role: 'user',
|
||||
})
|
||||
.select('id,email,name,username')
|
||||
.select('id,email,name,username,role')
|
||||
.single();
|
||||
|
||||
if (insertError || !createdUser) {
|
||||
@@ -151,6 +300,12 @@ authRouter.post('/register', async (req, res) => {
|
||||
}
|
||||
|
||||
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) });
|
||||
});
|
||||
|
||||
@@ -163,7 +318,7 @@ authRouter.post('/login', async (req, res) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const { data: userRecord, error: fetchError } = await supabase
|
||||
.from(USERS_TABLE)
|
||||
.select('id,email,name,username,password_hash')
|
||||
.select('id,email,name,username,password_hash,role')
|
||||
.eq('email', normalizedEmail)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -214,7 +369,7 @@ authRouter.post('/oauth', async (req, res) => {
|
||||
|
||||
const { data: existingUser, error: fetchError } = await supabase
|
||||
.from(USERS_TABLE)
|
||||
.select('id,email,name,username')
|
||||
.select('id,email,name,username,role')
|
||||
.eq('email', email)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -226,13 +381,17 @@ authRouter.post('/oauth', async (req, res) => {
|
||||
if (!userRecord) {
|
||||
const { data: insertedUser, error: insertError } = await supabase
|
||||
.from(USERS_TABLE)
|
||||
.insert({
|
||||
name,
|
||||
email,
|
||||
username,
|
||||
password_hash: 'GOOGLE_OAUTH',
|
||||
})
|
||||
.select('id,email,name,username')
|
||||
.upsert(
|
||||
{
|
||||
name,
|
||||
email,
|
||||
username,
|
||||
password_hash: 'GOOGLE_OAUTH',
|
||||
role: 'user',
|
||||
},
|
||||
{ onConflict: 'email' },
|
||||
)
|
||||
.select('id,email,name,username,role')
|
||||
.single();
|
||||
|
||||
if (insertError || !insertedUser) {
|
||||
@@ -242,6 +401,7 @@ authRouter.post('/oauth', async (req, res) => {
|
||||
}
|
||||
|
||||
const token = createToken(userRecord);
|
||||
emitStats();
|
||||
return res.json({ token, user: mapUserRecord(userRecord) });
|
||||
} catch (error) {
|
||||
console.error('Google OAuth hatası:', error);
|
||||
@@ -252,7 +412,7 @@ authRouter.post('/oauth', async (req, res) => {
|
||||
authRouter.get('/me', authMiddleware, async (req, res) => {
|
||||
const { data: userRecord, error } = await supabase
|
||||
.from(USERS_TABLE)
|
||||
.select('id,email,name,username')
|
||||
.select('id,email,name,username,role')
|
||||
.eq('id', req.user.sub)
|
||||
.single();
|
||||
|
||||
@@ -282,6 +442,24 @@ authRouter.post('/forgot-password', async (req, res) => {
|
||||
|
||||
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) => {
|
||||
const { text } = req.body || {};
|
||||
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) });
|
||||
try {
|
||||
const translated = await translateWithGlm(text);
|
||||
console.log('[Translate] Çeviri başarıyla döndü');
|
||||
return res.json({ text: translated });
|
||||
const translated = await translateWithActiveModel(text);
|
||||
const model = getActiveTranslationModel();
|
||||
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) {
|
||||
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ı.' });
|
||||
}
|
||||
});
|
||||
@@ -369,9 +559,21 @@ app.post('/generate-epub', authMiddleware, async (req, res) => {
|
||||
if (coverPath) {
|
||||
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') });
|
||||
} catch (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' });
|
||||
}
|
||||
});
|
||||
@@ -380,6 +582,6 @@ app.get('/', (_, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`imgPub EPUB server listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"epub-gen": "^0.1.0",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"nodemon": "^3.1.4",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
|
||||
@@ -78,8 +78,10 @@ const extractContent = (payload) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
export const isGlmConfigured = () => Boolean(GLM_API_KEY);
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
import { fetchCurrentUser, loginWithGoogle, logoutUser } from './utils/authApi';
|
||||
import { supabaseClient } from './lib/supabaseClient';
|
||||
import { createSocketClient } from './lib/socketClient';
|
||||
|
||||
export const wizardSteps = [
|
||||
{ label: 'Yükle', path: '/' },
|
||||
@@ -43,6 +44,9 @@ const App = () => {
|
||||
const currentUser = useAppStore((state) => state.currentUser);
|
||||
const authToken = useAppStore((state) => state.authToken);
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
const isAdminPage = location.pathname === '/myadminpage';
|
||||
const isAdmin = (currentUser?.role || 'user') === 'admin';
|
||||
const presenceSocket = useRef(null);
|
||||
|
||||
const handleSnackbarClose = (_, reason) => {
|
||||
if (reason === 'clickaway') return;
|
||||
@@ -53,6 +57,29 @@ const App = () => {
|
||||
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(() => {
|
||||
const syncUser = async () => {
|
||||
if (!authToken) return;
|
||||
@@ -184,6 +211,16 @@ const App = () => {
|
||||
<Box component="i" className="fa-solid fa-chevron-down" sx={{ fontSize: '0.85rem', color: 'inherit' }} />
|
||||
</Typography>
|
||||
<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>EPUB'larım</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>Çıkış</MenuItem>
|
||||
@@ -220,40 +257,44 @@ const App = () => {
|
||||
</Container>
|
||||
</Box>
|
||||
<Container maxWidth="lg" sx={{ pt: 14, pb: 6 }}>
|
||||
<Box mb={5} textAlign="center">
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
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
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
color: '#5A5751',
|
||||
fontSize: { xs: '1rem', md: '1.2rem' },
|
||||
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>
|
||||
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }} elevation={0}>
|
||||
<Stepper activeStep={activeStep} alternativeLabel>
|
||||
{wizardSteps.map((step) => (
|
||||
<Step key={step.path} onClick={() => navigate(step.path)} sx={{ cursor: 'pointer' }}>
|
||||
<StepLabel>{step.label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
{!isAdminPage && (
|
||||
<>
|
||||
<Box mb={5} textAlign="center">
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
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
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
color: '#5A5751',
|
||||
fontSize: { xs: '1rem', md: '1.2rem' },
|
||||
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>
|
||||
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }} elevation={0}>
|
||||
<Stepper activeStep={activeStep} alternativeLabel>
|
||||
{wizardSteps.map((step) => (
|
||||
<Step key={step.path} onClick={() => navigate(step.path)} sx={{ cursor: 'pointer' }}>
|
||||
<StepLabel>{step.label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
<Paper sx={{ p: { xs: 2, md: 4 }, minHeight: 400 }} elevation={0}>
|
||||
<Outlet />
|
||||
</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 ReactDOM from 'react-dom/client';
|
||||
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 UploadStep from './components/UploadStep';
|
||||
import CropStep from './components/CropStep';
|
||||
@@ -13,6 +13,8 @@ import DownloadStep from './components/DownloadStep';
|
||||
import Login from './pages/auth/Login';
|
||||
import Register from './pages/auth/Register';
|
||||
import ForgotPassword from './pages/auth/ForgotPassword';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
|
||||
const theme = createTheme({
|
||||
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([
|
||||
{
|
||||
path: '/',
|
||||
@@ -138,6 +156,7 @@ const router = createBrowserRouter([
|
||||
{ path: wizardSteps[4].path, element: <TranslationStep /> },
|
||||
{ path: wizardSteps[5].path, element: <EpubStep /> },
|
||||
{ path: wizardSteps[6].path, element: <DownloadStep /> },
|
||||
{ path: '/myadminpage', element: <AdminGate /> },
|
||||
],
|
||||
},
|
||||
{ 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 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 = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { token: null, user: null };
|
||||
@@ -10,7 +16,7 @@ const readStoredAuth = () => {
|
||||
try {
|
||||
const token = window.localStorage.getItem(TOKEN_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 };
|
||||
} catch (error) {
|
||||
console.warn('Stored auth okunamadı', error);
|
||||
@@ -55,6 +61,7 @@ export const useAppStore = create((set) => ({
|
||||
generatedEpub: null,
|
||||
authToken: null,
|
||||
currentUser: null,
|
||||
authReady: false,
|
||||
error: null,
|
||||
setError: (message) => set({ error: message }),
|
||||
clearError: () => set({ error: null }),
|
||||
@@ -101,22 +108,25 @@ export const useAppStore = create((set) => ({
|
||||
initializeAuth: () => {
|
||||
const { token, user } = readStoredAuth();
|
||||
if (token && user) {
|
||||
set({ authToken: token, currentUser: user });
|
||||
set({ authToken: token, currentUser: user, authReady: true });
|
||||
return;
|
||||
}
|
||||
set({ authReady: true });
|
||||
},
|
||||
setAuthSession: ({ token, user }) => {
|
||||
const normalizedUser = normalizeUser(user);
|
||||
if (typeof window !== 'undefined') {
|
||||
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) =>
|
||||
set((state) => {
|
||||
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: () => {
|
||||
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