This commit is contained in:
Landon & Emma 2026-04-24 14:42:41 -04:00 committed by GitHub
commit a4f92f1605
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 426 additions and 154 deletions

View file

@ -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

View file

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

View file

@ -39,6 +39,8 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (user) {
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;
},

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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";
@ -42,30 +43,35 @@ export default function Header() {
<Link
to={`${import.meta.env.VITE_API_URL}/random`}
aria-label="Go to Random Link"
className="pill button p-0! h-full aspect-square"
className="pill button p-0! h-full aspect-square dark:bg-orange-300 dark:border-orange-400"
data-tooltip="Go to a Random Mii"
>
<Icon icon="mdi:dice-3" fontSize={28} />
</Link>
</li>
<li>
<Link to={"/submit"} className="pill button h-full">
<Link to={"/submit"} className="pill button h-full dark:bg-orange-300 dark:border-orange-400">
Submit
</Link>
</li>
{!$session?.user ? (
<>
<li>
<Link to={"/login"} className="pill button h-full">
<ThemeToggle size="md" />
</li>
<li>
<Link to={"/login"} className="pill button h-full dark:bg-orange-300 dark:border-orange-400">
Login
</Link>
</li>
</>
) : (
<>
<li title="Your profile">
<Link
to={`/profile/${$session?.user?.id}`}
aria-label="Go to profile"
className="pill button gap-2! p-0! h-full max-w-64"
className="pill button gap-2! p-0! h-full max-w-64 dark:bg-orange-300 dark:border-orange-400"
data-tooltip="Your Profile"
>
<img
@ -82,11 +88,14 @@ 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`}
aria-label="Log Out"
className="pill button p-2! aspect-square h-full"
className="pill button p-2! aspect-square h-full dark:bg-orange-300 dark:border-orange-400"
data-tooltip="Log Out"
>
<Icon icon="ic:round-logout" fontSize={24} />

View file

@ -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>}

View file

@ -69,27 +69,29 @@ export default function ProfileSettings({ currentDescription }: Props) {
};
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 and username.</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 +104,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 +119,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 +131,26 @@ 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">&apos;{name}&apos;</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">&apos;{name}&apos;</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 - 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 />

View file

@ -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>

View file

@ -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>}

View file

@ -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>

View file

@ -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 <Icon icon="mdi:moon" fontSize={iconSizes[size]} />;
if (theme === "LIGHT") return <Icon icon="mdi:white-balance-sunny" fontSize={iconSizes[size]} />;
// SYSTEM or undefined - show computer/monitor icon
return <Icon icon="mdi:monitor" fontSize={iconSizes[size]} />;
};
const getTooltip = () => {
if (theme === "DARK") return "Dark Mode";
if (theme === "LIGHT") return "Light Mode";
return "System Theme";
};
return (
<button
onClick={handleClick}
className={`pill button rounded-full! aspect-square ${sizeClasses[size]} ${className}`}
title={getTooltip()}
aria-label={`Current theme: ${theme ?? "SYSTEM"}. Click to cycle.`}
data-tooltip={getTooltip()}
>
{getIcon()}
</button>
);
}

View file

@ -1,5 +1,7 @@
@import "tailwindcss";
@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>');
}

View file

@ -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
View file

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

View file

@ -9,7 +9,7 @@ export default function IndexPage() {
<h1 className="sr-only">
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
</h1>
<p className="text-center mb-4">We're currently going through some major code changes therefore some features won't work.</p>
<p className="text-center mb-4 dark:text-slate-300">We're currently going through some major code changes therefore some features won't work.</p>
<MiiList />
</>
);

View file

@ -12,13 +12,13 @@ export default function LoginPage() {
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 className="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center dark:bg-slate-800 dark:border-slate-600">
<h1 className="text-3xl font-bold mb-4 dark:text-slate-100">Welcome to TomodachiShare!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr className="grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8 dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Choose your login method</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div className="flex flex-col items-center gap-2">
@ -48,13 +48,13 @@ export default function LoginPage() {
</Link>
</div>
<p className="mt-8 text-xs text-zinc-400">
<p className="mt-8 text-xs text-zinc-400 dark:text-slate-500">
By signing up, you agree to the{" "}
<Link to="/terms-of-service" className="underline hover:text-zinc-600">
<Link to="/terms-of-service" className="underline hover:text-zinc-600 dark:hover:text-slate-300">
Terms of Service
</Link>{" "}
and{" "}
<Link to="/privacy" className="underline hover:text-zinc-600">
<Link to="/privacy" className="underline hover:text-zinc-600 dark:hover:text-slate-300">
Privacy Policy
</Link>
.

View file

@ -92,7 +92,7 @@ export default function MiiPage() {
</div>
)}
{mii.in_queue && (
<div className="bg-zinc-50 border-2 border-zinc-400 rounded-2xl shadow-lg p-4 flex items-start gap-3 text-zinc-600">
<div className="bg-zinc-50 border-2 border-zinc-400 rounded-2xl shadow-lg p-4 flex items-start gap-3 text-zinc-600 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-300">
<Icon icon="material-symbols:timer" className="text-2xl shrink-0" />
<p className="font-medium">
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() {
</div>
)}
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2 dark:bg-slate-800 dark:border-slate-600">
{/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center dark:from-slate-700 dark:to-slate-800">
<ImageViewer
src={`${API_URL}/mii/${mii.id}/image?type=mii`}
alt="mii headshot"
@ -115,13 +115,13 @@ export default function MiiPage() {
</div>
{/* QR Code */}
{mii.platform === "THREE_DS" ? (
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2 dark:bg-slate-700">
<ImageViewer
src={`${API_URL}/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code"
width={128}
height={128}
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all dark:border-slate-600"
/>
</div>
) : (
@ -133,11 +133,11 @@ export default function MiiPage() {
className="rounded-lg hover:brightness-90 mb-4 transition-all"
/>
)}
<hr className="w-full border-t-2 border-t-amber-400" />
<hr className="w-full border-t-2 border-t-amber-400 dark:border-t-slate-600" />
{/* Mii Info */}
{mii.platform === "THREE_DS" && (
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1 dark:text-slate-300">
<li>
Name:{" "}
<span className="text-right font-medium">
@ -154,10 +154,10 @@ export default function MiiPage() {
)}
{/* Mii Platform */}
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
<hr className="grow border-zinc-300" />
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"} dark:text-slate-400`}>
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Platform</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
@ -171,7 +171,7 @@ export default function MiiPage() {
<div
className={`rounded-xl flex justify-center items-center size-13 text-3xl border-2 shadow-sm ${
mii.platform === "THREE_DS" ? "bg-sky-100 border-sky-400" : "bg-white border-gray-300"
mii.platform === "THREE_DS" ? "bg-sky-100 border-sky-400" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-sky-500" />
@ -179,7 +179,7 @@ export default function MiiPage() {
<div
className={`rounded-xl flex justify-center items-center size-13 text-3xl border-2 shadow-sm ${
mii.platform === "SWITCH" ? "bg-red-100 border-red-400" : "bg-white border-gray-300"
mii.platform === "SWITCH" ? "bg-red-100 border-red-400" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
@ -187,10 +187,10 @@ export default function MiiPage() {
</div>
{/* Mii Gender */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
<hr className="grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="flex gap-1">
@ -208,7 +208,7 @@ export default function MiiPage() {
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300"
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
@ -216,7 +216,7 @@ export default function MiiPage() {
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "FEMALE" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300"
mii.gender === "FEMALE" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
@ -225,7 +225,7 @@ export default function MiiPage() {
{mii.platform !== "THREE_DS" && (
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "NONBINARY" ? "bg-purple-100 border-purple-400" : "bg-white border-gray-300"
mii.gender === "NONBINARY" ? "bg-purple-100 border-purple-400" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
@ -236,10 +236,10 @@ export default function MiiPage() {
{/* Makeup */}
{mii.platform === "SWITCH" && (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 mt-2 w-full">
<hr className="grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 mt-2 w-full dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Makeup</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div data-tooltip-span title={mii.makeup ?? "NULL"} className="flex gap-1">
@ -259,7 +259,7 @@ export default function MiiPage() {
{/* Full Makeup */}
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.makeup === "FULL" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300"
mii.makeup === "FULL" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="mdi:palette" className="text-pink-400" />
@ -268,7 +268,7 @@ export default function MiiPage() {
{/* Partial Makeup */}
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.makeup === "PARTIAL" ? "bg-purple-100 border-purple-400" : "bg-white border-gray-300"
mii.makeup === "PARTIAL" ? "bg-purple-100 border-purple-400" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="mdi:lipstick" className="text-purple-400" />
@ -277,10 +277,10 @@ export default function MiiPage() {
{/* No Makeup */}
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.makeup === "NONE" ? "bg-gray-200 border-gray-400" : "bg-white border-gray-300"
mii.makeup === "NONE" ? "bg-gray-200 border-gray-400 dark:bg-gray-600 dark:border-gray-700" : "bg-white border-gray-300 dark:bg-slate-800 dark:border-slate-600"
}`}
>
<Icon icon="codex:cross" className="text-gray-400" />
<Icon icon="codex:cross" className="text-gray-400 dark:text-gray-200" />
</div>
</div>
</>
@ -289,10 +289,10 @@ export default function MiiPage() {
<div className="col-span-2 flex flex-col gap-4 max-md:col-span-1">
{/* Information */}
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-1">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-1 dark:bg-slate-800 dark:border-slate-600">
<div className="flex justify-between items-start">
{/* Submission name */}
<h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1>
<h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0 dark:text-amber-500">{mii.name}</h1>
{/* Like button */}
<LikeButton likes={mii.likeCount ?? 0} miiId={mii.id} isLiked={isLiked} big />
</div>
@ -306,11 +306,11 @@ export default function MiiPage() {
</div>
{/* Author and Created date */}
<div className="mt-2">
<Link to={`/profile/${mii.userId}`} className="text-lg wrap-break-word">
<div className="mt-2 dark:text-slate-300">
<Link to={`/profile/${mii.userId}`} className="text-lg wrap-break-word dark:text-slate-200">
By <span className="font-bold">{mii.user.name}</span>
</Link>
<h4 className="text-sm">
<h4 className="text-sm dark:text-slate-400">
Created:{" "}
{new Date(mii.createdAt).toLocaleString("en-GB", {
day: "2-digit",
@ -330,7 +330,7 @@ export default function MiiPage() {
</div>
{/* Buttons */}
<div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
<div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center dark:bg-slate-800 dark:border-slate-600 *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
<AuthorButtons mii={mii} />
<ShareMiiButton miiId={mii.id} />
@ -343,8 +343,8 @@ export default function MiiPage() {
{/* Instructions */}
{mii.platform === "SWITCH" && (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto">
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto dark:bg-slate-800 dark:border-slate-600">
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2 dark:text-amber-500">
<Icon icon="fa7-solid:list" />
Instructions
</h2>

View file

@ -173,7 +173,7 @@ export default function SubmitPage() {
return (
<div className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 dark:bg-slate-800 dark:border-slate-600">
<Carousel
images={[
miiPortraitUri ?? "/loading.svg",
@ -183,7 +183,7 @@ export default function SubmitPage() {
/>
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
<h1 className="font-bold text-2xl line-clamp-1 dark:text-slate-100" title={name}>
{name || "Mii name"}
</h1>
<div id="tags" className="flex flex-wrap gap-1">
@ -206,19 +206,19 @@ export default function SubmitPage() {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 w-full">
<div>
<h2 className="text-2xl font-bold">Submit your Mii</h2>
<p className="text-sm text-zinc-500">Share your creation for others to see.</p>
<p className="text-sm text-zinc-500 dark:text-slate-400">Share your creation for others to see.</p>
</div>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<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>Info</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
{/* Platform select */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
<label htmlFor="name" className="font-semibold dark:text-slate-100">
Platform
</label>
<div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10">
@ -258,7 +258,7 @@ export default function SubmitPage() {
{/* Name */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
<label htmlFor="name" className="font-semibold dark:text-slate-100">
Name
</label>
<input
@ -274,7 +274,7 @@ export default function SubmitPage() {
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="tags" className="font-semibold">
<label htmlFor="tags" className="font-semibold dark:text-slate-100">
Tags
</label>
<TagSelector tags={tags} setTags={setTags} showTagLimit />
@ -282,7 +282,7 @@ export default function SubmitPage() {
{/* Description */}
<div className="w-full grid grid-cols-3 items-start">
<label htmlFor="description" className="font-semibold py-2">
<label htmlFor="description" className="font-semibold py-2 dark:text-slate-100">
Description
</label>
<textarea
@ -298,7 +298,7 @@ export default function SubmitPage() {
{/* Gender (switch only) */}
<div className={`w-full grid grid-cols-3 items-start z-20 ${platform === "SWITCH" ? "" : "hidden"}`}>
<label htmlFor="gender" className="font-semibold py-2">
<label htmlFor="gender" className="font-semibold py-2 dark:text-slate-100">
Gender
</label>
<div className="col-span-2 flex gap-1">
@ -308,7 +308,7 @@ export default function SubmitPage() {
aria-label="Filter for Male Miis"
data-tooltip="Male"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400 dark:bg-slate-800 dark:border-slate-600 dark:hover:border-slate-500"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
@ -320,7 +320,7 @@ export default function SubmitPage() {
aria-label="Filter for Female Miis"
data-tooltip="Female"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400 dark:bg-slate-800 dark:border-slate-600 dark:hover:border-slate-500"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
@ -332,7 +332,7 @@ export default function SubmitPage() {
aria-label="Filter for Nonbinary Miis"
data-tooltip="Nonbinary"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400 dark:bg-slate-800 dark:border-slate-600 dark:hover:border-slate-500"
}`}
>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
@ -342,7 +342,7 @@ export default function SubmitPage() {
{/* Makeup (switch only) */}
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
<label className="font-semibold py-2">Face Paint</label>
<label className="font-semibold py-2 dark:text-slate-100">Face Paint</label>
<div className="col-span-2 flex flex-col gap-1.5">
{[
@ -355,11 +355,11 @@ export default function SubmitPage() {
type="button"
onClick={() => setMakeup(value as MiiMakeup)}
className={`cursor-pointer rounded-xl text-left px-3 py-2 border-2 transition-all ${
makeup === value ? `bg-${color}-100 border-${color}-400 shadow-md` : "bg-white border-gray-300 hover:border-gray-400"
makeup === value ? `bg-${color}-100 border-${color}-400 shadow-md` : "bg-white border-gray-300 hover:border-gray-400 dark:bg-slate-800 dark:border-slate-600 dark:hover:border-slate-500"
}`}
>
<div className={`font-medium text-sm ${makeup === value ? `text-${color}-500` : "text-gray-500"}`}>{label}</div>
<div className="text-xs text-gray-500 mt-0.5">{desc}</div>
<div className={`font-medium text-sm ${makeup === value ? `text-${color}-500` : "text-gray-500 dark:text-slate-400"}`}>{label}</div>
<div className="text-xs text-gray-500 mt-0.5 dark:text-slate-500">{desc}</div>
</button>
))}
</div>
@ -368,10 +368,10 @@ export default function SubmitPage() {
{/* (Switch Only) Mii Screenshots */}
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2 dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Mii Screenshots</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div className="flex flex-col items-center gap-4 w-full">
@ -379,7 +379,7 @@ export default function SubmitPage() {
<div className="flex flex-col items-center gap-2 w-full">
<div className="flex items-center gap-2 self-start">
<span className="bg-orange-400 text-white text-xs font-bold rounded-full size-5 flex items-center justify-center shrink-0">1</span>
<span className="text-sm font-semibold text-zinc-600">Portrait screenshot</span>
<span className="text-sm font-semibold text-zinc-600 dark:text-slate-300">Portrait screenshot</span>
</div>
<div className="flex gap-3 w-full items-start max-sm:flex-col max-sm:items-center">
<div data-tooltip="Your screenshot should look like this">
@ -388,7 +388,7 @@ export default function SubmitPage() {
alt="Example portrait screenshot"
width={80}
height={80}
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70 dark:border-slate-600"
/>
</div>
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={setMiiPortraitUri} forceCrop />
@ -399,8 +399,8 @@ export default function SubmitPage() {
<div className="flex flex-col items-center gap-2 w-full">
<div className="flex items-center gap-2 self-start">
<span className="bg-orange-400 text-white text-xs font-bold rounded-full size-5 flex items-center justify-center shrink-0">2</span>
<span className="text-sm font-semibold text-zinc-600">
Features screenshot <span className="text-orange-500">(the features panel - see example)</span>
<span className="text-sm font-semibold text-zinc-600 dark:text-slate-300">
Features screenshot <span className="text-orange-500 dark:text-orange-400">(the features panel - see example)</span>
</span>
</div>
<div className="flex gap-3 w-full items-start max-sm:flex-col max-sm:items-center">
@ -410,7 +410,7 @@ export default function SubmitPage() {
alt="Example features screenshot showing the parts panel"
width={80}
height={80}
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70 dark:border-slate-600"
/>
</div>
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} />
@ -420,20 +420,20 @@ export default function SubmitPage() {
<SwitchSubmitTutorialButton />
</div>
<p className="text-xs text-zinc-400 text-center mt-2">A tutorial on how to screenshot the features is above.</p>
<p className="text-xs text-zinc-400 text-center mt-2 dark:text-slate-500">A tutorial on how to screenshot the features is above.</p>
</div>
{/* (3DS only) QR code scanning */}
<div className={`${platform === "THREE_DS" ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2 dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>QR Code</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<span className="dark:text-slate-300">or</span>
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
@ -443,22 +443,22 @@ export default function SubmitPage() {
<Camera isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<ThreeDsScanTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
<span className="text-xs text-zinc-400 dark:text-slate-500">For emulators, aes_keys.txt is required.</span>
</div>
</div>
{/* (Switch only) Mii instructions */}
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2 dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Mii Instructions</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div className="flex flex-col items-center gap-2">
{/* YouTube */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="youtube" className="font-semibold">
<label htmlFor="youtube" className="font-semibold dark:text-slate-100">
YouTube Video
</label>
<input
@ -479,34 +479,34 @@ export default function SubmitPage() {
<MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton />
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8 dark:text-slate-500">
Mii editor may be inaccurate. Instructions are REALLY recommended, but you do not have to add every instruction.
</span>
</div>
</div>
{/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<hr className="grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2 dark:text-slate-400">
<hr className="grow border-zinc-300 dark:border-slate-600" />
<span>Custom images</span>
<hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300 dark:border-slate-600" />
</div>
<div className="max-w-md w-full self-center flex flex-col items-center">
<Dropzone onDrop={handleDrop}>
<p className="text-center text-sm">
<p className="text-center text-sm dark:text-slate-300">
Drag and drop your images here
<br />
or click to open
</p>
</Dropzone>
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
<span className="text-xs text-zinc-400 mt-2 dark:text-slate-500">Animated images currently not supported.</span>
</div>
<ImageList files={files} setFiles={setFiles} />
<hr className="border-zinc-300 my-2" />
<hr className="border-zinc-300 my-2 dark:border-slate-600" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}

View file

@ -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;
};
}