From cf42825705a6c0acb9f06cf09222d946fd8539c4 Mon Sep 17 00:00:00 2001 From: szbk Date: Fri, 21 Nov 2025 01:21:37 +0300 Subject: [PATCH] =?UTF-8?q?admin=20page=20olu=C5=9Fturuldu.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + docker-compose.dev.yml | 6 + package.json | 1 + server/index.js | 236 ++++++++++++- server/package.json | 1 + server/src/services/glmClient.js | 4 +- server/src/services/translationService.js | 77 +++++ src/App.jsx | 111 +++++-- src/lib/socketClient.js | 87 +++++ src/main.jsx | 21 +- src/pages/AdminPage.jsx | 386 ++++++++++++++++++++++ src/store/useAppStore.js | 22 +- src/utils/adminApi.js | 14 + 13 files changed, 915 insertions(+), 60 deletions(-) create mode 100644 server/src/services/translationService.js create mode 100644 src/lib/socketClient.js create mode 100644 src/pages/AdminPage.jsx create mode 100644 src/utils/adminApi.js diff --git a/.env.example b/.env.example index dfd98be..a778496 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e5d033a..273f816 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/package.json b/package.json index ac4db3c..a0d7011 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/server/index.js b/server/index.js index f58bfc5..5616780 100644 --- a/server/index.js +++ b/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}`); }); diff --git a/server/package.json b/server/package.json index b56f448..d5ca6b7 100644 --- a/server/package.json +++ b/server/package.json @@ -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" } diff --git a/server/src/services/glmClient.js b/server/src/services/glmClient.js index b474ecf..299e5c1 100644 --- a/server/src/services/glmClient.js +++ b/server/src/services/glmClient.js @@ -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.'); } diff --git a/server/src/services/translationService.js b/server/src/services/translationService.js new file mode 100644 index 0000000..7306dc3 --- /dev/null +++ b/server/src/services/translationService.js @@ -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); +}; diff --git a/src/App.jsx b/src/App.jsx index c5c2dd9..20cad9f 100644 --- a/src/App.jsx +++ b/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 = () => { + {isAdmin && ( + { + handleMenuClose(); + navigate('/myadminpage'); + }} + > + Yönetim + + )} Hesap Ayarlarım EPUB'larım Çıkış @@ -220,40 +257,44 @@ const App = () => { - - - Tek ekranda görselden EPUB'a kadar tüm sihirbaz adımları - - - 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. - - - - - {wizardSteps.map((step) => ( - navigate(step.path)} sx={{ cursor: 'pointer' }}> - {step.label} - - ))} - - + {!isAdminPage && ( + <> + + + Tek ekranda görselden EPUB'a kadar tüm sihirbaz adımları + + + 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. + + + + + {wizardSteps.map((step) => ( + navigate(step.path)} sx={{ cursor: 'pointer' }}> + {step.label} + + ))} + + + + )} diff --git a/src/lib/socketClient.js b/src/lib/socketClient.js new file mode 100644 index 0000000..0a8e9d2 --- /dev/null +++ b/src/lib/socketClient.js @@ -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); +}; diff --git a/src/main.jsx b/src/main.jsx index 4c5b1be..11c1c24 100644 --- a/src/main.jsx +++ b/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 ; + } + if ((currentUser.role || 'user') !== 'admin') { + return ; + } + return ; +}; + const router = createBrowserRouter([ { path: '/', @@ -138,6 +156,7 @@ const router = createBrowserRouter([ { path: wizardSteps[4].path, element: }, { path: wizardSteps[5].path, element: }, { path: wizardSteps[6].path, element: }, + { path: '/myadminpage', element: }, ], }, { path: '/login', element: }, diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx new file mode 100644 index 0000000..a441599 --- /dev/null +++ b/src/pages/AdminPage.jsx @@ -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 ( + + + + + Yönetim Alanı + + + + Canlı veriler soket bağlantısı üzerinden bu alanda gösterilecek. + + + Şimdilik altyapı hazır, ilerleyen adımda canlı akışı ekleyeceğiz. + + + + + + + Toplam Kullanıcı + + + {stats.totalUsers} + + + + + + + Aktif Kullanıcı (canlı) + + + {stats.activeUsers} + + + + + + + Çeviri Modelleri + + + {models.map((model) => ( + + + + + {model.label} + + {model.active && } + + + {model.configured ? 'API anahtarı tanımlı' : 'API anahtarı eksik (.env)'} + + + Env: {model.requiredEnv?.join(', ') || '-'} + + + + + + ))} + + + + + + + Canlı Log + + } + sx={{ + bgcolor: '#112019', + color: '#a7f3d0', + '& .MuiChip-icon': { ml: 0.5 }, + }} + /> + } + sx={{ + bgcolor: '#111827', + color: '#dbeafe', + '& .MuiChip-icon': { ml: 0.5 }, + }} + /> + + + {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 ( + + + {log.ts ? log.ts.toLocaleTimeString() : ''} + + {log.tag ? ( + + ) : ( + + )} + + {log.message} + + + ); + })} + + + + + ); +}; + +export default AdminPage; diff --git a/src/store/useAppStore.js b/src/store/useAppStore.js index 36213ee..c9870a2 100644 --- a/src/store/useAppStore.js +++ b/src/store/useAppStore.js @@ -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') { diff --git a/src/utils/adminApi.js b/src/utils/adminApi.js new file mode 100644 index 0000000..40c0520 --- /dev/null +++ b/src/utils/adminApi.js @@ -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, + });