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) => {
|
export const updateProfile = async (data: ProfileUpdate) => {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
@ -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,13 +76,17 @@ 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;
|
||||||
|
|
||||||
@ -87,6 +94,9 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
|||||||
.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])) }));
|
||||||
|
|
||||||
updateProfile(update)
|
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="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>
|
||||||
|
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">
|
<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>
|
||||||
|
Loading…
Reference in New Issue
Block a user