mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
parent
2209a17687
commit
4bdfefc1c6
17 changed files with 410 additions and 66 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
53
backend/src/app/api/auth/theme/route.ts
Normal file
53
backend/src/app/api/auth/theme/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<>
|
||||||
<Link to={"/login"} className="pill button h-full">
|
<li>
|
||||||
Login
|
<ThemeToggle size="md" />
|
||||||
</Link>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
|
<Link to={"/login"} className="pill button h-full">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</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`}
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
<ProfilePictureSettings />
|
<div className="dark:border-slate-600">
|
||||||
|
<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">'{name}'</p>
|
<p className="indent-4 dark:text-slate-300">'{name}'</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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
61
frontend/src/components/theme-toggle.tsx
Normal file
61
frontend/src/components/theme-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
86
frontend/src/lib/theme.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue