chuni: add music list

This commit is contained in:
sk1982 2024-03-14 01:56:09 -04:00
parent 6007b9dd56
commit 3f22720bff
9 changed files with 771 additions and 9 deletions

58
package-lock.json generated
View File

@ -26,6 +26,7 @@
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-virtualized": "^9.22.5",
"sass": "^1.71.1", "sass": "^1.71.1",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1", "tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
@ -38,6 +39,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/react-virtualized": "^9.21.29",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.1.3", "eslint-config-next": "14.1.3",
@ -3427,6 +3429,16 @@
"@types/react": "*" "@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": { "node_modules/@types/scheduler": {
"version": "0.16.8", "version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@ -5059,8 +5071,7 @@
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
"devOptional": true
}, },
"node_modules/cuint": { "node_modules/cuint": {
"version": "0.2.2", "version": "0.2.2",
@ -5445,6 +5456,15 @@
"node": ">=6.0.0" "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": { "node_modules/dot-prop": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
@ -9396,7 +9416,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@ -9517,8 +9536,12 @@
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"dev": true },
"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": { "node_modules/react-remove-scroll-bar": {
"version": "2.3.5", "version": "2.3.5",
@ -9579,6 +9602,31 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "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": { "node_modules/read": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",

View File

@ -32,6 +32,7 @@
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-virtualized": "^9.22.5",
"sass": "^1.71.1", "sass": "^1.71.1",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1", "tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
@ -44,6 +45,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/react-virtualized": "^9.21.29",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.1.3", "eslint-config-next": "14.1.3",

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

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

View File

@ -15,12 +15,13 @@ const BACKGROUNDS = [
export type ChuniDifficultyContainerProps = { export type ChuniDifficultyContainerProps = {
children?: ReactNode, children?: ReactNode,
className?: string, 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 ?? ''}`}> 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} />)} {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>) </div>)
}; };

View File

@ -1,5 +1,6 @@
import { DB } from '@/types/db'; import { DB } from '@/types/db';
import { floorToDp } from '@/helpers/floor-dp'; import { floorToDp } from '@/helpers/floor-dp';
import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
export type ChuniLevelBadgeProps = { export type ChuniLevelBadgeProps = {
music: Pick<DB['chuni_static_music'], 'level' | 'worldsEndTag'>, 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="@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="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"> <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>
<div className="bg-black text-[45cqh] w-full flex-grow flex items-center justify-center text-white font-bold" lang="ja"> <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))} {music.worldsEndTag ?? (floorToDp(music.level!, 1))}

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

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

View File

@ -0,0 +1 @@
export const worldsEndStars = (level: number) => '★'.repeat(level).padEnd(5, '☆');