forked from sk1982/actaeon
chuni: add music list
This commit is contained in:
parent
6007b9dd56
commit
3f22720bff
58
package-lock.json
generated
58
package-lock.json
generated
@ -26,6 +26,7 @@
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"sass": "^1.71.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
|
||||
@ -38,6 +39,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-virtualized": "^9.21.29",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.3",
|
||||
@ -3427,6 +3429,16 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-virtualized": {
|
||||
"version": "9.21.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-virtualized/-/react-virtualized-9.21.29.tgz",
|
||||
"integrity": "sha512-+ODVQ+AyKngenj4OPpg43Hz4B9Rdjuz1Naxu9ypNc3Cjo0WVZTYhqXfF/Nm38i8PV/YXECRIl4mTAZK5hq2B+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
|
||||
@ -5059,8 +5071,7 @@
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/cuint": {
|
||||
"version": "0.2.2",
|
||||
@ -5445,6 +5456,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
|
||||
@ -9396,7 +9416,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
@ -9517,8 +9536,12 @@
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.5",
|
||||
@ -9579,6 +9602,31 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtualized": {
|
||||
"version": "9.22.5",
|
||||
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.5.tgz",
|
||||
"integrity": "sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"clsx": "^1.0.4",
|
||||
"dom-helpers": "^5.1.3",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtualized/node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/read": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
|
||||
|
@ -32,6 +32,7 @@
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"sass": "^1.71.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
|
||||
@ -44,6 +45,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-virtualized": "^9.21.29",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.3",
|
||||
|
29
src/actions/chuni/music.ts
Normal file
29
src/actions/chuni/music.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { getUser } from '@/actions/auth';
|
||||
import { db } from '@/db';
|
||||
import { chuniRating } from '@/helpers/chuni/rating';
|
||||
import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music';
|
||||
|
||||
export const getMusic = async (musicId?: number) => {
|
||||
const user = await getUser();
|
||||
|
||||
return await db.selectFrom('chuni_static_music as music')
|
||||
.leftJoin('chuni_score_best as score', join =>
|
||||
join.onRef('music.songId', '=', 'score.musicId')
|
||||
.onRef('music.chartId', '=', 'score.level')
|
||||
.on('score.user', '=', user?.id!)
|
||||
)
|
||||
.select([...CHUNI_MUSIC_PROPERTIES, 'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess',
|
||||
'score.scoreRank', 'score.scoreMax', chuniRating()])
|
||||
.where(({ selectFrom, eb, and, or }) => and([
|
||||
eb('music.version', '=', selectFrom('chuni_static_music')
|
||||
.select(({ fn }) => fn.max('version').as('latest'))),
|
||||
eb('music.level', '!=', 0),
|
||||
or([
|
||||
eb('music.worldsEndTag', 'is', null),
|
||||
eb('music.worldsEndTag', '!=', 'Invalid')
|
||||
]),
|
||||
...(typeof musicId === 'number' ? [eb('music.songId', '=', musicId)] : [])
|
||||
]))
|
||||
.orderBy(['music.songId asc', 'music.chartId asc'])
|
||||
.execute();
|
||||
};
|
13
src/app/(with-header)/chuni/music/page.tsx
Normal file
13
src/app/(with-header)/chuni/music/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { getMusic } from '@/actions/chuni/music';
|
||||
import { SelectItem, Slider } from '@nextui-org/react';
|
||||
import { FilterSorter } from '@/components/filter-sorter';
|
||||
import { ChuniMusicList } from '@/components/chuni/music-list';
|
||||
|
||||
|
||||
export default async function ChuniMusicPage() {
|
||||
const music = await getMusic();
|
||||
|
||||
return (
|
||||
<ChuniMusicList music={music} />
|
||||
);
|
||||
}
|
@ -15,12 +15,13 @@ const BACKGROUNDS = [
|
||||
export type ChuniDifficultyContainerProps = {
|
||||
children?: ReactNode,
|
||||
className?: string,
|
||||
difficulty: number
|
||||
difficulty: number,
|
||||
containerClassName?: string
|
||||
};
|
||||
|
||||
export const ChuniDifficultyContainer = ({ children, className, difficulty }: ChuniDifficultyContainerProps) => {
|
||||
export const ChuniDifficultyContainer = ({ children, className, difficulty, containerClassName }: ChuniDifficultyContainerProps) => {
|
||||
return (<div className={`relative ${className ?? ''}`}>
|
||||
{BACKGROUNDS[difficulty].map((className, i) => <div className={`${className} w-full h-full absolute inset-0 z-0 rounded`} key={i} />)}
|
||||
<div className="z-0 relative w-full h-full">{children}</div>
|
||||
<div className={`z-0 relative w-full h-full ${containerClassName ?? ''}`}>{children}</div>
|
||||
</div>)
|
||||
};
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DB } from '@/types/db';
|
||||
import { floorToDp } from '@/helpers/floor-dp';
|
||||
import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
|
||||
|
||||
export type ChuniLevelBadgeProps = {
|
||||
music: Pick<DB['chuni_static_music'], 'level' | 'worldsEndTag'>,
|
||||
@ -11,7 +12,7 @@ export const ChuniLevelBadge = ({ music, className }: ChuniLevelBadgeProps) => {
|
||||
<div className="@container-size w-full h-full leading-none ">
|
||||
<div className="px-[5cqw] py-[5cqh] w-full h-full bg-white flex flex-col items-center justify-center text-black">
|
||||
<div className="text-[24cqh] mb-[5cqh] text-center flex items-center justify-center">
|
||||
{music.worldsEndTag ? '★'.repeat(Math.ceil((music.level!) / 2)).padEnd(5, '☆') : '\u200b'}
|
||||
{music.worldsEndTag ? worldsEndStars(Math.ceil((music.level!) / 2)) : '\u200b'}
|
||||
</div>
|
||||
<div className="bg-black text-[45cqh] w-full flex-grow flex items-center justify-center text-white font-bold" lang="ja">
|
||||
{music.worldsEndTag ?? (floorToDp(music.level!, 1))}
|
||||
|
317
src/components/chuni/music-list.tsx
Normal file
317
src/components/chuni/music-list.tsx
Normal file
@ -0,0 +1,317 @@
|
||||
'use client'
|
||||
|
||||
import { Filterers, FilterSorter, Sorter } from '@/components/filter-sorter';
|
||||
import { WindowScroller, Grid, AutoSizer, List } from 'react-virtualized';
|
||||
import { SelectItem } from '@nextui-org/react';
|
||||
import { getMusic } from '@/actions/chuni/music';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
|
||||
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
||||
import { getJacketUrl } from '@/helpers/assets';
|
||||
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||
import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
|
||||
import { ChuniRating } from '@/components/chuni/rating';
|
||||
import Link from 'next/link';
|
||||
import { Squares2X2Icon } from '@heroicons/react/24/outline';
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export type ChuniMusicListProps = {
|
||||
music: Awaited<ReturnType<typeof getMusic>>
|
||||
};
|
||||
|
||||
const perPage = [25, 50, 100, 250, 500, Infinity];
|
||||
const sorters = [{
|
||||
name: 'Song ID',
|
||||
sort: (a, b) => a.songId! - b.songId!
|
||||
}, {
|
||||
name: 'Title',
|
||||
sort: (a, b) => a.title?.localeCompare(b.title!, 'ja-JP')!
|
||||
}, {
|
||||
name: 'Artist',
|
||||
sort: (a, b) => a.artist?.localeCompare(b.artist!, 'ja-JP')!
|
||||
}, {
|
||||
name: 'Level',
|
||||
sort: (a, b) => a.level! - b.level!
|
||||
}, {
|
||||
name: 'Score',
|
||||
sort: (a, b) => a.scoreMax! - b.scoreMax!
|
||||
}, {
|
||||
name: 'Rating',
|
||||
sort: (a, b) => +a.rating! - +b.rating!
|
||||
}] as Sorter<string, ChuniMusicListProps['music'][number]>[];
|
||||
|
||||
const searcher = (query: string, data: ChuniMusicListProps['music'][number]) => {
|
||||
return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query);
|
||||
};
|
||||
|
||||
const SCORES = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'];
|
||||
// TODO: check if these are correct
|
||||
const LAMPS = new Map([
|
||||
[1, 'Clear'],
|
||||
[2, 'Hard'],
|
||||
[3, 'Absolute'],
|
||||
[4, 'Absolute+'],
|
||||
[5, 'Absolute++'],
|
||||
[6, 'Catastrophy']
|
||||
]);
|
||||
const LAMP_DISPLAY = {
|
||||
1: 'gold',
|
||||
2: 'gold',
|
||||
3: 'gold',
|
||||
4: 'platinum',
|
||||
5: 'platinum',
|
||||
6: 'platinum'
|
||||
};
|
||||
|
||||
const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) => {
|
||||
let itemWidth = 0;
|
||||
let itemHeight = 0;
|
||||
let itemClass = '';
|
||||
|
||||
if (size === 'sm') {
|
||||
itemWidth = 175;
|
||||
itemHeight = 225;
|
||||
itemClass = 'w-[175px] h-[230px] py-1.5 px-1';
|
||||
} else {
|
||||
itemWidth = 285;
|
||||
itemHeight = 360;
|
||||
itemClass = 'w-[285px] h-[360px] py-1.5 px-1';
|
||||
}
|
||||
|
||||
const listRef = useRef<List | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.recomputeRowHeights(0);
|
||||
}, [size])
|
||||
|
||||
return (<WindowScroller>
|
||||
{({ height, isScrolling, onChildScroll, scrollTop }) =>
|
||||
(<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const itemsPerRow = 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}>
|
||||
<ChuniDifficultyContainer difficulty={item.chartId!} containerClassName="flex flex-col" className="w-full h-full border border-gray-500/75 rounded-md">
|
||||
<div className="aspect-square w-full p-[0.2rem] relative">
|
||||
<img src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)} alt={item.title ?? 'Music'} className="rounded" />
|
||||
{item.rating && !item.worldsEndTag && <div className={`${size === 'sm' ? '' : 'text-2xl'} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm px-0.5 rounded`}>
|
||||
<ChuniRating rating={+item.rating * 100} className="-my-0.5">
|
||||
{item.rating.slice(0, item.rating.indexOf('.') + 3)}
|
||||
</ChuniRating>
|
||||
</div>}
|
||||
<ChuniLevelBadge className={`${size === 'sm' ? 'w-14' : 'h-14'} absolute bottom-px right-px`} music={item} />
|
||||
</div>
|
||||
<div className="px-0.5 mb-1 flex">
|
||||
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
|
||||
{item.isSuccess ? <ChuniScoreBadge variant={LAMP_DISPLAY[item.isSuccess]} className="h-full">
|
||||
{LAMPS.get(item.isSuccess)}
|
||||
</ChuniScoreBadge> : null}
|
||||
</div>}
|
||||
|
||||
<div className={`h-full ${size === 'sm' ? 'w-1/2' : 'w-1/3'}`}>
|
||||
{item.scoreRank !== null && <ChuniScoreBadge variant={getVariantFromRank(item.scoreRank)} className="h-full">
|
||||
{item.scoreMax!.toLocaleString()}
|
||||
</ChuniScoreBadge>}
|
||||
</div>
|
||||
|
||||
<div className={`h-full ml-0.5 ${size === 'sm' ? 'w-1/2' : 'w-1/3'}`}>
|
||||
{(item.isFullCombo || item.isAllJustice) ? <ChuniScoreBadge variant={item.isAllJustice ? 'platinum' : 'gold'} className="h-full">
|
||||
{item.isAllJustice ? 'All Justice' : 'Full Combo'}
|
||||
</ChuniScoreBadge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/chuni/music/${item.songId}`}
|
||||
className={`${size === 'sm' ? 'text-xs' : 'text-lg'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold text-nowrap overflow-hidden drop-shadow-lg`}
|
||||
lang="ja">
|
||||
{item.title}
|
||||
</Link>
|
||||
<div className={`${size === 'sm' ? 'text-xs mb-0.5' : 'text-medium mb-1.5'} px-1 text-white text-center text-nowrap overflow-hidden drop-shadow-lg`} lang="ja">
|
||||
{item.artist}
|
||||
</div>
|
||||
</ChuniDifficultyContainer>
|
||||
</div>)}
|
||||
</div>} />)
|
||||
}}
|
||||
</AutoSizer>)}
|
||||
</WindowScroller>);
|
||||
};
|
||||
export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
||||
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 5xl:col-span-1',
|
||||
props: {
|
||||
children: [
|
||||
<SelectItem key="0" value="0">Basic</SelectItem>,
|
||||
<SelectItem key="1" value="1">Advanced</SelectItem>,
|
||||
<SelectItem key="2" value="2">Expert</SelectItem>,
|
||||
<SelectItem key="3" value="3">Master</SelectItem>,
|
||||
<SelectItem key="4" value="4">Ultima</SelectItem>,
|
||||
<SelectItem key="5" value="5">World's End</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 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>,
|
||||
...[...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 = [...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-full sm:col-span-6 md:col-span-4 lg:col-span-2 xl:col-span-2 5xl:col-span-1',
|
||||
props: {
|
||||
children: SCORES
|
||||
.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: 'slider',
|
||||
name: 'worldsEndStars',
|
||||
label: 'World\'s End Stars',
|
||||
value: [1, 5],
|
||||
className: 'col-span-full sm:col-span-6 md:col-span-4 5xl:col-span-2',
|
||||
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-4 5xl:col-span-2',
|
||||
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-full lg:col-span-4 5xl:col-span-3',
|
||||
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={music} sorters={sorters} filterers={filterers} pageSizes={perPage}
|
||||
displayModes={[{
|
||||
name: 'Small Grid',
|
||||
icon: <Squares2X2Icon />
|
||||
}, {
|
||||
name: 'Large Grid',
|
||||
icon: <Squares2X2Icon />
|
||||
}]} searcher={searcher}>
|
||||
{(displayMode, data) => <div className="w-full flex-grow my-2">
|
||||
<MusicGrid music={data} size={displayMode === 'Small Grid' ? 'sm' : 'lg'} />
|
||||
</div>}
|
||||
</FilterSorter>);
|
||||
};
|
350
src/components/filter-sorter.tsx
Normal file
350
src/components/filter-sorter.tsx
Normal file
@ -0,0 +1,350 @@
|
||||
'use client';
|
||||
|
||||
import { Accordion, AccordionItem, Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Pagination, Select, SelectItem, Slider, Spinner, Tooltip } from '@nextui-org/react';
|
||||
import React, { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Awaitable } from '@auth/core/types';
|
||||
import { XMarkIcon } from '@heroicons/react/16/solid';
|
||||
import { ArrowLongUpIcon } from '@heroicons/react/24/solid';
|
||||
import { useDebounceCallback, useIsMounted } from 'usehooks-ts';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
|
||||
type ValueType = {
|
||||
slider: React.ComponentProps<typeof Slider>['value'],
|
||||
select: React.ComponentProps<typeof Select>['selectedKeys']
|
||||
};
|
||||
|
||||
type FilterTypes = {
|
||||
select: typeof Select,
|
||||
slider: typeof Slider
|
||||
};
|
||||
|
||||
type FilterField<D, T extends keyof FilterTypes, N extends string> = {
|
||||
type: T,
|
||||
name: N,
|
||||
label: string,
|
||||
filter?: (val: any, data: D) => boolean,
|
||||
value: ValueType[T],
|
||||
props?: React.ComponentProps<FilterTypes[T]>,
|
||||
className?: string
|
||||
};
|
||||
|
||||
export type Filterers<D, N extends string> = FilterField<D, keyof FilterTypes, N>[];
|
||||
|
||||
export type Sorter<S extends string, D> = {
|
||||
name: S,
|
||||
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 },
|
||||
sort: S,
|
||||
search: string,
|
||||
pageSize: number,
|
||||
currentPage: number,
|
||||
}) => 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 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 defaultFilterState: Record<N, any> = {} as any;
|
||||
filterers.forEach(filter => {
|
||||
defaultFilterState[filter.name] = filter.value;
|
||||
});
|
||||
|
||||
const { sorter: defaultSorter, ascending: defaultAscending, 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 [displayMode, _setDisplayMode] = useState(defaultDisplayMode ?? new Set([displayModes?.length ? displayModes[0].name : '']));
|
||||
const [query, _setQuery] = useState('');
|
||||
const [processedData, setProcessedData] = useState(Array.isArray(data) ? data : []);
|
||||
const [totalCount, setTotalCount] = useState(Array.isArray(data) ? data.length : -1);
|
||||
const [currentPage, _setCurrentPage] = useState(defaultCurrentPage ?? 1);
|
||||
const [selectedKeys, setSelectedKeys] = useState(new Set(['1']));
|
||||
const pathname = usePathname();
|
||||
const prevNonce = useRef(1);
|
||||
const resetPage = useRef(false);
|
||||
const flush = useRef(false);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
const mounted = useIsMounted();
|
||||
|
||||
const localStateKey = `filter-sort-${pathname}`;
|
||||
|
||||
const dataRemote = !Array.isArray(data);
|
||||
const pageSizeNum = +[...pageSize][0];
|
||||
|
||||
const onChange = useDebounceCallback(() => {
|
||||
if (!mounted()) return;
|
||||
|
||||
let page = currentPage;
|
||||
if (resetPage.current) {
|
||||
setCurrentPage(1);
|
||||
resetPage.current = false;
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const sort = sorters.find(s => sorter.has(s.name))!;
|
||||
if (Array.isArray(data)) {
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
const filteredSorted = data
|
||||
.filter(d => filterers.every(f => f.filter?.(filterState[f.name], d)) && searcher?.(lower, d)!)
|
||||
.sort(sorters.find(s => sorter.has(s.name))!.sort);
|
||||
if (ascending)
|
||||
setProcessedData(filteredSorted);
|
||||
else
|
||||
setProcessedData(filteredSorted.reverse());
|
||||
setTotalCount(filteredSorted.length);
|
||||
|
||||
if (!Number.isNaN(pageSizeNum) && currentPage > (filteredSorted.length / pageSizeNum))
|
||||
setCurrentPage(1)
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nonce = Math.random();
|
||||
prevNonce.current = nonce;
|
||||
Promise.resolve(data({ filters: filterState, sort: sort.name, pageSize: pageSizeNum, search: query, currentPage: page }))
|
||||
.then(d => {
|
||||
if (nonce === prevNonce.current) {
|
||||
setProcessedData(d.data);
|
||||
setTotalCount(d.total);
|
||||
}
|
||||
});
|
||||
}, 100, {
|
||||
maxWait: 100,
|
||||
leading: false,
|
||||
trailing: true
|
||||
});
|
||||
|
||||
const deps = dataRemote ? [data, filterers, filterState, pageSize, query, currentPage, ascending, searcher, sorters, sorter, mounted, onChange] :
|
||||
[data, filterers, filterState, query, ascending, searcher, sorters, sorter, mounted, onChange];
|
||||
|
||||
useEffect(() => {
|
||||
onChange();
|
||||
if (flush.current) {
|
||||
onChange.flush();
|
||||
flush.current = false;
|
||||
}
|
||||
return () => {
|
||||
prevNonce.current = 1;
|
||||
onChange.cancel();
|
||||
}
|
||||
}, deps);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = (ev: KeyboardEvent) => {
|
||||
if (ev.code === 'KeyF' && ev.ctrlKey) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
setSelectedKeys(new Set(['1']));
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', cb);
|
||||
|
||||
return () => window.removeEventListener('keydown', cb);
|
||||
}, []);
|
||||
|
||||
type LocalState = { sorter?: typeof sorter,
|
||||
ascending?: typeof ascending,
|
||||
pageSize?: typeof pageSize,
|
||||
currentPage?: typeof currentPage,
|
||||
displayMode?: typeof displayMode
|
||||
} & { [K: string]: any };
|
||||
|
||||
const updateLocalState = (payload: Partial<LocalState>) => {
|
||||
payload = {
|
||||
sorter,
|
||||
ascending,
|
||||
pageSize,
|
||||
currentPage,
|
||||
displayMode,
|
||||
...filterState,
|
||||
...payload,
|
||||
};
|
||||
const data = JSON.stringify(payload, (k, v) => v instanceof Set ? { type: 'set', value: [...v] } : v);
|
||||
localStorage.setItem(localStateKey, data);
|
||||
}
|
||||
const setCurrentPage = (currentPage: number) => {
|
||||
updateLocalState({ currentPage });
|
||||
_setCurrentPage(currentPage);
|
||||
}
|
||||
|
||||
const setPageSize = (size: typeof pageSize) => {
|
||||
const sizeNum = +[...size][0];
|
||||
const newPageNum = Number.isNaN(sizeNum) ? 1 : Math.floor(pageSizeNum * (currentPage - 1) / sizeNum) + 1;
|
||||
_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)) => {
|
||||
resetPage.current = true;
|
||||
|
||||
if (typeof s === 'function')
|
||||
return _setFilterState(filterState => {
|
||||
const newState = s(filterState);
|
||||
updateLocalState(newState);
|
||||
return newState;
|
||||
});
|
||||
_setFilterState(s);
|
||||
updateLocalState(s);
|
||||
};
|
||||
|
||||
const setAscending = (a: (a: boolean) => boolean) => {
|
||||
_setAscending(s => {
|
||||
const ascending = a(s);
|
||||
updateLocalState({ ascending });
|
||||
return ascending;
|
||||
})
|
||||
};
|
||||
|
||||
const setSorter = (s: typeof sorter) => {
|
||||
updateLocalState({ sorter: s });
|
||||
_setSorter(s);
|
||||
};
|
||||
|
||||
const setDisplayMode = (d: typeof displayMode) => {
|
||||
updateLocalState({ displayMode: d });
|
||||
_setDisplayMode(d);
|
||||
}
|
||||
|
||||
const setQuery = (q: string) => {
|
||||
_setQuery(q);
|
||||
resetPage.current = true;
|
||||
}
|
||||
|
||||
const renderedData = useMemo(() => {
|
||||
if (!mounted()) return null;
|
||||
|
||||
const pageData = dataRemote || Number.isNaN(pageSizeNum) ? processedData :
|
||||
processedData.slice(pageSizeNum * (currentPage - 1), pageSizeNum * currentPage);
|
||||
|
||||
return children([...displayMode][0] as M, pageData);
|
||||
}, dataRemote ? [processedData, displayMode, currentPage, mounted] :
|
||||
[displayMode, processedData, pageSize, currentPage, mounted]);
|
||||
|
||||
return (<div className={`flex flex-col ${className ?? ''}`}>
|
||||
<Accordion selectedKeys={selectedKeys} onSelectionChange={setSelectedKeys as any}>
|
||||
<AccordionItem key="1" title="Sort Options">
|
||||
<div className="grid grid-cols-12 gap-2 overflow-hidden">
|
||||
{filterers.map(filter => {
|
||||
if (filter.type === 'slider')
|
||||
return <Slider key={filter.name} defaultValue={filterState[filter.name] as any} label={filter.label}
|
||||
className={filter.className}
|
||||
onChangeEnd={v => {
|
||||
if (Array.isArray(v) && v.length === 1) v = v[0];
|
||||
setFilterState(f => ({ ...f, [filter.name]: v }));
|
||||
}} size="md" {...filter.props as any} />;
|
||||
else if (filter.type === 'select')
|
||||
return <div className={`${filter.className ?? ''} flex`} key={filter.name}>
|
||||
<Select key={filter.name} selectedKeys={filterState[filter.name] as any} label={filter.label} radius="none" className="rounded-l-lg overflow-hidden"
|
||||
onSelectionChange={v => setFilterState(f => ({ ...f, [filter.name]: v }))} size="sm" {...filter.props as any} />
|
||||
<Button isIconOnly={true} color="danger" className="rounded-l-none rounded-r-lg h-full" onClick={() =>
|
||||
setFilterState(f => ({ ...f, [filter.name]: filter.value }))}>
|
||||
<XMarkIcon className="h-full p-2" />
|
||||
</Button>
|
||||
</div>;
|
||||
})}
|
||||
<div className="flex mt-0.5 gap-2 flex-wrap sm:flex-nowrap flex-col-reverse sm:flex-row col-span-12">
|
||||
<div className="flex gap-2 flex-grow">
|
||||
<Button className="h-full" color="danger" onClick={() => {
|
||||
setFilterState(defaultFilterState);
|
||||
setQuery('');
|
||||
}}>Reset</Button>
|
||||
<Input ref={searchRef} size="sm" label="Search" type="text" isClearable={true} value={query} onValueChange={setQuery} onClear={() => setQuery('')} />
|
||||
</div>
|
||||
<div className="flex gap-2 sm:w-1/3 flex-grow sm:flex-grow-0 sm:max-w-80 items-center">
|
||||
<Select name="page" label="Per Page" size="sm" className="w-1/2" selectedKeys={pageSize} onSelectionChange={sel => sel !== 'all' && sel.size && setPageSize(sel)}>
|
||||
{ (pageSizes ?? [25, 50, 100]).map(s => <SelectItem key={s === Infinity ? 'all' : s.toString()} value={s === Infinity ? 'all' : s.toString()}>
|
||||
{s === Infinity ? 'All' : s.toString()}
|
||||
</SelectItem>) }
|
||||
</Select>
|
||||
<Select name="sort" label="Sort" size="sm" className="w-1/2" selectedKeys={sorter} onSelectionChange={sel => sel !== 'all' && sel.size && setSorter(sel)}>
|
||||
{ sorters.map(s => <SelectItem key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</SelectItem>) }
|
||||
</Select>
|
||||
<Tooltip content={`Currently sorting ${ascending ? 'ascending' : 'descending'}`}>
|
||||
<div className="cursor-pointer rotate-90" onClick={() => setAscending(a => !a)}>
|
||||
<ArrowLongUpIcon className={`w-5 transition ${ascending ? '-rotate-45' : 'rotate-45'}`} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div className="relative flex flex-col flex-grow items-center">
|
||||
{displayModes && <div className="absolute right-0 top-0">
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button variant="light" isIconOnly={true} size="sm" className="p-1 bg-black/25 backdrop-blur-sm border border-gray-500/50">
|
||||
{displayModes.find(m => displayMode.has(m.name))?.icon}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu selectionMode="single" selectedKeys={displayMode} onSelectionChange={sel => sel !== 'all' && sel.size && setDisplayMode(sel)}>
|
||||
{displayModes.map(mode => <DropdownItem key={mode.name}>
|
||||
{mode.name}
|
||||
</DropdownItem>)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>}
|
||||
|
||||
{renderedData === null ? <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}>
|
||||
</Pagination>
|
||||
</div> }
|
||||
</div>
|
||||
</div>);
|
||||
};
|
||||
|
||||
export const FilterSorter = <D, M extends string, N extends string, S extends string>(props: FilterSorterProps<D, M, N, S>) => {
|
||||
const pathname = usePathname();
|
||||
const localStateKey = `filter-sort-${pathname}`;
|
||||
const [defaultData, setDefaultData] = useState<null | any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(localStateKey);
|
||||
if (!stored) {
|
||||
setDefaultData({});
|
||||
return;
|
||||
}
|
||||
let payload: any;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(stored, (k, v) => typeof v === 'object' && 'type' in v && v.type === 'set' ? new Set(v.value) : v);
|
||||
|
||||
setDefaultData(payload)
|
||||
} catch (e) {
|
||||
localStorage.removeItem(localStateKey)
|
||||
console.error(e);
|
||||
setDefaultData({});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (defaultData === null)
|
||||
return (<Spinner className={props.className} size="lg" />)
|
||||
|
||||
return (<FilterSorterComponent defaultData={defaultData} {...props} />);
|
||||
};
|
1
src/helpers/chuni/worlds-end-stars.ts
Normal file
1
src/helpers/chuni/worlds-end-stars.ts
Normal file
@ -0,0 +1 @@
|
||||
export const worldsEndStars = (level: number) => '★'.repeat(level).padEnd(5, '☆');
|
Loading…
Reference in New Issue
Block a user