From a41df38a2888f783c04c2c6f3b3c00cbc531e24b Mon Sep 17 00:00:00 2001 From: sk1982 Date: Sun, 17 Mar 2024 19:15:16 -0400 Subject: [PATCH] chuni: add userbox viewing --- src/actions/chuni/userbox.ts | 109 +++++++ src/app/(with-header)/chuni/userbox/page.tsx | 19 ++ src/components/chuni/userbox.tsx | 302 +++++++++++++++++++ src/components/save-icon.tsx | 3 + src/helpers/chuni/avatar.ts | 9 + src/helpers/chuni/items.ts | 8 + src/helpers/chuni/voice.ts | 23 ++ src/helpers/use-window-listener.ts | 11 + src/types/json-object-array.ts | 33 ++ src/types/pick-nullable.ts | 2 +- 10 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 src/actions/chuni/userbox.ts create mode 100644 src/app/(with-header)/chuni/userbox/page.tsx create mode 100644 src/components/chuni/userbox.tsx create mode 100644 src/components/save-icon.tsx create mode 100644 src/helpers/chuni/avatar.ts create mode 100644 src/helpers/chuni/items.ts create mode 100644 src/helpers/chuni/voice.ts create mode 100644 src/helpers/use-window-listener.ts create mode 100644 src/types/json-object-array.ts diff --git a/src/actions/chuni/userbox.ts b/src/actions/chuni/userbox.ts new file mode 100644 index 0000000..eed474f --- /dev/null +++ b/src/actions/chuni/userbox.ts @@ -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 = (builder: SelectQueryBuilder, 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) => 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; +}; + +type ImageKeys = 'id' | 'name' | 'sortName' | 'imagePath'; + +export type UserboxItems = { + mapIcon: Pick[], + namePlate: Pick[], + systemVoice: Pick[], + trophy: Pick[] +} & { + [K in `avatar${'Wear' | 'Head' | 'Face' | 'Skin' | 'Item' | 'Front' | 'Back'}`]: + Pick[] +}; + +export const getUserboxItems = async (user: UserPayload, profile: ChuniUserData): Promise => { + 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; +}; diff --git a/src/app/(with-header)/chuni/userbox/page.tsx b/src/app/(with-header)/chuni/userbox/page.tsx new file mode 100644 index 0000000..32ad1bb --- /dev/null +++ b/src/app/(with-header)/chuni/userbox/page.tsx @@ -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 (); +} diff --git a/src/components/chuni/userbox.tsx b/src/components/chuni/userbox.tsx new file mode 100644 index 0000000..7ff8ab1 --- /dev/null +++ b/src/components/chuni/userbox.tsx @@ -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> = { + 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; +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(initialEquipped.current); + const [saved, setSaved] = useState(Object.fromEntries(Object.keys(ITEM_KEYS).map(k => [k, true])) as any); + const audioRef = useRef(null); + const [playingVoice, setPlayingVoice] = useState(false); + const [selectedLine, setSelectedLine] = useState(new Set(['0035'])); + const [playPreviews, setPlayPreviews] = useState(true); + const [selectingVoice, setSelectingVoice] = useState(null); + + const equipItem = (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 = (...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 = (
+ + +
); + + const renderItem = (item: { name: string | undefined | null }, image: string, textClass='', containerClass='') => ( +
+ {item.name +
{ item.name }
+
+ ); + + return (
+
+ + {/* begin nameplate and trophy */} +
+
+
+ Profile + {(!saved.namePlate || !saved.trophy) && <> + + + } +
+
+ +
+
+ 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 + +
+
+
+ {/* end nameplate and trophy */} + + + + {/* begin avatar */} +
+
+ Avatar + {AVATAR_KEYS.some(k => !saved[k]) && <> + + + } +
+
+
+ +
+
+ {(['avatarHead', 'avatarFace', 'avatarWear', 'avatarSkin', 'avatarItem', 'avatarBack'] as const).map(k => ((k !== 'avatarSkin' || userboxItems.avatarSkin.length > 1) && equipItem(k, i)} items={userboxItems[k]} selectedItem={equipped[k]} + renderItem={i => renderItem(i, getImageUrl(`chuni/avatar/${i.iconPath}`)) }> + Change {k.slice(6)} + ))} +
+
+
+ {/* end avatar */} + + + + {/* begin system voice */} +
+
+ {/* end system voice */} + + + + {/* begin 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 + +
+ {/* end map icon */} + + {Object.values(saved).some(x => !x) && } + + {Object.values(saved).some(x => !x) && <> +
+
+ You have unsaved changes + + +
+ } + +
+ +
); +}; diff --git a/src/components/save-icon.tsx b/src/components/save-icon.tsx new file mode 100644 index 0000000..b7c7e2a --- /dev/null +++ b/src/components/save-icon.tsx @@ -0,0 +1,3 @@ +export const SaveIcon = ({ className }: { className?: string }) => + +; diff --git a/src/helpers/chuni/avatar.ts b/src/helpers/chuni/avatar.ts new file mode 100644 index 0000000..d0ec370 --- /dev/null +++ b/src/helpers/chuni/avatar.ts @@ -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 }; diff --git a/src/helpers/chuni/items.ts b/src/helpers/chuni/items.ts new file mode 100644 index 0000000..7e3c0da --- /dev/null +++ b/src/helpers/chuni/items.ts @@ -0,0 +1,8 @@ +export const enum ItemKind { + NAME_PLATE = 1, + FRAME = 2, + TROPHY = 3, + MAP_ICON = 8, + SYSTEM_VOICE = 9, + AVATAR_ACCESSORY = 11 +} diff --git a/src/helpers/chuni/voice.ts b/src/helpers/chuni/voice.ts new file mode 100644 index 0000000..0f8296f --- /dev/null +++ b/src/helpers/chuni/voice.ts @@ -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() +]; diff --git a/src/helpers/use-window-listener.ts b/src/helpers/use-window-listener.ts new file mode 100644 index 0000000..d0ea7a9 --- /dev/null +++ b/src/helpers/use-window-listener.ts @@ -0,0 +1,11 @@ +import { useEffect } from 'react'; + +export const useWindowListener = (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]); +}; diff --git a/src/types/json-object-array.ts b/src/types/json-object-array.ts new file mode 100644 index 0000000..41a136a --- /dev/null +++ b/src/types/json-object-array.ts @@ -0,0 +1,33 @@ +import { AliasedExpression, AliasNode, ColumnNode, Expression, IdentifierNode, ReferenceNode, sql } from 'kysely'; + +export const jsonObjectArray = (...refs: (Expression | AliasedExpression)[]) => { + 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`JSON_ARRAYAGG(JSON_OBJECT(${sql.raw(args.join(','))}))`; +}; diff --git a/src/types/pick-nullable.ts b/src/types/pick-nullable.ts index 548554b..e8d3f09 100644 --- a/src/types/pick-nullable.ts +++ b/src/types/pick-nullable.ts @@ -1,3 +1,3 @@ export type PickNullable> = T extends undefined | null ? T : - Pick, K>; + Pick, K> | null | undefined;