Compare commits

..

5 commits

Author SHA1 Message Date
ca2117c4a3 feat: improved edit form error messages 2026-04-08 23:57:57 +01:00
fb4cad1296
feat: merge pull request #23 from LandonAndEmma/main
Feat: Add Better Sliders
2026-04-08 23:57:24 +01:00
cbbe7887b8 feat: improve instructions formatting 2026-04-08 23:54:05 +01:00
7913ccf34d fix: youtube video is OPTIONAL (#24) 2026-04-08 23:03:12 +01:00
Landon & Emma
a9dcb21c20 Feat: Add Better Sliders
Fix #19
2026-04-08 10:59:44 -04:00
7 changed files with 151 additions and 117 deletions

View file

@ -35,6 +35,7 @@ const editSchema = z.object({
youtubeId: z
.string()
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
.or(z.literal(""))
.optional(),
instructions: switchMiiInstructionsSchema,
image1: z.union([z.instanceof(File), z.any()]).optional(),
@ -99,7 +100,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
image3: formData.get("image3"),
});
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
if (!parsed.success) {
const firstIssue = parsed.error.issues[0];
const path = firstIssue.path.length ? firstIssue.path.join(".") : "root";
const error = `${path}: ${firstIssue.message}`;
return rateLimit.sendResponse({ error }, 400);
}
const { name, tags, description, quarantined, gender, makeup, miiPortraitImage, miiFeaturesImage, youtubeId, instructions, image1, image2, image3 } =
parsed.data;

View file

@ -40,6 +40,7 @@ const submitSchema = z
youtubeId: z
.string()
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
.or(z.literal(""))
.optional(),
instructions: switchMiiInstructionsSchema,

View file

@ -119,12 +119,9 @@ input[type="range"]::-moz-range-track {
}
/* Thumb */
input[type="range"]::-webkit-slider-thumb {
@apply appearance-none size-4 bg-orange-300 border-2 border-orange-400 rounded-full shadow-md transition -mt-1.5;
}
input[type="range"]::-webkit-slider-thumb,
input[type="range"]::-moz-range-thumb {
@apply size-3.5 bg-orange-300 border-2 border-orange-400 rounded-full shadow-md transition;
@apply appearance-none size-4.5 bg-orange-400 border-2 border-orange-600 rounded-full shadow-md transition;
}
/* Hover */

View file

@ -5,7 +5,6 @@ import VoiceViewer from "./voice-viewer";
import PersonalityViewer from "./personality-viewer";
import { SwitchMiiInstructions } from "@/types";
import { Icon } from "@iconify/react";
import { COLORS } from "@/lib/switch";
interface Props {
@ -198,37 +197,35 @@ export default function MiiInstructions({ instructions }: Props) {
<div className="p-3 text-sm border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
{height && (
<div className="flex mb-1">
<label htmlFor="height" className="w-16">
Height
</label>
<div className="relative h-5 flex justify-center items-center">
<input id="height" type="range" min={0} max={128} step={1} disabled value={height} />
<div className="absolute h-4 w-1.5 rounded bg-orange-300 z-0"></div>
</div>
</div>
)}
{weight && (
<div className="flex">
<label htmlFor="weight" className="w-16">
Weight
</label>
<div className="relative h-5 flex justify-center items-center">
<input id="weight" type="range" min={0} max={128} step={1} disabled value={weight} />
<div className="absolute h-4 w-1.5 rounded bg-orange-300 z-0"></div>
</div>
</div>
)}
<table className="w-full">
<tbody>
{not(height) && <TableCell label="Height">{height === 64 ? "0" : height! > 64 ? `+${height! - 64}` : `${height! - 64}`}</TableCell>}
{not(weight) && <TableCell label="Weight">{weight === 64 ? "0" : weight! > 64 ? `+${weight! - 64}` : `${weight! - 64}`}</TableCell>}
</tbody>
</table>
{birthday && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
<table className="w-full">
<tbody>
{birthday.day && <TableCell label="Day">{birthday.day}</TableCell>}
{birthday.month && <TableCell label="Month">{birthday.month}</TableCell>}
{birthday.age && <TableCell label="Age">{birthday.age}</TableCell>}
{birthday.dontAge && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
</tbody>
</table>
</div>
)}
{voice && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
<table className="w-full">
<tbody>
{not(voice.speed) && <TableCell label="Speed">{voice.speed}</TableCell>}
{not(voice.pitch) && <TableCell label="Pitch">{voice.pitch}</TableCell>}
{not(voice.depth) && <TableCell label="Depth">{voice.depth}</TableCell>}
{not(voice.delivery) && <TableCell label="Delivery">{voice.delivery}</TableCell>}
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
</tbody>
</table>
</div>
@ -241,14 +238,6 @@ export default function MiiInstructions({ instructions }: Props) {
</div>
</div>
)}
{voice && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
<div className="w-min">
<VoiceViewer data={voice} />
</div>
</div>
)}
{personality && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>

View file

@ -1,48 +1,28 @@
"use client";
import { SwitchMiiInstructions } from "@/types";
import { ChangeEvent } from "react";
import EnhancedSlider from "@/components/submit-form/mii-editor/enhanced-slider";
interface Props {
data: SwitchMiiInstructions["voice"];
onChange?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, label: string) => void;
onClickTone?: (i: number) => void;
onChange: (value: number, label: string) => void;
onClickTone: (i: number) => void;
}
const VOICE_SETTINGS: string[] = ["Speed", "Pitch", "Depth", "Delivery"];
const VOICE_SETTINGS = ["Speed", "Pitch", "Depth", "Delivery"];
export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
return (
<div className="flex flex-col gap-1">
{VOICE_SETTINGS.map((label) => (
<div key={label} className="relative flex gap-3">
<label htmlFor={label} className="text-sm w-14">
{label}
</label>
<div className="relative h-5 flex justify-center items-center">
<input
type="range"
name={label}
className="grow z-10"
min={0}
max={50}
step={1}
value={data[label.toLowerCase() as keyof typeof data] ?? 25}
disabled={!onChange}
onChange={(e) => {
if (onChange) onChange(e, label.toLowerCase());
}}
/>
<div className="absolute h-4 w-1.5 rounded bg-orange-300 z-0"></div>
</div>
</div>
))}
<div className="flex flex-col">
{VOICE_SETTINGS.map((label) => {
const value = data[label.toLowerCase() as keyof typeof data] ?? 25;
return <EnhancedSlider key={label} label={label} value={value} onChange={(v) => onChange?.(v, label.toLowerCase())} min={0} max={50} mid={25} />;
})}
<div className="flex gap-3">
<div className="flex gap-3 mt-2">
<label htmlFor="delivery" className="text-sm w-14">
Tone
</label>
<div className="grid grid-cols-6 gap-1 grow">
<div className="grid grid-cols-6 gap-1 min-w-50">
{Array.from({ length: 6 }).map((_, i) => (
<button
type="button"
@ -50,7 +30,7 @@ export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
onClick={() => {
if (onClickTone) onClickTone(i);
}}
className={`transition-colors duration-100 rounded-xl ${data.tone === i ? "bg-orange-400!" : ""} ${onClickTone ? "hover:bg-orange-300 cursor-pointer" : ""}`}
className={`transition-colors duration-100 rounded-xl hover:bg-orange-300 cursor-pointer ${data.tone === i ? "bg-orange-400!" : ""}`}
>
{i + 1}
</button>

View file

@ -0,0 +1,78 @@
import { Icon } from "@iconify/react";
interface SliderProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
mid?: number;
step?: number;
className?: string;
}
export default function EnhancedSlider({ label, value, onChange, min = 0, max = 128, mid = 64, step = 1, className = "" }: SliderProps) {
const handleChange = (newValue: number) => {
const clampedValue = Math.min(max, Math.max(min, newValue));
onChange(clampedValue);
};
const nudge = (direction: number) => {
const newValue = value + direction * step;
handleChange(newValue);
};
const displayValue = value - mid;
const displayText = displayValue > 0 ? `+${displayValue}` : displayValue.toString();
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={`w-full ${className}`}>
<div className="flex justify-between items-center my-1 relative">
<h3 className="text-sm font-semibold">{label}</h3>
<span className="absolute left-1/2 transform -translate-x-1/2 text-xs font-bold text-orange-600 bg-orange-50 border-2 border-orange-400 px-2 py-1 rounded-full shadow-sm">
{displayText}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => nudge(-1)}
disabled={value <= min}
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
aria-label={`Decrease ${label}`}
>
<Icon icon="mdi:chevron-left" width="16" height="16" />
</button>
<div className="relative flex-1 h-8 flex items-center">
{/* Tick mark at center */}
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-0.5 h-3 bg-orange-400 rounded z-10 opacity-60"></div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => handleChange(e.target.valueAsNumber)}
className="w-full px-0.5 h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer focus:outline-0"
style={{
background: `linear-gradient(to right, #fb923c 0%, #fb923c ${percentage}%, #fed7aa ${percentage}%, #fed7aa 100%)`,
}}
/>
</div>
<button
type="button"
onClick={() => nudge(1)}
disabled={value >= max}
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
aria-label={`Increase ${label}`}
>
<Icon icon="mdi:chevron-right" width="16" height="16" />
</button>
</div>
</div>
);
}

View file

@ -1,17 +1,16 @@
import { useState } from "react";
import { MiiGender } from "@prisma/client";
import DatingPreferencesViewer from "@/components/mii/dating-preferences";
import VoiceViewer from "@/components/mii/voice-viewer";
import PersonalityViewer from "@/components/mii/personality-viewer";
import EnhancedSlider from "@/components/submit-form/mii-editor/enhanced-slider";
import { SwitchMiiInstructions } from "@/types";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function HeadTab({ instructions }: Props) {
export default function MiscTab({ instructions }: Props) {
const [height, setHeight] = useState(instructions.current.height ?? 64);
const [weight, setWeight] = useState(instructions.current.weight ?? 64);
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
@ -50,47 +49,31 @@ export default function HeadTab({ instructions }: Props) {
</div>
<div className="flex flex-col">
<label htmlFor="height" className="text-sm">
Height
</label>
<div className="relative h-5 flex justify-center items-center">
<input
type="range"
id="height"
className="grow z-10"
min={0}
max={128}
step={1}
value={height}
onChange={(e) => {
setHeight(e.target.valueAsNumber);
instructions.current.height = e.target.valueAsNumber;
}}
/>
<div className="absolute h-4 w-1.5 rounded bg-orange-300 z-0"></div>
</div>
<EnhancedSlider
label="Height"
value={height}
onChange={(v) => {
setHeight(v);
instructions.current.height = v;
}}
min={0}
max={128}
mid={64}
/>
</div>
<div className="flex flex-col">
<label htmlFor="weight" className="text-sm">
Weight
</label>
<div className="relative h-5 flex justify-center items-center">
<input
type="range"
id="weight"
className="grow z-10"
min={0}
max={128}
step={1}
value={weight}
onChange={(e) => {
setWeight(e.target.valueAsNumber);
instructions.current.weight = e.target.valueAsNumber;
}}
/>
<div className="absolute h-4 w-1.5 rounded bg-orange-300 z-0"></div>
</div>
<EnhancedSlider
label="Weight"
value={weight}
onChange={(v) => {
setWeight(v);
instructions.current.weight = v;
}}
min={0}
max={128}
mid={64}
/>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
@ -122,9 +105,9 @@ export default function HeadTab({ instructions }: Props) {
<VoiceViewer
data={voice}
onChange={(e, label) => {
setVoice((p) => ({ ...p, [label]: e.target.valueAsNumber }));
instructions.current.voice[label as keyof typeof voice] = e.target.valueAsNumber;
onChange={(v, label) => {
setVoice((p) => ({ ...p, [label]: v }));
instructions.current.voice[label as keyof typeof voice] = v;
}}
onClickTone={(i) => {
setVoice((p) => ({ ...p, tone: i }));