diff --git a/src/actions/chuni/profile.ts b/src/actions/chuni/profile.ts index 93202ed..41238ce 100644 --- a/src/actions/chuni/profile.ts +++ b/src/actions/chuni/profile.ts @@ -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(); diff --git a/src/app/(with-header)/chuni/userbox/userbox.tsx b/src/app/(with-header)/chuni/userbox/userbox.tsx index f31aeaf..e395f90 100644 --- a/src/app/(with-header)/chuni/userbox/userbox.tsx +++ b/src/app/(with-header)/chuni/userbox/userbox.tsx @@ -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; 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(initialEquipped.current); - const [saved, setSaved] = useState(Object.fromEntries(Object.keys(ITEM_KEYS).map(k => [k, true])) as any); + const [saved, setSaved] = useState({ ...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(null); + const [editingUsername, setEditingUsername] = useState(false); + const [username, setUsername] = useState(profile?.userName!); const setError = useErrorModal(); const setPlayPreviews = (play: boolean) => { @@ -73,19 +76,26 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => { setSaved(s => ({ ...s, [k]: false })); }; - const reset = (...items: K[]) => { + const reset = (...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 = (...items: K[]) => { + const save = (...items: K[]) => { if (!items.length) items = Object.keys(ITEM_KEYS) as any; const update: Partial = Object.fromEntries((Object.entries(equipped) as Entries) .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])) })); @@ -144,11 +154,11 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
Profile - {(!saved.namePlate || !saved.trophy) && <> - - } @@ -160,25 +170,37 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
-
- setEditingUsername(false)} + setUsername={u => { + setSaved(s => ({ ...s, username: false })); + setUsername(u); + }} /> + +
+ 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 - } selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}> Change Trophy +
diff --git a/src/components/chuni/name-modal.tsx b/src/components/chuni/name-modal.tsx new file mode 100644 index 0000000..ca37768 --- /dev/null +++ b/src/components/chuni/name-modal.tsx @@ -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(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 ( + + {onModalClose => <> + + Change Username + {!editingUsername.length &&
+ + You cannot have an empty username +
} + {warnings.map(warning =>
+ + {warning} +
)} + setEditingUsername(normalizeUsername(v))} + ref={inputRef} + className="max-sm:px-2" + classNames={{ input: `[font-feature-settings:"fwid"] font-semibold` }} /> +
+ {[...SYMBOLS].map(s =>
{ + 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} +
)} +
+
+ + + + + } +
+
) +}; diff --git a/src/components/chuni/nameplate.tsx b/src/components/chuni/nameplate.tsx index 9e0c7ae..700e2c8 100644 --- a/src/components/chuni/nameplate.tsx +++ b/src/components/chuni/nameplate.tsx @@ -43,7 +43,7 @@ export const ChuniNameplate = ({ className, profile }: ChuniNameplateProps) => {
Lv. {profile.level} - + {profile.userName?.padEnd(7, '\u3000')}