chuni: add ability to change username
This commit is contained in:
parent
10b117f7b9
commit
c96d9a5922
@ -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) => {
|
||||
const user = await requireUser();
|
||||
|
@ -19,6 +19,7 @@ import { useAudio } from '@/helpers/use-audio';
|
||||
import { Entries } from 'type-fest';
|
||||
import { useErrorModal } from '@/components/error-modal';
|
||||
import Image from 'next/image';
|
||||
import { ChuniNameModal } from '@/components/chuni/name-modal';
|
||||
|
||||
export type ChuniUserboxProps = {
|
||||
profile: ChuniUserData,
|
||||
@ -43,18 +44,20 @@ const AVATAR_KEYS = ['avatarWear', 'avatarHead', 'avatarFace', 'avatarSkin', 'av
|
||||
|
||||
type RequiredUserbox = NonNullable<UserboxItems>;
|
||||
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) => {
|
||||
const initialEquipped = useRef(Object.fromEntries(Object.entries(ITEM_KEYS)
|
||||
.map(([key, profileKey]) => [key, userboxItems[key as keyof RequiredUserbox]
|
||||
.find(i => i.id === profile?.[profileKey])])) as EquippedItem);
|
||||
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 [selectedLine, setSelectedLine] = useState(new Set(['0035']));
|
||||
const [playPreviews, _setPlayPreviews] = useState(true);
|
||||
const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null);
|
||||
const [editingUsername, setEditingUsername] = useState(false);
|
||||
const [username, setUsername] = useState(profile?.userName!);
|
||||
const setError = useErrorModal();
|
||||
|
||||
const setPlayPreviews = (play: boolean) => {
|
||||
@ -73,13 +76,17 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
||||
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])) }));
|
||||
setEquipped(e => ({ ...e, ...Object.fromEntries(Object
|
||||
.entries(initialEquipped.current).filter(([k]) => items.includes(k as any))) }))
|
||||
setEquipped(e => ({
|
||||
...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)
|
||||
items = Object.keys(ITEM_KEYS) as any;
|
||||
|
||||
@ -87,6 +94,9 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
||||
.filter(([k]) => items.includes(k as any))
|
||||
.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])) }));
|
||||
|
||||
updateProfile(update)
|
||||
@ -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="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>
|
||||
{(!saved.namePlate || !saved.trophy) && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy')}>
|
||||
{(!saved.namePlate || !saved.trophy || !saved.username) && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy', 'username')}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary" onPress={() => save('namePlate', 'trophy')}>
|
||||
<Button className="ml-2" color="primary" onPress={() => save('namePlate', 'trophy', 'username')}>
|
||||
Save
|
||||
</Button>
|
||||
</>}
|
||||
@ -160,25 +170,37 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
||||
<div className="w-full max-w-full">
|
||||
<ChuniNameplate profile={profile ? {
|
||||
...profile,
|
||||
userName: username,
|
||||
nameplateName: equipped.namePlate.name,
|
||||
nameplateImage: equipped.namePlate.imagePath,
|
||||
trophyName: equipped.trophy.name,
|
||||
trophyRareType: equipped.trophy.rareType
|
||||
} : null} className="w-full" />
|
||||
</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"
|
||||
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)}>
|
||||
Change Nameplate
|
||||
</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"
|
||||
renderItem={n => <ChuniTrophy rarity={n.rareType} name={n.name} />}
|
||||
selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}>
|
||||
Change Trophy
|
||||
</SelectModalButton>
|
||||
<Button onPress={() => setEditingUsername(true)}>
|
||||
Change Username
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
101
src/components/chuni/name-modal.tsx
Normal file
101
src/components/chuni/name-modal.tsx
Normal 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([...` #&*.@+-=:;?!~/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789${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>)
|
||||
};
|
@ -43,7 +43,7 @@ export const ChuniNameplate = ({ className, profile }: ChuniNameplateProps) => {
|
||||
<div className="flex items-baseline border-b border-gray-700">
|
||||
<span className="font-normal text-[14cqh]">Lv.</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')}
|
||||
</span>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user