diff --git a/src/actions/chuni/profile.ts b/src/actions/chuni/profile.ts index d7af5f3..79bcac7 100644 --- a/src/actions/chuni/profile.ts +++ b/src/actions/chuni/profile.ts @@ -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, value: any) => Promise>(); + +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 }; +}; diff --git a/src/components/chuni/userbox.tsx b/src/components/chuni/userbox.tsx index 053cdb6..af252d8 100644 --- a/src/components/chuni/userbox.tsx +++ b/src/components/chuni/userbox.tsx @@ -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(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 = (...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], '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) => {
{/* begin nameplate and trophy */} -
-
-
- Profile - {(!saved.namePlate || !saved.trophy) && <> - - - } -
+
+
+ Profile + {(!saved.namePlate || !saved.trophy) && <> + + + } +
+ + + +
{
{/* end nameplate and trophy */} - + {/* begin avatar */} -
-
- Avatar +
+
+ Avatar {AVATAR_KEYS.some(k => !saved[k]) && <> - + }
-
+ +
{
{/* end avatar */} - + {/* begin system voice */} -
-
- Voice +
+
+ Voice {!saved.systemVoice && <> - + }
-
+ + +
{equipped.systemVoice.name { equipped.systemVoice.name }
-
+
Enable Previews @@ -245,34 +278,42 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
{/* end system voice */} - + {/* begin map icon*/} -
-
- Map Icon +
+
+ Map Icon {!saved.mapIcon && <> - + }
- + + + {equipped.mapIcon.name { equipped.mapIcon.name } - 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 - +
+ 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 + +
{/* end map icon */} - {Object.values(saved).some(x => !x) && } @@ -281,7 +322,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
You have unsaved changes -
diff --git a/src/components/client-providers.tsx b/src/components/client-providers.tsx index 36c40c7..2ac3d9d 100644 --- a/src/components/client-providers.tsx +++ b/src/components/client-providers.tsx @@ -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 ( - - {children} - - ); + return ( + + + {children} + + + ); } diff --git a/src/components/error-modal.tsx b/src/components/error-modal.tsx new file mode 100644 index 0000000..43371ab --- /dev/null +++ b/src/components/error-modal.tsx @@ -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(null); + + return (<> + setError(null)}> + + {onClose => <> + + Error + + + {error} + + + + + } + + + + {children} + + ) +}; + +export const useErrorModal = () => useContext(ErrorContext); diff --git a/src/components/select-modal.tsx b/src/components/select-modal.tsx index fdfa7fe..4f10ce5 100644 --- a/src/components/select-modal.tsx +++ b/src/components/select-modal.tsx @@ -167,7 +167,7 @@ const SelectModal = ) }, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gridRowCount, gap, onSelectionChanged]); - return ( { + return ( { 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 =