chuni: add ability to change username

This commit is contained in:
sk1982 2024-04-12 00:38:15 -04:00
parent 10b117f7b9
commit c96d9a5922
4 changed files with 144 additions and 14 deletions

View File

@ -193,7 +193,14 @@ Object.entries(AvatarCategory).forEach(([category, number]) => {
}); });
}); });
export type ProfileUpdate = Partial<{ [K in keyof UserboxItems]: number }>; validators.set('userName', (u, p, val) => {
if (!val) throw new Error('Username is required.');
if ([...val].length > 25) throw new Error('Username is too long.')
return val;
});
export type ProfileUpdate = Partial<{ [K in keyof UserboxItems]: number } & { userName: string }>;
export const updateProfile = async (data: ProfileUpdate) => { export const updateProfile = async (data: ProfileUpdate) => {
const user = await requireUser(); const user = await requireUser();

View File

@ -19,6 +19,7 @@ import { useAudio } from '@/helpers/use-audio';
import { Entries } from 'type-fest'; import { Entries } from 'type-fest';
import { useErrorModal } from '@/components/error-modal'; import { useErrorModal } from '@/components/error-modal';
import Image from 'next/image'; import Image from 'next/image';
import { ChuniNameModal } from '@/components/chuni/name-modal';
export type ChuniUserboxProps = { export type ChuniUserboxProps = {
profile: ChuniUserData, profile: ChuniUserData,
@ -43,18 +44,20 @@ const AVATAR_KEYS = ['avatarWear', 'avatarHead', 'avatarFace', 'avatarSkin', 'av
type RequiredUserbox = NonNullable<UserboxItems>; type RequiredUserbox = NonNullable<UserboxItems>;
type EquippedItem = { [K in keyof RequiredUserbox]: RequiredUserbox[K][number] }; type EquippedItem = { [K in keyof RequiredUserbox]: RequiredUserbox[K][number] };
type SavedItem = { [K in keyof RequiredUserbox]: boolean }; type SavedItem = { [K in keyof RequiredUserbox]: boolean } & { username: boolean };
export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => { export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
const initialEquipped = useRef(Object.fromEntries(Object.entries(ITEM_KEYS) const initialEquipped = useRef(Object.fromEntries(Object.entries(ITEM_KEYS)
.map(([key, profileKey]) => [key, userboxItems[key as keyof RequiredUserbox] .map(([key, profileKey]) => [key, userboxItems[key as keyof RequiredUserbox]
.find(i => i.id === profile?.[profileKey])])) as EquippedItem); .find(i => i.id === profile?.[profileKey])])) as EquippedItem);
const [equipped, setEquipped] = useState<EquippedItem>(initialEquipped.current); const [equipped, setEquipped] = useState<EquippedItem>(initialEquipped.current);
const [saved, setSaved] = useState<SavedItem>(Object.fromEntries(Object.keys(ITEM_KEYS).map(k => [k, true])) as any); const [saved, setSaved] = useState<SavedItem>({ ...Object.fromEntries(Object.keys(ITEM_KEYS).map(k => [k, true])), username: true } as any);
const [playingVoice, setPlayingVoice] = useState(false); const [playingVoice, setPlayingVoice] = useState(false);
const [selectedLine, setSelectedLine] = useState(new Set(['0035'])); const [selectedLine, setSelectedLine] = useState(new Set(['0035']));
const [playPreviews, _setPlayPreviews] = useState(true); const [playPreviews, _setPlayPreviews] = useState(true);
const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null); const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null);
const [editingUsername, setEditingUsername] = useState(false);
const [username, setUsername] = useState(profile?.userName!);
const setError = useErrorModal(); const setError = useErrorModal();
const setPlayPreviews = (play: boolean) => { const setPlayPreviews = (play: boolean) => {
@ -73,19 +76,26 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
setSaved(s => ({ ...s, [k]: false })); setSaved(s => ({ ...s, [k]: false }));
}; };
const reset = <K extends keyof RequiredUserbox>(...items: K[]) => { const reset = <K extends keyof SavedItem>(...items: K[]) => {
setSaved(s => ({ ...s, ...Object.fromEntries(items.map(i => [i, true])) })); setSaved(s => ({ ...s, ...Object.fromEntries(items.map(i => [i, true])) }));
setEquipped(e => ({ ...e, ...Object.fromEntries(Object setEquipped(e => ({
.entries(initialEquipped.current).filter(([k]) => items.includes(k as any))) })) ...e, ...Object.fromEntries(Object
.entries(initialEquipped.current).filter(([k]) => items.includes(k as any)))
}));
if ((items as string[]).includes('username'))
setUsername(profile?.userName!);
}; };
const save = <K extends keyof RequiredUserbox>(...items: K[]) => { const save = <K extends keyof SavedItem>(...items: K[]) => {
if (!items.length) if (!items.length)
items = Object.keys(ITEM_KEYS) as any; items = Object.keys(ITEM_KEYS) as any;
const update: Partial<ProfileUpdate> = Object.fromEntries((Object.entries(equipped) as Entries<typeof equipped>) const update: Partial<ProfileUpdate> = Object.fromEntries((Object.entries(equipped) as Entries<typeof equipped>)
.filter(([k]) => items.includes(k as any)) .filter(([k]) => items.includes(k as any))
.map(([k, v]) => [ITEM_KEYS[k], v.id])); .map(([k, v]) => [ITEM_KEYS[k], v.id]));
if ((items as string[]).includes('username'))
update.userName = username;
setSaved(s => ({ ...s, ...Object.fromEntries(items.map(i => [i, true])) })); setSaved(s => ({ ...s, ...Object.fromEntries(items.map(i => [i, true])) }));
@ -144,11 +154,11 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
<div className="flex flex-col items-center justify-center w-full sm:bg-content1 col-span-full rounded-lg overflow-hidden xl:col-span-7 sm:shadow-inner"> <div className="flex flex-col items-center justify-center w-full sm:bg-content1 col-span-full rounded-lg overflow-hidden xl:col-span-7 sm:shadow-inner">
<div className="text-2xl font-semibold mr-auto p-3 flex w-full h-16 min-h-16 items-center"> <div className="text-2xl font-semibold mr-auto p-3 flex w-full h-16 min-h-16 items-center">
<span className="sm:ml-2">Profile</span> <span className="sm:ml-2">Profile</span>
{(!saved.namePlate || !saved.trophy) && <> {(!saved.namePlate || !saved.trophy || !saved.username) && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy')}> <Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy', 'username')}>
Reset Reset
</Button> </Button>
<Button className="ml-2" color="primary" onPress={() => save('namePlate', 'trophy')}> <Button className="ml-2" color="primary" onPress={() => save('namePlate', 'trophy', 'username')}>
Save Save
</Button> </Button>
</>} </>}
@ -160,25 +170,37 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
<div className="w-full max-w-full"> <div className="w-full max-w-full">
<ChuniNameplate profile={profile ? { <ChuniNameplate profile={profile ? {
...profile, ...profile,
userName: username,
nameplateName: equipped.namePlate.name, nameplateName: equipped.namePlate.name,
nameplateImage: equipped.namePlate.imagePath, nameplateImage: equipped.namePlate.imagePath,
trophyName: equipped.trophy.name, trophyName: equipped.trophy.name,
trophyRareType: equipped.trophy.rareType trophyRareType: equipped.trophy.rareType
} : null} className="w-full" /> } : null} className="w-full" />
</div> </div>
<div className="flex gap-2 w-full px-2 sm:px-1">
<SelectModalButton className="flex-grow flex-1" displayMode="grid" modalSize="full" rowSize={230} colSize={500} gap={6} items={userboxItems.namePlate} <ChuniNameModal username={profile?.userName!} isOpen={editingUsername}
onClose={() => setEditingUsername(false)}
setUsername={u => {
setSaved(s => ({ ...s, username: false }));
setUsername(u);
}} />
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 w-full px-2 sm:px-1">
<SelectModalButton className="col-span-2 sm:col-span-1" displayMode="grid" modalSize="full" rowSize={230} colSize={500} gap={6} items={userboxItems.namePlate}
modalId="nameplate" itemId="id" modalId="nameplate" itemId="id"
renderItem={n => renderItem(n, getImageUrl(`chuni/name-plate/${n.imagePath}`), 'w-full sm:text-lg', 'px-2 pb-1')} renderItem={n => renderItem(n, getImageUrl(`chuni/name-plate/${n.imagePath}`), 'w-full sm:text-lg', 'px-2 pb-1')}
selectedItem={equipped.namePlate} onSelected={i => equipItem('namePlate', i)}> selectedItem={equipped.namePlate} onSelected={i => equipItem('namePlate', i)}>
Change Nameplate Change Nameplate
</SelectModalButton> </SelectModalButton>
<SelectModalButton className="flex-grow flex-1" displayMode="list" modalSize="2xl" rowSize={66} items={userboxItems.trophy} <SelectModalButton displayMode="list" modalSize="2xl" rowSize={66} items={userboxItems.trophy}
modalId="trophy" itemId="id" modalId="trophy" itemId="id"
renderItem={n => <ChuniTrophy rarity={n.rareType} name={n.name} />} renderItem={n => <ChuniTrophy rarity={n.rareType} name={n.name} />}
selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}> selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}>
Change Trophy Change Trophy
</SelectModalButton> </SelectModalButton>
<Button onPress={() => setEditingUsername(true)}>
Change Username
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,101 @@
import { Modal, ModalBody, ModalContent, ModalHeader, ModalFooter } from '@nextui-org/modal';
import { Input } from '@nextui-org/input';
import { Button } from '@nextui-org/button';
import { useEffect, useRef, useState } from 'react';
import { ExclamationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import { useHashNavigation } from '@/helpers/use-hash-navigation';
const SYMBOLS = `・×÷♂♀∀☆○◎◇□△▽♪†‡ΣαβγθφψωДё`;
const ALLCHARS = new Set([...` ${SYMBOLS}`]);
const FULLWIDTHS = new Map([...ALLCHARS]
.map(c => [c.normalize('NFKC'), c]));
const normalizeUsername = (s: string) => [...s.normalize('NFKC')]
.map(c => {
if (c === 'ë') return 'ё'; // u+003b (latin small letter with diaeresis) -> u+0451 (cyrillic small letter io)
return FULLWIDTHS.get(c) ?? c;
}).join('');
type ChuniNameModalProps = {
username: string,
setUsername: (u: string) => void,
isOpen: boolean,
onClose: () => void
};
export const ChuniNameModal = ({ onClose, isOpen, username, setUsername }: ChuniNameModalProps) => {
const [editingUsername, setEditingUsername] = useState(username);
const inputRef = useRef<HTMLInputElement | null>(null);
const previousOpen = useRef(false);
const onModalClose = useHashNavigation({
onClose,
isOpen,
hash: '#edit-username'
});
let warnings: string[] = [];
if ([...editingUsername].length > 8)
warnings.push('Your username is longer than 8 characters. It may be squished in-game.');
if ([...editingUsername].some(c => !ALLCHARS.has(c)))
warnings.push('Your username contains characters not typically used in-game. It may not show correctly.');
useEffect(() => {
if (isOpen && !previousOpen.current)
setEditingUsername(username);
previousOpen.current = isOpen;
}, [isOpen, username, previousOpen]);
return (<Modal size="3xl" isOpen={isOpen} onClose={onModalClose}>
<ModalContent>
{onModalClose => <>
<ModalBody className="max-sm:px-2">
<ModalHeader>Change Username</ModalHeader>
{!editingUsername.length && <div className="flex text-danger mb-2 items-center max-sm:text-sm">
<ExclamationCircleIcon className="h-6 sm:h-7 mr-1.5" />
You cannot have an empty username
</div>}
{warnings.map(warning => <div key={warning} className="flex text-warning mb-2 items-center max-sm:text-sm">
<ExclamationTriangleIcon className="h-6 sm:h-7 mr-1.5" />
{warning}
</div>)}
<Input placeholder="Username" label="Username"
maxLength={25}
value={editingUsername}
isRequired
onValueChange={v => setEditingUsername(normalizeUsername(v))}
ref={inputRef}
className="max-sm:px-2"
classNames={{ input: `[font-feature-settings:"fwid"] font-semibold` }} />
<div className="flex flex-wrap gap-0.5 sm:gap-1 w-full justify-center">
{[...SYMBOLS].map(s => <div key={s}
className={`[font-feature-settings:"fwid"] font-bold sm:text-2xl sm:h-12 sm:w-12 h-10 w-10 border-2 border-divider flex items-center justify-center rounded-xl cursor-pointer transition hover:bg-content2 select-none`}
onClick={(e) => {
e.preventDefault();
setEditingUsername(val => {
if (!inputRef.current || [...val].length >= 25) return val;
const chars = [...val];
const { selectionStart, selectionEnd } = inputRef.current;
inputRef.current.focus();
setTimeout(() => {
inputRef.current!.focus();
inputRef.current!.selectionStart = selectionStart! + 1;
inputRef.current!.selectionEnd = selectionStart! + 1;
});
return chars.slice(0, selectionStart!).join('') + s + chars.slice(selectionEnd!).join('');
})
}}>
{s}
</div>)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" color="danger" onPress={onModalClose}>Cancel</Button>
<Button color="primary" isDisabled={!editingUsername}
onPress={() => { onModalClose(); setUsername(editingUsername); }}>Set</Button>
</ModalFooter>
</>}
</ModalContent>
</Modal>)
};

View File

@ -43,7 +43,7 @@ export const ChuniNameplate = ({ className, profile }: ChuniNameplateProps) => {
<div className="flex items-baseline border-b border-gray-700"> <div className="flex items-baseline border-b border-gray-700">
<span className="font-normal text-[14cqh]">Lv.</span> <span className="font-normal text-[14cqh]">Lv.</span>
<span className="text-[18cqh]">{profile.level}</span> <span className="text-[18cqh]">{profile.level}</span>
<span lang="ja" className="text-[21cqh] flex-grow text-center font-bold"> <span lang="ja" className={`text-[21cqh] flex-grow text-center font-bold [font-feature-settings:"fwid"]`}>
{profile.userName?.padEnd(7, '\u3000')} {profile.userName?.padEnd(7, '\u3000')}
</span> </span>
</div> </div>