From 3f22720bffd6b37e1cb3b257795d34f9ce527bf1 Mon Sep 17 00:00:00 2001 From: sk1982 Date: Thu, 14 Mar 2024 01:56:09 -0400 Subject: [PATCH] chuni: add music list --- package-lock.json | 58 ++- package.json | 2 + src/actions/chuni/music.ts | 29 ++ src/app/(with-header)/chuni/music/page.tsx | 13 + src/components/chuni/difficulty-container.tsx | 7 +- src/components/chuni/level-badge.tsx | 3 +- src/components/chuni/music-list.tsx | 317 ++++++++++++++++ src/components/filter-sorter.tsx | 350 ++++++++++++++++++ src/helpers/chuni/worlds-end-stars.ts | 1 + 9 files changed, 771 insertions(+), 9 deletions(-) create mode 100644 src/actions/chuni/music.ts create mode 100644 src/app/(with-header)/chuni/music/page.tsx create mode 100644 src/components/chuni/music-list.tsx create mode 100644 src/components/filter-sorter.tsx create mode 100644 src/helpers/chuni/worlds-end-stars.ts diff --git a/package-lock.json b/package-lock.json index ff90492..24a5dd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c33d4dc..1e1ab3d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/actions/chuni/music.ts b/src/actions/chuni/music.ts new file mode 100644 index 0000000..40e6fe5 --- /dev/null +++ b/src/actions/chuni/music.ts @@ -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(); +}; diff --git a/src/app/(with-header)/chuni/music/page.tsx b/src/app/(with-header)/chuni/music/page.tsx new file mode 100644 index 0000000..a9c2847 --- /dev/null +++ b/src/app/(with-header)/chuni/music/page.tsx @@ -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 ( + + ); +} diff --git a/src/components/chuni/difficulty-container.tsx b/src/components/chuni/difficulty-container.tsx index 64ac49e..43dcb9f 100644 --- a/src/components/chuni/difficulty-container.tsx +++ b/src/components/chuni/difficulty-container.tsx @@ -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 (
{BACKGROUNDS[difficulty].map((className, i) =>
)} -
{children}
+
{children}
) }; diff --git a/src/components/chuni/level-badge.tsx b/src/components/chuni/level-badge.tsx index 2c23564..a75c2c6 100644 --- a/src/components/chuni/level-badge.tsx +++ b/src/components/chuni/level-badge.tsx @@ -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, @@ -11,7 +12,7 @@ export const ChuniLevelBadge = ({ music, className }: ChuniLevelBadgeProps) => {
- {music.worldsEndTag ? '★'.repeat(Math.ceil((music.level!) / 2)).padEnd(5, '☆') : '\u200b'} + {music.worldsEndTag ? worldsEndStars(Math.ceil((music.level!) / 2)) : '\u200b'}
{music.worldsEndTag ?? (floorToDp(music.level!, 1))} diff --git a/src/components/chuni/music-list.tsx b/src/components/chuni/music-list.tsx new file mode 100644 index 0000000..9c03226 --- /dev/null +++ b/src/components/chuni/music-list.tsx @@ -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> +}; + +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[]; + +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(null); + + useEffect(() => { + listRef.current?.recomputeRowHeights(0); + }, [size]) + + return ( + {({ height, isScrolling, onChildScroll, scrollTop }) => + ( + {({ width }) => { + const itemsPerRow = Math.floor(width / itemWidth); + const rowCount = Math.ceil(music.length / itemsPerRow); + + return (
+ {music.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map(item =>
+ +
+ {item.title + {item.rating && !item.worldsEndTag &&
+ + {item.rating.slice(0, item.rating.indexOf('.') + 3)} + +
} + +
+
+ {size === 'lg' &&
+ {item.isSuccess ? + {LAMPS.get(item.isSuccess)} + : null} +
} + +
+ {item.scoreRank !== null && + {item.scoreMax!.toLocaleString()} + } +
+ +
+ {(item.isFullCombo || item.isAllJustice) ? + {item.isAllJustice ? 'All Justice' : 'Full Combo'} + : null} +
+
+ + {item.title} + +
+ {item.artist} +
+
+
)} +
} />) + }} +
)} +
); +}; +export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { + const { filterers } = useMemo(() => { + const genres = new Set(); + const worldsEndTags = new Set(); + + 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(), + className: 'col-span-6 md:col-span-3 5xl:col-span-1', + props: { + children: [ + Basic, + Advanced, + Expert, + Master, + Ultima, + World's End, + ], + selectionMode: 'multiple' + }, + filter: (val: Set, data) => !val.size || val.has(data.chartId?.toString()!) + }, { + type: 'select', + name: 'genre', + label: 'Genre', + value: new Set(), + className: 'col-span-6 md:col-span-3 5xl:col-span-1', + props: { + children: [...genres].sort() + .map(g => {g}), + selectionMode: 'multiple' + }, + filter: (val: Set, data) => !val.size || val.has(data.genre!) + }, { + type: 'select', + name: 'lamp', + label: 'Lamp', + value: new Set(), + className: 'col-span-6 md:col-span-3 lg:col-span-2 5xl:col-span-1', + props: { + children: [ + All Justice, + Full Combo, + ...[...LAMPS].map(([id, name]) => {name}) + ], + selectionMode: 'multiple' + }, + filter: (val: Set, 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(), + 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 => {t}), + selectionMode: 'multiple' + }, + filter: (val: Set, data) => !val.size || !data.worldsEndTag || val.has(data.worldsEndTag) + }, { + type: 'select', + name: 'score', + label: 'Score', + value: new Set(), + 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) => {s}) + .reverse(), + selectionMode: 'multiple' + }, + filter: (val: Set, 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) => { children } + } + }, { + 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 ( + + }, { + name: 'Large Grid', + icon: + }]} searcher={searcher}> + {(displayMode, data) =>
+ +
} +
); +}; diff --git a/src/components/filter-sorter.tsx b/src/components/filter-sorter.tsx new file mode 100644 index 0000000..bead680 --- /dev/null +++ b/src/components/filter-sorter.tsx @@ -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['value'], + select: React.ComponentProps['selectedKeys'] +}; + +type FilterTypes = { + select: typeof Select, + slider: typeof Slider +}; + +type FilterField = { + type: T, + name: N, + label: string, + filter?: (val: any, data: D) => boolean, + value: ValueType[T], + props?: React.ComponentProps, + className?: string +}; + +export type Filterers = FilterField[]; + +export type Sorter = { + name: S, + sort?: (a: D, b: D) => number +}; + +type FilterSorterProps = { + filterers: Filterers, + 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[], + displayModes?: { name: M, icon: ReactNode }[], + searcher?: (search: string, data: D) => boolean | undefined, + children: (displayMode: M, data: D[]) => React.ReactNode +}; + +const FilterSorterComponent = ({ defaultData, filterers, data, pageSizes, sorters, displayModes, searcher, className, children }: FilterSorterProps & { defaultData: any }) => { + const defaultFilterState: Record = {} 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(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(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(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) => { + 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 (
+ + +
+ {filterers.map(filter => { + if (filter.type === 'slider') + return { + 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
+ setQuery('')} /> +
+
+ + + +
setAscending(a => !a)}> + +
+
+
+
+
+ + +
+ {displayModes &&
+ + + + + sel !== 'all' && sel.size && setDisplayMode(sel)}> + {displayModes.map(mode => + {mode.name} + )} + + +
} + + {renderedData === null ? : renderedData} + + {totalCount !== -1 && !Number.isNaN(pageSizeNum) &&
+ + +
} +
+
); +}; + +export const FilterSorter = (props: FilterSorterProps) => { + const pathname = usePathname(); + const localStateKey = `filter-sort-${pathname}`; + const [defaultData, setDefaultData] = useState(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 () + + return (); +}; diff --git a/src/helpers/chuni/worlds-end-stars.ts b/src/helpers/chuni/worlds-end-stars.ts new file mode 100644 index 0000000..caf1a3e --- /dev/null +++ b/src/helpers/chuni/worlds-end-stars.ts @@ -0,0 +1 @@ +export const worldsEndStars = (level: number) => '★'.repeat(level).padEnd(5, '☆');