mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 21:27:46 +00:00
parent
ab4b42c5b4
commit
a9dcb21c20
4 changed files with 183 additions and 86 deletions
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
123
src/components/submit-form/mii-editor/enhanced-slider.tsx
Normal file
123
src/components/submit-form/mii-editor/enhanced-slider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue