chuni: add userbox item changing

This commit is contained in:
sk1982 2024-03-18 00:10:22 -04:00
parent 2f24f53311
commit 722db4c48d
5 changed files with 234 additions and 47 deletions

View File

@ -1,9 +1,15 @@
'use server';
import { sql } from 'kysely'; import { sql } from 'kysely';
import { db } from '@/db'; import { db, GeneratedDB } from '@/db';
import { chuniRating } from '@/helpers/chuni/rating'; import { chuniRating } from '@/helpers/chuni/rating';
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music'; import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
import { UserPayload } from '@/types/user'; import { UserPayload } from '@/types/user';
import { ItemKind } from '@/helpers/chuni/items';
import { AvatarCategory } from '@/helpers/chuni/avatar';
import { UserboxItems } from '@/actions/chuni/userbox';
import { requireUser } from '@/actions/auth';
type RecentRating = { type RecentRating = {
scoreMax: string, scoreMax: string,
@ -15,6 +21,8 @@ type RecentRating = {
const avatarNames = ['avatarBack', 'avatarFace', 'avatarItem', 'avatarWear', 'avatarFront', 'avatarSkin', const avatarNames = ['avatarBack', 'avatarFace', 'avatarItem', 'avatarWear', 'avatarFront', 'avatarSkin',
'avatarHead'] as const; 'avatarHead'] as const;
const ALLOW_EQUIP_UNEARNED = ['true', '1', 'yes'].includes(process.env.CHUNI_ALLOW_EQUIP_UNEARNED?.toLowerCase() ?? '');
export async function getUserData(user: UserPayload) { export async function getUserData(user: UserPayload) {
const res = await db.selectFrom('chuni_profile_data as p') const res = await db.selectFrom('chuni_profile_data as p')
.leftJoin('actaeon_chuni_static_name_plate as nameplate', 'p.nameplateId', 'nameplate.id') .leftJoin('actaeon_chuni_static_name_plate as nameplate', 'p.nameplateId', 'nameplate.id')
@ -108,3 +116,102 @@ export async function getUserRating(user: UserPayload) {
return { recent, top }; return { recent, top };
} }
const validators = new Map<keyof GeneratedDB['chuni_profile_data'], (user: number, profile: NonNullable<ChuniUserData>, value: any) => Promise<any>>();
const itemValidators = [
['mapIconId', 'actaeon_chuni_static_map_icon as map_icon', 'map_icon.id', ItemKind.MAP_ICON],
['nameplateId', 'actaeon_chuni_static_name_plate as name_plate', 'name_plate.id', ItemKind.NAME_PLATE],
['voiceId', 'actaeon_chuni_static_system_voice as system_voice', 'system_voice.id', ItemKind.SYSTEM_VOICE],
['trophyId', 'actaeon_chuni_static_trophies as trophy', 'trophy.id', ItemKind.TROPHY]
] as const;
itemValidators.forEach(([key, table, joinKey, itemKind]) => {
validators.set(key, async (user, profile, value) => {
value = parseInt(value);
if (Number.isNaN(value))
throw new Error(`Invalid value for key "${key}".`)
const res = await db.selectFrom(table)
.leftJoin('chuni_item_item as item', join => join
.onRef('item.itemId', '=', joinKey as any)
.on('item.user', '=', user)
.on('item.itemKind', '=', itemKind))
.where(joinKey as any, '=', value)
.select('item.itemId')
.executeTakeFirst();
if (!res)
throw new Error(`Item with id ${value} does not exist.`);
if (res.itemId === null && value !== profile[key] && !ALLOW_EQUIP_UNEARNED)
throw new Error(`You do not own that item.`);
return value;
});
});
Object.entries(AvatarCategory).forEach(([category, number]) => {
const key = `avatar${category[0]}${category.slice(1).toLowerCase()}`;
validators.set(key as any, async (user, profile, value) => {
value = parseInt(value);
if (Number.isNaN(value))
throw new Error(`Invalid value for key "${key}".`)
const res = await db.selectFrom('chuni_static_avatar as avatar')
.leftJoin('chuni_item_item as item', join => join
.onRef('item.itemId', '=', 'avatar.avatarAccessoryId')
.on('item.user', '=', user)
.on('item.itemKind', '=', ItemKind.AVATAR_ACCESSORY))
.where(({ eb, and, selectFrom }) => and([
eb('avatar.version', '=', selectFrom('chuni_static_avatar')
.select(({ fn }) => fn.max('version').as('latest'))),
eb('avatar.category', '=', number),
eb('avatar.avatarAccessoryId', '=', value)
]))
.select('item.itemId')
.executeTakeFirst();
if (!res)
throw new Error(`Item with id ${value} does not exist.`);
if (res.itemId === null && value !== profile[key as keyof ChuniUserData] && !ALLOW_EQUIP_UNEARNED)
throw new Error(`You do not own that item.`);
return value;
});
});
export type ProfileUpdate = Partial<{ [K in keyof UserboxItems]: number }>;
export const updateProfile = async (data: ProfileUpdate) => {
const user = await requireUser();
const profile = await getUserData(user);
if (!profile)
return { error: true, message: 'You do not have a Chunithm profile.' };
const update: ProfileUpdate = {};
for (const [key, value] of Object.entries(data)) {
if (!validators.has(key as any))
return { error: true, message: `Unknown key "${key}"` };
try {
update[key as keyof ProfileUpdate] = ((await (validators.get(key as any)!(user.id, profile, value))) ?? value) as any;
} catch (e: any) {
return { error: true, message: e?.message ?? 'Unknown error occurred.' };
}
}
await db.updateTable('chuni_profile_data')
.where(({ and, eb, selectFrom }) => and([
eb('user', '=', user.id),
eb('version', '=', selectFrom('chuni_profile_data')
.select(({ fn }) => fn.max('version').as('latest')))
]))
.set(update)
.execute();
return { error: false };
};

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChuniUserData, getUserData } from '@/actions/chuni/profile'; import { ChuniUserData, getUserData, ProfileUpdate, updateProfile } from '@/actions/chuni/profile';
import { UserboxItems } from '@/actions/chuni/userbox'; import { UserboxItems } from '@/actions/chuni/userbox';
import { ChuniNameplate } from '@/components/chuni/nameplate'; import { ChuniNameplate } from '@/components/chuni/nameplate';
import { avatar, Button, ButtonGroup, Checkbox, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Select, SelectItem, user } from '@nextui-org/react'; import { avatar, Button, ButtonGroup, Checkbox, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Select, SelectItem, user } from '@nextui-org/react';
@ -14,6 +14,8 @@ import { PlayIcon, StopIcon } from '@heroicons/react/24/solid';
import { SaveIcon } from '@/components/save-icon'; import { SaveIcon } from '@/components/save-icon';
import { useAudio } from '@/helpers/use-audio'; import { useAudio } from '@/helpers/use-audio';
import { useIsMounted } from 'usehooks-ts'; import { useIsMounted } from 'usehooks-ts';
import { Entries } from 'type-fest';
import { useErrorModal } from '@/components/error-modal';
export type ChuniUserboxProps = { export type ChuniUserboxProps = {
profile: ChuniUserData, profile: ChuniUserData,
@ -50,6 +52,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
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 setError = useErrorModal();
const setPlayPreviews = (play: boolean) => { const setPlayPreviews = (play: boolean) => {
_setPlayPreviews(play); _setPlayPreviews(play);
@ -73,6 +76,24 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
.entries(initialEquipped.current).filter(([k]) => items.includes(k as any))) })) .entries(initialEquipped.current).filter(([k]) => items.includes(k as any))) }))
}; };
const save = <K extends keyof RequiredUserbox>(...items: K[]) => {
if (!items.length)
items = Object.keys(ITEM_KEYS) as any;
const update: Partial<ProfileUpdate> = Object.fromEntries((Object.entries(equipped) as Entries<typeof equipped>)
.filter(([k]) => items.includes(k as any))
.map(([k, v]) => [ITEM_KEYS[k], 'id' in v ? v.id : v.avatarAccessoryId]));
setSaved(s => ({ ...s, ...Object.fromEntries(items.map(i => [i, true])) }));
updateProfile(update)
.then(({ error, message }) => {
if (!error) return;
setError(`Failed to set item: ${message}`);
setSaved(s => ({ ...s, ...Object.fromEntries(items.map(i => [i, false])) }));
});
};
const audioRef = useAudio({ const audioRef = useAudio({
play: () => setPlayingVoice(true), play: () => setPlayingVoice(true),
ended: () => setPlayingVoice(false), ended: () => setPlayingVoice(false),
@ -117,17 +138,22 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
<div className="grid grid-cols-12 justify-items-center max-w-[50rem] xl:max-w-[100rem] gap-2 flex-grow relative"> <div className="grid grid-cols-12 justify-items-center max-w-[50rem] xl:max-w-[100rem] gap-2 flex-grow relative">
{/* begin nameplate and trophy */} {/* begin nameplate and trophy */}
<div className="flex items-center justify-center w-full col-span-full xl:col-span-7"> <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 h-full w-full xl:max-w-none py-2 sm:p-4 sm:bg-content2 rounded-lg 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 mb-4 mr-auto px-2 flex w-full h-10"> <span className="sm:ml-2">Profile</span>
Profile {(!saved.namePlate || !saved.trophy) && <>
{(!saved.namePlate || !saved.trophy) && <> <Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy')}>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy')}> Reset
Reset </Button>
</Button> <Button className="ml-2" color="primary" onPress={() => save('namePlate', 'trophy')}>
<Button className="ml-2" color="primary">Save</Button> Save
</>} </Button>
</div> </>}
</div>
<Divider className="mb-2 hidden sm:block" />
<div className="flex flex-col items-center h-full w-full xl:max-w-none sm:px-2 sm:pb-4">
<div className="w-full max-w-full"> <div className="w-full max-w-full">
<ChuniNameplate profile={profile ? { <ChuniNameplate profile={profile ? {
...profile, ...profile,
@ -155,20 +181,23 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
</div> </div>
{/* end nameplate and trophy */} {/* end nameplate and trophy */}
<Divider className="sm:hidden mt-2 col-span-full" /> <Divider className="sm:hidden mt-2 -mb-2 col-span-full" />
{/* begin avatar */} {/* begin avatar */}
<div className="col-span-full xl:col-span-5 flex flex-col w-full py-2 sm:pl-3 sm:pr-6 rounded-lg sm:shadow-inner sm:bg-content2"> <div className="col-span-full xl:col-span-5 flex flex-col w-full rounded-lg sm:shadow-inner sm:bg-content1">
<div className="text-2xl font-semibold px-2 mt-2 -mb-3 flex h-12"> <div className="text-2xl font-semibold p-3 flex h-16 min-h-16 items-center">
Avatar <span className="sm:ml-2">Avatar</span>
{AVATAR_KEYS.some(k => !saved[k]) && <> {AVATAR_KEYS.some(k => !saved[k]) && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset(...AVATAR_KEYS)}> <Button className="ml-auto" color="danger" variant="light" onPress={() => reset(...AVATAR_KEYS)}>
Reset Reset
</Button> </Button>
<Button className="ml-2" color="primary">Save</Button> <Button className="ml-2" color="primary" onPress={() => save(...AVATAR_KEYS)}>
Save
</Button>
</>} </>}
</div> </div>
<div className="flex flex-col sm:flex-row h-full w-full items-center "> <Divider className="mb-2 hidden sm:block" />
<div className="flex flex-col sm:flex-row h-full w-full items-center sm:pl-3 sm:pr-6 sm:pb-2">
<div className="w-full max-w-96"> <div className="w-full max-w-96">
<ChuniAvatar className="w-full sm:w-auto sm:h-96" <ChuniAvatar className="w-full sm:w-auto sm:h-96"
wear={equipped.avatarWear.texturePath} wear={equipped.avatarWear.texturePath}
@ -191,29 +220,33 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
</div> </div>
{/* end avatar */} {/* end avatar */}
<Divider className="sm:hidden mt-2 col-span-full" /> <Divider className="sm:hidden mt-2 -mb-2 col-span-full" />
{/* begin system voice */} {/* begin system voice */}
<div className="flex flex-col p-4 w-full col-span-full xl:col-span-6 sm:bg-content2 rounded-lg sm:shadow-inner items-center"> <div className="flex flex-col w-full col-span-full xl:col-span-6 sm:bg-content1 rounded-lg sm:shadow-inner items-center">
<div className="text-2xl font-semibold mb-4 px-2 flex w-full h-10"> <div className="text-2xl font-semibold p-3 w-full h-16 min-h-16 flex items-center">
Voice <span className="sm:ml-2">Voice</span>
{!saved.systemVoice && <> {!saved.systemVoice && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('systemVoice')}> <Button className="ml-auto" color="danger" variant="light" onPress={() => reset('systemVoice')}>
Reset Reset
</Button> </Button>
<Button className="ml-2" color="primary">Save</Button> <Button className="ml-2" color="primary" onPress={() => save('systemVoice')}>
Save
</Button>
</>} </>}
</div> </div>
<div className="flex w-full flex-col sm:flex-row items-center"> <Divider className="mb-4 hidden sm:block" />
<div className="flex w-full flex-col sm:flex-row items-center px-2 sm:px-4 sm:pb-4 h-full">
<div className="flex flex-col"> <div className="flex flex-col">
<img className="w-80 max-w-full" <img className="w-80 max-w-full"
alt={equipped.systemVoice.name ?? ''} src={getImageUrl(`chuni/system-voice-icon/${equipped.systemVoice.imagePath}`)} /> alt={equipped.systemVoice.name ?? ''} src={getImageUrl(`chuni/system-voice-icon/${equipped.systemVoice.imagePath}`)} />
<span className="text-center">{ equipped.systemVoice.name }</span> <span className="text-center">{ equipped.systemVoice.name }</span>
</div> </div>
<div className="flex flex-col flex-grow w-full mt-3 sm:-mt-5 sm:w-auto gap-2"> <div className="flex flex-col flex-grow w-fullmt-3 sm:-mt-5 sm:w-auto gap-2 w-full">
<Checkbox isSelected={playPreviews} onValueChange={setPlayPreviews} size="lg" className="text-nowrap"> <Checkbox isSelected={playPreviews} onValueChange={setPlayPreviews} size="lg" className="text-nowrap">
<span className="text-sm">Enable Previews</span> <span className="text-sm">Enable Previews</span>
</Checkbox> </Checkbox>
@ -245,34 +278,42 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
</div> </div>
{/* end system voice */} {/* end system voice */}
<Divider className="sm:hidden mt-2 col-span-full" /> <Divider className="sm:hidden mt-2 -mb-2 col-span-full" />
{/* begin map icon*/} {/* begin map icon*/}
<div className="flex flex-col p-4 w-full col-span-full xl:col-span-6 sm:bg-content2 rounded-lg sm:shadow-inner items-center"> <div className="flex flex-col w-full col-span-full xl:col-span-6 sm:bg-content1 rounded-lg sm:shadow-inner items-center">
<div className="text-2xl font-semibold mb-4 px-2 flex w-full h-10"> <div className="text-2xl font-semibold p-3 flex items-center w-full h-16 min-h-16">
Map Icon <span className="sm:ml-2">Map Icon</span>
{!saved.mapIcon && <> {!saved.mapIcon && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('mapIcon')}> <Button className="ml-auto" color="danger" variant="light" onPress={() => reset('mapIcon')}>
Reset Reset
</Button> </Button>
<Button className="ml-2" color="primary">Save</Button> <Button className="ml-2" color="primary" onPress={() => save('mapIcon')}>
Save
</Button>
</>} </>}
</div> </div>
<img className="w-52 max-w-full -mt-4 sm:-mt-12" <Divider className="mb-4 hidden sm:block" />
<img className="w-52 max-w-full -mt-2"
alt={equipped.mapIcon.name ?? ''} src={getImageUrl(`chuni/map-icon/${equipped.mapIcon.imagePath}`)} /> alt={equipped.mapIcon.name ?? ''} src={getImageUrl(`chuni/map-icon/${equipped.mapIcon.imagePath}`)} />
<span className="text-center mb-2">{ equipped.mapIcon.name }</span> <span className="text-center mb-2">{ equipped.mapIcon.name }</span>
<SelectModalButton onSelected={i => i && equipItem('mapIcon', i)} selectedItem={equipped.mapIcon} <div className="px-2 w-full flex justify-center">
displayMode="grid" modalSize="full" rowSize={210} colSize={175} items={userboxItems.mapIcon} gap={6} <SelectModalButton onSelected={i => i && equipItem('mapIcon', i)} selectedItem={equipped.mapIcon}
className="w-full sm:w-auto" modalId="map-icon" displayMode="grid" modalSize="full" rowSize={210} colSize={175} items={userboxItems.mapIcon} gap={6}
renderItem={i => renderItem(i, getImageUrl(`chuni/map-icon/${i.imagePath}`))}> className="w-full sm:w-auto mb-4" modalId="map-icon"
Change Map Icon renderItem={i => renderItem(i, getImageUrl(`chuni/map-icon/${i.imagePath}`))}>
</SelectModalButton> Change Map Icon
</SelectModalButton>
</div>
</div> </div>
{/* end map icon */} {/* end map icon */}
{Object.values(saved).some(x => !x) && <Button className="fixed bottom-3 right-3 hidden sm:flex" color="primary" radius="full" startContent={<SaveIcon className="h-6" />}> {Object.values(saved).some(x => !x) && <Button className="fixed bottom-3 right-3 hidden sm:flex" color="primary" radius="full"
startContent={<SaveIcon className="h-6" />} onPress={() => save()}>
Save All Save All
</Button>} </Button>}
@ -281,7 +322,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
<div className="flex sm:hidden fixed z-40 items-center font-semibold bottom-0 left-0 w-full p-3 bg-content1 gap-2 flex-wrap "> <div className="flex sm:hidden fixed z-40 items-center font-semibold bottom-0 left-0 w-full p-3 bg-content1 gap-2 flex-wrap ">
You have unsaved changes You have unsaved changes
<Button className="ml-auto" color="primary"> <Button className="ml-auto" color="primary" onPress={() => save()}>
Save All Save All
</Button> </Button>
</div> </div>

View File

@ -3,11 +3,14 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { NextUIProvider } from '@nextui-org/react'; import { NextUIProvider } from '@nextui-org/react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { ErrorProvider } from '@/components/error-modal';
export function ClientProviders({ children }: { children: ReactNode }) { export function ClientProviders({ children }: { children: ReactNode }) {
return (<NextUIProvider className="h-full flex"> return (<ErrorProvider>
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem> <NextUIProvider className="h-full flex">
{children} <NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
</NextThemesProvider> {children}
</NextUIProvider>); </NextThemesProvider>
</NextUIProvider>
</ErrorProvider>);
} }

View File

@ -0,0 +1,36 @@
'use client';
import { createContext, ReactNode, useContext, useState } from 'react';
import { Button, Modal, ModalContent, ModalHeader } from '@nextui-org/react';
import { ModalBody, ModalFooter } from '@nextui-org/modal';
const ErrorContext = createContext<(err: string) => void>(() => {});
export const ErrorProvider = ({ children }: { children: ReactNode }) => {
const [error, setError] = useState<string | null>(null);
return (<>
<Modal isOpen={!!error} onClose={() => setError(null)}>
<ModalContent>
{onClose => <>
<ModalHeader className="text-danger">
Error
</ModalHeader>
<ModalBody>
{error}
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>
Close
</Button>
</ModalFooter>
</>}
</ModalContent>
</Modal>
<ErrorContext.Provider value={setError}>
{children}
</ErrorContext.Provider>
</>)
};
export const useErrorModal = () => useContext(ErrorContext);

View File

@ -167,7 +167,7 @@ const SelectModal = <T extends 'grid' | 'list', D extends { name?: string | null
</AutoSizer>) </AutoSizer>)
}, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gridRowCount, gap, onSelectionChanged]); }, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gridRowCount, gap, onSelectionChanged]);
return (<Modal size={modalSize} onClose={() => { return (<Modal classNames={{ wrapper: 'overflow-hidden' }} size={modalSize} onClose={() => {
onSelected(outputSelected.current); onSelected(outputSelected.current);
}} isOpen={isOpen} }} isOpen={isOpen}
className={`!rounded-2xl !max-h-[90dvh] sm:!max-h-[85dvh] ${modalSize === 'full' ? 'md:max-w-[90dvw]' : ''}`}> className={`!rounded-2xl !max-h-[90dvh] sm:!max-h-[85dvh] ${modalSize === 'full' ? 'md:max-w-[90dvw]' : ''}`}>
@ -228,7 +228,7 @@ export const SelectModalButton = <T extends 'grid' | 'list', D extends { name?:
}} /> }} />
<Button {...(props as object)} onClick={() => { <Button {...(props as object)} onClick={() => {
setOpen(true); setOpen(true);
router.push(`#modal-${modalId}`); router.push(`#modal-${modalId}`,{ scroll: false });
}} /> }} />
</>); </>);
}; };