Compare commits

..

5 commits

Author SHA1 Message Date
Landon & Emma
a0d06b5251
Merge 3dfba9701d into af7f1380bc 2026-04-24 19:10:06 +01:00
Landon & Emma
3dfba9701d
Merge branch 'trafficlunar:main' into main 2026-04-24 14:09:32 -04:00
af7f1380bc fix: can't scroll misc tab on mobile 2026-04-24 18:30:35 +01:00
Landon & Emma
c2567d1b9f
Merge branch 'trafficlunar:main' into main 2026-04-24 13:12:06 -04:00
51d46fc9ce chore: update development instructions 2026-04-24 11:52:09 +01:00
4 changed files with 320 additions and 307 deletions

View file

@ -2,7 +2,7 @@
This is probably outdated. This is probably outdated.
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety. Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the backend, [Vite with React](https://vite.dev/) for the frontend, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
## Getting started ## Getting started
@ -14,21 +14,21 @@ $ cd tomodachi-share
$ pnpm install $ pnpm install
``` ```
Prisma types are generated automatically, however, sometimes you might need to: Prisma types are generated automatically, however, if you changed the schema or need to trigger a manual refresh:
```bash ```bash
# Generate Prisma client types # Generate Prisma client types
$ pnpm prisma generate $ pnpm --filter backend prisma generate
# Or, if you've added new database properties # Or, if you've added new database properties
$ pnpm prisma migrate dev $ pnpm --filter backend prisma migrate dev
$ pnpm prisma generate $ pnpm --filter backend prisma generate
``` ```
I recommend opting out of Next.js' telemetry program but it is not a requirement. I recommend opting out of Next.js' telemetry program but it is not a requirement.
```bash ```bash
$ pnpm exec next telemetry disable $ pnpm --filter backend exec next telemetry disable
``` ```
## Environment variables ## Environment variables
@ -62,10 +62,11 @@ services:
After starting the docker applications, apply TomodachiShare's database schema migrations. After starting the docker applications, apply TomodachiShare's database schema migrations.
```bash ```bash
$ pnpm prisma migrate dev $ pnpm --filter backend prisma migrate dev
``` ```
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest of the variables. After, in both the backend and frontend, make a copy of the `.env.example` file and rename it to `.env`.
For the backend, the database variables should be pre-configured, but you'll need to fill in the rest of the variables.
For the `AUTH_SECRET`, run the following in the command line: For the `AUTH_SECRET`, run the following in the command line:
@ -74,7 +75,7 @@ $ pnpx auth secret
``` ```
> [!NOTE] > [!NOTE]
> This command may put the secret in a file named `.env.local`, if that happens copy it and paste it into `.env` > This command may put the secret in a file named `.env.local`, if that happens copy it and paste it into `backend/.env`
Now, let's get the Discord and GitHub authentication set up. If you don't plan on editing any code associated with authentication, you likely only need to setup one of these services. Now, let's get the Discord and GitHub authentication set up. If you don't plan on editing any code associated with authentication, you likely only need to setup one of these services.
@ -84,10 +85,16 @@ For GitHub, navigate to your profile settings, then 'Developer Settings', and cr
Google is annoying so I'm not explaining it. Google is annoying so I'm not explaining it.
After configuring the environment variables, you can run a development server. ## Development Server
The frontend and backend need to be ran simulatenously, therefore you need two separate terminals.
```bash ```bash
$ pnpm dev # Terminal 1
$ pnpm --filter backend dev
# Terminal 2
$ pnpm --filter frontend dev
``` ```
## Building ## Building
@ -96,8 +103,10 @@ It's a good idea to build the project locally before submitting a pull request.
```bash ```bash
# Build the project # Build the project
$ pnpm build $ pnpm --filter backend build
$ pnpm --filter frontend build
# Run the built version # Run the built version (Note: Vite likes to change the port when this happens, so you probably need to change both .env files)
$ pnpm start $ pnpm --filter backend start
$ pnpm --filter frontend build
``` ```

4
frontend/.env.example Normal file
View file

@ -0,0 +1,4 @@
VITE_BASE_URL="http://localhost:5173"
VITE_API_URL="http://localhost:3000"
VITE_ADMIN_USER_ID=1
VITE_CONTRIBUTORS_USER_IDS=1

View file

@ -1,80 +1,80 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared"; import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import React, { useState } from "react"; import React, { useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import HeadTab from "./tabs/head"; import HeadTab from "./tabs/head";
import HairTab from "./tabs/hair"; import HairTab from "./tabs/hair";
import EyebrowsTab from "./tabs/eyebrows"; import EyebrowsTab from "./tabs/eyebrows";
import EyesTab from "./tabs/eyes"; import EyesTab from "./tabs/eyes";
import NoseTab from "./tabs/nose"; import NoseTab from "./tabs/nose";
import LipsTab from "./tabs/lips"; import LipsTab from "./tabs/lips";
import EarsTab from "./tabs/ears"; import EarsTab from "./tabs/ears";
import GlassesTab from "./tabs/glasses"; import GlassesTab from "./tabs/glasses";
import OtherTab from "./tabs/other"; import OtherTab from "./tabs/other";
import MiscTab from "./tabs/misc"; import MiscTab from "./tabs/misc";
interface Props { interface Props {
instructions: React.RefObject<SwitchMiiInstructions>; instructions: React.RefObject<SwitchMiiInstructions>;
} }
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc"; type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
export const TAB_ICONS: Record<Tab, string> = { export const TAB_ICONS: Record<Tab, string> = {
head: "mingcute:head-fill", head: "mingcute:head-fill",
hair: "mingcute:hair-fill", hair: "mingcute:hair-fill",
eyebrows: "material-symbols:eyebrow", eyebrows: "material-symbols:eyebrow",
eyes: "mdi:eye", eyes: "mdi:eye",
nose: "mingcute:nose-fill", nose: "mingcute:nose-fill",
lips: "material-symbols-light:lips", lips: "material-symbols-light:lips",
ears: "ion:ear", ears: "ion:ear",
glasses: "solar:glasses-bold", glasses: "solar:glasses-bold",
other: "mdi:sparkles", other: "mdi:sparkles",
misc: "material-symbols:settings", misc: "material-symbols:settings",
}; };
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = { export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
head: HeadTab, head: HeadTab,
hair: HairTab, hair: HairTab,
eyebrows: EyebrowsTab, eyebrows: EyebrowsTab,
eyes: EyesTab, eyes: EyesTab,
nose: NoseTab, nose: NoseTab,
lips: LipsTab, lips: LipsTab,
ears: EarsTab, ears: EarsTab,
glasses: GlassesTab, glasses: GlassesTab,
other: OtherTab, other: OtherTab,
misc: MiscTab, misc: MiscTab,
}; };
export default function MiiEditor({ instructions }: Props) { export default function MiiEditor({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("head"); const [tab, setTab] = useState<Tab>("head");
return ( return (
<> <>
<div className="w-full h-91 flex flex-col sm:flex-row bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden"> <div className="w-full h-91 flex flex-col sm:flex-row bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">
<div className="w-full flex flex-row sm:flex-col max-sm:max-h-9 sm:max-w-9"> <div className="w-full flex flex-row sm:flex-col max-sm:max-h-9 sm:max-w-9">
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => ( {(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={`size-full aspect-square flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`} className={`size-full aspect-square flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`}
> >
{/* ml because of border on left causing icons to look miscentered */} {/* ml because of border on left causing icons to look miscentered */}
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" /> <Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
</button> </button>
))} ))}
</div> </div>
{/* Keep all tabs loaded to avoid flickering */} {/* Keep all tabs loaded to avoid flickering */}
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => { {(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
const TabComponent = TAB_COMPONENTS[t]; const TabComponent = TAB_COMPONENTS[t];
return ( return (
<div key={t} className={t === tab ? "grow relative p-3" : "hidden"}> <div key={t} className={t === tab ? "grow relative p-3 min-h-0" : "hidden"}>
<TabComponent instructions={instructions} /> <TabComponent instructions={instructions} />
</div> </div>
); );
})} })}
</div> </div>
</> </>
); );
} }

