chuni: add playlog page
This commit is contained in:
parent
9f54d8bfb2
commit
3c1b0d1946
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptLibraryMappings">
|
||||
<excludedPredefinedLibrary name="actaeon/server/node_modules" />
|
||||
</component>
|
||||
</project>
|
@ -40,6 +40,8 @@ export const getMusic = async (musicId?: number) => {
|
||||
.execute();
|
||||
};
|
||||
|
||||
export type ChuniMusic = Awaited<ReturnType<typeof getMusic>>[number];
|
||||
|
||||
const getMusicById = async (user: UserPayload, musicId: number) => {
|
||||
if (isNaN(musicId))
|
||||
return { error: true, message: 'Invalid music ID.' };
|
||||
|
@ -5,19 +5,33 @@ import { db } from '@/db';
|
||||
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
|
||||
import { chuniRating } from '@/helpers/chuni/rating';
|
||||
import { sql } from 'kysely';
|
||||
import { PlaylogFilterState } from '@/components/chuni/playlog-list';
|
||||
|
||||
const SORT_KEYS = {
|
||||
Date: 'id',
|
||||
Rating: 'rating',
|
||||
Level: 'level',
|
||||
Score: 'score'
|
||||
} as const;
|
||||
|
||||
const SORT_KEYS_SET = new Set(Object.keys(SORT_KEYS));
|
||||
|
||||
export type GetPlaylogOptions = {
|
||||
limit: number
|
||||
limit: number,
|
||||
offset?: number,
|
||||
sort?: keyof typeof SORT_KEYS,
|
||||
ascending?: boolean,
|
||||
search?: string
|
||||
} & ({} |
|
||||
{ musicId: number } |
|
||||
{ musicId: number, chartId: number });
|
||||
|
||||
{ musicId: number, chartId: number }) &
|
||||
Partial<PlaylogFilterState>;
|
||||
export async function getPlaylog(opts: GetPlaylogOptions) {
|
||||
const user = await requireUser();
|
||||
const musicId = 'musicId' in opts ? +opts.musicId : NaN;
|
||||
const chartId = 'chartId' in opts ? +opts.chartId : NaN;
|
||||
|
||||
const playlog = await db.with('p', db => db
|
||||
const builder = db.with('p', db => db
|
||||
.selectFrom('chuni_score_playlog as playlog')
|
||||
.innerJoin('chuni_static_music as music', join => join
|
||||
.onRef('music.songId', '=', 'playlog.musicId')
|
||||
@ -42,25 +56,75 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
|
||||
.orderBy('playlog.id desc')
|
||||
)
|
||||
.selectFrom('p')
|
||||
.where(({ and, eb }) => and([
|
||||
.where(({ and, eb, or, fn }) => and([
|
||||
...(!Number.isNaN(musicId) ? [eb('p.songId', '=', musicId)] : []),
|
||||
...(!Number.isNaN(chartId) ? [eb('p.chartId', '=', chartId)] : []),
|
||||
...(opts.difficulty?.size ? [eb('p.chartId', 'in', [...opts.difficulty].map(x => +x))] : []),
|
||||
...(opts.genre?.size ? [eb('p.genre', 'in', [...opts.genre])] : []),
|
||||
...(opts.lamp?.has('clear') ? [eb('p.isClear', '=', 1)] : []),
|
||||
...((opts.lamp?.has('aj') && opts.lamp?.has('fc')) ? [or([
|
||||
eb('p.isAllJustice', '=', 1),
|
||||
eb('p.isFullCombo', '=', 1)
|
||||
])] : // all justice and full combo selected, return either
|
||||
[]),
|
||||
...((opts.lamp?.has('aj') && !opts.lamp?.has('fc')) ? [eb('p.isAllJustice', '=', 1)] : []), // return only all justice
|
||||
...((!opts.lamp?.has('aj') && opts.lamp?.has('fc')) ? [
|
||||
eb('p.isAllJustice', '=', 0),
|
||||
eb('p.isFullCombo', '=', 1)
|
||||
] : []), // return only full combo
|
||||
...(opts.worldsEndTag?.size ? [or([
|
||||
eb('p.worldsEndTag', 'is', null),
|
||||
eb('p.worldsEndTag', 'in', [...opts.worldsEndTag])
|
||||
])] : []),
|
||||
...(opts.score?.size ? [eb('p.rank', 'in', [...opts.score].map(x => +x))] : []),
|
||||
...(opts.worldsEndStars ? [or([
|
||||
eb('p.worldsEndTag', 'is', null),
|
||||
and([
|
||||
eb('p.level', '>=', opts.worldsEndStars[0] * 2 - 1),
|
||||
eb('p.level', '<=', opts.worldsEndStars[1] * 2 - 1),
|
||||
])
|
||||
])] : []),
|
||||
...(opts.level ? [or([
|
||||
eb('p.worldsEndTag', 'is not', null),
|
||||
and([
|
||||
eb('p.level', '>=', opts.level[0]),
|
||||
eb('p.level', '<=', opts.level[1])
|
||||
])
|
||||
])] : []),
|
||||
...(opts.rating ? [or([
|
||||
eb('p.worldsEndTag', 'is not', null),
|
||||
and([
|
||||
eb('p.rating', '>=', opts.rating[0] as any),
|
||||
eb('p.rating', '<=', opts.rating[1] as any)
|
||||
])
|
||||
])] : []),
|
||||
...(opts.search?.length ? [or([
|
||||
eb(fn('lower', ['p.artist']), 'like', `%${opts.search.toLowerCase()}%`),
|
||||
eb(fn('lower', ['p.title']), 'like', `%${opts.search.toLowerCase()}%`)
|
||||
])] : []),
|
||||
...(opts.dateRange?.from ? [
|
||||
eb('sortNumber', '>=', opts.dateRange.from.valueOf() / 1000)
|
||||
] : []),
|
||||
...(opts.dateRange?.to ? [
|
||||
eb('sortNumber', '<=', opts.dateRange.to.valueOf() / 1000)
|
||||
] : [])
|
||||
]))
|
||||
.orderBy(SORT_KEYS_SET.has(opts.sort!) ? `${SORT_KEYS[opts.sort as keyof typeof SORT_KEYS]} ${opts.ascending ? 'asc' : 'desc'}` :
|
||||
'p.id desc');
|
||||
|
||||
const playlog = await builder
|
||||
.selectAll()
|
||||
.limit(+opts.limit)
|
||||
.offset(opts.offset && !Number.isNaN(opts.offset) ? +opts.offset : 0)
|
||||
.limit(Number.isNaN(opts.limit) ? 100 : opts.limit)
|
||||
.execute();
|
||||
|
||||
let remaining = 0;
|
||||
let total = 0;
|
||||
if (playlog.length)
|
||||
remaining = Number((await db.selectFrom('chuni_score_playlog as playlog')
|
||||
.where(({ and, eb }) => and([
|
||||
eb('playlog.user', '=', user.id),
|
||||
eb('playlog.id', '<', playlog.at(-1)!.id),
|
||||
...(!Number.isNaN(musicId) ? [eb('playlog.musicId', '=', musicId)] : []),
|
||||
...(!Number.isNaN(chartId) ? [eb('playlog.level', '=', chartId)] : []),
|
||||
]))
|
||||
.select(({ fn }) => fn.countAll().as('remaining'))
|
||||
.executeTakeFirstOrThrow()).remaining);
|
||||
total = Number((await builder
|
||||
.select(({ fn }) => fn.countAll().as('total'))
|
||||
.executeTakeFirstOrThrow()).total);
|
||||
|
||||
return { data: playlog, remaining };
|
||||
return { data: playlog, total };
|
||||
}
|
||||
|
||||
export type ChuniPlaylog = Awaited<ReturnType<typeof getPlaylog>>;
|
||||
|
@ -6,6 +6,8 @@ import { requireUser } from '@/actions/auth';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ChuniTopRating } from '@/components/chuni/top-rating';
|
||||
import { ChuniTopRatingSidebar } from '@/components/chuni/top-rating-sidebar';
|
||||
import { Button } from '@nextui-org/react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function ChuniDashboard() {
|
||||
const user = await requireUser();
|
||||
@ -35,6 +37,13 @@ export default async function ChuniDashboard() {
|
||||
badgeClass="h-5 lg:h-[1.125rem] xl:h-6 2xl:h-[1.125rem] 4xl:h-6 5xl:h-[1.125rem]"
|
||||
playlog={entry} key={i} />)}
|
||||
</div>
|
||||
<div className="w-full mb-3 px-2">
|
||||
<Link href="/chuni/playlog" className="w-full flex justify-center">
|
||||
<Button className="w-full sm:w-96">
|
||||
View More
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
6
src/app/(with-header)/chuni/playlog/page.tsx
Normal file
6
src/app/(with-header)/chuni/playlog/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { ChuniPlaylogList } from '@/components/chuni/playlog-list';
|
||||
|
||||
|
||||
export default function ChuniPlaylogPage() {
|
||||
return (<ChuniPlaylogList />);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { addFavoriteMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music';
|
||||
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { addFavoriteMusic, ChuniMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music';
|
||||
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { MusicPlayer } from '@/components/music-player';
|
||||
import { getJacketUrl, getMusicUrl } from '@/helpers/assets';
|
||||
import { Ticker } from '@/components/ticker';
|
||||
@ -13,8 +13,8 @@ import React, { useState } from 'react';
|
||||
import { useErrorModal } from '@/components/error-modal';
|
||||
|
||||
type ChuniMusicDetailProps = {
|
||||
music: Awaited<ReturnType<typeof getMusic>>,
|
||||
playlog: Awaited<ReturnType<typeof getPlaylog>>
|
||||
music: ChuniMusic[],
|
||||
playlog: ChuniPlaylog
|
||||
};
|
||||
|
||||
export const ChuniMusicDetail = ({ music, playlog }: ChuniMusicDetailProps) => {
|
||||
|
@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { Filterers, FilterSorter, Sorter } from '@/components/filter-sorter';
|
||||
import { WindowScroller, Grid, AutoSizer, List } from 'react-virtualized';
|
||||
import { FilterSorter, Sorter } from '@/components/filter-sorter';
|
||||
import { WindowScroller, AutoSizer, List } from 'react-virtualized';
|
||||
import { Button, SelectItem } from '@nextui-org/react';
|
||||
import { addFavoriteMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
|
||||
import { addFavoriteMusic, ChuniMusic, removeFavoriteMusic } from '@/actions/chuni/music';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
||||
import { getJacketUrl } from '@/helpers/assets';
|
||||
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||
@ -15,25 +14,12 @@ import Link from 'next/link';
|
||||
import { HeartIcon as OutlineHeartIcon, Squares2X2Icon } from '@heroicons/react/24/outline';
|
||||
import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid';
|
||||
import { Ticker, TickerHoverProvider } 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)
|
||||
return n + 1;
|
||||
return ((n - 6) * 0.1 + 7).toFixed(1);
|
||||
};
|
||||
|
||||
const getLevelValFromStop = (n: number) => {
|
||||
if (n < 7)
|
||||
return n + 1;
|
||||
return ((n - 6) * 0.1 + 7);
|
||||
};
|
||||
import { CHUNI_FILTER_DIFFICULTY, CHUNI_FILTER_FAVORITE, CHUNI_FILTER_GENRE, CHUNI_FILTER_LAMP, CHUNI_FILTER_LEVEL, CHUNI_FILTER_RATING, CHUNI_FILTER_SCORE, CHUNI_FILTER_WORLDS_END_STARS, CHUNI_FILTER_WORLDS_END_TAG } from '@/helpers/chuni/filter';
|
||||
import { WindowScrollerGrid } from '@/components/window-scroller-grid';
|
||||
|
||||
export type ChuniMusicListProps = {
|
||||
music: Awaited<ReturnType<typeof getMusic>>
|
||||
music: ChuniMusic[]
|
||||
};
|
||||
|
||||
const perPage = [25, 50, 100, 250, 500, Infinity];
|
||||
@ -73,105 +59,86 @@ const MusicGrid = ({ music, size, setMusicList, fullMusicList }: ChuniMusicListP
|
||||
if (size === 'xs') {
|
||||
itemWidth = 125;
|
||||
itemHeight = 180;
|
||||
itemClass = 'w-[125px] h-[180px] py-0.5 px-0.5';
|
||||
itemClass = 'py-0.5 px-0.5 h-full';
|
||||
} else if (size === 'sm') {
|
||||
itemWidth = 175;
|
||||
itemHeight = 235;
|
||||
itemClass = 'w-[175px] h-[235px] py-1.5 px-1';
|
||||
itemClass = 'py-1.5 px-1 h-full';
|
||||
} else {
|
||||
itemWidth = 285;
|
||||
itemHeight = 375;
|
||||
itemClass = 'w-[285px] h-[375px] py-1.5 px-1';
|
||||
itemClass = 'py-1.5 px-1 h-full';
|
||||
}
|
||||
|
||||
const listRef = useRef<List | null>(null);
|
||||
const setError = useErrorModal();
|
||||
const [pendingFavorite, setPendingFavorite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.recomputeRowHeights(0);
|
||||
}, [size])
|
||||
return (<WindowScrollerGrid rowSize={itemHeight} colSize={itemWidth} items={music}>
|
||||
{item => <TickerHoverProvider>
|
||||
{setHover => <div className={itemClass}><ChuniDifficultyContainer difficulty={item.chartId!}
|
||||
containerClassName="flex flex-col"
|
||||
className="w-full h-full border border-gray-500/75 rounded-md"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}>
|
||||
<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 === '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 === 'lg' ? 'h-14' : 'w-14'} absolute bottom-px right-px`} music={item} />
|
||||
|
||||
return (<WindowScroller>
|
||||
{useCallback(({ height, isScrolling, onChildScroll, scrollTop }) =>
|
||||
(<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const itemsPerRow = Math.max(1, Math.floor(width / itemWidth));
|
||||
const rowCount = Math.ceil(music.length / itemsPerRow);
|
||||
<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(fullMusicList.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(fullMusicList.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>}
|
||||
|
||||
return (<List rowCount={rowCount} autoHeight height={height} width={width} rowHeight={itemHeight} isScrolling={isScrolling}
|
||||
onScroll={onChildScroll} scrollTop={scrollTop} ref={listRef}
|
||||
rowRenderer={({ index, key, style }) => <div key={key} style={style} className="w-full h-full flex justify-center">
|
||||
{music.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map(item => <div key={`${item.songId}-${item.chartId}`} className={itemClass}>
|
||||
<TickerHoverProvider>
|
||||
{setHover => <ChuniDifficultyContainer difficulty={item.chartId!}
|
||||
containerClassName="flex flex-col"
|
||||
className="w-full h-full border border-gray-500/75 rounded-md"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}>
|
||||
<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 === '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 === 'lg' ? 'h-14' : 'w-14'} absolute bottom-px right-px`} music={item} />
|
||||
<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>
|
||||
|
||||
<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(fullMusicList.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(fullMusicList.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 === '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 === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
|
||||
<ChuniLampComboBadge {...item} />
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/chuni/music/${item.songId}`}
|
||||
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 === '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>}
|
||||
</TickerHoverProvider>
|
||||
</div>)}
|
||||
</div>} />)
|
||||
}}
|
||||
</AutoSizer>), [music, fullMusicList, pendingFavorite, itemWidth])}
|
||||
</WindowScroller>);
|
||||
<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 === '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 === '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>}
|
||||
</TickerHoverProvider>}
|
||||
</WindowScrollerGrid>);
|
||||
};
|
||||
|
||||
const DISPLAY_MODES = [{
|
||||
@ -191,163 +158,23 @@ const DISPLAY_IDS = {
|
||||
'Large Grid': 'lg'
|
||||
} as const;
|
||||
|
||||
const FILTERERS = [
|
||||
CHUNI_FILTER_DIFFICULTY,
|
||||
CHUNI_FILTER_GENRE,
|
||||
CHUNI_FILTER_LAMP,
|
||||
CHUNI_FILTER_WORLDS_END_TAG,
|
||||
CHUNI_FILTER_SCORE,
|
||||
CHUNI_FILTER_FAVORITE,
|
||||
CHUNI_FILTER_WORLDS_END_STARS,
|
||||
CHUNI_FILTER_LEVEL,
|
||||
CHUNI_FILTER_RATING
|
||||
];
|
||||
|
||||
export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
||||
const [localMusic, setLocalMusic] = useState(music);
|
||||
|
||||
const { filterers } = useMemo(() => {
|
||||
const genres = new Set<string>();
|
||||
const worldsEndTags = new Set<string>();
|
||||
|
||||
music.forEach(m => {
|
||||
if (m.genre) genres.add(m.genre);
|
||||
if (m.worldsEndTag) worldsEndTags.add(m.worldsEndTag);
|
||||
});
|
||||
|
||||
const filterers = [{
|
||||
type: 'select',
|
||||
name: 'difficulty',
|
||||
label: 'Difficulty',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: CHUNI_DIFFICULTIES.map((name, i) => <SelectItem key={i.toString()} value={i.toString()}>
|
||||
{name}
|
||||
</SelectItem>),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || val.has(data.chartId?.toString()!)
|
||||
}, {
|
||||
type: 'select',
|
||||
name: 'genre',
|
||||
label: 'Genre',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: [...genres].sort()
|
||||
.map(g => <SelectItem key={g} value={g}>{g}</SelectItem>),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || val.has(data.genre!)
|
||||
}, {
|
||||
type: 'select',
|
||||
name: 'lamp',
|
||||
label: 'Lamp',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: [
|
||||
<SelectItem key="aj" value="aj">All Justice</SelectItem>,
|
||||
<SelectItem key="fc" value="fc">Full Combo</SelectItem>,
|
||||
...[...CHUNI_LAMPS].map(([id, name]) => <SelectItem key={id.toString()} value={id.toString()}>{name}</SelectItem>)
|
||||
],
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => {
|
||||
if (!val.size) return true;
|
||||
|
||||
const checkLamps = [...CHUNI_LAMPS].some(([id]) => val.has(id.toString()));
|
||||
if (checkLamps && (!data.isSuccess || !val.has(data.isSuccess.toString())))
|
||||
return false
|
||||
|
||||
if (val.has('aj') && val.has('fc') && !(data.isFullCombo || data.isAllJustice))
|
||||
return false
|
||||
else if (val.has('aj') && !val.has('fc') && !data.isAllJustice)
|
||||
return false;
|
||||
else if (val.has('fc') && !data.isFullCombo)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}, {
|
||||
type: 'select',
|
||||
name: 'worldsEndTag',
|
||||
label: 'World\'s End Tag',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 xl:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: [...worldsEndTags].sort()
|
||||
.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || !data.worldsEndTag || val.has(data.worldsEndTag)
|
||||
}, {
|
||||
type: 'select',
|
||||
name: 'score',
|
||||
label: 'Score',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 sm:col-span-6 md:col-span-3 lg:col-span-2 xl:col-span-2 2xl:col-span-3 5xl:col-span-1',
|
||||
props: {
|
||||
children: CHUNI_SCORE_RANKS
|
||||
.map((s, i) => <SelectItem key={i.toString()} value={i.toString()}>{s}</SelectItem>)
|
||||
.reverse(),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || val.has(data.scoreRank?.toString()!)
|
||||
}, {
|
||||
type: 'switch',
|
||||
name: 'favorite',
|
||||
label: 'Favorites',
|
||||
value: false,
|
||||
className: 'justify-self-end col-span-6 md:col-span-3 md:col-start-10 lg:col-span-2 lg:col-start-auto 2xl:col-span-1 5xl:col-start-12',
|
||||
filter: (val: boolean, data) => !val || data.favorite
|
||||
}, {
|
||||
type: 'slider',
|
||||
name: 'worldsEndStars',
|
||||
label: 'World\'s End Stars',
|
||||
value: [1, 5],
|
||||
className: 'col-span-full md:col-span-6 md:col-start-4 md:row-start-2 lg:row-start-auto lg:col-start-auto lg:col-span-4 5xl:col-span-2 5xl:row-start-1 5xl:col-start-6',
|
||||
filter: ([a, b]: number[], val) => {
|
||||
if (!val.worldsEndTag) return true;
|
||||
const stars = Math.ceil(val.level! / 2);
|
||||
return stars >= a && stars <= b;
|
||||
},
|
||||
props: {
|
||||
maxValue: 5,
|
||||
minValue: 1,
|
||||
showSteps: true,
|
||||
getValue: (v) => Array.isArray(v) ?
|
||||
`${worldsEndStars(v[0])}\u2013${worldsEndStars(v[1])}` : worldsEndStars(v),
|
||||
renderValue: ({ children, className, ...props }: any) => <span className="text-[0.65rem]" {...props}>{ children }</span>
|
||||
}
|
||||
}, {
|
||||
type: 'slider',
|
||||
name: 'level',
|
||||
label: 'Level',
|
||||
value: [0, 90],
|
||||
className: 'col-span-full md:col-span-6 lg:col-span-4 5xl:col-span-2 5xl:row-start-1 5xl:col-start-8',
|
||||
filter: ([a, b]: number[], val) => {
|
||||
if (val.worldsEndTag) return true;
|
||||
a = getLevelValFromStop(a);
|
||||
b = getLevelValFromStop(b);
|
||||
return val.level! + 0.05 > a && val.level! - 0.05 < b;
|
||||
},
|
||||
props: {
|
||||
maxValue: 90,
|
||||
minValue: 0,
|
||||
getValue: (v) => Array.isArray(v) ?
|
||||
`${getLevelFromStop(v[0])}\u2013${getLevelFromStop(v[1])}` : getLevelFromStop(v)
|
||||
}
|
||||
}, {
|
||||
type: 'slider',
|
||||
name: 'rating',
|
||||
label: 'Rating',
|
||||
value: [0, 17.55],
|
||||
className: 'col-span-full md:col-span-6 lg:col-span-4 5xl:col-span-2 5xl:row-start-1 5xl:col-start-10',
|
||||
filter: ([a, b]: number[], val) => {
|
||||
if (val.worldsEndTag) return true;
|
||||
return +val.rating >= a && +val.rating <= b;
|
||||
},
|
||||
props: {
|
||||
maxValue: 17.55,
|
||||
minValue: 0,
|
||||
step: 0.01
|
||||
}
|
||||
}] as Filterers<(typeof music)[number], string>;
|
||||
|
||||
return { filterers };
|
||||
}, [music]);
|
||||
|
||||
return (
|
||||
<FilterSorter className="flex-grow" data={localMusic} sorters={sorters} filterers={filterers} pageSizes={perPage}
|
||||
<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={DISPLAY_IDS[displayMode as keyof typeof DISPLAY_IDS]}
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getMusic } from '@/actions/chuni/music';
|
||||
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { ChuniMusic, getMusic } from '@/actions/chuni/music';
|
||||
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { Accordion, AccordionItem } from '@nextui-org/react';
|
||||
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
|
||||
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||
@ -13,8 +13,8 @@ import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
|
||||
import { useState } from 'react';
|
||||
|
||||
type ChuniMusicPlaylogProps = {
|
||||
music: Awaited<ReturnType<typeof getMusic>>,
|
||||
playlog: Awaited<ReturnType<typeof getPlaylog>>
|
||||
music: ChuniMusic[],
|
||||
playlog: ChuniPlaylog
|
||||
};
|
||||
|
||||
export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { getJacketUrl } from '@/helpers/assets';
|
||||
import Link from 'next/link';
|
||||
import { ChuniRating } from '@/components/chuni/rating';
|
||||
@ -9,11 +9,13 @@ import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
||||
import { formatJst } from '@/helpers/format-jst';
|
||||
import { Ticker, TickerHoverProvider } from '@/components/ticker';
|
||||
import { Divider } from '@nextui-org/react';
|
||||
|
||||
export type ChuniPlaylogCardProps = {
|
||||
playlog: Awaited<ReturnType<typeof getPlaylog>>['data'][number],
|
||||
playlog: ChuniPlaylog['data'][number],
|
||||
className?: string,
|
||||
badgeClass?: string
|
||||
badgeClass?: string,
|
||||
showDetails?: boolean
|
||||
};
|
||||
|
||||
const getChangeSign = (val: number) => {
|
||||
@ -28,11 +30,11 @@ const getChangeColor = (val: number) => {
|
||||
return 'text-blue-500';
|
||||
};
|
||||
|
||||
export const ChuniPlaylogCard = ({ playlog, className, badgeClass }: ChuniPlaylogCardProps) => {
|
||||
export const ChuniPlaylogCard = ({ playlog, className, badgeClass, showDetails }: ChuniPlaylogCardProps) => {
|
||||
return (<TickerHoverProvider>{setHover => <div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
className={`rounded-md bg-content1 relative flex flex-col p-2 pt-1 border border-black/25 ${className ?? ''}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 mr-2 mt-auto">
|
||||
<div className="flex-shrink-0 mr-2 mt-2">
|
||||
<ChuniDifficultyContainer difficulty={playlog.chartId ?? 0} className="w-28 aspect-square relative p-1">
|
||||
<ChuniLevelBadge className="absolute -bottom-1.5 -right-1.5 w-12" music={playlog}/>
|
||||
<img className="aspect-square w-full rounded overflow-hidden"
|
||||
@ -41,25 +43,34 @@ export const ChuniPlaylogCard = ({ playlog, className, badgeClass }: ChuniPlaylo
|
||||
</ChuniDifficultyContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col leading-tight overflow-hidden text-nowrap w-full">
|
||||
<div className="text-xs text-right -mb-0.5 w-full">{formatJst(playlog.userPlayDate!)}</div>
|
||||
<div className={`flex flex-col leading-tight overflow-hidden text-nowrap flex-grow ${showDetails ? '-mt-2 md:mt-2' : '-mt-2'}`}>
|
||||
<div className={`text-xs text-right mt-2 -mb-1 w-full ${showDetails ? 'md:hidden' : ''}`}>{formatJst(playlog.userPlayDate!)}</div>
|
||||
<Link href={`/chuni/music/${playlog.songId}`} lang="ja"
|
||||
className="hover:text-secondary transition mb-1 font-semibold">
|
||||
<Ticker hoverOnly noDelay><span className="underline">{playlog.title}</span></Ticker>
|
||||
</Link>
|
||||
<Ticker hoverOnly noDelay className="text-sm mb-1">{playlog.artist}</Ticker>
|
||||
<span lang="ja" className="text-sm">{playlog.genre}</span>
|
||||
<div className="text-sm flex items-center">
|
||||
{!playlog.worldsEndTag && <div className="text-sm flex items-center">
|
||||
Rating: <ChuniRating className="text-medium" rating={+playlog.rating * 100}/>
|
||||
<span className={`text-xs ${getChangeColor(playlog.playerRatingChange)}`}> (
|
||||
{getChangeSign(playlog.playerRatingChange)}
|
||||
{(playlog.playerRatingChange / 100).toFixed(2)}
|
||||
)</span>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="text-xs">
|
||||
Max Combo: {playlog.maxCombo?.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && <div className="hidden md:flex flex-col text-sm gap-1 items-end text-nowrap ml-1">
|
||||
<div className="text-xs my-1">{formatJst(playlog.userPlayDate!)}</div>
|
||||
<div>Tap: {(playlog.rateTap! / 100).toFixed(2)}%</div>
|
||||
<div>Flick: {(playlog.rateFlick! / 100).toFixed(2)}%</div>
|
||||
<div>Hold: {(playlog.rateHold! / 100).toFixed(2)}%</div>
|
||||
<div>Slide: {(playlog.rateSlide! / 100).toFixed(2)}%</div>
|
||||
<div>Air: {(playlog.rateAir! / 100).toFixed(2)}%</div>
|
||||
</div>}
|
||||
</div>
|
||||
<div
|
||||
className={`${badgeClass ? badgeClass : 'h-5'} my-auto flex gap-0.5 overflow-hidden`}>
|
||||
@ -70,12 +81,22 @@ export const ChuniPlaylogCard = ({ playlog, className, badgeClass }: ChuniPlaylo
|
||||
<ChuniLampComboBadge {...playlog} />
|
||||
{!!playlog.isNewRecord && <ChuniScoreBadge variant="gold" fontSize="sm">NEW RECORD</ChuniScoreBadge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap text-xs justify-around drop-shadow-sm">
|
||||
<div className="mr-0.5 text-chuni-justice-critical">Justice Critical: {playlog.judgeHeaven}</div>
|
||||
<div className="mr-0.5 text-chuni-justice">Justice: {playlog.judgeCritical}</div>
|
||||
<div className="mr-0.5 text-chuni-attack">Attack: {playlog.judgeAttack}</div>
|
||||
<div className="mr-0.5 text-chuni-miss">Miss: {playlog.judgeGuilty}</div>
|
||||
<div className="flex flex-wrap text-xs justify-around drop-shadow-sm gap-1">
|
||||
<div className="text-chuni-justice-critical">Justice Critical: {playlog.judgeHeaven}</div>
|
||||
<div className="text-chuni-justice">Justice: {playlog.judgeCritical}</div>
|
||||
<div className="text-chuni-attack">Attack: {playlog.judgeAttack}</div>
|
||||
<div className="text-chuni-miss">Miss: {playlog.judgeGuilty}</div>
|
||||
</div>
|
||||
{showDetails && <>
|
||||
<Divider className="md:hidden my-2"/>
|
||||
<div className="flex flex-wrap text-xs justify-around md:hidden gap-1 mb-1">
|
||||
<div>Tap: {(playlog.rateTap! / 100).toFixed(2)}%</div>
|
||||
<div>Flick: {(playlog.rateFlick! / 100).toFixed(2)}%</div>
|
||||
<div>Hold: {(playlog.rateHold! / 100).toFixed(2)}%</div>
|
||||
<div>Slide: {(playlog.rateSlide! / 100).toFixed(2)}%</div>
|
||||
<div>Air: {(playlog.rateAir! / 100).toFixed(2)}%</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
</TickerHoverProvider>);
|
||||
};
|
||||
|
109
src/components/chuni/playlog-list.tsx
Normal file
109
src/components/chuni/playlog-list.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { CHUNI_FILTER_DIFFICULTY, CHUNI_FILTER_FAVORITE, CHUNI_FILTER_GENRE, CHUNI_FILTER_LAMP, CHUNI_FILTER_LEVEL, CHUNI_FILTER_RATING, CHUNI_FILTER_SCORE, CHUNI_FILTER_WORLDS_END_STARS, CHUNI_FILTER_WORLDS_END_TAG, getLevelValFromStop } from '@/helpers/chuni/filter';
|
||||
import { FilterField, FilterSorter } from '@/components/filter-sorter';
|
||||
import { SelectItem } from '@nextui-org/react';
|
||||
import React, { useState } from 'react';
|
||||
import { ChuniMusic } from '@/actions/chuni/music';
|
||||
import { ArrayIndices } from 'type-fest';
|
||||
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { WindowScrollerGrid } from '@/components/window-scroller-grid';
|
||||
import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
|
||||
import { useBreakpoint } from '@/helpers/use-breakpoint';
|
||||
|
||||
const FILTERERS = ([
|
||||
CHUNI_FILTER_DIFFICULTY,
|
||||
CHUNI_FILTER_GENRE,
|
||||
{ ...CHUNI_FILTER_LAMP,
|
||||
props: {
|
||||
children: [
|
||||
<SelectItem key="aj" value="aj">All Justice</SelectItem>,
|
||||
<SelectItem key="fc" value="fc">Full Combo</SelectItem>,
|
||||
<SelectItem key="clear" value="clear">Clear</SelectItem>,
|
||||
],
|
||||
selectionMode: 'multiple'
|
||||
}
|
||||
},
|
||||
CHUNI_FILTER_WORLDS_END_TAG,
|
||||
{ ...CHUNI_FILTER_SCORE,
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1'
|
||||
},
|
||||
// CHUNI_FILTER_FAVORITE,
|
||||
({
|
||||
type: 'dateSelect',
|
||||
name: 'dateRange',
|
||||
label: 'Date Range',
|
||||
value: undefined,
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1',
|
||||
filter: () => false
|
||||
} as FilterField<ChuniMusic, 'dateSelect', 'dateRange'>),
|
||||
{ ...CHUNI_FILTER_WORLDS_END_STARS,
|
||||
className: 'col-span-full md:col-span-6 lg:col-span-4 5xl:col-span-2'
|
||||
},
|
||||
{ ...CHUNI_FILTER_LEVEL,
|
||||
className: 'col-span-full md:col-span-6 lg:col-span-4 5xl:col-span-2'
|
||||
},
|
||||
{ ...CHUNI_FILTER_RATING,
|
||||
className: 'col-span-full md:col-span-6 lg:col-span-4 5xl:col-span-2'
|
||||
}
|
||||
] as const)
|
||||
|
||||
export type PlaylogFilterState = {
|
||||
[K in ArrayIndices<(typeof FILTERERS)> as (typeof FILTERERS)[K]['name']]: (typeof FILTERERS[K])['value']
|
||||
};
|
||||
|
||||
const SORTERS = [{
|
||||
name: 'Date'
|
||||
}, {
|
||||
name: 'Rating'
|
||||
}, {
|
||||
name: 'Level'
|
||||
}, {
|
||||
name: 'Score'
|
||||
}] as const;
|
||||
|
||||
const PER_PAGE = [25, 50, 100, 250];
|
||||
|
||||
const REMOTE_FILTERERS = FILTERERS.map(({ filter, ...x }) => x);
|
||||
|
||||
const ChuniPlaylogGrid = ({ items }: { items: ChuniPlaylog['data'] }) => {
|
||||
const breakpoint = useBreakpoint();
|
||||
let colSize = 1000;
|
||||
let rowSize = 275;
|
||||
|
||||
if (breakpoint !== 'sm' && breakpoint !== undefined) {
|
||||
colSize = 550;
|
||||
rowSize = 200;
|
||||
}
|
||||
|
||||
return (<WindowScrollerGrid rowSize={rowSize} colSize={colSize} items={items}>
|
||||
{item => <div className="p-1 w-full h-full max-w-full">
|
||||
<ChuniPlaylogCard playlog={item} showDetails
|
||||
badgeClass="h-4 sm:h-5 md:-mt-3"
|
||||
className="w-full h-full max-w-full" />
|
||||
</div>}
|
||||
</WindowScrollerGrid>);
|
||||
};
|
||||
|
||||
export const ChuniPlaylogList = () => {
|
||||
return (<FilterSorter className="flex-grow"
|
||||
filterers={REMOTE_FILTERERS}
|
||||
defaultAscending={false}
|
||||
data={({ filters: f, pageSize, currentPage, search, sort, ascending }): Promise<ChuniPlaylog> => {
|
||||
const filterState = { ...f, level: [...f.level],
|
||||
dateRange: f.dateRange ? { ...f.dateRange } : undefined } as PlaylogFilterState;
|
||||
filterState.level[0] = getLevelValFromStop(filterState.level[0]);
|
||||
filterState.level[1] = getLevelValFromStop(filterState.level[1]);
|
||||
if (filterState.dateRange?.to) {
|
||||
filterState.dateRange.to = new Date(filterState.dateRange.to);
|
||||
filterState.dateRange.to.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
return getPlaylog({ ...filterState, sort, limit: pageSize, offset: pageSize * (currentPage - 1), search, ascending });
|
||||
}}
|
||||
sorters={SORTERS} pageSizes={PER_PAGE}>
|
||||
{(_, d) => <div className="w-full max-w-full flex-grow my-2">
|
||||
<ChuniPlaylogGrid items={d} />
|
||||
</div>}
|
||||
</FilterSorter>);
|
||||
};
|
@ -37,7 +37,7 @@ export const DateSelect = ({ range, onChange, ...inputProps }: DateSelectProps)
|
||||
<Popover isOpen={breakpoint !== undefined && open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger className="aria-expanded:scale-1 aria-expanded:opacity-100 select-none">
|
||||
<div className="w-full">
|
||||
<Input value={(range?.from || range?.to) ? `${range?.from?.toLocaleDateString() ?? ''}\u2013${range?.to?.toLocaleDateString() ?? ''}` : undefined}
|
||||
<Input value={(range?.from || range?.to) ? `${range?.from?.toLocaleDateString() ?? ''}\u2013${range?.to?.toLocaleDateString() ?? ''}` : ''}
|
||||
type="text" {...inputProps} isReadOnly />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
@ -9,12 +9,15 @@ import { useDebounceCallback, useIsMounted } from 'usehooks-ts';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SearchIcon } from '@nextui-org/shared-icons';
|
||||
import { DateSelect } from '@/components/date-select';
|
||||
import { ArrayIndices } from 'type-fest';
|
||||
import internal from 'stream';
|
||||
import { useBreakpoint } from '@/helpers/use-breakpoint';
|
||||
|
||||
|
||||
type ValueType = {
|
||||
slider: React.ComponentProps<typeof Slider>['value'],
|
||||
select: React.ComponentProps<typeof Select>['selectedKeys'],
|
||||
switch: React.ComponentProps<typeof Switch>['isSelected'],
|
||||
slider: [number, number],
|
||||
select: Set<string>,
|
||||
switch: boolean,
|
||||
dateSelect: React.ComponentProps<typeof DateSelect>['range']
|
||||
};
|
||||
|
||||
@ -35,50 +38,57 @@ export type FilterField<D, T extends keyof FilterTypes, N extends string> = {
|
||||
className?: string
|
||||
};
|
||||
|
||||
export type Filterers<D, N extends string> = FilterField<D, keyof FilterTypes, N>[];
|
||||
export type Filterers<D, N extends string> = readonly FilterField<D, keyof FilterTypes, N>[];
|
||||
|
||||
export type Sorter<S extends string, D> = {
|
||||
name: S,
|
||||
sort?: (a: D, b: D) => number
|
||||
readonly name: S,
|
||||
readonly sort?: (a: D, b: D) => number
|
||||
};
|
||||
|
||||
type FilterSorterProps<D, M extends string, N extends string, S extends string> = {
|
||||
filterers: Filterers<D, N>,
|
||||
className?: string,
|
||||
data: D[] | ((options: {
|
||||
filters: { [A in N]: any },
|
||||
pageSizes?: number[],
|
||||
readonly sorters: Readonly<Sorter<S, D>[]>,
|
||||
displayModes?: { name: M, icon: ReactNode }[],
|
||||
searcher?: (search: string, data: D) => boolean | undefined,
|
||||
children: (displayMode: M, data: D[]) => React.ReactNode,
|
||||
defaultAscending?: boolean
|
||||
} & ({
|
||||
filterers: Filterers<D, N>,
|
||||
data: D[]
|
||||
} | {
|
||||
filterers: Filterers<any, N>,
|
||||
data: ((options: {
|
||||
filters: { [K in N]: any },
|
||||
sort: S,
|
||||
search: string,
|
||||
pageSize: number,
|
||||
currentPage: number,
|
||||
ascending: boolean
|
||||
}) => Awaitable<{ data: D[], total: number }>),
|
||||
pageSizes?: number[],
|
||||
sorters: Sorter<S, D>[],
|
||||
displayModes?: { name: M, icon: ReactNode }[],
|
||||
searcher?: (search: string, data: D) => boolean | undefined,
|
||||
children: (displayMode: M, data: D[]) => React.ReactNode
|
||||
};
|
||||
});
|
||||
|
||||
const debounceOptions = {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
maxWait: 100
|
||||
} as const;
|
||||
|
||||
const FilterSorterComponent = <D, M extends string, N extends string, S extends string>({ defaultData, filterers, data, pageSizes, sorters, displayModes, searcher, className, children }: FilterSorterProps<D, M, N, S> & { defaultData: any }) => {
|
||||
const queryDebounceOptions = { leading: false, trailing: true };
|
||||
|
||||
const FilterSorterComponent = <D, M extends string, N extends string, S extends string>({ defaultData, defaultAscending, filterers, data, pageSizes, sorters, displayModes, searcher, className, children }: FilterSorterProps<D, M, N, S> & { defaultData: any }) => {
|
||||
const defaultFilterState: Record<N, any> = {} as any;
|
||||
filterers.forEach(filter => {
|
||||
defaultFilterState[filter.name] = filter.value;
|
||||
});
|
||||
|
||||
const { sorter: defaultSorter, ascending: defaultAscending, pageSize: defaultPageSize, currentPage: defaultCurrentPage,
|
||||
const { sorter: defaultSorter, ascending: storedAscending, pageSize: defaultPageSize, currentPage: defaultCurrentPage,
|
||||
displayMode: defaultDisplayMode, ...payloadFilterState } = defaultData;
|
||||
|
||||
const [filterState, _setFilterState] = useState<typeof defaultFilterState>(Object.keys(payloadFilterState).length ? payloadFilterState :
|
||||
defaultFilterState);
|
||||
const [pageSize, _setPageSize] = useState(defaultPageSize ?? new Set([(pageSizes?.[0] ?? 25).toString()]));
|
||||
const [sorter, _setSorter] = useState(defaultSorter ?? new Set(sorters.length ? [sorters[0].name] : []));
|
||||
const [ascending, _setAscending] = useState<boolean>(defaultAscending ?? true);
|
||||
const [ascending, _setAscending] = useState<boolean>(storedAscending ?? defaultAscending ?? true);
|
||||
const [displayMode, _setDisplayMode] = useState(defaultDisplayMode ?? new Set([displayModes?.length ? displayModes[0].name : '']));
|
||||
const [query, _setQuery] = useState('');
|
||||
const [processedData, setProcessedData] = useState(Array.isArray(data) ? data : []);
|
||||
@ -92,14 +102,15 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
|
||||
const flush = useRef(false);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
const mounted = useIsMounted();
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
const localStateKey = `filter-sort-${pathname}`;
|
||||
|
||||
const dataRemote = !Array.isArray(data);
|
||||
const pageSizeNum = +[...pageSize][0];
|
||||
|
||||
const deps = dataRemote ? [data, filterers, filterState, pageSize, query, currentPage, ascending, searcher, sorters, sorter, mounted] :
|
||||
[data, filterers, filterState, query, ascending, searcher, sorters, sorter, mounted];
|
||||
const deps = dataRemote ? [data, filterers, pageSize, filterState, currentPage, ascending, searcher, sorters, sorter, mounted, query] :
|
||||
[data, filterers, filterState, ascending, searcher, sorters, sorter, mounted, query];
|
||||
|
||||
const onChange = useDebounceCallback(useCallback(() => {
|
||||
if (!mounted()) return;
|
||||
@ -135,7 +146,7 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
|
||||
const nonce = Math.random();
|
||||
prevNonce.current = nonce;
|
||||
setLoadingRemoteData(true);
|
||||
Promise.resolve(data({ filters: filterState, sort: sort.name, pageSize: pageSizeNum, search: query, currentPage: page }))
|
||||
Promise.resolve(data({ ascending, filters: filterState, sort: sort.name, pageSize: pageSizeNum, search: query, currentPage: page }))
|
||||
.then(d => {
|
||||
if (nonce === prevNonce.current) {
|
||||
setProcessedData(d.data);
|
||||
@ -143,7 +154,12 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingRemoteData(false));
|
||||
}, deps), 100, debounceOptions);
|
||||
}, deps), dataRemote ? 250 : 100, debounceOptions);
|
||||
|
||||
const onQuery = useDebounceCallback(useCallback(() => {
|
||||
onChange();
|
||||
onChange.flush();
|
||||
}, deps), 500)
|
||||
|
||||
useEffect(() => {
|
||||
onChange();
|
||||
@ -155,7 +171,29 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
|
||||
prevNonce.current = 1;
|
||||
onChange.cancel();
|
||||
}
|
||||
}, [data, filterers, filterState, pageSize, query, currentPage, ascending, searcher, sorters, sorter]);
|
||||
}, [data, filterers, filterState, currentPage, ascending, searcher, sorters, sorter]);
|
||||
|
||||
const initialQuery = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery.current) {
|
||||
initialQuery.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
onQuery();
|
||||
return () => {
|
||||
prevNonce.current = 1;
|
||||
onQuery.cancel();
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataRemote) {
|
||||
onChange();
|
||||
onChange.flush();
|
||||
}
|
||||
}, [pageSize, dataRemote, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = (ev: KeyboardEvent) => {
|
||||
@ -203,9 +241,6 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
|
||||
_setCurrentPage(Number.isNaN(newPageNum) ? 1 : newPageNum);
|
||||
_setPageSize(size);
|
||||
updateLocalState({ pageSize: size, currentPage: newPageNum });
|
||||
|
||||
onChange();
|
||||
onChange.flush();
|
||||
};
|
||||
|
||||
const setFilterState = (s: typeof filterState | ((s: typeof filterState) => typeof filterState)) => {
|
||||
@ -250,6 +285,9 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
|
||||
const pageData = dataRemote || Number.isNaN(pageSizeNum) ? processedData :
|
||||
processedData.slice(pageSizeNum * (currentPage - 1), pageSizeNum * currentPage);
|
||||
|
||||
if (!pageData.length)
|
||||
return (<div className="m-auto text-lg text-gray-500">No results found.</div>)
|
||||
|
||||
return children([...displayMode][0] as M, pageData);
|
||||
}, dataRemote ? [processedData, displayMode, currentPage, mounted] :
|
||||
[displayMode, processedData, pageSize, currentPage, mounted]);
|
||||
@ -338,9 +376,9 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
|
||||
|
||||
{(renderedData === null || loadingRemoteData) ? <Spinner className="m-auto" /> : renderedData}
|
||||
|
||||
{totalCount !== -1 && !Number.isNaN(pageSizeNum) && <div className="mt-auto mb-4" >
|
||||
<Pagination total={Math.ceil(totalCount / pageSizeNum)} showControls
|
||||
isCompact siblings={2} page={currentPage} initialPage={1} onChange={setCurrentPage}>
|
||||
{totalCount > 0 && !Number.isNaN(pageSizeNum) && <div className="mt-auto mb-4" >
|
||||
<Pagination total={Math.ceil(totalCount / pageSizeNum)} showControls size={breakpoint ? 'md' : 'sm'}
|
||||
isCompact siblings={breakpoint ? 2 : 1} page={currentPage} initialPage={1} onChange={setCurrentPage}>
|
||||
</Pagination>
|
||||
</div> }
|
||||
</div>
|
||||
|
@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react';
|
||||
import './ticker.scss';
|
||||
|
||||
|
35
src/components/window-scroller-grid.tsx
Normal file
35
src/components/window-scroller-grid.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
import { AutoSizer, List, WindowScroller } from 'react-virtualized';
|
||||
|
||||
type WindowScrollerGridProps<D> = {
|
||||
rowSize: number,
|
||||
colSize: number,
|
||||
items: D[],
|
||||
children: (data: D) => ReactNode
|
||||
};
|
||||
|
||||
export const WindowScrollerGrid = <D extends any>({ rowSize, colSize, items, children }: WindowScrollerGridProps<D>) => {
|
||||
const listRef = useRef<List | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.recomputeRowHeights(0);
|
||||
}, [rowSize, colSize, items]);
|
||||
|
||||
return (<WindowScroller>{({ height, isScrolling, onChildScroll, scrollTop }) =>
|
||||
(<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const itemsPerRow = Math.max(1, Math.floor(width / colSize));
|
||||
const rowCount = Math.ceil(items.length / itemsPerRow);
|
||||
|
||||
return (<List ref={listRef} autoHeight isScrolling={isScrolling} onScroll={onChildScroll} scrollTop={scrollTop}
|
||||
rowCount={rowCount} height={height} rowHeight={rowSize} width={width} rowRenderer={({ index, key, style }) =>
|
||||
(<div key={key} style={{ ...style, height: `${rowSize}px` }} className="max-w-full h-full w-full flex justify-center">
|
||||
{items.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map((item, i) => (<div key={i} style={{ width: `${colSize}px` }} className="h-full max-w-full">
|
||||
{children(item)}
|
||||
</div>))}
|
||||
</div>)
|
||||
} />)
|
||||
}}
|
||||
</AutoSizer>)
|
||||
}</WindowScroller>)
|
||||
};
|
176
src/helpers/chuni/filter.tsx
Normal file
176
src/helpers/chuni/filter.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
|
||||
import { SelectItem } from '@nextui-org/react';
|
||||
import React from 'react';
|
||||
import { FilterField } from '@/components/filter-sorter';
|
||||
import { ChuniMusic } from '@/actions/chuni/music';
|
||||
import { CHUNI_GENRES } from '@/helpers/chuni/genres';
|
||||
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
|
||||
import { CHUNI_WORLDS_END_TAGS } from '@/helpers/chuni/worlds-end-tags';
|
||||
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
|
||||
import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
|
||||
|
||||
export const CHUNI_FILTER_DIFFICULTY: FilterField<ChuniMusic, 'select', 'difficulty'> = {
|
||||
type: 'select',
|
||||
name: 'difficulty',
|
||||
label: 'Difficulty',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: CHUNI_DIFFICULTIES.map((name, i) => <SelectItem key={i.toString()} value={i.toString()}>
|
||||
{name}
|
||||
</SelectItem>),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || val.has(data.chartId?.toString()!)
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_GENRE: FilterField<ChuniMusic, 'select', 'genre'> = {
|
||||
type: 'select',
|
||||
name: 'genre',
|
||||
label: 'Genre',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: CHUNI_GENRES.map(g => <SelectItem key={g} value={g}>{g}</SelectItem>),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || val.has(data.genre!)
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_LAMP: FilterField<ChuniMusic, 'select', 'lamp'> = {
|
||||
type: 'select',
|
||||
name: 'lamp',
|
||||
label: 'Lamp',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: [
|
||||
<SelectItem key="aj" value="aj">All Justice</SelectItem>,
|
||||
<SelectItem key="fc" value="fc">Full Combo</SelectItem>,
|
||||
...[...CHUNI_LAMPS].map(([id, name]) => <SelectItem key={id.toString()} value={id.toString()}>{name}</SelectItem>)
|
||||
],
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => {
|
||||
if (!val.size) return true;
|
||||
|
||||
const checkLamps = [...CHUNI_LAMPS].some(([id]) => val.has(id.toString()));
|
||||
if (checkLamps && (!data.isSuccess || !val.has(data.isSuccess.toString())))
|
||||
return false
|
||||
|
||||
if (val.has('aj') && val.has('fc') && !(data.isFullCombo || data.isAllJustice))
|
||||
return false
|
||||
else if (val.has('aj') && !val.has('fc') && !data.isAllJustice)
|
||||
return false;
|
||||
else if (val.has('fc') && !data.isFullCombo)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_WORLDS_END_TAG: FilterField<ChuniMusic, 'select', 'worldsEndTag'> = {
|
||||
type: 'select',
|
||||
name: 'worldsEndTag',
|
||||
label: 'World\'s End Tag',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 lg:col-span-2 xl:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: CHUNI_WORLDS_END_TAGS.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || !data.worldsEndTag || val.has(data.worldsEndTag)
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_SCORE: FilterField<ChuniMusic, 'select', 'score'> = {
|
||||
type: 'select',
|
||||
name: 'score',
|
||||
label: 'Score',
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 sm:col-span-6 md:col-span-3 lg:col-span-2 xl:col-span-2 2xl:col-span-3 5xl:col-span-1',
|
||||
props: {
|
||||
children: CHUNI_SCORE_RANKS
|
||||
.map((s, i) => <SelectItem key={i.toString()} value={i.toString()}>{s}</SelectItem>)
|
||||
.reverse(),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || val.has(data.scoreRank?.toString()!)
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_FAVORITE: FilterField<ChuniMusic, 'switch', 'favorite'> = {
|
||||
type: 'switch',
|
||||
name: 'favorite',
|
||||
label: 'Favorites',
|
||||
value: false,
|
||||
className: 'justify-self-end col-span-6 md:col-span-3 md:col-start-10 lg:col-span-2 lg:col-start-auto 2xl:col-span-1 5xl:col-start-12',
|
||||
filter: (val: boolean, data) => !val || data.favorite
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_WORLDS_END_STARS: FilterField<ChuniMusic, 'slider', 'worldsEndStars'> = {
|
||||
type: 'slider',
|
||||
name: 'worldsEndStars',
|
||||
label: 'World\'s End Stars',
|
||||
value: [1, 5],
|
||||
className: 'col-span-full md:col-span-6 md:col-start-4 md:row-start-2 lg:row-start-auto lg:col-start-auto lg:col-span-4 5xl:col-span-2 5xl:row-start-1 5xl:col-start-6',
|
||||
filter: ([a, b]: number[], val) => {
|
||||
if (!val.worldsEndTag) return true;
|
||||
const stars = Math.ceil(val.level! / 2);
|
||||
return stars >= a && stars <= b;
|
||||
},
|
||||
props: {
|
||||
maxValue: 5,
|
||||
minValue: 1,
|
||||
showSteps: true,
|
||||
getValue: (v) => Array.isArray(v) ?
|
||||
`${worldsEndStars(v[0])}\u2013${worldsEndStars(v[1])}` : worldsEndStars(v),
|
||||
renderValue: ({ children, className, ...props }: any) => <span className="text-[0.65rem]" {...props}>{ children }</span>
|
||||
}
|
||||
};
|
||||
|
||||
export const getLevelFromStop = (n: number) => {
|
||||
if (n < 7)
|
||||
return n + 1;
|
||||
return ((n - 6) * 0.1 + 7).toFixed(1);
|
||||
};
|
||||
|
||||
export const getLevelValFromStop = (n: number) => {
|
||||
if (n < 7)
|
||||
return n + 1;
|
||||
return ((n - 6) * 0.1 + 7);
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_LEVEL: FilterField<ChuniMusic, 'slider', 'level'> = {
|
||||
type: 'slider',
|
||||
name: 'level',
|
||||
label: 'Level',
|
||||
value: [0, 90],
|
||||
className: 'col-span-full md:col-span-6 lg:col-span-4 5xl:col-span-2 5xl:row-start-1 5xl:col-start-8',
|
||||
filter: ([a, b]: number[], val) => {
|
||||
if (val.worldsEndTag) return true;
|
||||
a = getLevelValFromStop(a);
|
||||
b = getLevelValFromStop(b);
|
||||
return val.level! + 0.05 > a && val.level! - 0.05 < b;
|
||||
},
|
||||
props: {
|
||||
maxValue: 90,
|
||||
minValue: 0,
|
||||
getValue: (v) => Array.isArray(v) ?
|
||||
`${getLevelFromStop(v[0])}\u2013${getLevelFromStop(v[1])}` : getLevelFromStop(v).toString()
|
||||
}
|
||||
};
|
||||
|
||||
export const CHUNI_FILTER_RATING: FilterField<ChuniMusic, 'slider', 'rating'> = {
|
||||
type: 'slider',
|
||||
name: 'rating',
|
||||
label: 'Rating',
|
||||
value: [0, 17.55],
|
||||
className: 'col-span-full md:col-span-6 lg:col-span-4 5xl:col-span-2 5xl:row-start-1 5xl:col-start-10',
|
||||
filter: ([a, b]: number[], val) => {
|
||||
if (val.worldsEndTag) return true;
|
||||
return +val.rating >= a && +val.rating <= b;
|
||||
},
|
||||
props: {
|
||||
maxValue: 17.55,
|
||||
minValue: 0,
|
||||
step: 0.01
|
||||
}
|
||||
};
|
11
src/helpers/chuni/genres.ts
Normal file
11
src/helpers/chuni/genres.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const CHUNI_GENRES = [
|
||||
'ORIGINAL',
|
||||
'ゲキマイ',
|
||||
'イロドリミドリ',
|
||||
'POPS & ANIME',
|
||||
'VARIETY',
|
||||
'niconico',
|
||||
'東方Project',
|
||||
'言ノ葉Project',
|
||||
'GAME',
|
||||
] as const;
|
28
src/helpers/chuni/worlds-end-tags.ts
Normal file
28
src/helpers/chuni/worlds-end-tags.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export const CHUNI_WORLDS_END_TAGS = [
|
||||
'!',
|
||||
'?',
|
||||
'嘘',
|
||||
'歌',
|
||||
'改',
|
||||
'覚',
|
||||
'割',
|
||||
'狂',
|
||||
'撃',
|
||||
'光',
|
||||
'止',
|
||||
'時',
|
||||
'招',
|
||||
'蔵',
|
||||
'速',
|
||||
'弾',
|
||||
'跳',
|
||||
'謎',
|
||||
'半',
|
||||
'避',
|
||||
'布',
|
||||
'敷',
|
||||
'舞',
|
||||
'分',
|
||||
'戻',
|
||||
'両'
|
||||
] as const;
|
Loading…
Reference in New Issue
Block a user