Feat: Add Better Sliders

Fix #19
This commit is contained in:
Landon & Emma 2026-04-08 10:59:44 -04:00
parent ab4b42c5b4
commit a9dcb21c20
4 changed files with 183 additions and 86 deletions

View file

@ -200,24 +200,18 @@ export default function MiiInstructions({ instructions }: Props) {
{height && ( {height && (
<div className="flex mb-1"> <div className="flex mb-1">
<label htmlFor="height" className="w-16"> <span className="w-16">Height</span>
Height <span className="font-semibold text-orange-600">
</label> {height === 64 ? "0" : height > 64 ? `+${height - 64}` : `${height - 64}`}
<div className="relative h-5 flex justify-center items-center"> </span>
<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> </div>
)} )}
{weight && ( {weight && (
<div className="flex"> <div className="flex">
<label htmlFor="weight" className="w-16"> <span className="w-16">Weight</span>
Weight <span className="font-semibold text-orange-600">
</label> {weight === 64 ? "0" : weight > 64 ? `+${weight - 64}` : `${weight - 64}`}
<div className="relative h-5 flex justify-center items-center"> </span>
<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> </div>
)} )}
{birthday && ( {birthday && (

View file

@ -1,48 +1,45 @@
"use client"; "use client";
import { SwitchMiiInstructions } from "@/types"; import { SwitchMiiInstructions } from "@/types";
import { ChangeEvent } from "react"; import EnhancedSlider from "@/components/submit-form/mii-editor/enhanced-slider";
interface Props { interface Props {
data: SwitchMiiInstructions["voice"]; data: SwitchMiiInstructions["voice"];
onChange?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, label: string) => void; onChange?: (value: number, label: string) => void;
onClickTone?: (i: number) => 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) { export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-2">
{VOICE_SETTINGS.map((label) => ( {VOICE_SETTINGS.map((label) => {
<div key={label} className="relative flex gap-3"> const value = data[label.toLowerCase() as keyof typeof data] ?? 25;
<label htmlFor={label} className="text-sm w-14"> return onChange ? (
{label} <EnhancedSlider
</label> key={label}
<div className="relative h-5 flex justify-center items-center"> label={label}
<input value={value}
type="range" onChange={(v) => onChange?.(v, label.toLowerCase())}
name={label}
className="grow z-10"
min={0} min={0}
max={50} max={50}
step={1} mid={25}
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 key={label} className="flex">
<span className="w-14">{label}</span>
<span className="font-semibold text-orange-600">
{value === 25 ? "0" : value > 25 ? `+${value - 25}` : `${value - 25}`}
</span>
</div> </div>
</div> );
))} })}
<div className="flex gap-3"> <div className="flex gap-3">
<label htmlFor="delivery" className="text-sm w-14"> <label htmlFor="delivery" className="text-sm w-14">
Tone Tone
</label> </label>
<div className="grid grid-cols-6 gap-1 grow"> <div className="grid grid-cols-6 gap-1 min-w-[200px]">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<button <button
type="button" type="button"

View file

@ -0,0 +1,123 @@
import { Icon } from "@iconify/react";
import React, { useState, useEffect } from "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 [internalValue, setInternalValue] = useState(value);
// Sync with external value
React.useEffect(() => {
setInternalValue(value);
}, [value]);
const handleChange = (newValue: number) => {
const clampedValue = Math.min(max, Math.max(min, newValue));
setInternalValue(clampedValue);
onChange(clampedValue);
};
const nudge = (direction: number) => {
const newValue = internalValue + (direction * step);
handleChange(newValue);
};
const displayValue = internalValue - mid;
const displayText = displayValue > 0 ? `+${displayValue}` : displayValue.toString();
return (
<div className={`w-full ${className}`}>
<div className="flex justify-between items-center mb-2 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-white 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={internalValue <= min}
className="bg-white border-2 border-orange-400 text-orange-400 font-bold w-8 h-8 rounded-lg cursor-pointer flex items-center justify-center flex-shrink-0 transition-transform 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={internalValue}
onChange={(e) => handleChange(Number(e.target.value))}
className="w-full h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer slider-thumb"
style={{
background: `linear-gradient(to right, #fb923c 0%, #fb923c ${((internalValue - min) / (max - min)) * 100}%, #fed7aa ${((internalValue - min) / (max - min)) * 100}%, #fed7aa 100%)`
}}
/>
</div>
<button
type="button"
onClick={() => nudge(1)}
disabled={internalValue >= max}
className="bg-white border-2 border-orange-400 text-orange-400 font-bold w-8 h-8 rounded-lg cursor-pointer flex items-center justify-center flex-shrink-0 transition-transform 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>
<style jsx>{`
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fb923c;
border: 2px solid #ea580c;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider-thumb::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #fb923c;
border: 2px solid #ea580c;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider-thumb:focus {
outline: none;
}
`}</style>
</div>
);
}

View file

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