mirror of
https://gitea.tendokyu.moe/sk1982/actaeon.git
synced 2026-04-03 16:05:13 -05:00
chuni: add userbox item changing
This commit is contained in:
parent
2f24f53311
commit
722db4c48d
|
|
@ -1,9 +1,15 @@
|
|||
'use server';
|
||||
|
||||
import { sql } from 'kysely';
|
||||
|
||||
import { db } from '@/db';
|
||||
import { db, GeneratedDB } from '@/db';
|
||||
import { chuniRating } from '@/helpers/chuni/rating';
|
||||
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
|
||||
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 = {
|
||||
scoreMax: string,
|
||||
|
|
@ -15,6 +21,8 @@ type RecentRating = {
|
|||
const avatarNames = ['avatarBack', 'avatarFace', 'avatarItem', 'avatarWear', 'avatarFront', 'avatarSkin',
|
||||
'avatarHead'] as const;
|
||||
|
||||
const ALLOW_EQUIP_UNEARNED = ['true', '1', 'yes'].includes(process.env.CHUNI_ALLOW_EQUIP_UNEARNED?.toLowerCase() ?? '');
|
||||
|
||||
export async function getUserData(user: UserPayload) {
|
||||
const res = await db.selectFrom('chuni_profile_data as p')
|
||||
.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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'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 { ChuniNameplate } from '@/components/chuni/nameplate';
|
||||
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 { useAudio } from '@/helpers/use-audio';
|
||||
import { useIsMounted } from 'usehooks-ts';
|
||||
import { Entries } from 'type-fest';
|
||||
import { useErrorModal } from '@/components/error-modal';
|
||||
|
||||
export type ChuniUserboxProps = {
|
||||
profile: ChuniUserData,
|
||||
|
|
@ -50,6 +52,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
|||
const [selectedLine, setSelectedLine] = useState(new Set(['0035']));
|
||||
const [playPreviews, _setPlayPreviews] = useState(true);
|
||||
const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null);
|
||||
const setError = useErrorModal();
|
||||
|
||||
const setPlayPreviews = (play: boolean) => {
|
||||
_setPlayPreviews(play);
|
||||
|
|
@ -73,6 +76,24 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
|||
.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({
|
||||
play: () => setPlayingVoice(true),
|
||||
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">
|
||||
|
||||
{/* 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 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 mb-4 mr-auto px-2 flex w-full h-10">
|
||||
Profile
|
||||
{(!saved.namePlate || !saved.trophy) && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy')}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary">Save</Button>
|
||||
</>}
|
||||
</div>
|
||||
<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')}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary" onPress={() => save('namePlate', 'trophy')}>
|
||||
Save
|
||||
</Button>
|
||||
</>}
|
||||
</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">
|
||||
<ChuniNameplate profile={profile ? {
|
||||
...profile,
|
||||
|
|
@ -155,20 +181,23 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
|||
</div>
|
||||
{/* 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 */}
|
||||
<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="text-2xl font-semibold px-2 mt-2 -mb-3 flex h-12">
|
||||
Avatar
|
||||
<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 p-3 flex h-16 min-h-16 items-center">
|
||||
<span className="sm:ml-2">Avatar</span>
|
||||
{AVATAR_KEYS.some(k => !saved[k]) && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset(...AVATAR_KEYS)}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary">Save</Button>
|
||||
<Button className="ml-2" color="primary" onPress={() => save(...AVATAR_KEYS)}>
|
||||
Save
|
||||
</Button>
|
||||
</>}
|
||||
</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">
|
||||
<ChuniAvatar className="w-full sm:w-auto sm:h-96"
|
||||
wear={equipped.avatarWear.texturePath}
|
||||
|
|
@ -191,29 +220,33 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
|||
</div>
|
||||
{/* 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 */}
|
||||
<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="text-2xl font-semibold mb-4 px-2 flex w-full h-10">
|
||||
Voice
|
||||
<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 p-3 w-full h-16 min-h-16 flex items-center">
|
||||
<span className="sm:ml-2">Voice</span>
|
||||
|
||||
{!saved.systemVoice && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('systemVoice')}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary">Save</Button>
|
||||
<Button className="ml-2" color="primary" onPress={() => save('systemVoice')}>
|
||||
Save
|
||||
</Button>
|
||||
</>}
|
||||
</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">
|
||||
<img className="w-80 max-w-full"
|
||||
alt={equipped.systemVoice.name ?? ''} src={getImageUrl(`chuni/system-voice-icon/${equipped.systemVoice.imagePath}`)} />
|
||||
<span className="text-center">{ equipped.systemVoice.name }</span>
|
||||
</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">
|
||||
<span className="text-sm">Enable Previews</span>
|
||||
</Checkbox>
|
||||
|
|
@ -245,34 +278,42 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
|||
</div>
|
||||
{/* 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*/}
|
||||
<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="text-2xl font-semibold mb-4 px-2 flex w-full h-10">
|
||||
Map Icon
|
||||
<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 p-3 flex items-center w-full h-16 min-h-16">
|
||||
<span className="sm:ml-2">Map Icon</span>
|
||||
|
||||
{!saved.mapIcon && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('mapIcon')}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary">Save</Button>
|
||||
<Button className="ml-2" color="primary" onPress={() => save('mapIcon')}>
|
||||
Save
|
||||
</Button>
|
||||
</>}
|
||||
</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}`)} />
|
||||
<span className="text-center mb-2">{ equipped.mapIcon.name }</span>
|
||||
<SelectModalButton onSelected={i => i && equipItem('mapIcon', i)} selectedItem={equipped.mapIcon}
|
||||
displayMode="grid" modalSize="full" rowSize={210} colSize={175} items={userboxItems.mapIcon} gap={6}
|
||||
className="w-full sm:w-auto" modalId="map-icon"
|
||||
renderItem={i => renderItem(i, getImageUrl(`chuni/map-icon/${i.imagePath}`))}>
|
||||
Change Map Icon
|
||||
</SelectModalButton>
|
||||
<div className="px-2 w-full flex justify-center">
|
||||
<SelectModalButton onSelected={i => i && equipItem('mapIcon', i)} selectedItem={equipped.mapIcon}
|
||||
displayMode="grid" modalSize="full" rowSize={210} colSize={175} items={userboxItems.mapIcon} gap={6}
|
||||
className="w-full sm:w-auto mb-4" modalId="map-icon"
|
||||
renderItem={i => renderItem(i, getImageUrl(`chuni/map-icon/${i.imagePath}`))}>
|
||||
Change Map Icon
|
||||
</SelectModalButton>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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
|
||||
</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 ">
|
||||
You have unsaved changes
|
||||
|
||||
<Button className="ml-auto" color="primary">
|
||||
<Button className="ml-auto" color="primary" onPress={() => save()}>
|
||||
Save All
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { NextUIProvider } from '@nextui-org/react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { ErrorProvider } from '@/components/error-modal';
|
||||
|
||||
export function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return (<NextUIProvider className="h-full flex">
|
||||
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
</NextUIProvider>);
|
||||
return (<ErrorProvider>
|
||||
<NextUIProvider className="h-full flex">
|
||||
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
</NextUIProvider>
|
||||
</ErrorProvider>);
|
||||
}
|
||||
|
|
|
|||
36
src/components/error-modal.tsx
Normal file
36
src/components/error-modal.tsx
Normal 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);
|
||||
|
|
@ -167,7 +167,7 @@ const SelectModal = <T extends 'grid' | 'list', D extends { name?: string | null
|
|||
</AutoSizer>)
|
||||
}, [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);
|
||||
}} isOpen={isOpen}
|
||||
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={() => {
|
||||
setOpen(true);
|
||||
router.push(`#modal-${modalId}`);
|
||||
router.push(`#modal-${modalId}`,{ scroll: false });
|
||||
}} />
|
||||
</>);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user