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..2e73b6b
--- /dev/null
+++ b/frontend/src/components/theme-toggle.tsx
@@ -0,0 +1,55 @@
+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 computer/monitor icon
+ return
;
+ };
+
+ const getTooltip = () => {
+ if (theme === "DARK") return "Dark Mode";
+ if (theme === "LIGHT") return "Light Mode";
+ return "System Theme";
+ };
+
+ return (
+
+ {getIcon()}
+
+ );
+}
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/pages/index.tsx b/frontend/src/pages/index.tsx
index a3f6a22..b78097b 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -9,7 +9,7 @@ export default function IndexPage() {
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
- We're currently going through some major code changes therefore some features won't work.
+ We're currently going through some major code changes therefore some features won't work.
>
);
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index 864e649..8300a3f 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -12,13 +12,13 @@ export default function LoginPage() {
return (
-
-
Welcome to TomodachiShare!
+
+
Welcome to TomodachiShare!
-
-
+
+
Choose your login method
-
+
@@ -48,13 +48,13 @@ export default function LoginPage() {
-
+
By signing up, you agree to the{" "}
-
+
Terms of Service
{" "}
and{" "}
-
+
Privacy Policy
.
diff --git a/frontend/src/pages/mii.tsx b/frontend/src/pages/mii.tsx
index a7b5e5b..8281de4 100644
--- a/frontend/src/pages/mii.tsx
+++ b/frontend/src/pages/mii.tsx
@@ -92,7 +92,7 @@ export default function MiiPage() {
)}
{mii.in_queue && (
-
+
This Mii is waiting to be manually reviewed and is hidden from the main page. The review could take between a few hours and a few days.
@@ -102,9 +102,9 @@ export default function MiiPage() {
)}
-
+
{/* Mii Image */}
-
+
{/* QR Code */}
{mii.platform === "THREE_DS" ? (
-
+
) : (
@@ -133,11 +133,11 @@ export default function MiiPage() {
className="rounded-lg hover:brightness-90 mb-4 transition-all"
/>
)}
-
+
{/* Mii Info */}
{mii.platform === "THREE_DS" && (
-
+
Name:{" "}
@@ -154,10 +154,10 @@ export default function MiiPage() {
)}
{/* Mii Platform */}
-
-
+
+
Platform
-
+
@@ -171,7 +171,7 @@ export default function MiiPage() {
@@ -179,7 +179,7 @@ export default function MiiPage() {
@@ -187,10 +187,10 @@ export default function MiiPage() {
{/* Mii Gender */}
-
-
+
+
Gender
-
+
@@ -208,7 +208,7 @@ export default function MiiPage() {
@@ -216,7 +216,7 @@ export default function MiiPage() {
@@ -225,7 +225,7 @@ export default function MiiPage() {
{mii.platform !== "THREE_DS" && (
@@ -236,10 +236,10 @@ export default function MiiPage() {
{/* Makeup */}
{mii.platform === "SWITCH" && (
<>
-
-
+
+
Makeup
-
+
@@ -259,7 +259,7 @@ export default function MiiPage() {
{/* Full Makeup */}
@@ -268,7 +268,7 @@ export default function MiiPage() {
{/* Partial Makeup */}
@@ -277,10 +277,10 @@ export default function MiiPage() {
{/* No Makeup */}
-
+
>
@@ -289,10 +289,10 @@ export default function MiiPage() {
{/* Information */}
-
+
{/* Submission name */}
-
{mii.name}
+ {mii.name}
{/* Like button */}
@@ -306,11 +306,11 @@ export default function MiiPage() {
{/* Author and Created date */}
-
-
+
+
By {mii.user.name}
-
+
Created:{" "}
{new Date(mii.createdAt).toLocaleString("en-GB", {
day: "2-digit",
@@ -330,7 +330,7 @@ export default function MiiPage() {
{/* Buttons */}
-
+
@@ -343,8 +343,8 @@ export default function MiiPage() {
{/* Instructions */}
{mii.platform === "SWITCH" && (
-
-
+
+
Instructions
diff --git a/frontend/src/pages/submit.tsx b/frontend/src/pages/submit.tsx
index 47f32c2..a808cde 100644
--- a/frontend/src/pages/submit.tsx
+++ b/frontend/src/pages/submit.tsx
@@ -173,7 +173,7 @@ export default function SubmitPage() {
return (