Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/routes/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,13 @@ def login():
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role.role if user.role else "User",
},
}
),
200,
)

# Tokens are stored in HttpOnly cookies — never exposed to JavaScript
set_access_cookies(response, access_token)
set_refresh_cookies(response, refresh_token)
Expand Down Expand Up @@ -156,13 +158,13 @@ def me():
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404

return (
jsonify(
{
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role.role if user.role else "User",
}
),
200,
Expand Down
28 changes: 28 additions & 0 deletions backend/routes/users_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from backend.extensions import db
from backend.models.user import User
from backend.models.user_stats import UserStats

users_bp = Blueprint("users", __name__)
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -155,3 +156,30 @@ def update_avatar(user_id):
db.session.commit()
logger.info("Avatar updated for user %s", user_id)
return jsonify({"msg": "Avatar updated"}), 200


@users_bp.route("/leaderboard", methods=["GET"])
@jwt_required()
def get_leaderboard():
results = (
db.session.query(User, UserStats)
.join(UserStats, User.id == UserStats.id)
.order_by(UserStats.xp.desc())
.limit(50)
.all()
)

leaderboard = [
{
"position": pos,
"user_id": user.id,
"username": user.username,
"xp": stats.xp,
"level": stats.level,
"rank": stats.rank,
"level_percentage": min(int((stats.xp % 1000) / 10), 100),
}
for pos, (user, stats) in enumerate(results, start=1)
]

return jsonify(leaderboard), 200
34 changes: 28 additions & 6 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import LanguageQuestsPage from "./pages/LanguageQuestsPage";
import QuestPage from "./pages/QuestPage";
import AdminPanel from "./pages/AdminPanel";
import EditQuestPage from "./components/Admin/EditQuestPage";
import LeaderboardPage from "./pages/LeaderboardPage";
import UnderworldPage from "./pages/Underworld/Underworld";
import BossChallengePage from "./pages/Underworld/BossChallenge";
import LoadingSpinner from "./components/Layout/LoadingSpinner";

/**
* Redirect to login if the user is not authenticated.
* Shows nothing (spinner) while the auth state is being resolved on first load.
* Shows a spinner while the auth state is being resolved on first load.
*/
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
Expand All @@ -23,22 +24,43 @@ function ProtectedRoute({ children }) {
return children;
}

/**
* Redirect to dashboard if the user is already authenticated.
* Prevents authenticated users from seeing the login/signup pages.
* Shows a spinner while auth state is resolving to avoid a flash of the form.
*/
function PublicRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <LoadingSpinner />;
if (user) return <Navigate to="/dashboard" replace />;
return children;
}

function AdminRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <LoadingSpinner />;
if (!user) return <Navigate to="/" replace />;
if (user.role !== "Admin") return <Navigate to="/dashboard" replace />;
return children;
}

export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Public routes */}
<Route path="/" element={<Login />} />
<Route path="/signup" element={<Signup />} />
{/* Public routes — redirect to dashboard if already authenticated */}
<Route path="/" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/signup" element={<PublicRoute><Signup /></PublicRoute>} />

{/* Protected routes */}
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/quests/:language" element={<ProtectedRoute><LanguageQuestsPage /></ProtectedRoute>} />
<Route path="/quest/:questId" element={<ProtectedRoute><QuestPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPanel /></ProtectedRoute>} />
<Route path="/admin/edit_quest/:questId" element={<ProtectedRoute><EditQuestPage /></ProtectedRoute>} />
<Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
<Route path="/admin/edit_quest/:questId" element={<AdminRoute><EditQuestPage /></AdminRoute>} />
<Route path="/leaderboard" element={<ProtectedRoute><LeaderboardPage /></ProtectedRoute>} />
<Route path="/underworld" element={<ProtectedRoute><UnderworldPage /></ProtectedRoute>} />
<Route path="/underworld/challenge/:bossId" element={<ProtectedRoute><BossChallengePage /></ProtectedRoute>} />
</Routes>
Expand Down
202 changes: 126 additions & 76 deletions frontend/src/pages/AdminPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,100 +1,150 @@
/* eslint-disable no-unused-vars */

import React, {useState, useEffect} from "react";
import { useState } from "react";
import Navbar from "../components/Layout/Navbar";
import { getAllQuests } from "../services/questsServices";


// Admin Dashboard Components
import Dashboard from "../components/Admin/Dashboard";
import AddQuest from "../components/Admin/AddQuest";
import EditQuest from "../components/Admin/EditQuest";
import NewBoss from "../components/Admin/NewBoss";
import QuestsLogs from "../components/Admin/QuestsLogs";

