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
7 changes: 7 additions & 0 deletions frontend/app/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export const metadata = {
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("stellarhunts.theme");if(t!=="light"&&t!=="dark"){t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";}document.documentElement.classList.add(t);}catch(e){}})();`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
Expand Down
32 changes: 18 additions & 14 deletions frontend/components/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Menu, X, Gamepad, Trophy, Book, User, Users } from "lucide-react";
import ThemeToggle from "@/components/ThemeToggle";

const NavigationHeader = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
Expand Down Expand Up @@ -67,20 +68,23 @@ const NavigationHeader = () => {
</Button>
</div>

{/* Mobile menu button */}
<div className="md:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-gray-300 hover:text-white"
>
{isMenuOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</Button>
{/* Theme toggle + mobile menu button */}
<div className="flex items-center gap-1">
<ThemeToggle className="text-gray-300 hover:text-white" />
<div className="md:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-gray-300 hover:text-white"
>
{isMenuOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</Button>
</div>
</div>
</div>
</nav>
Expand Down
31 changes: 31 additions & 0 deletions frontend/components/ThemeToggle.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/hooks/useTheme";

/**
* ThemeToggle
* -----------
* Light/dark theme switch (#694). Cycles between the two themes and persists
* the choice through `useTheme`. Renders a sun or moon icon depending on the
* active theme.
*/
export default function ThemeToggle({ className }) {
const { theme, toggleTheme } = useTheme();
const isDark = theme === "dark";

return (
<Button
type="button"
variant="ghost"
size="icon"
className={className}
onClick={toggleTheme}
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</Button>
);
}
69 changes: 69 additions & 0 deletions frontend/hooks/useTheme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import { useCallback, useEffect, useState } from "react";

/**
* localStorage key holding the user's explicit theme choice.
*/
export const THEME_STORAGE_KEY = "stellarhunts.theme";

/**
* useTheme
* --------
* Light/dark theme state for the app (#694).
*
* - Resolves the initial theme from the persisted choice, falling back to
* the system `prefers-color-scheme` preference on first visit.
* - Applies `dark` / `light` classes to `document.documentElement`, matching
* the Tailwind `darkMode: ["class"]` configuration.
* - Persists an explicit toggle choice in `localStorage`.
*
* @returns {{ theme: "light"|"dark", setTheme: Function, toggleTheme: Function }}
*/
export function useTheme() {
const [theme, setThemeState] = useState("light");

// On mount, read the persisted choice or the system preference. The inline
// script in the root layout has already applied the class before hydration,
// so this only syncs React state with what is on the document.
useEffect(() => {
if (typeof window === "undefined") return;

const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "light" || stored === "dark") {
setThemeState(stored);
return;
}

const prefersDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
setThemeState(prefersDark ? "dark" : "light");
}, []);

const applyTheme = useCallback((next) => {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.classList.toggle("dark", next === "dark");
root.classList.toggle("light", next === "light");
}, []);

const setTheme = useCallback(
(next) => {
setThemeState(next);
applyTheme(next);
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_STORAGE_KEY, next);
}
},
[applyTheme]
);

const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);

return { theme, setTheme, toggleTheme };
}

export default useTheme;
Loading