bugos userhandling deck handling keszen van #106

Merged
Donat merged 1 commits from admin into main 2025-11-26 08:26:21 +01:00
6 changed files with 1026 additions and 4 deletions
Showing only changes of commit 66287a84c6 - Show all commits
+4
View File
@@ -25,6 +25,7 @@ import ChooseDeck from "./pages/Game/ChooseDeck"
import PlayerSetup from "./pages/Game/PlayerSetup"
import GameModalsDemo from "./pages/Game/GameModalsDemo"
import { GameWebSocketProvider } from "./contexts/GameWebSocketContext"
import Admin from "./pages/Admin/Admin"
function App() {
const [isMobile, setIsMobile] = useState(false)
@@ -78,6 +79,9 @@ function App() {
<Route path={ROUTES.REPORTS} element={<Reports />} />
<Route path={ROUTES.CHOOSE_DECK} element={<ChooseDeck />} />
<Route path={ROUTES.PLAYER_SETUP} element={<PlayerSetup />} />
<Route path={ROUTES.ADMIN} element={<Admin />} />
</Routes>
</Router>
</GameWebSocketProvider>
+173
View File
@@ -0,0 +1,173 @@
// ...new file...
import { apiClient } from "./userApi"
/**
* Ellenőrzi localStorage.authLevel === "1". Ha nem, redirect a megadott útvonalra.
* Visszatér: true (admin) | false (nem admin, redirect történt)
*/
export function ensureAdminOrRedirect(redirectPath = "/") {
try {
const level = localStorage.getItem("authLevel")
const isAdmin = String(level) === "1"
if (!isAdmin) {
window.location.replace(redirectPath)
return false
}
return true
} catch (err) {
window.location.replace(redirectPath)
return false
}
}
/**
* Admin: paginált paklik lekérése (admin endpoint)
* Visszatér: tömb of decks (raw vagy {decks: []} formátum kezelve)
*/
export async function adminListDecks(from = 0, to = 99) {
if (!ensureAdminOrRedirect("/")) return []
try {
const resp = await apiClient.get(`/admin/decks/page/${from}/${to}`)
const data = resp?.data
return data?.decks || data || []
} catch (err) {
console.error("adminApi.adminListDecks error:", err)
throw err
}
}
/**
* Admin: keresés admin végponton.
* Ha nincs külön kereső végpont, a backend/resp lehet tömb vagy {decks: []}
* Visszatér: tömb of decks
*/
export async function adminSearchDecks(query = "", limit = 100, offset = 0) {
if (!ensureAdminOrRedirect("/")) return []
try {
if (!query) {
// fallback: list page
return await adminListDecks(offset, offset + (limit - 1))
}
// Preferált: admin search endpoint /admin/decks/search/{term}
const resp = await apiClient.get(`/admin/decks/search/${encodeURIComponent(query)}`, {
params: { limit, offset }
})
const data = resp?.data
return data?.decks || data || []
} catch (err) {
console.error("adminApi.adminSearchDecks error:", err)
throw err
}
}
/**
* Admin: paginált felhasználók lekérése
* Visszatér: { users: [], pagination: {...} } vagy tömb of users
*/
export async function adminListUsers(from = 0, to = 99, includeDeleted = false) {
if (!ensureAdminOrRedirect("/")) return { users: [], pagination: { from, to, returned: 0, totalCount: 0, includeDeleted } }
try {
const resp = await apiClient.get(`/admin/users/page/${from}/${to}`, {
params: { includeDeleted }
})
return resp?.data || { users: [] }
} catch (err) {
console.error("adminApi.adminListUsers error:", err)
throw err
}
}
/**
* Admin: Get user by id
*/
export async function adminGetUserById(userId, includeDeleted = false) {
if (!ensureAdminOrRedirect("/")) return null
try {
const resp = await apiClient.get(`/admin/users/${userId}`, { params: { includeDeleted } })
return resp?.data || null
} catch (err) {
console.error("adminApi.adminGetUserById error:", err)
throw err
}
}
/**
* Admin: Search users
*/
export async function adminSearchUsers(query = "", includeDeleted = false, limit = 100, offset = 0) {
if (!ensureAdminOrRedirect("/")) return []
try {
if (!query) {
// fallback: list page
const to = offset + (limit - 1)
const res = await adminListUsers(offset, to, includeDeleted)
// adminListUsers may return object or array
if (Array.isArray(res)) return res
return res.users || []
}
const resp = await apiClient.get(`/admin/users/search/${encodeURIComponent(query)}`, {
params: { includeDeleted, limit, offset }
})
const data = resp?.data
// prefer array but handle wrapped
return data?.users || data || []
} catch (err) {
console.error("adminApi.adminSearchUsers error:", err)
throw err
}
}
/**
* Admin: Update user
*/
export async function adminUpdateUser(userId, payload = {}) {
if (!ensureAdminOrRedirect("/")) return null
try {
const resp = await apiClient.patch(`/admin/users/${userId}`, payload)
return resp?.data || null
} catch (err) {
console.error("adminApi.adminUpdateUser error:", err)
throw err
}
}
/**
* Admin: Deactivate user
*/
export async function adminDeactivateUser(userId) {
if (!ensureAdminOrRedirect("/")) return null
try {
const resp = await apiClient.post(`/admin/users/${userId}/deactivate`)
return resp?.data || null
} catch (err) {
console.error("adminApi.adminDeactivateUser error:", err)
throw err
}
}
/**
* Admin: Delete user
*/
export async function adminDeleteUser(userId) {
if (!ensureAdminOrRedirect("/")) return null
try {
const resp = await apiClient.delete(`/admin/users/${userId}`)
return resp?.data || null
} catch (err) {
console.error("adminApi.adminDeleteUser error:", err)
throw err
}
}
export default {
ensureAdminOrRedirect,
adminListDecks,
adminSearchDecks,
// new user API exports
adminListUsers,
adminGetUserById,
adminSearchUsers,
adminUpdateUser,
adminDeactivateUser,
adminDeleteUser,
}
+56
View File
@@ -107,3 +107,59 @@ export const resetPassword = async (token, newPassword) => {
}
};
// Add: response interceptor + error parser
apiClient.interceptors.response.use(
(resp) => resp,
(error) => {
// Log useful debug info
try {
// eslint-disable-next-line no-console
console.error("API Error:", {
message: error.message,
status: error?.response?.status,
url: error?.config?.url,
data: error?.response?.data,
})
} catch (e) {}
// forward
return Promise.reject(error)
}
)
/**
* Normalize axios error into readable string
* Prefer backend-provided message (resp.data.message), fallback to resp.data or error.message.
*/
export function getApiErrorMessage(error) {
if (!error) return "Ismeretlen hiba"
// axios error with response
const resp = error.response
if (resp) {
const data = resp.data
if (data) {
if (typeof data === "string") return data
if (data.message) return String(data.message)
// sometimes backend sends { errors: [...] }
if (data.errors && Array.isArray(data.errors)) return data.errors.map(e => e.message || JSON.stringify(e)).join("; ")
// fallback to whole payload
try { return JSON.stringify(data) } catch (e) {}
}
return `HTTP ${resp.status} ${resp.statusText || ""}`.trim()
}
// non-response axios error
return error.message || String(error)
}
export default {
login,
register,
getUserStats,
verifyEmail,
getUserProfile,
updateUserProfile,
deleteUserProfile,
forgotPassword,
resetPassword,
apiClient,
}
@@ -2,7 +2,7 @@ import React, { useState } from "react"
import Logo from "../../assets/pictures/Logo"
import { Link } from "react-router-dom"
import HandleNavigate from "../../utils/HandleNavigate/HandleNavigate" // ✅ importáld a navigációs hookot
import { FaSignOutAlt, FaChartBar, FaUser, FaBars } from "react-icons/fa"
import { FaSignOutAlt, FaChartBar, FaUser, FaBars, FaUserShield } from "react-icons/fa"
const navLinkClass = "px-3 py-2 rounded-lg text-white transition-all duration-200 hover:bg-white/10"
const navLinkClassPlay =
@@ -13,6 +13,9 @@ const Navbar = () => {
const [userMenuOpen, setUserMenuOpen] = useState(false)
const isLoggedIn = Boolean(localStorage.getItem("authLevel") && localStorage.getItem("username"))
// Új: ellenőrizzük, hogy admin-e (authLevel === "1")
const isAdmin = localStorage.getItem("authLevel") === "1"
// ✅ Használjuk a HandleNavigate hookot
const { goLanding, goAbout, goHome, goLogin, goContacts } = HandleNavigate()
@@ -111,6 +114,21 @@ const Navbar = () => {
<FaChartBar className="w-4 h-4" />
<span className="text-sm">Statisztikák</span>
</button>
{/* Admin gomb a felhasználói lenyílóban, csak adminoknak */}
{isAdmin && (
<button
onClick={() => {
setUserMenuOpen(false)
window.location.href = "/admin"
}}
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-white/10 text-white"
>
<FaUserShield className="w-4 h-4" />
<span className="text-sm">Admin</span>
</button>
)}
<button
onClick={() => {
setUserMenuOpen(false)
@@ -201,11 +219,26 @@ const Navbar = () => {
</button>
{isLoggedIn && (
<Link to="/decks" onClick={() => setMenuOpen(false)} className="px-3 py-2 rounded-lg text-white transition-all duration-200 hover:bg-white/10 block text-center">
<Link
to="/decks"
onClick={() => setMenuOpen(false)}
className="px-3 py-2 rounded-lg text-white transition-all duration-200 hover:bg-white/10 block text-center"
>
Paklik
</Link>
)}
{/* Admin gomb csak authLevel === "1" esetén */}
{isAdmin && (
<Link
to="/admin"
onClick={() => setMenuOpen(false)}
className="px-3 py-2 rounded-lg text-white transition-all duration-200 hover:bg-white/10 block text-center"
>
Admin
</Link>
)}
<button
onClick={() => {
goHome()
@@ -0,0 +1,750 @@
import React, { useState, useEffect } from "react"
import Navbar from "../../components/Navbar/Navbar.jsx"
import Footer from "../../components/Footer/Footer.jsx"
import Background from "../../assets/backgrounds/Background.jsx"
import ButtonGreen from "../../components/Buttons/ButtonGreen.jsx"
import { FaLayerGroup, FaUser, FaFilter, FaCalendarAlt, FaArrowUp, FaArrowDown, FaSortAlphaDown, FaSortAlphaUp, FaQuestionCircle, FaPlus, FaChevronLeft, FaChevronRight } from "react-icons/fa"
import { motion } from "framer-motion"
import {
ensureAdminOrRedirect,
adminListDecks,
adminSearchDecks,
adminListUsers,
adminGetUserById,
adminSearchUsers,
adminUpdateUser,
adminDeactivateUser,
adminDeleteUser
} from "../../api/adminApi"
import { getApiErrorMessage } from "../../api/userApi"
// --- Új importok: navigáció + SearchBox + PopUp + DeckInfoPopUp + deckApi használat ---
import HandleNavigate from "../../utils/HandleNavigate/HandleNavigate"
import SearchBox from "../../components/Search/SearchBox"
import PopUp from "../../components/PopUp/PopUp"
import DeckInfoPopUp from "../../components/PopUp/DeckInfoPopUp"
export default function Admin() {
const [active, setActive] = useState("decks") // "decks" | "users"
// --- Új: navigáció hook ---
const { goDeckCreator } = HandleNavigate()
// --- Szűrés / lista állapotok (DeckManager-ről átemelve) ---
const deckTypes = [
{ label: "Luck", color: "var(--color-luck)" },
{ label: "Question", color: "var(--color-question)" },
{ label: "Joker", color: "var(--color-fun)" },
]
const origins = ["Mind", "Vállalati", "Saját"]
const [query, setQuery] = useState("")
const [allDecks, setAllDecks] = useState([]) // összes betöltött pakli (backend)
const [loading, setLoading] = useState(false)
const [selectedType, setSelectedType] = useState("All")
const [selectedOrigin, setSelectedOrigin] = useState("Mind")
const [sortBy, setSortBy] = useState("date-desc")
const [showSortHelp, setShowSortHelp] = useState(false)
const [selectedDeck, setSelectedDeck] = useState(null)
const [itemsPerPage, setItemsPerPage] = useState(20)
const [currentPage, setCurrentPage] = useState(1)
// --- User management states ---
const [users, setUsers] = useState([])
const [userQuery, setUserQuery] = useState("")
const [userLoading, setUserLoading] = useState(false)
const [selectedUser, setSelectedUser] = useState(null)
const [userItemsPerPage, setUserItemsPerPage] = useState(20)
const [userCurrentPage, setUserCurrentPage] = useState(1)
const [userTotalCount, setUserTotalCount] = useState(0)
const [includeDeletedUsers, setIncludeDeletedUsers] = useState(false)
// track latest search to avoid race
const [userSearchToken, setUserSearchToken] = useState(0)
useEffect(() => {
ensureAdminOrRedirect("/")
let mounted = true
const load = async () => {
setLoading(true)
try {
const result = await adminListDecks(0, 99)
if (!mounted) return
// admin endpoints may already return array of deck objects or {decks:[...]}
const rawDecks = Array.isArray(result) ? result : (result.decks || result)
const mapped = (rawDecks || []).map(d => ({
id: d.id,
name: d.name || "",
type: d.type === 2 ? "Question" : d.type === 1 ? "Joker" : "Luck",
created: d.creationdate ? new Date(d.creationdate).toLocaleDateString() : "",
origin: d.ctype === 2 ? "Vállalati" : d.ctype === 0 ? "Mind" : "Saját",
raw: d
}))
setAllDecks(mapped)
} catch (err) {
console.error("Admin: failed to load decks (admin API)", err)
setAllDecks([])
} finally {
if (mounted) setLoading(false)
}
}
load()
return () => { mounted = false }
}, [])
// Debounced search using adminSearchDecks
useEffect(() => {
const t = setTimeout(async () => {
setLoading(true)
try {
if (!query) {
// if empty, reload full admin list (or keep cached allDecks)
const res = await adminListDecks(0, 99)
const raw = Array.isArray(res) ? res : (res.decks || res)
const mapped = (raw || []).map(d => ({
id: d.id,
name: d.name || "",
type: d.type === 2 ? "Question" : d.type === 1 ? "Joker" : "Luck",
created: d.creationdate ? new Date(d.creationdate).toLocaleDateString() : "",
origin: d.ctype === 2 ? "Vállalati" : d.ctype === 0 ? "Mind" : "Saját",
raw: d
}))
setAllDecks(mapped)
} else {
const res = await adminSearchDecks(query, 100, 0)
const raw = Array.isArray(res) ? res : (res.decks || res)
const mapped = (raw || []).map(d => ({
id: d.id,
name: d.name || "",
type: d.type === 2 ? "Question" : d.type === 1 ? "Joker" : "Luck",
created: d.creationdate ? new Date(d.creationdate).toLocaleDateString() : "",
origin: d.ctype === 2 ? "Vállalati" : d.ctype === 0 ? "Mind" : "Saját",
raw: d
}))
setAllDecks(mapped)
}
} catch (err) {
console.error("Admin search error (admin API):", err)
setAllDecks([])
} finally {
setLoading(false)
}
}, 300)
return () => clearTimeout(t)
}, [query])
// Load users when admin panel opened or includeDeleted changes
useEffect(() => {
let mounted = true
const loadUsers = async () => {
if (active !== "users") return
setUserLoading(true)
try {
const res = await adminListUsers(0, 99, includeDeletedUsers)
// res may be { users: [], pagination: {} } or array
const raw = Array.isArray(res) ? res : (res.users || [])
if (!mounted) return
setUsers((raw || []).map(u => ({
id: u.id,
username: u.username || u.name || "",
email: u.email || "",
created: u.creationdate ? new Date(u.creationdate).toLocaleDateString() : "",
deleted: !!u.deleted,
raw: u
})))
// set total if pagination provided
if (!Array.isArray(res) && res.pagination && typeof res.pagination.totalCount === "number") {
setUserTotalCount(res.pagination.totalCount)
} else {
setUserTotalCount((raw || []).length)
}
} catch (err) {
console.error("Admin: failed to load users (admin API)", err)
setUsers([])
setUserTotalCount(0)
} finally {
if (mounted) setUserLoading(false)
}
}
loadUsers()
return () => { mounted = false }
}, [active, includeDeletedUsers])
// Debounced user search
useEffect(() => {
const token = userSearchToken + 1
setUserSearchToken(token)
const t = setTimeout(async () => {
if (active !== "users") return
setUserLoading(true)
try {
if (!userQuery) {
const res = await adminListUsers(0, 99, includeDeletedUsers)
const raw = Array.isArray(res) ? res : (res.users || [])
setUsers((raw || []).map(u => ({
id: u.id,
username: u.username || u.name || "",
email: u.email || "",
created: u.creationdate ? new Date(u.creationdate).toLocaleDateString() : "",
deleted: !!u.deleted,
raw: u
})))
setUserTotalCount(!Array.isArray(res) && res.pagination ? res.pagination.totalCount : (raw || []).length)
} else {
const res = await adminSearchUsers(userQuery, includeDeletedUsers, 100, 0)
const raw = Array.isArray(res) ? res : (res.users || res)
setUsers((raw || []).map(u => ({
id: u.id,
username: u.username || u.name || "",
email: u.email || "",
created: u.creationdate ? new Date(u.creationdate).toLocaleDateString() : "",
deleted: !!u.deleted,
raw: u
})))
setUserTotalCount((raw || []).length)
}
} catch (err) {
console.error("Admin: user search error", err)
setUsers([])
setUserTotalCount(0)
} finally {
setUserLoading(false)
}
}, 300)
return () => clearTimeout(t)
}, [userQuery, active, includeDeletedUsers])
// Filter logic
let filteredDecks = allDecks.filter((deck) => {
const typeMatch = selectedType === "All" || deck.type === selectedType
const originMatch = selectedOrigin === "Mind" || deck.origin === selectedOrigin
const searchMatch = !query || deck.name.toLowerCase().includes(query.toLowerCase())
return typeMatch && originMatch && searchMatch
})
// Sort logic
filteredDecks = [...filteredDecks].sort((a, b) => {
if (sortBy === "date-asc") {
return a.created.localeCompare(b.created)
} else if (sortBy === "date-desc") {
return b.created.localeCompare(a.created)
} else if (sortBy === "abc-asc") {
return a.name.localeCompare(b.name)
} else if (sortBy === "abc-desc") {
return b.name.localeCompare(a.name)
}
return 0
})
// Pagination logic
const totalDecks = filteredDecks.length
const totalPages = Math.max(1, Math.ceil(totalDecks / itemsPerPage))
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const paginatedDecks = filteredDecks.slice(startIndex, endIndex)
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1)
}, [selectedType, selectedOrigin, query, sortBy, itemsPerPage])
const NavButton = ({ id, icon: Icon, label }) => (
<button
onClick={() => setActive(id)}
aria-pressed={active === id}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-sm font-medium
${active === id ? "bg-emerald-500 text-white shadow-lg" : "text-gray-200 hover:bg-white/5"}`}
>
<Icon className="w-5 h-5" />
<span>{label}</span>
</button>
)
// --- Teljes Deck handling (DeckManager-ről áthozva) ---
const DeckHandling = () => (
<motion.div
key="decks"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.25 }}
className="space-y-4"
>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold text-white">Deck management</h2>
<ButtonGreen text="Create deck" onClick={goDeckCreator} width="w-36" />
</div>
<div className="mt-4">
<div className="flex gap-2 items-center w-full flex-wrap mb-4">
<SearchBox
value={query}
onChange={(e) => setQuery(e.target.value)}
width={240}
placeholder="Keresés..."
className="mr-4"
/>
<FaFilter style={{ color: "var(--color-success)" }} className="mr-1 sm:mr-2 text-sm sm:text-base" />
<span className="text-[color:var(--color-text)] font-semibold mr-1 sm:mr-2 text-xs sm:text-sm">Típus:</span>
<button
className={`px-2 sm:px-3 py-1 rounded-lg font-medium transition-all duration-200 text-xs sm:text-sm ${
selectedType === "All"
? "bg-[color:var(--color-surface-selected)] text-[color:var(--color-text)] border border-[color:var(--color-surface)]"
: "text-[color:var(--color-text)] bg-[color:var(--color-success)]/10 hover:bg-[color:var(--color-success)]/30"
}`}
onClick={() => setSelectedType("All")}
>
Mind
</button>
{deckTypes.map((type) => (
<button
key={type.label}
className={`px-2 sm:px-3 py-1 rounded-lg font-medium transition-all duration-200 ml-1 text-xs sm:text-sm ${
selectedType === type.label
? "text-[color:var(--color-text-inverse)] border border-[color:var(--color-surface)]"
: "text-[color:var(--color-text)] bg-[color:var(--color-success)]/10 hover:bg-[color:var(--color-success)]/30"
}`}
style={selectedType === type.label ? { background: type.color } : undefined}
onClick={() => setSelectedType(type.label)}
>
{type.label === "Luck"
? "Szerencse"
: type.label === "Question"
? "Kérdés"
: type.label === "Joker"
? "Joker"
: type.label}
</button>
))}
<span className="text-[color:var(--color-text)] font-semibold mr-1 sm:mr-2 ml-1 sm:ml-2 text-xs sm:text-sm">Eredet:</span>
<select
className="px-2 sm:px-3 py-1 rounded-lg bg-[color:var(--color-success)]/10 hover:bg-[color:var(--color-success)]/30 text-[color:var(--color-text)] border-none focus:ring-2 focus:ring-[color:var(--color-success)] outline-none text-xs sm:text-sm"
value={selectedOrigin}
onChange={(e) => setSelectedOrigin(e.target.value)}
>
{origins.map((origin) => (
<option key={origin} value={origin} className="bg-zinc-800 text-white">
{origin}
</option>
))}
</select>
<span className="text-[color:var(--color-text)] font-semibold mr-1 sm:mr-2 ml-1 sm:ml-2 flex items-center gap-1 text-xs sm:text-sm">
Rendezés:
<button
type="button"
className="ml-1 text-[color:var(--color-success)] hover:text-[color:var(--color-text)] focus:outline-none"
onClick={() => setShowSortHelp(true)}
aria-label="Rendezési magyarázat megnyitása"
style={{ fontSize: 18, lineHeight: 1 }}
>
<FaQuestionCircle />
</button>
</span>
<select
className="px-2 sm:px-3 py-1 rounded-lg bg-[color:var(--color-success)]/10 hover:bg-[color:var(--color-success)]/30 text-[color:var(--color-text)] border-none focus:ring-2 focus:ring-[color:var(--color-success)] outline-none flex items-center text-xs sm:text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
aria-label="Rendezés"
>
<option value="date-asc">📅</option>
<option value="date-desc">📅</option>
<option value="abc-asc">AZ</option>
<option value="abc-desc">ZA</option>
</select>
</div>
{/* Items per page selector and count */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-center mb-6 bg-[color:var(--color-surface)]/60 backdrop-blur-lg rounded-xl px-6 py-3 shadow">
<div className="flex items-center gap-3">
<span className="text-[color:var(--color-text-muted)] text-sm font-medium">
Elemek oldalanként:
</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="px-3 py-1.5 rounded-lg bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)] focus:ring-2 focus:ring-[color:var(--color-success)] outline-none transition-all duration-200"
>
<option value={20}>20</option>
<option value={30}>30</option>
<option value={40}>40</option>
<option value={50}>50</option>
</select>
</div>
<div className="text-[color:var(--color-text-muted)] text-sm">
{totalDecks > 0 ? (
<>
{startIndex + 1}-{Math.min(endIndex, totalDecks)} / {totalDecks} pakli
</>
) : (
<>0 pakli</>
)}
</div>
</div>
{/* Decks Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8 mt-6 sm:mt-8">
{/* Create New Deck card */}
<div
onClick={() => goDeckCreator()}
className="flex flex-col items-center justify-center h-40 sm:h-48 bg-[color:var(--color-card)] border-2 border-dashed border-[color:var(--color-success)] rounded-xl sm:rounded-2xl cursor-pointer hover:bg-[color:var(--color-success)]/20 transition-all duration-200 shadow-lg"
>
<FaPlus style={{ color: "var(--color-success)" }} className="text-4xl sm:text-5xl mb-2" />
<span className="text-[color:var(--color-text)] font-semibold text-sm sm:text-base">Új pakli létrehozása</span>
</div>
{loading && <div className="col-span-full text-center text-[color:var(--color-text-muted)]">Betöltés...</div>}
{!loading && paginatedDecks.length === 0 && <div className="col-span-full text-center text-[color:var(--color-text-muted)]">Nincsenek találatok.</div>}
{!loading && paginatedDecks.map((deck) => {
const deckType = deckTypes.find((t) => t.label === deck.type)
const borderColor = deckType ? deckType.color : "var(--color-success)"
return (
<div
key={deck.id}
className="flex flex-col justify-between h-40 sm:h-48 bg-[color:var(--color-card)] rounded-xl sm:rounded-2xl p-4 sm:p-6 shadow-lg border-t-4 hover:scale-105 transition-transform duration-200 cursor-pointer"
style={{ borderTopColor: borderColor }}
onClick={() => setSelectedDeck(deck)}
>
<div>
<span
className="inline-block px-2 sm:px-3 py-1 rounded-full text-[10px] sm:text-xs font-bold mb-2"
style={{
background: deckType?.color,
color: "var(--color-text-inverse)",
}}
>
{deck.type === "Luck"
? "Szerencse"
: deck.type === "Question"
? "Kérdés"
: "Joker"}
</span>
<h2 className="text-xl font-bold text-[color:var(--color-text)] mb-1 truncate">
{deck.name}
</h2>
</div>
<div className="text-[color:var(--color-text-muted)] text-sm mt-2">
Létrehozva: {deck.created}
</div>
</div>
)
})}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-8">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2 ${
currentPage === 1
? 'bg-[color:var(--color-surface)] text-[color:var(--color-text-muted)] cursor-not-allowed'
: 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] hover:bg-[color:var(--color-success)]/80 hover:scale-105'
}`}
>
<FaChevronLeft />
Előző
</button>
<div className="flex items-center gap-2">
{[...Array(totalPages)].map((_, index) => {
const pageNum = index + 1
if (
pageNum === 1 ||
pageNum === totalPages ||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
) {
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 rounded-lg font-medium transition-all duration-200 ${
currentPage === pageNum
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] scale-110 shadow-lg'
: 'bg-[color:var(--color-surface)] text-[color:var(--color-text)] hover:bg-[color:var(--color-surface-selected)]'
}`}
>
{pageNum}
</button>
)
} else if (pageNum === currentPage - 2 || pageNum === currentPage + 2) {
return <span key={pageNum} className="text-[color:var(--color-text-muted)]">...</span>
}
return null
})}
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2 ${
currentPage === totalPages
? 'bg-[color:var(--color-surface)] text-[color:var(--color-text-muted)] cursor-not-allowed'
: 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] hover:bg-[color:var(--color-success)]/80 hover:scale-105'
}`}
>
Következő
<FaChevronRight />
</button>
</div>
)}
{/* Sort help popup */}
{showSortHelp && (
<PopUp onClose={() => setShowSortHelp(false)}>
<h2 className="text-lg font-bold mb-4">Rendezési lehetőségek magyarázata</h2>
<ul className="space-y-2 text-[color:var(--color-night)]">
<li><span className="font-bold">📅</span> Dátum szerint növekvő sorrendben (legrégebbi elöl)</li>
<li><span className="font-bold">📅</span> Dátum szerint csökkenő sorrendban (legújabb elöl)</li>
<li><span className="font-bold">AZ</span> Név szerint növekvő sorrendben (A-tól Z-ig)</li>
<li><span className="font-bold">ZA</span> Név szerint csökkenő sorrendben (Z-től A-ig)</li>
</ul>
<div className="mt-6 flex justify-end">
<button className="px-4 py-2 rounded-lg bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] font-semibold" onClick={() => setShowSortHelp(false)}>Bezárás</button>
</div>
</PopUp>
)}
{/* Deck Info Popup */}
{selectedDeck && <DeckInfoPopUp deck={selectedDeck} onClose={() => setSelectedDeck(null)} />}
</div>
</motion.div>
)
const UserHandlingMock = () => (
<motion.div
key="users"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.25 }}
className="space-y-4"
>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold text-white">User management</h2>
<div className="flex items-center gap-3">
<ButtonGreen text="Invite user" onClick={() => alert("Invite / invite-flow to implement")} width="w-36" />
</div>
</div>
<div className="mt-4">
<div className="flex gap-2 items-center w-full flex-wrap mb-4">
<SearchBox
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
width={240}
placeholder="Keresés felhasználók között..."
className="mr-4"
/>
<label className="text-[color:var(--color-text)] font-semibold text-xs sm:text-sm flex items-center gap-2">
<input type="checkbox" checked={includeDeletedUsers} onChange={e => setIncludeDeletedUsers(e.target.checked)} className="mr-1" />
Törölt is
</label>
</div>
<div className="bg-[color:var(--color-surface)]/60 backdrop-blur-lg rounded-xl px-6 py-3 shadow mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-[color:var(--color-text-muted)] text-sm font-medium">Elemek oldalanként:</span>
<select
value={userItemsPerPage}
onChange={(e) => setUserItemsPerPage(Number(e.target.value))}
className="px-3 py-1.5 rounded-lg bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)] focus:ring-2 focus:ring-[color:var(--color-success)] outline-none transition-all duration-200"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={30}>30</option>
</select>
</div>
<div className="text-[color:var(--color-text-muted)] text-sm">
{userTotalCount > 0 ? `${Math.min((userCurrentPage-1)*userItemsPerPage + 1, userTotalCount)}-${Math.min(userCurrentPage*userItemsPerPage, userTotalCount)} / ${userTotalCount} felhasználó` : '0 felhasználó'}
</div>
</div>
{/* User list */}
<div className="grid grid-cols-1 gap-4">
{userLoading && <div className="text-center text-[color:var(--color-text-muted)]">Betöltés...</div>}
{!userLoading && users.length === 0 && <div className="text-center text-[color:var(--color-text-muted)]">Nincsenek találatok.</div>}
{!userLoading && users.slice((userCurrentPage-1)*userItemsPerPage, userCurrentPage*userItemsPerPage).map(u => (
<div key={u.id} className="flex items-center justify-between bg-[color:var(--color-card)] rounded-xl p-4 shadow">
<div>
<div className="font-semibold text-[color:var(--color-text)]">{u.username}</div>
<div className="text-sm text-[color:var(--color-text-muted)]">{u.email}</div>
<div className="text-xs text-[color:var(--color-text-muted)] mt-1">Létrehozva: {u.created}</div>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs ${u.deleted ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`}>{u.deleted ? 'Törölt' : 'Aktív'}</span>
<button className="px-3 py-1 rounded bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]" onClick={() => setSelectedUser(u)}>Megtekint</button>
</div>
</div>
))}
</div>
{/* Pagination controls for users */}
{ Math.ceil(Math.max(userTotalCount, users.length) / userItemsPerPage) > 1 && (
<div className="flex justify-center items-center gap-2 mt-6">
{(() => {
const totalPages = Math.max(1, Math.ceil(Math.max(userTotalCount, users.length) / userItemsPerPage))
return (
<>
<button onClick={() => setUserCurrentPage(p => Math.max(1, p-1))} className="px-4 py-2 rounded-lg bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]">Előző</button>
<div className="flex items-center gap-2">
{[...Array(totalPages)].map((_, i) => {
const p = i+1
if (p === 1 || p === totalPages || (p >= userCurrentPage-1 && p <= userCurrentPage+1)) {
return <button key={p} onClick={() => setUserCurrentPage(p)} className={`w-10 h-10 rounded-lg ${userCurrentPage===p ? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]' : 'bg-[color:var(--color-surface)] text-[color:var(--color-text)]'}`}>{p}</button>
}
if (p === userCurrentPage-2 || p === userCurrentPage+2) return <span key={p} className="text-[color:var(--color-text-muted)]">...</span>
return null
})}
</div>
<button onClick={() => setUserCurrentPage(p => Math.min(totalPages, p+1))} className="px-4 py-2 rounded-lg bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]">Következő</button>
</>
)
})()}
</div>
)}
{/* User details popup */}
{selectedUser && (
<PopUp onClose={() => setSelectedUser(null)}>
<h2 className="text-lg font-bold mb-2">{selectedUser.username}</h2>
<div className="text-sm text-[color:var(--color-text-muted)] mb-4">
Email: {selectedUser.email} <br />
Létrehozva: {selectedUser.created} <br />
Status: {selectedUser.deleted ? 'Törölt' : 'Aktív'}
</div>
<div className="flex gap-2 justify-end">
{!selectedUser.deleted && (
<button
className="px-3 py-2 rounded bg-yellow-600 text-white"
onClick={async () => {
if (!confirm('Biztosan deaktiválod ezt a felhasználót?')) return
try {
const uuid = await resolveUserUuid(selectedUser)
if (!uuid) {
alert("Nem található érvényes userId (UUID) a kiválasztott felhasználónál. Ellenőrizd a backend választ vagy a user objektumot a konzolban.")
console.error("Selected user object:", selectedUser)
return
}
await adminDeactivateUser(uuid)
// reflect change locally
setUsers(curr => curr.map(u => u.id === selectedUser.id ? { ...u, deleted: true } : u))
setSelectedUser(prev => ({ ...prev, deleted: true }))
alert('Felhasználó deaktiválva.')
} catch (err) {
console.error(err)
alert(getApiErrorMessage(err) || 'Hiba a deaktiválás során.')
}
}}
>
Deaktiválás
</button>
)}
<button
className="px-3 py-2 rounded bg-red-600 text-white"
onClick={async () => {
if (!confirm('Végleg törlöd a felhasználót?')) return
try {
const uuid = await resolveUserUuid(selectedUser)
if (!uuid) {
alert("Nem található érvényes userId (UUID) a kiválasztott felhasználónál. Ellenőrizd a backend választ vagy a user objektumot a konzolban.")
console.error("Selected user object:", selectedUser)
return
}
await adminDeleteUser(uuid)
setUsers(curr => curr.filter(u => u.id !== selectedUser.id))
setSelectedUser(null)
alert('Felhasználó törölve.')
} catch (err) {
console.error(err)
alert(getApiErrorMessage(err) || 'Hiba a törlés során.')
}
}}
>
Törlés
</button>
<button className="px-3 py-2 rounded bg-[color:var(--color-surface)]" onClick={() => setSelectedUser(null)}>Bezár</button>
</div>
</PopUp>
)}
</div>
</motion.div>
)
// ---------- NEW: UUID helper + resolver ----------
const isUuid = (val) => {
if (!val || typeof val !== "string") return false
// simple RFC4122 v4-ish UUID check (hex + dashes)
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val.trim())
}
// Try various places on the user object for a UUID, fallback to search by username/email
const resolveUserUuid = async (user) => {
if (!user) return null
const candidates = [
user.id,
user.raw?.id,
user.raw?.userid,
user.raw?.userId,
user.raw?.uuid,
user.raw?.user_id
]
for (const c of candidates) if (isUuid(String(c || ""))) return String(c).trim()
// fallback: try to find canonical user by username or email via adminSearchUsers
const q = user.username || user.email || user.raw?.username || user.raw?.email
if (!q) return null
try {
const res = await adminSearchUsers(q, includeDeletedUsers ? true : false, 10, 0)
const arr = Array.isArray(res) ? res : (res.users || res || [])
if (Array.isArray(arr) && arr.length) {
// try to match by username/email exactly, or take first with valid id
const exact = arr.find(u => (u.username && u.username === user.username) || (u.email && u.email === user.email))
const pick = exact || arr[0]
if (pick && isUuid(pick.id)) return pick.id
}
} catch (err) {
console.error("resolveUserUuid: search failed", err)
}
return null
}
// ---------- end new helpers ----------
return (
<div className="w-full min-h-screen flex flex-col relative overflow-x-hidden">
<div className="fixed inset-0 -z-10 pointer-events-none">
<Background />
</div>
<div className="fixed top-0 left-0 right-0 z-30">
<Navbar />
</div>
<main className="flex-1 min-h-[calc(100vh-64px)] mt-[64px] px-4 sm:px-6 py-8">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-6">
{/* Left navigation column */}
<aside className="md:col-span-3 lg:col-span-2 bg-white/5 rounded-2xl p-4 sticky top-[88px] h-fit">
<h3 className="text-lg font-semibold text-white mb-4">Admin panel</h3>
<div className="space-y-3">
<NavButton id="decks" icon={FaLayerGroup} label="Deck handling" />
<NavButton id="users" icon={FaUser} label="User handling" />
</div>
</aside>
{/* Right content column */}
<section className="md:col-span-9 lg:col-span-10">
<div className="bg-white/6 rounded-2xl p-6 min-h-[300px]">
{active === "decks" ? <DeckHandling /> : <UserHandlingMock />}
</div>
</section>
</div>
</main>
<Footer />
</div>
)
}
+6
View File
@@ -36,8 +36,14 @@ export const ROUTES = {
REPORTS: '/report',
CONTACTS: '/contacts',
TEST: '/test',
// admin
ADMIN: '/admin',
}
// Helper functions to generate dynamic routes
export const routeHelpers = {
deckDetails: (deckId) => `/deck/${deckId}`,