chuni: add favorites

This commit is contained in:
sk1982 2024-03-18 01:34:25 -04:00
parent 722db4c48d
commit 28741a8b03
5 changed files with 199 additions and 43 deletions

View File

@ -1,7 +1,10 @@
import { getUser } from '@/actions/auth';
'use server';
import { getUser, requireUser } from '@/actions/auth';
import { db } from '@/db';
import { chuniRating } from '@/helpers/chuni/rating';
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
import { UserPayload } from '@/types/user';
export const getMusic = async (musicId?: number) => {
const user = await getUser();
@ -12,8 +15,16 @@ export const getMusic = async (musicId?: number) => {
.onRef('music.chartId', '=', 'score.level')
.on('score.user', '=', user?.id!)
)
.select([...CHUNI_MUSIC_PROPERTIES, 'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess',
'score.scoreRank', 'score.scoreMax', chuniRating()])
.leftJoin('chuni_item_favorite as favorite', join =>
join.onRef('music.songId', '=', 'favorite.favId')
.onRef('music.version', '=', 'favorite.version')
.on('favorite.favKind', '=', 1)
.on('favorite.user', '=', user?.id!)
)
.select(({ fn }) => [...CHUNI_MUSIC_PROPERTIES,
'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess', 'score.scoreRank', 'score.scoreMax',
fn<boolean>('NOT ISNULL', ['favorite.favId']).as('favorite'),
chuniRating()])
.where(({ selectFrom, eb, and, or }) => and([
eb('music.version', '=', selectFrom('chuni_static_music')
.select(({ fn }) => fn.max('version').as('latest'))),
@ -27,3 +38,61 @@ export const getMusic = async (musicId?: number) => {
.orderBy(['music.songId asc', 'music.chartId asc'])
.execute();
};
const getMusicById = async (user: UserPayload, musicId: number) => {
if (isNaN(musicId))
return { error: true, message: 'Invalid music ID.' };
const music = await db.selectFrom('chuni_static_music as music')
.select('music.version')
.where(({ selectFrom, eb }) => eb('music.version', '=', selectFrom('chuni_static_music')
.select(({ fn }) => fn.max('version').as('latest'))))
.executeTakeFirst();
if (!music)
return { error: true, message: `Unknown music ID ${musicId}.` };
return { error: false, music };
}
export const addFavoriteMusic = async (musicId: number) => {
const user = await requireUser();
const data = await getMusicById(user, musicId);
if (data.error) return data;
const existingFavorite = await db.selectFrom('chuni_item_favorite')
.where(({ eb, and }) => and([
eb('version', '=', data.music?.version!),
eb('user', '=', user.id),
eb('favId', '=', musicId),
eb('favKind', '=', 1)
]))
.select('favId')
.executeTakeFirst();
if (existingFavorite) return;
await db.insertInto('chuni_item_favorite')
.values({
version: data.music?.version!,
user: user.id,
favId: musicId,
favKind: 1
})
.executeTakeFirst();
};
export const removeFavoriteMusic = async (musicId: number) => {
const user = await requireUser();
const data = await getMusicById(user, musicId);
if (data.error) return data;
await db.deleteFrom('chuni_item_favorite')
.where(({ eb, and }) => and([
eb('version', '=', data.music?.version!),
eb('user', '=', user.id),
eb('favId', '=', musicId),
eb('favKind', '=', 1)
]))
.executeTakeFirst();
}

View File

@ -1,13 +1,10 @@
import { getMusic } from '@/actions/chuni/music';
import { notFound } from 'next/navigation';
import { MusicPlayer } from '@/components/music-player';
import { getJacketUrl, getMusicUrl } from '@/helpers/assets';
import { Ticker } from '@/components/ticker';
import { getPlaylog } from '@/actions/chuni/playlog';
import { Accordion, AccordionItem } from '@nextui-org/react';
import { ChuniMusicPlaylog } from '@/components/chuni/music-playlog';
export default async function ChuniMusicDetail({ params }: { params: { musicId: string } }) {
import { ChuniMusicDetail } from '@/components/chuni/music-detail';
export default async function ChuniMusicDetailPage({ params }: { params: { musicId: string } }) {
const musicId = parseInt(params.musicId);
if (Number.isNaN(musicId))
return notFound();
@ -21,16 +18,5 @@ export default async function ChuniMusicDetail({ params }: { params: { musicId:
if (!music.length)
return notFound();
const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1];
return (<div className="flex flex-col items-center sm:mt-2">
<MusicPlayer className="xl:self-start xl:mt-3 xl:ml-3 mb-3 sm:mb-6" image={getJacketUrl(`chuni/jacket/${music[0].jacketPath}`)}
audio={getMusicUrl(`chuni/music/music${cueId?.padStart(4, '0')}`)}>
<Ticker className="font-semibold text-center sm:text-left">{ music[0].title }</Ticker>
<Ticker className="text-center sm:text-left">{ music[0].artist }</Ticker>
<span className="text-medium">{ music[0].genre }</span>
</MusicPlayer>
<ChuniMusicPlaylog music={music} playlog={playlog} />
</div>);
return (<ChuniMusicDetail music={music} playlog={playlog} />)
}

View File

@ -0,0 +1,51 @@
'use client';
import { addFavoriteMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music';
import { getPlaylog } from '@/actions/chuni/playlog';
import { MusicPlayer } from '@/components/music-player';
import { getJacketUrl, getMusicUrl } from '@/helpers/assets';
import { Ticker } from '@/components/ticker';
import { ChuniMusicPlaylog } from '@/components/chuni/music-playlog';
import { Button } from '@nextui-org/react';
import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid';
import { HeartIcon as OutlineHeartIcon } from '@heroicons/react/24/outline';
import React, { useState } from 'react';
import { useErrorModal } from '@/components/error-modal';
type ChuniMusicDetailProps = {
music: Awaited<ReturnType<typeof getMusic>>,
playlog: Awaited<ReturnType<typeof getPlaylog>>
};
export const ChuniMusicDetail = ({ music, playlog }: ChuniMusicDetailProps) => {
const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1];
const [favorite, setFavorite] = useState(music[0].favorite);
const [pendingFavorite, setPendingFavorite] = useState(false);
const setError = useErrorModal();
return (<div className="flex flex-col items-center sm:mt-2">
<MusicPlayer className="xl:self-start xl:mt-3 xl:ml-3 mb-3 sm:mb-6" image={getJacketUrl(`chuni/jacket/${music[0].jacketPath}`)}
audio={getMusicUrl(`chuni/music/music${cueId?.padStart(4, '0')}`)}>
<Ticker className="font-semibold text-center sm:text-left">{ music[0].title }</Ticker>
<Ticker className="text-center sm:text-left">{ music[0].artist }</Ticker>
<span className="text-medium">{ music[0].genre }</span>
<Button isIconOnly className={`absolute right-2 top-2 ${favorite ? 'text-red-500' : 'text-gray-500'}`} radius="full"
variant="light" onPress={() => {
if (pendingFavorite) return;
setPendingFavorite(true);
const f = favorite;
setFavorite(!f);
(f ? removeFavoriteMusic : addFavoriteMusic)(music[0].songId!)
.then(res => {
if (res?.error) {
setFavorite(f);
return setError(`Failed to set favorite: ${res.message}`);
}
}).finally(() => setPendingFavorite(false))
}}>
{favorite ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
</Button>
</MusicPlayer>
<ChuniMusicPlaylog music={music} playlog={playlog} />
</div>);
};

View File

@ -2,21 +2,23 @@
import { Filterers, FilterSorter, Sorter } from '@/components/filter-sorter';
import { WindowScroller, Grid, AutoSizer, List } from 'react-virtualized';
import { SelectItem } from '@nextui-org/react';
import { getMusic } from '@/actions/chuni/music';
import React, { useEffect, useMemo, useRef } from 'react';
import { Button, SelectItem } from '@nextui-org/react';
import { addFavoriteMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
import { getJacketUrl } from '@/helpers/assets';
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
import { ChuniScoreBadge, ChuniLampSuccessBadge, getVariantFromLamp, getVariantFromRank, ChuniLampComboBadge } from '@/components/chuni/score-badge';
import { ChuniScoreBadge, ChuniLampSuccessBadge, getVariantFromRank, ChuniLampComboBadge } from '@/components/chuni/score-badge';
import { ChuniRating } from '@/components/chuni/rating';
import Link from 'next/link';
import { Squares2X2Icon } from '@heroicons/react/24/outline';
import { HeartIcon as OutlineHeartIcon, Squares2X2Icon } from '@heroicons/react/24/outline';
import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid';
import { Ticker } from '@/components/ticker';
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
import { useErrorModal } from '@/components/error-modal';
const getLevelFromStop = (n: number) => {
if (n < 7)
@ -59,12 +61,16 @@ const searcher = (query: string, data: ChuniMusicListProps['music'][number]) =>
return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query);
};
const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) => {
const MusicGrid = ({ music, size, setMusicList }: ChuniMusicListProps & { size: 'sm' | 'lg' | 'xs', setMusicList: (m: typeof music) => void }) => {
let itemWidth = 0;
let itemHeight = 0;
let itemClass = '';
if (size === 'sm') {
if (size === 'xs') {
itemWidth = 125;
itemHeight = 180;
itemClass = 'w-[125px] h-[180px] py-0.5 px-0.5';
} else if (size === 'sm') {
itemWidth = 175;
itemHeight = 235;
itemClass = 'w-[175px] h-[235px] py-1.5 px-1';
@ -75,6 +81,8 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
}
const listRef = useRef<List | null>(null);
const setError = useErrorModal();
const [pendingFavorite, setPendingFavorite] = useState(false);
useEffect(() => {
listRef.current?.recomputeRowHeights(0);
@ -94,33 +102,60 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
<ChuniDifficultyContainer difficulty={item.chartId!} containerClassName="flex flex-col" className="w-full h-full border border-gray-500/75 rounded-md [&:hover_.ticker]:[animation-play-state:running]">
<div className="aspect-square w-full p-[0.2rem] relative">
<img src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)} alt={item.title ?? 'Music'} className="rounded" />
{item.rating && !item.worldsEndTag && <div className={`${size === 'sm' ? '' : 'text-2xl'} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm px-0.5 rounded`}>
{item.rating && !item.worldsEndTag && <div className={`${size === 'lg' ? 'text-2xl' : ''} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm px-0.5 rounded`}>
<ChuniRating rating={+item.rating * 100} className="-my-0.5">
{item.rating.slice(0, item.rating.indexOf('.') + 3)}
</ChuniRating>
</div>}
<ChuniLevelBadge className={`${size === 'sm' ? 'w-14' : 'h-14'} absolute bottom-px right-px`} music={item} />
<ChuniLevelBadge className={`${size === 'lg' ? 'h-14' : 'w-14'} absolute bottom-px right-px`} music={item} />
<Button isIconOnly className={`absolute top-0 left-0 pt-1 bg-gray-600/25 ${item.favorite ? 'text-red-500': ''}`}
size={size === 'xs' ? 'sm' : 'md'} variant="flat" radius="full"
onPress={() => {
if (pendingFavorite) return;
const favorite = item.favorite;
setMusicList(music.map(m => {
if (m.songId !== item.songId)
return m;
return { ...m, favorite: !favorite };
}));
setPendingFavorite(true);
(item.favorite ? removeFavoriteMusic : addFavoriteMusic)(item.songId!)
.then(res => {
if (res?.error) {
setMusicList(music.map(m => {
if (m.songId !== item.songId)
return m;
return { ...m, favorite };
}));
return setError(`Failed to set favorite: ${res.message}`);
}
})
.finally(() => setPendingFavorite(false))
}}>
{item.favorite ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
</Button>
</div>
<div className="px-0.5 mb-1 flex">
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
{item.isSuccess ? <ChuniLampSuccessBadge success={item.isSuccess} /> : null}
</div>}
<div className={`h-full ${size === 'sm' ? 'w-1/2' : 'w-1/3'}`}>
<div className={`h-full ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
{item.scoreRank !== null && <ChuniScoreBadge variant={getVariantFromRank(item.scoreRank)} className="h-full">
{item.scoreMax!.toLocaleString()}
</ChuniScoreBadge>}
</div>
<div className={`h-full ml-0.5 ${size === 'sm' ? 'w-1/2' : 'w-1/3'}`}>
<div className={`h-full ml-0.5 ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
<ChuniLampComboBadge {...item} />
</div>
</div>
<Link href={`/chuni/music/${item.songId}`}
className={`${size === 'sm' ? 'text-xs' : 'text-lg'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
className={`${size === 'lg' ? 'text-lg' : 'text-xs'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
<Ticker hoverOnly noDelay>{item.title}</Ticker>
</Link>
<Ticker className={`${size === 'sm' ? 'text-xs mb-0.5' : 'text-medium mb-1.5'} text-center px-1 drop-shadow-2xl text-white`} hoverOnly noDelay>{item.artist}</Ticker>
<Ticker className={`${size === 'lg' ? 'text-medium mb-1.5' : 'text-xs mb-0.5' } text-center px-1 drop-shadow-2xl text-white`} hoverOnly noDelay>{item.artist}</Ticker>
</ChuniDifficultyContainer>
</div>)}
</div>} />)
@ -128,7 +163,27 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
</AutoSizer>)}
</WindowScroller>);
};
const DISPLAY_MODES = [{
name: 'Extra Small Grid',
icon: <Squares2X2Icon />
}, {
name: 'Small Grid',
icon: <Squares2X2Icon />
}, {
name: 'Large Grid',
icon: <Squares2X2Icon />
}];
const DISPLAY_IDS = {
'Extra Small Grid': 'xs',
'Small Grid': 'sm',
'Large Grid': 'lg'
} as const;
export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
const [localMusic, setLocalMusic] = useState(music);
const { filterers } = useMemo(() => {
const genres = new Set<string>();
const worldsEndTags = new Set<string>();
@ -275,16 +330,10 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
}, [music]);
return (
<FilterSorter className="flex-grow" data={music} sorters={sorters} filterers={filterers} pageSizes={perPage}
displayModes={[{
name: 'Small Grid',
icon: <Squares2X2Icon />
}, {
name: 'Large Grid',
icon: <Squares2X2Icon />
}]} searcher={searcher}>
<FilterSorter className="flex-grow" data={localMusic} sorters={sorters} filterers={filterers} pageSizes={perPage}
displayModes={DISPLAY_MODES} searcher={searcher}>
{(displayMode, data) => <div className="w-full flex-grow my-2">
<MusicGrid music={data} size={displayMode === 'Small Grid' ? 'sm' : 'lg'} />
<MusicGrid music={data} size={DISPLAY_IDS[displayMode as keyof typeof DISPLAY_IDS]} setMusicList={setLocalMusic} />
</div>}
</FilterSorter>);
};

View File

@ -7,6 +7,7 @@ export const CHUNI_MUSIC_PROPERTIES = ['music.songId',
'music.jacketPath',
'music.worldsEndTag',
'music.genre',
'music.version',
'music.level'
// sql<string>`CAST(music.level AS DECIMAL(3, 1))`.as('level')
] as const;