chuni: add playlog page

This commit is contained in:
sk1982 2024-03-19 16:33:22 -04:00
parent 9f54d8bfb2
commit 3c1b0d1946
17 changed files with 655 additions and 337 deletions

View File

@ -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>

View File

@ -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.' };

View File

@ -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>>;

View File

@ -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>);
}

View File

@ -0,0 +1,6 @@
import { ChuniPlaylogList } from '@/components/chuni/playlog-list';
export default function ChuniPlaylogPage() {
return (<ChuniPlaylogList />);
}

View File

@ -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) => {

View File

@ -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,38 +59,23 @@ 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 (<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);
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!}
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)}
@ -165,13 +136,9 @@ const MusicGrid = ({ music, size, setMusicList, fullMusicList }: ChuniMusicListP
<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>);
</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]}

View File

@ -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) => {

View File

@ -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:&nbsp;<ChuniRating className="text-medium" rating={+playlog.rating * 100}/>
<span className={`text-xs ${getChangeColor(playlog.playerRatingChange)}`}>&nbsp;(
{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>);
};

View 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>);
};

View File

@ -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>

View File

@ -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>

View File

@ -1,5 +1,3 @@
'use client';
import React, { createContext, ReactNode, useContext, useState } from 'react';
import './ticker.scss';

View 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>)
};

View 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
}
};

View File

@ -0,0 +1,11 @@
export const CHUNI_GENRES = [
'ORIGINAL',
'ゲキマイ',
'イロドリミドリ',
'POPS & ANIME',
'VARIETY',
'niconico',
'東方Project',
'言葉Project',
'GAME',
] as const;

View File

@ -0,0 +1,28 @@
export const CHUNI_WORLDS_END_TAGS = [
'',
'',
'嘘',
'歌',
'改',
'覚',
'割',
'狂',
'撃',
'光',
'止',
'時',
'招',
'蔵',
'速',
'弾',
'跳',
'謎',
'半',
'避',
'布',
'敷',
'舞',
'分',
'戻',
'両'
] as const;