const AdminPanel = () => {
const [activeSection, setActiveSection] = useState("dashboard");
const NAV_ITEMS = [
{
key: "dashboard",
label: "Dashboard",
icon: (
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
),
},
{
key: "add_quest",
label: "Add Quest",
icon: (
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
key: "edit_quest",
label: "Edit Quest",
icon: (
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
),
},
{
key: "underworld_boss",
label: "New Boss",
icon: (
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
</svg>
),
},
{
key: "quests_logs",
label: "Quests Logs",
icon: (
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
),
},
];

function renderSection(activeSection) {
switch (activeSection) {
case "add_quest": return <AddQuest />;
case "edit_quest": return <EditQuest />;
case "underworld_boss": return <NewBoss />;
case "quests_logs": return <QuestsLogs />;
case "dashboard":
default: return <Dashboard />;
}
}

const renderSection = () => {
switch (activeSection) {
case "add_quest":
return <AddQuest />;
case "edit_quest":
return <EditQuest />;
case "underworld_boss":
return <NewBoss />;
case "quests_logs":
return <QuestsLogs />;
case "dashboard":
default:
return <Dashboard />;
}
};
export default function AdminPanel() {
const [activeSection, setActiveSection] = useState("dashboard");
const activeItem = NAV_ITEMS.find((i) => i.key === activeSection);

return (
<>
<Navbar />
<div className="min-h-screen flex bg-gradient-to-b from-[#141e30] to-[#123556] text-white">
{/* Sidebar */}
<aside className="w-64 shadow-md">
<div className="p-6 text-xl font-bold border-b primary_text">
Admin Panel
<div className="min-h-screen flex bg-gradient-to-b from-[#141e30] to-[#123556]">

{/* ── Sidebar ── */}
<aside className="w-56 flex-shrink-0 flex flex-col bg-[#0b1524]/80 border-r border-white/[0.06] backdrop-blur-md sticky top-14 h-[calc(100vh-3.5rem)]">

{/* Sidebar header */}
<div className="px-5 py-5 border-b border-white/[0.06]">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-[#03e9f4]/15 border border-[#03e9f4]/30 flex items-center justify-center flex-shrink-0">
<svg className="w-3.5 h-3.5 text-[#03e9f4]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="text-white text-xs font-bold tracking-wide secondary_text">Admin Panel</p>
<p className="text-white/30 text-[10px] normal_text normal_text--small">Management console</p>
</div>
</div>
</div>
<nav className="mt-6">
<ul className="space-y-2 px-2">
<li>
<button
onClick={() => setActiveSection("dashboard")}
className="w-full text-left py-2 px-4 rounded primary_button"
>
Dashboard
</button>
</li>
<li>
<button
onClick={() => setActiveSection("add_quest")}
className="w-full text-left py-2 px-4 rounded primary_button"
>
Add Quest
</button>
</li>
<li>
<button
onClick={() => setActiveSection("edit_quest")}
className="w-full text-left py-2 px-4 rounded primary_button"
>
Edit Quest
</button>
</li>
<li>
<button
onClick={() => setActiveSection("underworld_boss")}
className="w-full text-left py-2 px-4 rounded primary_button"
>
Create New Underworld Boss
</button>
</li>
<li>

{/* Nav items */}
<nav className="flex-1 px-3 py-4 space-y-3 overflow-y-auto">
<p className="font-semibold uppercase tracking-widest px-2 mb-2 normal_text normal_text--small">
Navigation
</p>
{NAV_ITEMS.map((item) => {
const active = activeSection === item.key;
return (
<button
onClick={() => setActiveSection("quests_logs")}
className="w-full text-left py-2 px-4 rounded primary_button"
key={item.key}
onClick={() => setActiveSection(item.key)}
className={`primary_button w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-150 text-left ${
active
? "bg-[#03e9f4]/[0.1] text-[#03e9f4] border border-[#03e9f4]/20"
: "text-white/45 hover:text-white/80 hover:bg-white/[0.04] border border-transparent"
}`}
>
Quests Logs
{item.icon}
{item.label}
{active && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-[#03e9f4] flex-shrink-0" />
)}
</button>
</li>
</ul>
);
})}
</nav>

{/* Sidebar footer */}
<div className="px-4 py-3 border-t border-white/[0.06]">
<p className="text-white/15 text-[10px] text-center">Admin access only</p>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 p-10 shadow-2xl">
<div className="container mx-auto">
{renderSection()}

{/* ── Main content ── */}
<main className="flex-1 min-w-0">

{/* Page header bar */}
<div className="border-b border-white/[0.06] bg-[#0b1524]/40 px-8 py-4 flex items-center gap-3">
<span className="text-white/75">{activeItem?.icon}</span>
<div>
<h1 className="text-white font-semibold text-sm normal_text normal_text--medium">{activeItem?.label}</h1>
{/* <p className="text-white/25 text-[11px] normal_text normal_text--small">Admin Panel</p> */}
</div>
</div>

<div className="p-8">
{renderSection(activeSection)}
</div>
</main>
</div>
</div>
</>
);
};

export default AdminPanel;
}
Loading
Loading