chuni: add userbox viewing
This commit is contained in:
parent
1be321bdaa
commit
a41df38a28
109
src/actions/chuni/userbox.ts
Normal file
109
src/actions/chuni/userbox.ts
Normal file
@ -0,0 +1,109 @@
|
||||
'use server';
|
||||
|
||||
import { UserPayload } from '@/types/user';
|
||||
import { db, GeneratedDB } from '@/db';
|
||||
import { ExpressionBuilder, SelectQueryBuilder } from 'kysely';
|
||||
import { ItemKind } from '@/helpers/chuni/items';
|
||||
import { jsonObjectArray } from '@/types/json-object-array';
|
||||
import { AvatarCategory } from '@/helpers/chuni/avatar';
|
||||
import { DB } from '@/types/db';
|
||||
import { ChuniUserData } from '@/actions/chuni/profile';
|
||||
|
||||
const ALLOW_EQUIP_UNEARNED = ['true', '1', 'yes'].includes(process.env.CHUNI_ALLOW_EQUIP_UNEARNED?.toLowerCase() ?? '');
|
||||
|
||||
const joinItem = <DB extends GeneratedDB, TB extends keyof DB, O>(builder: SelectQueryBuilder<DB, TB, O>, joinKey: any, user: number, itemKind: ItemKind, ...equipped: (number | null | undefined)[]) => {
|
||||
return (builder.leftJoin('chuni_item_item', join =>
|
||||
join.onRef('chuni_item_item.itemId' as any, '=', joinKey)
|
||||
.on('chuni_item_item.user' as any, '=', user)
|
||||
.on('chuni_item_item.itemKind' as any, '=', itemKind)
|
||||
) as any)
|
||||
.where((eb: ExpressionBuilder<any, any>) => eb.or(ALLOW_EQUIP_UNEARNED ? [eb.lit(true)] : [
|
||||
eb('chuni_item_item.itemId', 'is not', null), // owned item
|
||||
...equipped.map(id => eb(joinKey, '=', id ?? -1)) // equipped but not owned
|
||||
])) as SelectQueryBuilder<DB, TB, O>;
|
||||
};
|
||||
|
||||
type ImageKeys = 'id' | 'name' | 'sortName' | 'imagePath';
|
||||
|
||||
export type UserboxItems = {
|
||||
mapIcon: Pick<DB['actaeon_chuni_static_map_icon'], ImageKeys>[],
|
||||
namePlate: Pick<DB['actaeon_chuni_static_name_plate'], ImageKeys>[],
|
||||
systemVoice: Pick<DB['actaeon_chuni_static_system_voice'], ImageKeys | 'cuePath'>[],
|
||||
trophy: Pick<DB['actaeon_chuni_static_trophies'], 'id' | 'name' | 'rareType' | 'explainText'>[]
|
||||
} & {
|
||||
[K in `avatar${'Wear' | 'Head' | 'Face' | 'Skin' | 'Item' | 'Front' | 'Back'}`]:
|
||||
Pick<DB['chuni_static_avatar'], 'avatarAccessoryId' | 'name' | 'iconPath' | 'texturePath'>[]
|
||||
};
|
||||
|
||||
export const getUserboxItems = async (user: UserPayload, profile: ChuniUserData): Promise<UserboxItems> => {
|
||||
const res = await db
|
||||
.with('map_icons', eb => joinItem(eb.selectFrom('actaeon_chuni_static_map_icon as map_icon'),
|
||||
'map_icon.id', user.id, ItemKind.MAP_ICON, profile?.mapIconId)
|
||||
.select(eb => jsonObjectArray(
|
||||
eb.ref('map_icon.id'),
|
||||
eb.ref('map_icon.name'),
|
||||
eb.ref('map_icon.sortName'),
|
||||
eb.ref('map_icon.imagePath')
|
||||
).as('mapIcon'))
|
||||
)
|
||||
.with('name_plates', eb => joinItem(eb.selectFrom('actaeon_chuni_static_name_plate as name_plate'),
|
||||
'name_plate.id', user.id, ItemKind.NAME_PLATE, profile?.nameplateId)
|
||||
.select(eb => jsonObjectArray(
|
||||
eb.ref('name_plate.id'),
|
||||
eb.ref('name_plate.name'),
|
||||
eb.ref('name_plate.sortName'),
|
||||
eb.ref('name_plate.imagePath')
|
||||
).as('namePlate')))
|
||||
.with('system_voices', eb => joinItem(eb.selectFrom('actaeon_chuni_static_system_voice as system_voice'),
|
||||
'system_voice.id', user.id, ItemKind.SYSTEM_VOICE, profile?.voiceId)
|
||||
.select(eb => jsonObjectArray(
|
||||
eb.ref('system_voice.id'),
|
||||
eb.ref('system_voice.name'),
|
||||
eb.ref('system_voice.sortName'),
|
||||
eb.ref('system_voice.imagePath'),
|
||||
eb.ref('system_voice.cuePath'),
|
||||
).as('systemVoice')))
|
||||
.with('trophies', eb => joinItem(eb.selectFrom('actaeon_chuni_static_trophies as trophy'),
|
||||
'trophy.id', user.id, ItemKind.TROPHY, profile?.nameplateId)
|
||||
.select(eb => jsonObjectArray(
|
||||
eb.ref('trophy.id'),
|
||||
eb.ref('trophy.name'),
|
||||
eb.ref('trophy.rareType'),
|
||||
eb.ref('trophy.explainText')
|
||||
).as('trophy')))
|
||||
.with('avatars', eb => joinItem(eb.selectFrom('chuni_static_avatar as avatar'),
|
||||
'avatar.avatarAccessoryId', user.id, ItemKind.AVATAR_ACCESSORY, profile?.avatarBack, profile?.avatarFace, profile?.avatarItem,
|
||||
profile?.avatarWear, profile?.avatarFront, profile?.avatarSkin, profile?.avatarHead)
|
||||
.where(({ selectFrom, eb }) => eb('avatar.version', '=', selectFrom('chuni_static_avatar')
|
||||
.select(({ fn }) => fn.max('version').as('latest'))))
|
||||
.groupBy('avatar.category')
|
||||
.select(eb => ['avatar.category', jsonObjectArray(
|
||||
eb.ref('avatar.avatarAccessoryId').as('id'),
|
||||
eb.ref('avatar.name'),
|
||||
eb.ref('avatar.iconPath'),
|
||||
eb.ref('avatar.texturePath')
|
||||
).as('avatar')]))
|
||||
.selectFrom(['map_icons', 'name_plates', 'system_voices', 'trophies', 'avatars'])
|
||||
.select(eb => ['map_icons.mapIcon', 'name_plates.namePlate', 'system_voices.systemVoice', 'trophies.trophy',
|
||||
jsonObjectArray(eb.ref('avatars.category'), eb.ref('avatars.avatar')).as('avatar')])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const data = Object.fromEntries(Object.entries(res)
|
||||
.map(([key, val]) => [key, JSON.parse(val)]));
|
||||
|
||||
const { avatar, ...output } = data;
|
||||
|
||||
const itemTypes: { [key: number]: any[] } = {};
|
||||
Object.entries(AvatarCategory).forEach(([category, number]) => {
|
||||
const key = `avatar${category[0]}${category.slice(1).toLowerCase()}`;
|
||||
output[key] = itemTypes[number] = [];
|
||||
});
|
||||
(avatar as { category: number, avatar: UserboxItems['avatarBack'] }[])
|
||||
?.forEach(({ category, avatar }) => itemTypes[category].push(...avatar));
|
||||
|
||||
output.mapIcon ??= [];
|
||||
output.namePlate ??= [];
|
||||
output.systemVoice ??= [];
|
||||
output.trophy ??= [];
|
||||
return output as any;
|
||||
};
|
19
src/app/(with-header)/chuni/userbox/page.tsx
Normal file
19
src/app/(with-header)/chuni/userbox/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { requireUser } from '@/actions/auth';
|
||||
import { getUserData } from '@/actions/chuni/profile';
|
||||
import { getUserboxItems } from '@/actions/chuni/userbox';
|
||||
import { ChuniUserbox } from '@/components/chuni/userbox';
|
||||
import { Viewport } from 'next';
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
interactiveWidget: 'resizes-content'
|
||||
};
|
||||
|
||||
export default async function ChuniUserboxPage() {
|
||||
const user = await requireUser();
|
||||
const profile = await getUserData(user);
|
||||
const userboxItems = await getUserboxItems(user, profile);
|
||||
|
||||
return (<ChuniUserbox profile={profile} userboxItems={userboxItems} />);
|
||||
}
|
302
src/components/chuni/userbox.tsx
Normal file
302
src/components/chuni/userbox.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
'use client';
|
||||
|
||||
import { ChuniUserData, getUserData } 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';
|
||||
import { SelectModalButton } from '@/components/select-modal';
|
||||
import { ChuniTrophy } from '@/components/chuni/trophy';
|
||||
import { getAudioUrl, getImageUrl } from '@/helpers/assets';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChuniAvatar } from '@/components/chuni/avatar';
|
||||
import { CHUNI_VOICE_LINES } from '@/helpers/chuni/voice';
|
||||
import { PlayIcon, StopIcon } from '@heroicons/react/24/solid';
|
||||
import { SaveIcon } from '@/components/save-icon';
|
||||
|
||||
export type ChuniUserboxProps = {
|
||||
profile: ChuniUserData,
|
||||
userboxItems: UserboxItems
|
||||
};
|
||||
|
||||
const ITEM_KEYS: Record<keyof UserboxItems, keyof NonNullable<ChuniUserData>> = {
|
||||
namePlate: 'nameplateId',
|
||||
trophy: 'trophyId',
|
||||
mapIcon: 'mapIconId',
|
||||
systemVoice: 'voiceId',
|
||||
avatarWear: 'avatarWear',
|
||||
avatarHead: 'avatarHead',
|
||||
avatarFace: 'avatarFace',
|
||||
avatarSkin: 'avatarSkin',
|
||||
avatarItem: 'avatarItem',
|
||||
avatarFront: 'avatarFront',
|
||||
avatarBack: 'avatarBack'
|
||||
};
|
||||
|
||||
const AVATAR_KEYS = ['avatarWear', 'avatarHead', 'avatarFace', 'avatarSkin', 'avatarItem', 'avatarFront', 'avatarBack'] as const;
|
||||
|
||||
type RequiredUserbox = NonNullable<UserboxItems>;
|
||||
type EquippedItem = { [K in keyof RequiredUserbox]: RequiredUserbox[K][number] };
|
||||
type SavedItem = { [K in keyof RequiredUserbox]: 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 => ('id' in i ? i.id : i.avatarAccessoryId) === profile?.[profileKey])])) as EquippedItem);
|
||||
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 audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [playingVoice, setPlayingVoice] = useState(false);
|
||||
const [selectedLine, setSelectedLine] = useState(new Set(['0035']));
|
||||
const [playPreviews, setPlayPreviews] = useState(true);
|
||||
const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null);
|
||||
|
||||
const equipItem = <K extends keyof RequiredUserbox>(k: K, item: RequiredUserbox[K][number] | undefined | null) => {
|
||||
if (!item || equipped[k] === item) return;
|
||||
|
||||
setEquipped(e => ({ ...e, [k]: item }));
|
||||
setSaved(s => ({ ...s, [k]: false }));
|
||||
};
|
||||
|
||||
const reset = <K extends keyof RequiredUserbox>(...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))) }))
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.volume = 0.25;
|
||||
|
||||
const setPlay = () => {
|
||||
setPlayingVoice(true);
|
||||
};
|
||||
const setStop = () => {
|
||||
setPlayingVoice(false);
|
||||
};
|
||||
audio.addEventListener('play', setPlay);
|
||||
audio.addEventListener('ended', setStop);
|
||||
audio.addEventListener('pause', setStop)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('play', setPlay);
|
||||
audio.removeEventListener('ended', setStop);
|
||||
audio.removeEventListener('pause', setStop);
|
||||
audio.pause();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const play = (src: string) => {
|
||||
if (!audioRef.current || !playPreviews) return;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = src;
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.play();
|
||||
};
|
||||
|
||||
const stop = () => audioRef.current?.pause();
|
||||
|
||||
const voicePreview = (<div className="flex rounded-xl overflow-hidden flex-grow">
|
||||
<Select label="Preview Voice Line" size="sm" radius="none" className="overflow-hidden min-w-56"
|
||||
isDisabled={!playPreviews}
|
||||
selectedKeys={selectedLine} onSelectionChange={s => {
|
||||
if (typeof s === 'string' || !s.size) return;
|
||||
setSelectedLine(s as any);
|
||||
play(getAudioUrl(`chuni/system-voice/${selectingVoice?.cuePath ?? equipped.systemVoice.cuePath}_${[...s][0]}`))
|
||||
}}>
|
||||
{CHUNI_VOICE_LINES.map(([line, id]) => <SelectItem key={id}>{line}</SelectItem>)}
|
||||
</Select>
|
||||
<Button isIconOnly color="primary" className="p-1.5 h-full" radius="none" size="lg" isDisabled={!playPreviews}
|
||||
onPress={() => playingVoice ? stop() :
|
||||
play(getAudioUrl(`chuni/system-voice/${selectingVoice?.cuePath ?? equipped.systemVoice.cuePath}_${[...selectedLine][0]}`))}>
|
||||
{playingVoice ? <StopIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
</div>);
|
||||
|
||||
const renderItem = (item: { name: string | undefined | null }, image: string, textClass='', containerClass='') => (
|
||||
<div className={`w-full h-full flex flex-col border border-gray-500 rounded-2xl shadow-inner ${containerClass}`}>
|
||||
<img alt={item.name ?? ''} className={`w-full ${textClass}`} src={image} />
|
||||
<div className={textClass}>{ item.name }</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (<div className="flex justify-center w-full">
|
||||
<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="w-full max-w-full">
|
||||
<ChuniNameplate profile={profile ? {
|
||||
...profile,
|
||||
nameplateName: equipped.namePlate.name,
|
||||
nameplateImage: equipped.namePlate.imagePath,
|
||||
trophyName: equipped.trophy.name,
|
||||
trophyRareType: equipped.trophy.rareType
|
||||
} : null} className="w-full" />
|
||||
</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}
|
||||
modalId="nameplate"
|
||||
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)}>
|
||||
Change Nameplate
|
||||
</SelectModalButton>
|
||||
<SelectModalButton className="flex-grow flex-1" displayMode="list" modalSize="2xl" rowSize={66} items={userboxItems.trophy}
|
||||
modalId="trophy"
|
||||
renderItem={n => <ChuniTrophy rarity={n.rareType} name={n.name} />}
|
||||
selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}>
|
||||
Change Trophy
|
||||
</SelectModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* end nameplate and trophy */}
|
||||
|
||||
<Divider className="sm:hidden mt-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
|
||||
{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>
|
||||
</>}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row h-full w-full items-center ">
|
||||
<div className="w-full max-w-96">
|
||||
<ChuniAvatar className="w-full sm:w-auto sm:h-96"
|
||||
wear={equipped.avatarWear.texturePath}
|
||||
head={equipped.avatarHead.texturePath}
|
||||
face={equipped.avatarFace.texturePath}
|
||||
skin={equipped.avatarSkin.texturePath}
|
||||
item={equipped.avatarItem.texturePath}
|
||||
back={equipped.avatarBack.texturePath}/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 w-full px-2 sm:px-0 sm:flex flex-col gap-1.5 sm:ml-3 flex-grow">
|
||||
{(['avatarHead', 'avatarFace', 'avatarWear', 'avatarSkin', 'avatarItem', 'avatarBack'] as const).map(k => ((k !== 'avatarSkin' || userboxItems.avatarSkin.length > 1) && <SelectModalButton
|
||||
key={k} displayMode="grid" modalSize="3xl" colSize={175} rowSize={205} gap={5} modalId={k}
|
||||
className={(k === 'avatarBack' && userboxItems.avatarSkin.length === 1) ? 'w-full col-span-full' : 'w-full'}
|
||||
onSelected={i => equipItem(k, i)} items={userboxItems[k]} selectedItem={equipped[k]}
|
||||
renderItem={i => renderItem(i, getImageUrl(`chuni/avatar/${i.iconPath}`)) }>
|
||||
Change {k.slice(6)}
|
||||
</SelectModalButton>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* end avatar */}
|
||||
|
||||
<Divider className="sm:hidden mt-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">
|
||||
<audio ref={audioRef} />
|
||||
|
||||
<div className="text-2xl font-semibold mb-4 px-2 flex w-full h-10">
|
||||
Voice
|
||||
|
||||
{!saved.systemVoice && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('systemVoice')}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary">Save</Button>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col sm:flex-row items-center">
|
||||
<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">
|
||||
<Checkbox isSelected={playPreviews} onValueChange={setPlayPreviews} size="lg" className="text-nowrap">
|
||||
<span className="text-sm">Enable Previews</span>
|
||||
</Checkbox>
|
||||
{ voicePreview }
|
||||
<SelectModalButton selectedItem={equipped.systemVoice} items={userboxItems.systemVoice}
|
||||
displayMode="grid" rowSize={150} colSize={175} gap={6} modalSize="full"
|
||||
modalId="system-voice"
|
||||
footer={<><div className="flex flex-grow gap-2 items-center max-w-full sm:max-w-[min(100%,18rem)]">
|
||||
{ voicePreview }
|
||||
</div>
|
||||
<Checkbox isSelected={playPreviews} onValueChange={setPlayPreviews} size="lg" className="text-nowrap mr-auto">
|
||||
<span className="text-sm">Enable Previews</span>
|
||||
</Checkbox>
|
||||
</>}
|
||||
onSelected={i => {
|
||||
setSelectingVoice(i ?? null);
|
||||
stop();
|
||||
if (i) equipItem('systemVoice', i);
|
||||
}}
|
||||
onSelectionChanged={i => {
|
||||
play(getAudioUrl(`chuni/system-voice/${i.cuePath}_${[...selectedLine][0]}`));
|
||||
setSelectingVoice(i);
|
||||
}}
|
||||
renderItem={i => renderItem(i, getImageUrl(`chuni/system-voice-icon/${i.imagePath}`))}>
|
||||
Change Voice
|
||||
</SelectModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* end system voice */}
|
||||
|
||||
<Divider className="sm:hidden mt-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
|
||||
|
||||
{!saved.mapIcon && <>
|
||||
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('mapIcon')}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button className="ml-2" color="primary">Save</Button>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<img className="w-52 max-w-full -mt-4 sm:-mt-12"
|
||||
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>
|
||||
{/* 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" />}>
|
||||
Save All
|
||||
</Button>}
|
||||
|
||||
{Object.values(saved).some(x => !x) && <>
|
||||
<div className="block sm:hidden h-20" />
|
||||
<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">
|
||||
Save All
|
||||
</Button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
</div>
|
||||
|
||||
</div>);
|
||||
};
|
3
src/components/save-icon.tsx
Normal file
3
src/components/save-icon.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const SaveIcon = ({ className }: { className?: string }) => <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" >
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v18h18V6l-3-3H3ZM7.5 3v6h9V3M6 21v-9h12v9M14.25 5.25v1.5"/>
|
||||
</svg>;
|
9
src/helpers/chuni/avatar.ts
Normal file
9
src/helpers/chuni/avatar.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const AvatarCategory = {
|
||||
WEAR: 1,
|
||||
HEAD: 2,
|
||||
FACE: 3,
|
||||
SKIN: 4,
|
||||
ITEM: 5,
|
||||
FRONT: 6,
|
||||
BACK: 7
|
||||
} as { [key: string]: number };
|
8
src/helpers/chuni/items.ts
Normal file
8
src/helpers/chuni/items.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const enum ItemKind {
|
||||
NAME_PLATE = 1,
|
||||
FRAME = 2,
|
||||
TROPHY = 3,
|
||||
MAP_ICON = 8,
|
||||
SYSTEM_VOICE = 9,
|
||||
AVATAR_ACCESSORY = 11
|
||||
}
|
23
src/helpers/chuni/voice.ts
Normal file
23
src/helpers/chuni/voice.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
|
||||
|
||||
export const CHUNI_VOICE_LINES: readonly [string, string][] = [
|
||||
['𝅘𝅥𝅮 SEGA 𝅘𝅥𝅮', '0035'],
|
||||
['CHUNITHM', '0036'],
|
||||
['Welcome to CHUNITHM', '0041'],
|
||||
['Full Combo', '0001'],
|
||||
['All Justice', '0002'],
|
||||
['Full Chain', '0008'],
|
||||
['New Record', '0009'],
|
||||
['All Clear', '0010'],
|
||||
['Genkai Toppa', '0046'],
|
||||
['Quest Clear', '0047'],
|
||||
['Continue?', '0050'],
|
||||
['Continue!', '0051'],
|
||||
['See You Next Play', '0052'],
|
||||
...CHUNI_SCORE_RANKS
|
||||
.map((rank, i) => [
|
||||
`Rank ${rank}`,
|
||||
(i + 100).toString().padStart(4, '0')
|
||||
] as [string, string])
|
||||
.reverse()
|
||||
];
|
11
src/helpers/use-window-listener.ts
Normal file
11
src/helpers/use-window-listener.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useWindowListener = <K extends keyof WindowEventMap>(event: K,
|
||||
listener: (this: Window, ev: WindowEventMap[K]) => any,
|
||||
deps: any[] = [],
|
||||
options?: boolean | AddEventListenerOptions) => {
|
||||
useEffect(() => {
|
||||
window.addEventListener(event, listener, options);
|
||||
return () => window.removeEventListener(event, listener, options);
|
||||
}, [event, listener, options, ...deps]);
|
||||
};
|
33
src/types/json-object-array.ts
Normal file
33
src/types/json-object-array.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { AliasedExpression, AliasNode, ColumnNode, Expression, IdentifierNode, ReferenceNode, sql } from 'kysely';
|
||||
|
||||
export const jsonObjectArray = (...refs: (Expression<any> | AliasedExpression<any, string>)[]) => {
|
||||
const args: string[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
let node = ref.toOperationNode();
|
||||
let name: string | null = null;
|
||||
|
||||
if (AliasNode.is(node)) {
|
||||
if (!IdentifierNode.is(node.alias))
|
||||
throw TypeError(`unexpected alias type ${node.alias}`)
|
||||
name = node.alias.name;
|
||||
node = node.node;
|
||||
}
|
||||
|
||||
if (!ReferenceNode.is(node))
|
||||
throw TypeError(`unexpected node type ${node.kind}`);
|
||||
|
||||
if (!ColumnNode.is(node.column))
|
||||
throw TypeError('cannot use select all with json');
|
||||
|
||||
name ??= node.column.column.name;
|
||||
args.push(`'${name}'`);
|
||||
|
||||
let identifier: string = '`' + node.column.column.name + '`';
|
||||
if (node.table)
|
||||
identifier = '`' + node.table.table.identifier.name + '`.' +identifier;
|
||||
args.push(identifier);
|
||||
}
|
||||
|
||||
return sql<string>`JSON_ARRAYAGG(JSON_OBJECT(${sql.raw(args.join(','))}))`;
|
||||
};
|
@ -1,3 +1,3 @@
|
||||
export type PickNullable<T, K extends keyof NonNullable<T>> =
|
||||
T extends undefined | null ? T :
|
||||
Pick<NonNullable<T>, K>;
|
||||
Pick<NonNullable<T>, K> | null | undefined;
|
||||
|
Loading…
Reference in New Issue
Block a user