View file

@ -1,213 +1,213 @@
import { useState } from "react"; import { useState } from "react";
import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared"; import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared";
import EnhancedSlider from "../enhanced-slider"; import EnhancedSlider from "../enhanced-slider";
import DatingPreferencesViewer from "../../../mii/dating-preferences"; import DatingPreferencesViewer from "../../../mii/dating-preferences";
import VoiceViewer from "../../../mii/voice-viewer"; import VoiceViewer from "../../../mii/voice-viewer";
import PersonalityViewer from "../../../mii/personality-viewer"; import PersonalityViewer from "../../../mii/personality-viewer";
interface Props { interface Props {
instructions: React.RefObject<SwitchMiiInstructions>; instructions: React.RefObject<SwitchMiiInstructions>;
} }
export default function MiscTab({ instructions }: Props) { export default function MiscTab({ instructions }: Props) {
const [height, setHeight] = useState(instructions.current.height ?? 64); const [height, setHeight] = useState(instructions.current.height ?? 64);
const [weight, setWeight] = useState(instructions.current.weight ?? 64); const [weight, setWeight] = useState(instructions.current.weight ?? 64);
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []); const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
const [voice, setVoice] = useState({ const [voice, setVoice] = useState({
speed: instructions.current.voice.speed ?? 25, speed: instructions.current.voice.speed ?? 25,
pitch: instructions.current.voice.pitch ?? 25, pitch: instructions.current.voice.pitch ?? 25,
depth: instructions.current.voice.depth ?? 25, depth: instructions.current.voice.depth ?? 25,
delivery: instructions.current.voice.delivery ?? 25, delivery: instructions.current.voice.delivery ?? 25,
tone: instructions.current.voice.tone ?? 0, tone: instructions.current.voice.tone ?? 0,
}); });
const [birthday, setBirthday] = useState({ const [birthday, setBirthday] = useState({
day: instructions.current.birthday.day ?? (null as number | null), day: instructions.current.birthday.day ?? (null as number | null),
month: instructions.current.birthday.month ?? (null as number | null), month: instructions.current.birthday.month ?? (null as number | null),
age: instructions.current.birthday.age ?? (null as number | null), age: instructions.current.birthday.age ?? (null as number | null),
dontAge: instructions.current.birthday.dontAge, dontAge: instructions.current.birthday.dontAge,
}); });
const [personality, setPersonality] = useState({ const [personality, setPersonality] = useState({
movement: instructions.current.personality.movement ?? -1, movement: instructions.current.personality.movement ?? -1,
speech: instructions.current.personality.speech ?? -1, speech: instructions.current.personality.speech ?? -1,
energy: instructions.current.personality.energy ?? -1, energy: instructions.current.personality.energy ?? -1,
thinking: instructions.current.personality.thinking ?? -1, thinking: instructions.current.personality.thinking ?? -1,
overall: instructions.current.personality.overall ?? -1, overall: instructions.current.personality.overall ?? -1,
}); });
return ( return (
<> <>
<h1 className="font-bold text-xl">Misc</h1> <h1 className="font-bold text-xl">Misc</h1>
<div className="grow h-full overflow-y-auto pb-3"> <div className="grow h-full overflow-y-auto pb-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Body</span> <span>Body</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<EnhancedSlider <EnhancedSlider
label="Height" label="Height"
value={height} value={height}
onChange={(v) => { onChange={(v) => {
setHeight(v); setHeight(v);
instructions.current.height = v; instructions.current.height = v;
}} }}
min={0} min={0}
max={128} max={128}
mid={64} mid={64}
/> />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<EnhancedSlider <EnhancedSlider
label="Weight" label="Weight"
value={weight} value={weight}
onChange={(v) => { onChange={(v) => {
setWeight(v); setWeight(v);
instructions.current.weight = v; instructions.current.weight = v;
}} }}
min={0} min={0}
max={128} max={128}
mid={64} mid={64}
/> />
</div> </div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Dating Preferences</span> <span>Dating Preferences</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<DatingPreferencesViewer <DatingPreferencesViewer
data={datingPreferences} data={datingPreferences}
onChecked={(e, gender) => { onChecked={(e, gender) => {
setDatingPreferences((prev) => { setDatingPreferences((prev) => {
const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender); const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
instructions.current.datingPreferences = updated; instructions.current.datingPreferences = updated;
return updated; return updated;
}); });
}} }}
/> />
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Voice</span> <span>Voice</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<VoiceViewer <VoiceViewer
data={voice} data={voice}
onChange={(v, label) => { onChange={(v, label) => {
setVoice((p) => ({ ...p, [label]: v })); setVoice((p) => ({ ...p, [label]: v }));
instructions.current.voice[label as keyof typeof voice] = v; instructions.current.voice[label as keyof typeof voice] = v;
}} }}
onClickTone={(i) => { onClickTone={(i) => {
setVoice((p) => ({ ...p, tone: i })); setVoice((p) => ({ ...p, tone: i }));
instructions.current.voice.tone = i; instructions.current.voice.tone = i;
}} }}
/> />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Birthday</span> <span>Birthday</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div> <div>
<label htmlFor="day" className="text-xs"> <label htmlFor="day" className="text-xs">
Day Day
</label> </label>
<input <input
type="number" type="number"
id="day" id="day"
min={1} min={1}
max={31} max={31}
className="pill input text-sm py-1! px-3! w-full" className="pill input text-sm py-1! px-3! w-full"
value={birthday.day ?? undefined} value={birthday.day ?? undefined}
onChange={(e) => { onChange={(e) => {
setBirthday((p) => ({ ...p, day: e.target.valueAsNumber })); setBirthday((p) => ({ ...p, day: e.target.valueAsNumber }));
instructions.current.birthday.day = e.target.valueAsNumber; instructions.current.birthday.day = e.target.valueAsNumber;
}} }}
/> />
</div> </div>
<div> <div>
<label htmlFor="month" className="text-xs"> <label htmlFor="month" className="text-xs">
Month Month
</label> </label>
<input <input
type="number" type="number"
id="month" id="month"
min={1} min={1}
max={12} max={12}
className="pill input text-sm py-1! px-3! w-full" className="pill input text-sm py-1! px-3! w-full"
value={birthday.month ?? undefined} value={birthday.month ?? undefined}
onChange={(e) => { onChange={(e) => {
setBirthday((p) => ({ ...p, month: e.target.valueAsNumber })); setBirthday((p) => ({ ...p, month: e.target.valueAsNumber }));
instructions.current.birthday.month = e.target.valueAsNumber; instructions.current.birthday.month = e.target.valueAsNumber;
}} }}
/> />
</div> </div>
<div> <div>
<label htmlFor="age" className="text-xs"> <label htmlFor="age" className="text-xs">
Age Age
</label> </label>
<input <input
type="number" type="number"
id="age" id="age"
min={1} min={1}
max={1000} max={1000}
className="pill input text-sm py-1! px-3! w-full" className="pill input text-sm py-1! px-3! w-full"
value={birthday.age ?? undefined} value={birthday.age ?? undefined}
onChange={(e) => { onChange={(e) => {
setBirthday((p) => ({ ...p, age: e.target.valueAsNumber })); setBirthday((p) => ({ ...p, age: e.target.valueAsNumber }));
instructions.current.birthday.age = e.target.valueAsNumber; instructions.current.birthday.age = e.target.valueAsNumber;
}} }}
/> />
</div> </div>
<div className="flex gap-1.5 col-span-2"> <div className="flex gap-1.5 col-span-2">
<input <input
type="checkbox" type="checkbox"
id="dontAge" id="dontAge"
className="checkbox" className="checkbox"
checked={birthday.dontAge} checked={birthday.dontAge}
onChange={(e) => { onChange={(e) => {
setBirthday((p) => ({ ...p, dontAge: e.target.checked })); setBirthday((p) => ({ ...p, dontAge: e.target.checked }));
instructions.current.birthday.dontAge = e.target.checked; instructions.current.birthday.dontAge = e.target.checked;
}} }}
/> />
<label htmlFor="dontAge" className="text-sm select-none"> <label htmlFor="dontAge" className="text-sm select-none">
Don't Age Don't Age
</label> </label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Personality</span> <span>Personality</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<PersonalityViewer <PersonalityViewer
data={personality} data={personality}
onClick={(key, i) => { onClick={(key, i) => {
setPersonality((p) => { setPersonality((p) => {
const updated = { ...p, [key]: i }; const updated = { ...p, [key]: i };
instructions.current.personality = updated; instructions.current.personality = updated;
return updated; return updated;
}); });
}} }}
/> />
</div> </div>
</> </>
); );
} }