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")
|
||||
}
|
||||
|
||||
enum Theme {
|
||||
LIGHT
|
||||
DARK
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
|
@ -14,6 +20,7 @@ model User {
|
|||
emailVerified DateTime?
|
||||
image String?
|
||||
description String? @db.VarChar(512)
|
||||
theme Theme @default(SYSTEM)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
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) {
|
||||
session.user.id = user.id;
|
||||
session.user.email = user.email;
|
||||
// @ts-expect-error - theme is added to User model
|
||||
session.user.theme = user.theme;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function RedirectBanner() {
|
|||
if (from !== "old-domain") return null;
|
||||
|
||||
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" />
|
||||
<span>We have moved URLs, welcome to tomodachishare.com!</span>
|
||||
</div>
|
||||
|
|
@ -51,7 +51,7 @@ export default function AdminBanner() {
|
|||
return (
|
||||
<>
|
||||
{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">
|
||||
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
|
||||
<span>{message}</span>
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ export default function Dropzone({ onDrop, options, children }: Props) {
|
|||
{...getRootProps()}
|
||||
onDragOver={() => setIsDraggingOver(true)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{/* Used to transition from border-dashed to border-solid */}
|
||||
<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"
|
||||
}`}
|
||||
></div>
|
||||
|
|
|
|||
|
|
@ -7,20 +7,20 @@ export default function Footer() {
|
|||
<div className="max-w-4xl mx-auto px-4 py-4">
|
||||
{/* Main disclaimer */}
|
||||
<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>
|
||||
|
||||
{/* Links section */}
|
||||
<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
|
||||
</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>
|
||||
|
||||
<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
|
||||
</Link>
|
||||
|
||||
|
|
@ -37,22 +37,22 @@ export default function Footer() {
|
|||
Discord
|
||||
</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>
|
||||
|
||||
<Link
|
||||
to="https://trafficlunar.net"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<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>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import SearchBar from "./search-bar";
|
||||
import ThemeToggle from "./theme-toggle";
|
||||
import { Link } from "react-router";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { session } from "../session";
|
||||
|
|
@ -54,11 +55,16 @@ export default function Header() {
|
|||
</Link>
|
||||
</li>
|
||||
{!$session?.user ? (
|
||||
<>
|
||||
<li>
|
||||
<ThemeToggle size="md" />
|
||||
</li>
|
||||
<li>
|
||||
<Link to={"/login"} className="pill button h-full">
|
||||
Login
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<ThemeToggle size="md" />
|
||||
</li>
|
||||
<li title="Logout">
|
||||
<Link
|
||||
to={`${import.meta.env.VITE_API_URL}/api/auth/signout`}
|
||||
|
|
|
|||
|
|
@ -53,18 +53,18 @@ export default function DeleteAccount() {
|
|||
/>
|
||||
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</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>}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -7,6 +8,8 @@ import SubmitDialogButton from "./submit-dialog-button";
|
|||
import DeleteAccount from "./delete-account";
|
||||
import z from "zod";
|
||||
import { useNavigate } from "react-router";
|
||||
import { session } from "../../session";
|
||||
import { type Theme, applyTheme } from "../../lib/theme";
|
||||
|
||||
interface Props {
|
||||
currentDescription: string | null | undefined;
|
||||
|
|
@ -14,12 +17,22 @@ interface Props {
|
|||
|
||||
export default function ProfileSettings({ currentDescription }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const $session = useStore(session);
|
||||
const [description, setDescription] = useState(currentDescription);
|
||||
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 [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 parsed = z.string().trim().max(256).safeParse(description);
|
||||
if (!parsed.success) {
|
||||
|
|
@ -68,28 +81,50 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
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 (
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold">Profile Settings</h2>
|
||||
<p className="text-sm text-zinc-500">Update your profile picture, description, name, etc.</p>
|
||||
<h2 className="text-2xl font-bold dark:text-slate-100">Settings</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-slate-400">Update your account info, username, and site-wide theme.</p>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<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 dark:border-slate-600" />
|
||||
<span>Account Info</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="grow border-zinc-300 dark:border-slate-600" />
|
||||
</div>
|
||||
|
||||
{/* Profile Picture */}
|
||||
<div className="dark:border-slate-600">
|
||||
<ProfilePictureSettings />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
|
||||
<div className="col-span-3">
|
||||
<label className="font-semibold">About Me</label>
|
||||
<p className="text-sm text-zinc-500">Write about yourself on your profile</p>
|
||||
<label className="font-semibold dark:text-slate-100">About Me</label>
|
||||
<p className="text-sm text-zinc-500 dark:text-slate-400">Write about yourself on your profile</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1 h-min col-span-2">
|
||||
|
|
@ -102,7 +137,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
value={description || ""}
|
||||
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>
|
||||
|
||||
<SubmitDialogButton
|
||||
|
|
@ -117,8 +152,8 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
{/* Change Name */}
|
||||
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
|
||||
<div className="col-span-3">
|
||||
<label className="font-semibold">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>
|
||||
<label className="font-semibold dark:text-slate-100">Change Name</label>
|
||||
<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 className="flex justify-end gap-1 h-min col-span-2">
|
||||
|
|
@ -129,26 +164,59 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
error={nameChangeError}
|
||||
onSubmit={handleSubmitNameChange}
|
||||
>
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1">
|
||||
<p className="font-semibold">New name:</p>
|
||||
<p className="indent-4">'{name}'</p>
|
||||
<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 dark:text-slate-100">New name:</p>
|
||||
<p className="indent-4 dark:text-slate-300">'{name}'</p>
|
||||
</div>
|
||||
</SubmitDialogButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
{/* Separator - Personalization */}
|
||||
<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>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>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="grow border-zinc-300 dark:border-slate-600" />
|
||||
</div>
|
||||
|
||||
{/* Delete Account */}
|
||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
||||
<div>
|
||||
<label className="font-semibold">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>
|
||||
<label className="font-semibold dark:text-slate-100">Delete Account</label>
|
||||
<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>
|
||||
|
||||
<DeleteAccount />
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export default function ProfilePictureSettings() {
|
|||
return (
|
||||
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
|
||||
<div className="col-span-3">
|
||||
<label className="font-semibold">Profile Picture</label>
|
||||
<p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 7 days.</p>
|
||||
<label className="font-semibold dark:text-slate-100">Profile Picture</label>
|
||||
<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 className="flex flex-col col-span-2">
|
||||
|
|
@ -61,7 +61,7 @@ export default function ProfilePictureSettings() {
|
|||
alt="new profile picture"
|
||||
width={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>
|
||||
</div>
|
||||
|
|
@ -83,19 +83,19 @@ export default function ProfilePictureSettings() {
|
|||
error={error}
|
||||
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" })}
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center">
|
||||
<p className="font-semibold mb-2">New profile picture:</p>
|
||||
<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 dark:text-slate-100">New profile picture:</p>
|
||||
<img
|
||||
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.png"}
|
||||
alt="new profile picture"
|
||||
width={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>
|
||||
</SubmitDialogButton>
|
||||
|
|
|
|||
|
|
@ -50,18 +50,18 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
|
|||
/>
|
||||
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">{description}</p>
|
||||
<p className="text-sm text-zinc-500 dark:text-slate-400">{description}</p>
|
||||
|
||||
{children}
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
|
|
|||
|
|
@ -28,20 +28,20 @@ export default function SearchBar() {
|
|||
};
|
||||
|
||||
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
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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
|
||||
onClick={handleSearch}
|
||||
aria-label="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" />
|
||||
</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";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--animate-like: like 0.5s ease;
|
||||
|
||||
|
|
@ -24,27 +26,33 @@
|
|||
}
|
||||
|
||||
.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 {
|
||||
@apply hover:bg-orange-400 transition cursor-pointer;
|
||||
@apply hover:bg-orange-400 transition cursor-pointer
|
||||
dark:hover:bg-slate-600;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
|
|
@ -60,7 +68,8 @@
|
|||
@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: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] {
|
||||
|
|
@ -72,7 +81,8 @@
|
|||
}
|
||||
|
||||
[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,
|
||||
|
|
@ -86,11 +96,13 @@
|
|||
}
|
||||
|
||||
[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 {
|
||||
@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 {
|
||||
|
|
@ -108,6 +120,10 @@
|
|||
background: #ff8903;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-track {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* Range input */
|
||||
input[type="range"] {
|
||||
@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;
|
||||
}
|
||||
|
||||
.dark input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-track {
|
||||
@apply h-1 bg-orange-300 rounded-full;
|
||||
}
|
||||
|
||||
.dark input[type="range"]::-moz-range-track {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
input[type="range"]::-webkit-slider-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;
|
||||
}
|
||||
|
||||
.dark input[type="range"]::-webkit-slider-thumb,
|
||||
.dark input[type="range"]::-moz-range-thumb {
|
||||
background: #64748b;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Hover */
|
||||
input[type="range"]:hover::-webkit-slider-thumb {
|
||||
@apply not-disabled:bg-orange-500;
|
||||
}
|
||||
|
||||
.dark input[type="range"]:hover::-webkit-slider-thumb {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
input[type="range"]:hover::-moz-range-thumb {
|
||||
@apply not-disabled:bg-orange-500;
|
||||
}
|
||||
|
||||
.dark input[type="range"]:hover::-moz-range-thumb {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/* syntax highlighting is a bit broken when it's at the top so it's at the bottom */
|
||||
|
|
@ -151,3 +190,13 @@ body {
|
|||
</svg>');
|
||||
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 { useLocation, useNavigate } from "react-router";
|
||||
import { session } from "./session";
|
||||
import { initializeTheme } from "./lib/theme";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
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;
|
||||
|
||||
// Initialize theme from session/cookie
|
||||
useEffect(() => {
|
||||
initializeTheme($session?.user?.theme ?? null);
|
||||
}, [$session?.user?.theme]);
|
||||
|
||||
// Calculate header height
|
||||
useEffect(() => {
|
||||
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";
|
||||
|
||||
export type Theme = "LIGHT" | "DARK" | "SYSTEM";
|
||||
|
||||
interface SessionData {
|
||||
user?: {
|
||||
id: string;
|
||||
image: string;
|
||||
name: string;
|
||||
theme?: Theme;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue