diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a5ccbae..a55c585 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/app/api/auth/theme/route.ts b/backend/src/app/api/auth/theme/route.ts new file mode 100644 index 0000000..2d497c7 --- /dev/null +++ b/backend/src/app/api/auth/theme/route.ts @@ -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 }); +} diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 56b515e..a442028 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -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; }, diff --git a/frontend/src/components/admin/banner.tsx b/frontend/src/components/admin/banner.tsx index 4ac02b5..8175e19 100644 --- a/frontend/src/components/admin/banner.tsx +++ b/frontend/src/components/admin/banner.tsx @@ -12,7 +12,7 @@ function RedirectBanner() { if (from !== "old-domain") return null; return ( -
+
We have moved URLs, welcome to tomodachishare.com!
@@ -51,7 +51,7 @@ export default function AdminBanner() { return ( <> {shouldShow && message && ( -
+
{message} diff --git a/frontend/src/components/dropzone.tsx b/frontend/src/components/dropzone.tsx index cd0c5be..ef142eb 100644 --- a/frontend/src/components/dropzone.tsx +++ b/frontend/src/components/dropzone.tsx @@ -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 */}
diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx index 393002b..981afd3 100644 --- a/frontend/src/components/footer.tsx +++ b/frontend/src/components/footer.tsx @@ -7,20 +7,20 @@ export default function Footer() {
{/* Main disclaimer */}
-

TomodachiShare is not affiliated with Nintendo

+

TomodachiShare is not affiliated with Nintendo

{/* Links section */}
- + Terms of Service -
{/* Copyright */}
-

© {new Date().getFullYear()} TomodachiShare. All rights reserved.

+

© {new Date().getFullYear()} TomodachiShare. All rights reserved.

diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index c3d8493..812de6d 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -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() { {!$session?.user ? ( -
  • - - Login - -
  • + <> +
  • + +
  • +
  • + + Login + +
  • + ) : ( <>
  • @@ -82,6 +88,9 @@ export default function Header() { {$session?.user?.name ?? "unknown"}
  • +
  • + +
  • -

    Delete Account

    +

    Delete Account

    -

    Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.

    +

    Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.

    {error && Error: {error}} diff --git a/frontend/src/components/profile-settings/index.tsx b/frontend/src/components/profile-settings/index.tsx index 6e6fa57..07d1289 100644 --- a/frontend/src/components/profile-settings/index.tsx +++ b/frontend/src/components/profile-settings/index.tsx @@ -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("SYSTEM"); + const [themeSaveError, setThemeSaveError] = useState(undefined); const [descriptionChangeError, setDescriptionChangeError] = useState(undefined); const [nameChangeError, setNameChangeError] = useState(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 ( -
    +
    -

    Profile Settings

    -

    Update your profile picture, description, name, etc.

    +

    Settings

    +

    Update your account info, username, and site-wide theme.

    {/* Separator */} -
    -
    +
    +
    Account Info -
    +
    {/* Profile Picture */} - +
    + +
    {/* Description */}
    - -

    Write about yourself on your profile

    + +

    Write about yourself on your profile

    @@ -102,7 +137,7 @@ export default function ProfileSettings({ currentDescription }: Props) { value={description || ""} onChange={(e) => setDescription(e.target.value)} /> -

    {(description || "").length}/256

    +

    {(description || "").length}/256

    - -

    This is your name shown on your profile and miis — feel free to change it anytime

    + +

    This is your name shown on your profile and miis — feel free to change it anytime

    @@ -129,26 +164,59 @@ export default function ProfileSettings({ currentDescription }: Props) { error={nameChangeError} onSubmit={handleSubmitNameChange} > -
    -

    New name:

    -

    '{name}'

    +
    +

    New name:

    +

    '{name}'

    - {/* Separator */} -
    -
    + {/* Separator - Personalization */} +
    +
    + Personalization +
    +
    + + {/* Theme Selection */} +
    +
    + +

    Choose your preferred color theme for the site

    +
    + +
    + + +
    +
    + + {/* Separator - Danger Zone */} +
    +
    Danger Zone -
    +
    {/* Delete Account */}
    - -

    This will permanently remove your account and all uploaded Miis. This action cannot be undone

    + +

    This will permanently remove your account and all uploaded Miis. This action cannot be undone

    diff --git a/frontend/src/components/profile-settings/profile-picture.tsx b/frontend/src/components/profile-settings/profile-picture.tsx index 765afe4..7de4cf6 100644 --- a/frontend/src/components/profile-settings/profile-picture.tsx +++ b/frontend/src/components/profile-settings/profile-picture.tsx @@ -43,8 +43,8 @@ export default function ProfilePictureSettings() { return (
    - -

    Manage your profile picture. Can only be changed once every 7 days.

    + +

    Manage your profile picture. Can only be changed once every 7 days.

    @@ -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" />
    @@ -83,19 +83,19 @@ export default function ProfilePictureSettings() { error={error} onSubmit={handleSubmit} > -

    +

    After submitting, you can change it again on {changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} .

    -
    -

    New profile picture:

    +
    +

    New profile picture:

    new profile picture
    diff --git a/frontend/src/components/profile-settings/submit-dialog-button.tsx b/frontend/src/components/profile-settings/submit-dialog-button.tsx index 2de2168..0384227 100644 --- a/frontend/src/components/profile-settings/submit-dialog-button.tsx +++ b/frontend/src/components/profile-settings/submit-dialog-button.tsx @@ -50,18 +50,18 @@ export default function SubmitDialogButton({ title, description, onSubmit, error />
    -

    {title}

    +

    {title}

    -

    {description}

    +

    {description}

    {children} {error && Error: {error}} diff --git a/frontend/src/components/search-bar.tsx b/frontend/src/components/search-bar.tsx index 73d5b1a..c3849b1 100644 --- a/frontend/src/components/search-bar.tsx +++ b/frontend/src/components/search-bar.tsx @@ -28,20 +28,20 @@ export default function SearchBar() { }; return ( -
    +
    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" /> diff --git a/frontend/src/components/theme-toggle.tsx b/frontend/src/components/theme-toggle.tsx new file mode 100644 index 0000000..b2ee2d9 --- /dev/null +++ b/frontend/src/components/theme-toggle.tsx @@ -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 ; + if (theme === "LIGHT") return ; + // SYSTEM or undefined - show both + return ( +
    + + / + +
    + ); + }; + + const getTooltip = () => { + if (theme === "DARK") return "Dark Mode"; + if (theme === "LIGHT") return "Light Mode"; + return "System Theme"; + }; + + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 6262283..d98d428 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { '); background-size: 20px 20px; } + +.dark body { + background-image: url('data:image/svg+xml;utf8,\ + \ + \ + \ + \ + \ + '); +} diff --git a/frontend/src/layout.tsx b/frontend/src/layout.tsx index e316e6c..12ed133 100644 --- a/frontend/src/layout.tsx +++ b/frontend/src/layout.tsx @@ -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"); diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts new file mode 100644 index 0000000..2c69400 --- /dev/null +++ b/frontend/src/lib/theme.ts @@ -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(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"); + } + } + }); + } +} diff --git a/frontend/src/session.ts b/frontend/src/session.ts index cd33d74..7e399b9 100644 --- a/frontend/src/session.ts +++ b/frontend/src/session.ts @@ -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; }; }