Add dark mode

Fix #8
This commit is contained in:
Landon & Emma 2026-04-22 13:16:54 -04:00
parent 2209a17687
commit 4bdfefc1c6
17 changed files with 410 additions and 66 deletions

View file

@ -7,6 +7,12 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum Theme {
LIGHT
DARK
SYSTEM
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
@ -14,6 +20,7 @@ model User {
emailVerified DateTime? emailVerified DateTime?
image String? image String?
description String? @db.VarChar(512) description String? @db.VarChar(512)
theme Theme @default(SYSTEM)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import z from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
const themeSchema = z.enum(["LIGHT", "DARK", "SYSTEM"]);
export async function GET(request: NextRequest) {
const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const user = await prisma.user.findUnique({
where: { id: Number(session.user.id) },
select: { theme: true },
});
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
return NextResponse.json({ theme: user.theme });
} catch (error) {
console.error("Failed to get theme:", error);
return NextResponse.json({ error: "Failed to get theme" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 5);
const check = await rateLimit.handle();
if (check) return check;
const { theme } = await request.json();
const validation = themeSchema.safeParse(theme);
if (!validation.success) return rateLimit.sendResponse({ error: "Invalid theme value" }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { theme: validation.data },
});
} catch (error) {
console.error("Failed to update theme:", error);
return rateLimit.sendResponse({ error: "Failed to update theme" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -39,6 +39,8 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (user) { if (user) {
session.user.id = user.id; session.user.id = user.id;
session.user.email = user.email; session.user.email = user.email;
// @ts-expect-error - theme is added to User model
session.user.theme = user.theme;
} }
return session; return session;
}, },

View file

@ -12,7 +12,7 @@ function RedirectBanner() {
if (from !== "old-domain") return null; if (from !== "old-domain") return null;
return ( return (
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start"> <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start dark:bg-slate-700 dark:border-y-slate-600 dark:text-slate-100">
<Icon icon="humbleicons:link" className="text-2xl min-w-6" /> <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
<span>We have moved URLs, welcome to tomodachishare.com!</span> <span>We have moved URLs, welcome to tomodachishare.com!</span>
</div> </div>
@ -51,7 +51,7 @@ export default function AdminBanner() {
return ( return (
<> <>
{shouldShow && message && ( {shouldShow && message && (
<div className="relative w-full min-h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between"> <div className="relative w-full min-h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between dark:bg-slate-700 dark:border-y-slate-600 dark:text-slate-100">
<div className="flex gap-2 h-full items-center w-fit"> <div className="flex gap-2 h-full items-center w-fit">
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" /> <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{message}</span> <span>{message}</span>

View file

@ -30,13 +30,13 @@ export default function Dropzone({ onDrop, options, children }: Props) {
{...getRootProps()} {...getRootProps()}
onDragOver={() => setIsDraggingOver(true)} onDragOver={() => setIsDraggingOver(true)}
onDragLeave={() => setIsDraggingOver(false)} onDragLeave={() => setIsDraggingOver(false)}
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${ className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 dark:bg-slate-800 dark:border-slate-600 ${
isDraggingOver && "scale-105 brightness-90 shadow-xl" isDraggingOver && "scale-105 brightness-90 shadow-xl"
}`} }`}
> >
{/* Used to transition from border-dashed to border-solid */} {/* Used to transition from border-dashed to border-solid */}
<div <div
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${ className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 dark:outline-slate-500 ${
isDraggingOver ? "opacity-100" : "opacity-0" isDraggingOver ? "opacity-100" : "opacity-0"
}`} }`}
></div> ></div>

View file

@ -7,20 +7,20 @@ export default function Footer() {
<div className="max-w-4xl mx-auto px-4 py-4"> <div className="max-w-4xl mx-auto px-4 py-4">
{/* Main disclaimer */} {/* Main disclaimer */}
<div className="text-center mb-2"> <div className="text-center mb-2">
<p className="text-sm text-zinc-600 font-medium">TomodachiShare is not affiliated with Nintendo</p> <p className="text-sm text-zinc-600 font-medium dark:text-slate-400">TomodachiShare is not affiliated with Nintendo</p>
</div> </div>
{/* Links section */} {/* Links section */}
<div className="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12"> <div className="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12">
<Link to="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> <Link to="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline dark:text-slate-400 dark:hover:text-slate-300">
Terms of Service Terms of Service
</Link> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline dark:text-slate-600" aria-hidden="true">
</span> </span>
<Link to="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> <Link to="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline dark:text-slate-400 dark:hover:text-slate-300">
Privacy Policy Privacy Policy
</Link> </Link>
@ -37,22 +37,22 @@ export default function Footer() {
Discord Discord
</Link> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline dark:text-slate-600" aria-hidden="true">
</span> </span>
<Link <Link
to="https://trafficlunar.net" to="https://trafficlunar.net"
target="_blank" target="_blank"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group dark:text-slate-400 dark:hover:text-slate-300"
> >
Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span> Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200 dark:text-orange-500 dark:group-hover:text-orange-400">trafficlunar</span>
</Link> </Link>
</div> </div>
{/* Copyright */} {/* Copyright */}
<div className="text-center mt-4 mb-4"> <div className="text-center mt-4 mb-4">
<p className="text-xs text-zinc-400">© {new Date().getFullYear()} TomodachiShare. All rights reserved.</p> <p className="text-xs text-zinc-400 dark:text-slate-500">© {new Date().getFullYear()} TomodachiShare. All rights reserved.</p>
</div> </div>
</div> </div>
</footer> </footer>

View file

@ -1,5 +1,6 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import SearchBar from "./search-bar"; import SearchBar from "./search-bar";
import ThemeToggle from "./theme-toggle";
import { Link } from "react-router"; import { Link } from "react-router";
import { useStore } from "@nanostores/react"; import { useStore } from "@nanostores/react";
import { session } from "../session"; import { session } from "../session";
@ -54,11 +55,16 @@ export default function Header() {
</Link> </Link>
</li> </li>
{!$session?.user ? ( {!$session?.user ? (
<>
<li>
<ThemeToggle size="md" />
</li>
<li> <li>
<Link to={"/login"} className="pill button h-full"> <Link to={"/login"} className="pill button h-full">
Login Login
</Link> </Link>
</li> </li>
</>
) : ( ) : (
<> <>
<li title="Your profile"> <li title="Your profile">
@ -82,6 +88,9 @@ export default function Header() {
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{$session?.user?.name ?? "unknown"}</span> <span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{$session?.user?.name ?? "unknown"}</span>
</Link> </Link>
</li> </li>
<li>
<ThemeToggle size="md" />
</li>
<li title="Logout"> <li title="Logout">
<Link <Link
to={`${import.meta.env.VITE_API_URL}/api/auth/signout`} to={`${import.meta.env.VITE_API_URL}/api/auth/signout`}

View file

@ -53,18 +53,18 @@ export default function DeleteAccount() {
/> />
<div <div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${ className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col dark:bg-slate-800 dark:border-slate-600 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`} }`}
> >
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Delete Account</h2> <h2 className="text-xl font-bold dark:text-slate-100">Delete Account</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> <button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" /> <Icon icon="material-symbols:close-rounded" />
</button> </button>
</div> </div>
<p className="text-sm text-zinc-500">Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.</p> <p className="text-sm text-zinc-500 dark:text-slate-400">Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.</p>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>} {error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}

View file

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import { userNameSchema } from "@tomodachi-share/shared/schemas"; import { userNameSchema } from "@tomodachi-share/shared/schemas";
@ -7,6 +8,8 @@ import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account"; import DeleteAccount from "./delete-account";
import z from "zod"; import z from "zod";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { session } from "../../session";
import { type Theme, applyTheme } from "../../lib/theme";
interface Props { interface Props {
currentDescription: string | null | undefined; currentDescription: string | null | undefined;
@ -14,12 +17,22 @@ interface Props {
export default function ProfileSettings({ currentDescription }: Props) { export default function ProfileSettings({ currentDescription }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const $session = useStore(session);
const [description, setDescription] = useState(currentDescription); const [description, setDescription] = useState(currentDescription);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedTheme, setSelectedTheme] = useState<Theme>("SYSTEM");
const [themeSaveError, setThemeSaveError] = useState<string | undefined>(undefined);
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined); const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
const [nameChangeError, setNameChangeError] = useState<string | undefined>(undefined); const [nameChangeError, setNameChangeError] = useState<string | undefined>(undefined);
// Initialize theme from session when it loads
useEffect(() => {
if ($session?.user?.theme) {
setSelectedTheme($session.user.theme);
}
}, [$session?.user?.theme]);
const handleSubmitDescriptionChange = async (close: () => void) => { const handleSubmitDescriptionChange = async (close: () => void) => {
const parsed = z.string().trim().max(256).safeParse(description); const parsed = z.string().trim().max(256).safeParse(description);
if (!parsed.success) { if (!parsed.success) {
@ -68,28 +81,50 @@ export default function ProfileSettings({ currentDescription }: Props) {
navigate(0); navigate(0);
}; };
const handleThemeSave = async (close: () => void) => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/theme`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: selectedTheme }),
credentials: "include",
});
if (!response.ok) {
const { error } = await response.json();
setThemeSaveError(error);
return;
}
// Apply the theme immediately
applyTheme(selectedTheme);
close();
navigate(0);
};
return ( return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 dark:bg-slate-900 dark:border-slate-700">
<div> <div>
<h2 className="text-2xl font-bold">Profile Settings</h2> <h2 className="text-2xl font-bold dark:text-slate-100">Settings</h2>
<p className="text-sm text-zinc-500">Update your profile picture, description, name, etc.</p> <p className="text-sm text-zinc-500 dark:text-slate-400">Update your account info, username, and site-wide theme.</p>
</div> </div>
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1 dark:text-slate-400">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Account Info</span> <span>Account Info</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300 dark:border-slate-600" />
</div> </div>
{/* Profile Picture */} {/* Profile Picture */}
<div className="dark:border-slate-600">
<ProfilePictureSettings /> <ProfilePictureSettings />
</div>
{/* Description */} {/* Description */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3"> <div className="col-span-3">
<label className="font-semibold">About Me</label> <label className="font-semibold dark:text-slate-100">About Me</label>
<p className="text-sm text-zinc-500">Write about yourself on your profile</p> <p className="text-sm text-zinc-500 dark:text-slate-400">Write about yourself on your profile</p>
</div> </div>
<div className="flex justify-end gap-1 h-min col-span-2"> <div className="flex justify-end gap-1 h-min col-span-2">
@ -102,7 +137,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
value={description || ""} value={description || ""}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
<p className="text-xs text-zinc-400 mt-1 text-right">{(description || "").length}/256</p> <p className="text-xs text-zinc-400 mt-1 text-right dark:text-slate-500">{(description || "").length}/256</p>
</div> </div>
<SubmitDialogButton <SubmitDialogButton
@ -117,8 +152,8 @@ export default function ProfileSettings({ currentDescription }: Props) {
{/* Change Name */} {/* Change Name */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3"> <div className="col-span-3">
<label className="font-semibold">Change Name</label> <label className="font-semibold dark:text-slate-100">Change Name</label>
<p className="text-sm text-zinc-500">This is your name shown on your profile and miis feel free to change it anytime</p> <p className="text-sm text-zinc-500 dark:text-slate-400">This is your name shown on your profile and miis feel free to change it anytime</p>
</div> </div>
<div className="flex justify-end gap-1 h-min col-span-2"> <div className="flex justify-end gap-1 h-min col-span-2">
@ -129,26 +164,59 @@ export default function ProfileSettings({ currentDescription }: Props) {
error={nameChangeError} error={nameChangeError}
onSubmit={handleSubmitNameChange} onSubmit={handleSubmitNameChange}
> >
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1"> <div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 dark:bg-slate-800 dark:border-slate-600">
<p className="font-semibold">New name:</p> <p className="font-semibold dark:text-slate-100">New name:</p>
<p className="indent-4">&apos;{name}&apos;</p> <p className="indent-4 dark:text-slate-300">&apos;{name}&apos;</p>
</div> </div>
</SubmitDialogButton> </SubmitDialogButton>
</div> </div>
</div> </div>
{/* Separator */} {/* Separator - Personalization */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1 dark:text-slate-400">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Personalization</span>
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
{/* Theme Selection */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold dark:text-slate-100">Site Theme</label>
<p className="text-sm text-zinc-500 dark:text-slate-400">Choose your preferred color theme for the site</p>
</div>
<div className="flex justify-end gap-1 h-min col-span-2">
<select
className="pill input flex-1 rounded-xl! cursor-pointer"
value={selectedTheme}
onChange={(e) => setSelectedTheme(e.target.value as Theme)}
>
<option value="LIGHT">Light</option>
<option value="DARK">Dark</option>
<option value="SYSTEM">System</option>
</select>
<SubmitDialogButton
title="Confirm Theme Change"
description="Are you sure you want to save this theme preference to your account?"
error={themeSaveError}
onSubmit={handleThemeSave}
/>
</div>
</div>
{/* Separator - Danger Zone */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1 dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Danger Zone</span> <span>Danger Zone</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300 dark:border-slate-600" />
</div> </div>
{/* Delete Account */} {/* Delete Account */}
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
<div> <div>
<label className="font-semibold">Delete Account</label> <label className="font-semibold dark:text-slate-100">Delete Account</label>
<p className="text-sm text-zinc-500">This will permanently remove your account and all uploaded Miis. This action cannot be undone</p> <p className="text-sm text-zinc-500 dark:text-slate-400">This will permanently remove your account and all uploaded Miis. This action cannot be undone</p>
</div> </div>
<DeleteAccount /> <DeleteAccount />

View file

@ -43,8 +43,8 @@ export default function ProfilePictureSettings() {
return ( return (
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3"> <div className="col-span-3">
<label className="font-semibold">Profile Picture</label> <label className="font-semibold dark:text-slate-100">Profile Picture</label>
<p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 7 days.</p> <p className="text-sm text-zinc-500 dark:text-slate-400">Manage your profile picture. Can only be changed once every 7 days.</p>
</div> </div>
<div className="flex flex-col col-span-2"> <div className="flex flex-col col-span-2">
@ -61,7 +61,7 @@ export default function ProfilePictureSettings() {
alt="new profile picture" alt="new profile picture"
width={96} width={96}
height={96} height={96}
className="rounded-full aspect-square border-2 border-amber-500 object-cover" className="rounded-full aspect-square border-2 border-amber-500 object-cover dark:border-slate-600"
/> />
</Dropzone> </Dropzone>
</div> </div>
@ -83,19 +83,19 @@ export default function ProfilePictureSettings() {
error={error} error={error}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<p className="text-sm text-zinc-500 mt-2"> <p className="text-sm text-zinc-500 mt-2 dark:text-slate-400">
After submitting, you can change it again on {changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} After submitting, you can change it again on {changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
. .
</p> </p>
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center"> <div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center dark:bg-slate-800 dark:border-slate-600">
<p className="font-semibold mb-2">New profile picture:</p> <p className="font-semibold mb-2 dark:text-slate-100">New profile picture:</p>
<img <img
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.png"} src={newPicture ? URL.createObjectURL(newPicture) : "/guest.png"}
alt="new profile picture" alt="new profile picture"
width={128} width={128}
height={128} height={128}
className="rounded-full aspect-square border-2 border-amber-500 ml-auto object-cover" className="rounded-full aspect-square border-2 border-amber-500 ml-auto object-cover dark:border-slate-600"
/> />
</div> </div>
</SubmitDialogButton> </SubmitDialogButton>

View file

@ -50,18 +50,18 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
/> />
<div <div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${ className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col dark:bg-slate-800 dark:border-slate-600 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`} }`}
> >
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">{title}</h2> <h2 className="text-xl font-bold dark:text-slate-100">{title}</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> <button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" /> <Icon icon="material-symbols:close-rounded" />
</button> </button>
</div> </div>
<p className="text-sm text-zinc-500">{description}</p> <p className="text-sm text-zinc-500 dark:text-slate-400">{description}</p>
{children} {children}
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>} {error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}

View file

@ -28,20 +28,20 @@ export default function SearchBar() {
}; };
return ( return (
<div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md"> <div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md dark:ring-slate-500/50">
<input <input
type="text" type="text"
placeholder="Search..." placeholder="Search..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40" className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:placeholder:text-white/40"
/> />
<button <button
onClick={handleSearch} onClick={handleSearch}
aria-label="Search" aria-label="Search"
data-tooltip="Search" data-tooltip="Search"
className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl" className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl dark:bg-slate-600 dark:hover:bg-slate-500"
> >
<Icon icon="ic:baseline-search" /> <Icon icon="ic:baseline-search" />
</button> </button>

View file

@ -0,0 +1,61 @@
import { Icon } from "@iconify/react";
import { useStore } from "@nanostores/react";
import { themeStore, cycleTheme, applyTheme, type Theme } from "../lib/theme";
interface ThemeToggleProps {
size?: "sm" | "md" | "lg";
className?: string;
}
export default function ThemeToggle({ size = "md", className = "" }: ThemeToggleProps) {
const theme = useStore(themeStore);
const sizeClasses = {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-12 w-12",
};
const iconSizes = {
sm: 16,
md: 20,
lg: 24,
};
const handleClick = () => {
const currentTheme: Theme = theme ?? "SYSTEM";
const nextTheme = cycleTheme(currentTheme);
applyTheme(nextTheme);
};
const getIcon = () => {
if (theme === "DARK") return <Icon icon="mdi:moon" fontSize={iconSizes[size]} />;
if (theme === "LIGHT") return <Icon icon="mdi:white-balance-sunny" fontSize={iconSizes[size]} />;
// SYSTEM or undefined - show both
return (
<div className="flex items-center">
<Icon icon="mdi:white-balance-sunny" fontSize={iconSizes[size] - 4} />
<span className="mx-0.5 text-xs">/</span>
<Icon icon="mdi:moon" fontSize={iconSizes[size] - 4} />
</div>
);
};
const getTooltip = () => {
if (theme === "DARK") return "Dark Mode";
if (theme === "LIGHT") return "Light Mode";
return "System Theme";
};
return (
<button
onClick={handleClick}
className={`pill button rounded-full! aspect-square ${sizeClasses[size]} ${className}`}
title={getTooltip()}
aria-label={`Current theme: ${theme ?? "SYSTEM"}. Click to cycle.`}
data-tooltip={getTooltip()}
>
{getIcon()}
</button>
);
}

View file

@ -1,5 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
--animate-like: like 0.5s ease; --animate-like: like 0.5s ease;
@ -24,27 +26,33 @@
} }
.pill { .pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md; @apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md
dark:bg-slate-700 dark:border-slate-600;
} }
.button { .button {
@apply hover:bg-orange-400 transition cursor-pointer; @apply hover:bg-orange-400 transition cursor-pointer
dark:hover:bg-slate-600;
} }
.button:disabled { .button:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto; @apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto
dark:text-zinc-400 dark:bg-slate-800! dark:border-slate-700!;
} }
.input { .input {
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40; @apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40
dark:bg-slate-800! dark:text-slate-100 dark:placeholder:text-white/40 dark:ring-slate-500/50;
} }
.input:disabled { .input:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300!; @apply text-zinc-600 bg-zinc-100! border-zinc-300!
dark:text-zinc-400 dark:bg-slate-800! dark:border-slate-700!;
} }
.checkbox { .checkbox {
@apply flex items-center justify-center appearance-none size-5 bg-orange-300 border-2 border-orange-400 rounded-md cursor-pointer checked:bg-orange-400; @apply flex items-center justify-center appearance-none size-5 bg-orange-300 border-2 border-orange-400 rounded-md cursor-pointer checked:bg-orange-400
dark:bg-slate-700 dark:border-slate-600 dark:checked:bg-slate-500;
} }
.checkbox::after { .checkbox::after {
@ -60,7 +68,8 @@
@apply relative appearance-none bg-zinc-400 rounded-2xl h-5 w-8.5 cursor-pointer transition-all @apply relative appearance-none bg-zinc-400 rounded-2xl h-5 w-8.5 cursor-pointer transition-all
after:transition-all after:bg-zinc-100 after:rounded-full after:h-3.5 after:absolute after:w-3.5 after:transition-all after:bg-zinc-100 after:rounded-full after:h-3.5 after:absolute after:w-3.5
after:left-[3px] after:top-[3px] hover:bg-zinc-500 checked:bg-orange-400 checked:after:left-[16px] after:left-[3px] after:top-[3px] hover:bg-zinc-500 checked:bg-orange-400 checked:after:left-[16px]
checked:hover:bg-orange-500 ml-auto; checked:hover:bg-orange-500 ml-auto
dark:bg-slate-600 dark:hover:bg-slate-500 dark:checked:bg-slate-500;
} }
[data-tooltip] { [data-tooltip] {
@ -72,7 +81,8 @@
} }
[data-tooltip]::after { [data-tooltip]::after {
@apply content-[attr(data-tooltip)] absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white opacity-0 scale-75 transition-all duration-200 ease-out origin-top shadow-md whitespace-nowrap select-none pointer-events-none; @apply content-[attr(data-tooltip)] absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white opacity-0 scale-75 transition-all duration-200 ease-out origin-top shadow-md whitespace-nowrap select-none pointer-events-none
dark:bg-slate-600 dark:border-slate-600;
} }
[data-tooltip]:hover::before, [data-tooltip]:hover::before,
@ -86,11 +96,13 @@
} }
[data-tooltip-span] > .tooltip { [data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999; @apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999
dark:bg-slate-600 dark:border-slate-600;
} }
[data-tooltip-span] > .tooltip::before { [data-tooltip-span] > .tooltip::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400; @apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400
dark:border-b-slate-600;
} }
[data-tooltip-span]:hover > .tooltip { [data-tooltip-span]:hover > .tooltip {
@ -108,6 +120,10 @@
background: #ff8903; background: #ff8903;
} }
.dark *::-webkit-scrollbar-track {
background: #475569;
}
/* Range input */ /* Range input */
input[type="range"] { input[type="range"] {
@apply appearance-none bg-transparent not-disabled:cursor-pointer; @apply appearance-none bg-transparent not-disabled:cursor-pointer;
@ -118,27 +134,50 @@ input[type="range"]::-webkit-slider-runnable-track {
@apply h-1 bg-orange-300 rounded-full; @apply h-1 bg-orange-300 rounded-full;
} }
.dark input[type="range"]::-webkit-slider-runnable-track {
background: #475569;
}
input[type="range"]::-moz-range-track { input[type="range"]::-moz-range-track {
@apply h-1 bg-orange-300 rounded-full; @apply h-1 bg-orange-300 rounded-full;
} }
.dark input[type="range"]::-moz-range-track {
background: #475569;
}
/* Thumb */ /* Thumb */
input[type="range"]::-webkit-slider-thumb, input[type="range"]::-webkit-slider-thumb,
input[type="range"]::-moz-range-thumb { input[type="range"]::-moz-range-thumb {
@apply appearance-none size-4.5 bg-orange-400 border-2 border-orange-600 rounded-full shadow-md transition; @apply appearance-none size-4.5 bg-orange-400 border-2 border-orange-600 rounded-full shadow-md transition;
} }
.dark input[type="range"]::-webkit-slider-thumb,
.dark input[type="range"]::-moz-range-thumb {
background: #64748b;
border-color: #94a3b8;
}
/* Hover */ /* Hover */
input[type="range"]:hover::-webkit-slider-thumb { input[type="range"]:hover::-webkit-slider-thumb {
@apply not-disabled:bg-orange-500; @apply not-disabled:bg-orange-500;
} }
.dark input[type="range"]:hover::-webkit-slider-thumb {
background: #94a3b8;
}
input[type="range"]:hover::-moz-range-thumb { input[type="range"]:hover::-moz-range-thumb {
@apply not-disabled:bg-orange-500; @apply not-disabled:bg-orange-500;
} }
.dark input[type="range"]:hover::-moz-range-thumb {
background: #94a3b8;
}
body { body {
@apply bg-amber-50 text-slate-800 min-h-screen; @apply bg-amber-50 text-slate-800 min-h-screen
dark:bg-slate-900 dark:text-slate-100;
font-family: "Lexend Variable", sans-serif; font-family: "Lexend Variable", sans-serif;
/* syntax highlighting is a bit broken when it's at the top so it's at the bottom */ /* syntax highlighting is a bit broken when it's at the top so it's at the bottom */
@ -151,3 +190,13 @@ body {
</svg>'); </svg>');
background-size: 20px 20px; background-size: 20px 20px;
} }
.dark body {
background-image: url('data:image/svg+xml;utf8,\
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">\
<rect width="10" height="10" fill="%230f172a"/>\
<rect x="10" y="10" width="10" height="10" fill="%230f172a"/>\
<rect x="10" width="10" height="10" fill="%231e293b"/>\
<rect y="10" width="10" height="10" fill="%231e293b"/>\
</svg>');
}

View file

@ -5,6 +5,7 @@ import Header from "./components/header";
import { useEffect } from "react"; import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { session } from "./session"; import { session } from "./session";
import { initializeTheme } from "./lib/theme";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const $session = useStore(session); const $session = useStore(session);
@ -13,6 +14,11 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
// Initialize theme from session/cookie
useEffect(() => {
initializeTheme($session?.user?.theme ?? null);
}, [$session?.user?.theme]);
// Calculate header height // Calculate header height
useEffect(() => { useEffect(() => {
const header = document.querySelector("header"); const header = document.querySelector("header");

86
frontend/src/lib/theme.ts Normal file
View file

@ -0,0 +1,86 @@
import { atom } from "nanostores";
export type Theme = "LIGHT" | "DARK" | "SYSTEM";
const THEME_COOKIE_NAME = "theme";
// Theme store - undefined means not yet initialized
export const themeStore = atom<Theme | undefined>(undefined);
// Get system theme preference
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
// Get resolved theme (actual light/dark to apply)
export function getResolvedTheme(theme: Theme): "light" | "dark" {
if (theme === "SYSTEM") return getSystemTheme();
return theme === "DARK" ? "dark" : "light";
}
// Get theme from cookie
export function getThemeCookie(): Theme | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(^| )${THEME_COOKIE_NAME}=([^;]+)`));
const value = match?.[2];
if (value === "LIGHT" || value === "DARK" || value === "SYSTEM") return value;
return null;
}
// Set theme cookie
export function setThemeCookie(theme: Theme): void {
if (typeof document === "undefined") return;
// Cookie expires in 1 year
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `${THEME_COOKIE_NAME}=${theme};expires=${expires};path=/;SameSite=Lax`;
}
// Apply theme to document
export function applyTheme(theme: Theme): void {
const resolved = getResolvedTheme(theme);
const root = document.documentElement;
if (resolved === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
setThemeCookie(theme);
themeStore.set(theme);
}
// Cycle to next theme
export function cycleTheme(current: Theme): Theme {
const order: Theme[] = ["LIGHT", "DARK", "SYSTEM"];
const currentIndex = order.indexOf(current);
const nextIndex = (currentIndex + 1) % order.length;
return order[nextIndex];
}
// Initialize theme from various sources
export function initializeTheme(serverTheme?: Theme | null): void {
// Priority: cookie > server > system default
const cookieTheme = getThemeCookie();
const initialTheme = cookieTheme ?? serverTheme ?? "SYSTEM";
applyTheme(initialTheme);
// Listen for system theme changes when on SYSTEM
if (typeof window !== "undefined") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
const currentTheme = themeStore.get();
if (currentTheme === "SYSTEM") {
const resolved = getResolvedTheme("SYSTEM");
const root = document.documentElement;
if (resolved === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}
});
}
}

View file

@ -1,10 +1,13 @@
import { atom } from "nanostores"; import { atom } from "nanostores";
export type Theme = "LIGHT" | "DARK" | "SYSTEM";
interface SessionData { interface SessionData {
user?: { user?: {
id: string; id: string;
image: string; image: string;
name: string; name: string;
theme?: Theme;
}; };
} }