mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
5 commits
ab4b42c5b4
...
ca2117c4a3
| Author | SHA1 | Date | |
|---|---|---|---|
| ca2117c4a3 | |||
| fb4cad1296 | |||
| cbbe7887b8 | |||
| 7913ccf34d | |||
|
|
a9dcb21c20 |
7 changed files with 151 additions and 117 deletions
|
|
@ -35,6 +35,7 @@ const editSchema = z.object({
|
||||||
youtubeId: z
|
youtubeId: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
|
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
|
||||||
|
.or(z.literal(""))
|
||||||
.optional(),
|
.optional(),
|
||||||
instructions: switchMiiInstructionsSchema,
|
instructions: switchMiiInstructionsSchema,
|
||||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
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"),
|
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 } =
|
const { name, tags, description, quarantined, gender, makeup, miiPortraitImage, miiFeaturesImage, youtubeId, instructions, image1, image2, image3 } =
|
||||||
parsed.data;
|
parsed.data;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const submitSchema = z
|
||||||
youtubeId: z
|
youtubeId: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
|
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
|
||||||
|
.or(z.literal(""))
|
||||||
.optional(),
|
.optional(),
|
||||||
instructions: switchMiiInstructionsSchema,
|
instructions: switchMiiInstructionsSchema,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,12 +119,9 @@ input[type="range"]::-moz-range-track {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thumb */
|
/* Thumb */
|
||||||
input[type="range"]::-webkit-slider-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"]::-moz-range-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 */
|
/* Hover */
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import VoiceViewer from "./voice-viewer";
|
||||||
import PersonalityViewer from "./personality-viewer";
|
import PersonalityViewer from "./personality-viewer";
|
||||||
|
|
||||||
import { SwitchMiiInstructions } from "@/types";
|
import { SwitchMiiInstructions } from "@/types";
|
||||||
import { Icon } from "@iconify/react";
|
|
||||||
import { COLORS } from "@/lib/switch";
|
import { COLORS } from "@/lib/switch";
|
||||||
|
|
||||||
interface Props {
|
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">
|
<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>
|
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
||||||
|
|
||||||
{height && (
|
<table className="w-full">
|
||||||
<div className="flex mb-1">
|
<tbody>
|
||||||
<label htmlFor="height" className="w-16">
|
{not(height) && <TableCell label="Height">{height === 64 ? "0" : height! > 64 ? `+${height! - 64}` : `${height! - 64}`}</TableCell>}
|
||||||
Height
|
{not(weight) && <TableCell label="Weight">{weight === 64 ? "0" : weight! > 64 ? `+${weight! - 64}` : `${weight! - 64}`}</TableCell>}
|
||||||
</label>
|
</tbody>
|
||||||
<div className="relative h-5 flex justify-center items-center">
|
</table>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{birthday && (
|
{birthday && (
|
||||||
<div className="pl-2 not-nth-2:mt-4">
|
<div className="pl-2 not-nth-2:mt-4">
|
||||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
|
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
{birthday.day && <TableCell label="Day">{birthday.day}</TableCell>}
|
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
|
||||||
{birthday.month && <TableCell label="Month">{birthday.month}</TableCell>}
|
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
|
||||||
{birthday.age && <TableCell label="Age">{birthday.age}</TableCell>}
|
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
|
||||||
{birthday.dontAge && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,14 +238,6 @@ export default function MiiInstructions({ instructions }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{personality && (
|
||||||
<div className="pl-2 not-nth-2:mt-4">
|
<div className="pl-2 not-nth-2:mt-4">
|
||||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
|
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,28 @@
|
||||||
"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">
|
||||||
{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 <EnhancedSlider key={label} label={label} value={value} onChange={(v) => onChange?.(v, label.toLowerCase())} min={0} max={50} mid={25} />;
|
||||||
{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 gap-3">
|
<div className="flex gap-3 mt-2">
|
||||||
<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-50">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -50,7 +30,7 @@ export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onClickTone) onClickTone(i);
|
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}
|
{i + 1}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
78
src/components/submit-form/mii-editor/enhanced-slider.tsx
Normal file
78
src/components/submit-form/mii-editor/enhanced-slider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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