chuni: add music detail page
This commit is contained in:
parent
1424be95fc
commit
e43f0f680d
4
.idea/watcherTasks.xml
Normal file
4
.idea/watcherTasks.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
|
||||||
|
</project>
|
@ -14,6 +14,8 @@ export type GetPlaylogOptions = {
|
|||||||
|
|
||||||
export async function getPlaylog(opts: GetPlaylogOptions) {
|
export async function getPlaylog(opts: GetPlaylogOptions) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
const musicId = 'musicId' in opts ? +opts.musicId : NaN;
|
||||||
|
const chartId = 'chartId' in opts ? +opts.chartId : NaN;
|
||||||
|
|
||||||
const playlog = await db.with('p', db => db
|
const playlog = await db.with('p', db => db
|
||||||
.selectFrom('chuni_score_playlog as playlog')
|
.selectFrom('chuni_score_playlog as playlog')
|
||||||
@ -41,8 +43,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
|
|||||||
)
|
)
|
||||||
.selectFrom('p')
|
.selectFrom('p')
|
||||||
.where(({ and, eb }) => and([
|
.where(({ and, eb }) => and([
|
||||||
...('musicId' in opts ? [eb('p.songId', '=', opts.musicId)] : []),
|
...(!Number.isNaN(musicId) ? [eb('p.songId', '=', musicId)] : []),
|
||||||
...('chartId' in opts ? [eb('p.chartId', '=', opts.chartId)] : []),
|
...(!Number.isNaN(chartId) ? [eb('p.chartId', '=', chartId)] : []),
|
||||||
]))
|
]))
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.limit(+opts.limit)
|
.limit(+opts.limit)
|
||||||
@ -54,8 +56,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
|
|||||||
.where(({ and, eb }) => and([
|
.where(({ and, eb }) => and([
|
||||||
eb('playlog.user', '=', user.id),
|
eb('playlog.user', '=', user.id),
|
||||||
eb('playlog.id', '<', playlog.at(-1)!.id),
|
eb('playlog.id', '<', playlog.at(-1)!.id),
|
||||||
...('musicId' in opts ? [eb('playlog.musicId', '=', opts.musicId)] : []),
|
...(!Number.isNaN(musicId) ? [eb('playlog.musicId', '=', musicId)] : []),
|
||||||
...('chartId' in opts ? [eb('playlog.level', '=', opts.chartId)] : []),
|
...(!Number.isNaN(chartId) ? [eb('playlog.level', '=', chartId)] : []),
|
||||||
]))
|
]))
|
||||||
.select(({ fn }) => fn.countAll().as('remaining'))
|
.select(({ fn }) => fn.countAll().as('remaining'))
|
||||||
.executeTakeFirstOrThrow()).remaining);
|
.executeTakeFirstOrThrow()).remaining);
|
||||||
|
36
src/app/(with-header)/chuni/music/[musicId]/page.tsx
Normal file
36
src/app/(with-header)/chuni/music/[musicId]/page.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { getMusic } from '@/actions/chuni/music';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { MusicPlayer } from '@/components/music-player';
|
||||||
|
import { getJacketUrl, getMusicUrl } from '@/helpers/assets';
|
||||||
|
import { Ticker } from '@/components/ticker';
|
||||||
|
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||||
|
import { Accordion, AccordionItem } from '@nextui-org/react';
|
||||||
|
import { ChuniMusicPlaylog } from '@/components/chuni/music-playlog';
|
||||||
|
|
||||||
|
export default async function ChuniMusicDetail({ params }: { params: { musicId: string } }) {
|
||||||
|
const musicId = parseInt(params.musicId);
|
||||||
|
if (Number.isNaN(musicId))
|
||||||
|
return notFound();
|
||||||
|
|
||||||
|
const [music, playlog] = await Promise.all([
|
||||||
|
getMusic(musicId),
|
||||||
|
getPlaylog({ musicId, limit: 500 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
if (!music.length)
|
||||||
|
return notFound();
|
||||||
|
|
||||||
|
|
||||||
|
const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1];
|
||||||
|
|
||||||
|
return (<div className="flex flex-col items-center sm:mt-2">
|
||||||
|
<MusicPlayer className="xl:self-start xl:mt-3 xl:ml-3 mb-3 sm:mb-6" image={getJacketUrl(`chuni/jacket/${music[0].jacketPath}`)}
|
||||||
|
audio={getMusicUrl(`chuni/music/music${cueId?.padStart(4, '0')}`)}>
|
||||||
|
<Ticker className="font-semibold text-center sm:text-left">{ music[0].title }</Ticker>
|
||||||
|
<Ticker className="text-center sm:text-left">{ music[0].artist }</Ticker>
|
||||||
|
<span className="text-medium">{ music[0].genre }</span>
|
||||||
|
</MusicPlayer>
|
||||||
|
<ChuniMusicPlaylog music={music} playlog={playlog} />
|
||||||
|
</div>);
|
||||||
|
}
|
@ -9,10 +9,14 @@ import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
|
|||||||
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
||||||
import { getJacketUrl } from '@/helpers/assets';
|
import { getJacketUrl } from '@/helpers/assets';
|
||||||
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||||
import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
|
import { ChuniScoreBadge, getVariantFromLamp, getVariantFromRank } from '@/components/chuni/score-badge';
|
||||||
import { ChuniRating } from '@/components/chuni/rating';
|
import { ChuniRating } from '@/components/chuni/rating';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Squares2X2Icon } from '@heroicons/react/24/outline';
|
import { Squares2X2Icon } from '@heroicons/react/24/outline';
|
||||||
|
import { Ticker } from '@/components/ticker';
|
||||||
|
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
|
||||||
|
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
|
||||||
|
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
|
||||||
|
|
||||||
const getLevelFromStop = (n: number) => {
|
const getLevelFromStop = (n: number) => {
|
||||||
if (n < 7)
|
if (n < 7)
|
||||||
@ -55,25 +59,6 @@ const searcher = (query: string, data: ChuniMusicListProps['music'][number]) =>
|
|||||||
return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query);
|
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' }) => {
|
const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) => {
|
||||||
let itemWidth = 0;
|
let itemWidth = 0;
|
||||||
let itemHeight = 0;
|
let itemHeight = 0;
|
||||||
@ -81,12 +66,12 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
|||||||
|
|
||||||
if (size === 'sm') {
|
if (size === 'sm') {
|
||||||
itemWidth = 175;
|
itemWidth = 175;
|
||||||
itemHeight = 225;
|
itemHeight = 235;
|
||||||
itemClass = 'w-[175px] h-[230px] py-1.5 px-1';
|
itemClass = 'w-[175px] h-[235px] py-1.5 px-1';
|
||||||
} else {
|
} else {
|
||||||
itemWidth = 285;
|
itemWidth = 285;
|
||||||
itemHeight = 360;
|
itemHeight = 375;
|
||||||
itemClass = 'w-[285px] h-[360px] py-1.5 px-1';
|
itemClass = 'w-[285px] h-[375px] py-1.5 px-1';
|
||||||
}
|
}
|
||||||
|
|
||||||
const listRef = useRef<List | null>(null);
|
const listRef = useRef<List | null>(null);
|
||||||
@ -106,7 +91,7 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
|||||||
onScroll={onChildScroll} scrollTop={scrollTop} ref={listRef}
|
onScroll={onChildScroll} scrollTop={scrollTop} ref={listRef}
|
||||||
rowRenderer={({ index, key, style }) => <div key={key} style={style} className="w-full h-full flex justify-center">
|
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}>
|
{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">
|
<ChuniDifficultyContainer difficulty={item.chartId!} containerClassName="flex flex-col" className="w-full h-full border border-gray-500/75 rounded-md [&:hover_.ticker]:[animation-play-state:running]">
|
||||||
<div className="aspect-square w-full p-[0.2rem] relative">
|
<div className="aspect-square w-full p-[0.2rem] relative">
|
||||||
<img src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)} alt={item.title ?? 'Music'} className="rounded" />
|
<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`}>
|
{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`}>
|
||||||
@ -118,8 +103,8 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-0.5 mb-1 flex">
|
<div className="px-0.5 mb-1 flex">
|
||||||
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
|
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
|
||||||
{item.isSuccess ? <ChuniScoreBadge variant={LAMP_DISPLAY[item.isSuccess]} className="h-full">
|
{item.isSuccess ? <ChuniScoreBadge variant={getVariantFromLamp(item.isSuccess)} className="h-full">
|
||||||
{LAMPS.get(item.isSuccess)}
|
{CHUNI_LAMPS.get(item.isSuccess)}
|
||||||
</ChuniScoreBadge> : null}
|
</ChuniScoreBadge> : null}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
@ -136,13 +121,10 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/chuni/music/${item.songId}`}
|
<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`}
|
className={`${size === 'sm' ? 'text-xs' : 'text-lg'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
|
||||||
lang="ja">
|
<Ticker hoverOnly noDelay>{item.title}</Ticker>
|
||||||
{item.title}
|
|
||||||
</Link>
|
</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">
|
<Ticker className={`${size === 'sm' ? 'text-xs mb-0.5' : 'text-medium mb-1.5'} text-center px-1 drop-shadow-2xl`} hoverOnly noDelay>{item.artist}</Ticker>
|
||||||
{item.artist}
|
|
||||||
</div>
|
|
||||||
</ChuniDifficultyContainer>
|
</ChuniDifficultyContainer>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>} />)
|
</div>} />)
|
||||||
@ -167,14 +149,9 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
|||||||
value: new Set<string>(),
|
value: new Set<string>(),
|
||||||
className: 'col-span-6 md:col-span-3 5xl:col-span-1',
|
className: 'col-span-6 md:col-span-3 5xl:col-span-1',
|
||||||
props: {
|
props: {
|
||||||
children: [
|
children: CHUNI_DIFFICULTIES.map((name, i) => <SelectItem key={i.toString()} value={i.toString()}>
|
||||||
<SelectItem key="0" value="0">Basic</SelectItem>,
|
{name}
|
||||||
<SelectItem key="1" value="1">Advanced</SelectItem>,
|
</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'
|
selectionMode: 'multiple'
|
||||||
},
|
},
|
||||||
filter: (val: Set<string>, data) => !val.size || val.has(data.chartId?.toString()!)
|
filter: (val: Set<string>, data) => !val.size || val.has(data.chartId?.toString()!)
|
||||||
@ -200,14 +177,14 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
|||||||
children: [
|
children: [
|
||||||
<SelectItem key="aj" value="aj">All Justice</SelectItem>,
|
<SelectItem key="aj" value="aj">All Justice</SelectItem>,
|
||||||
<SelectItem key="fc" value="fc">Full Combo</SelectItem>,
|
<SelectItem key="fc" value="fc">Full Combo</SelectItem>,
|
||||||
...[...LAMPS].map(([id, name]) => <SelectItem key={id.toString()} value={id.toString()}>{name}</SelectItem>)
|
...[...CHUNI_LAMPS].map(([id, name]) => <SelectItem key={id.toString()} value={id.toString()}>{name}</SelectItem>)
|
||||||
],
|
],
|
||||||
selectionMode: 'multiple'
|
selectionMode: 'multiple'
|
||||||
},
|
},
|
||||||
filter: (val: Set<string>, data) => {
|
filter: (val: Set<string>, data) => {
|
||||||
if (!val.size) return true;
|
if (!val.size) return true;
|
||||||
|
|
||||||
const checkLamps = [...LAMPS].some(([id]) => val.has(id.toString()));
|
const checkLamps = [...CHUNI_LAMPS].some(([id]) => val.has(id.toString()));
|
||||||
if (checkLamps && (!data.isSuccess || !val.has(data.isSuccess.toString())))
|
if (checkLamps && (!data.isSuccess || !val.has(data.isSuccess.toString())))
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@ -238,7 +215,7 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
|||||||
value: new Set<string>(),
|
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',
|
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: {
|
props: {
|
||||||
children: SCORES
|
children: CHUNI_SCORE_RANKS
|
||||||
.map((s, i) => <SelectItem key={i.toString()} value={i.toString()}>{s}</SelectItem>)
|
.map((s, i) => <SelectItem key={i.toString()} value={i.toString()}>{s}</SelectItem>)
|
||||||
.reverse(),
|
.reverse(),
|
||||||
selectionMode: 'multiple'
|
selectionMode: 'multiple'
|
||||||
|
89
src/components/chuni/music-playlog.tsx
Normal file
89
src/components/chuni/music-playlog.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getMusic } from '@/actions/chuni/music';
|
||||||
|
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||||
|
import { Accordion, AccordionItem } from '@nextui-org/react';
|
||||||
|
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
|
||||||
|
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||||
|
import { ChuniRating } from '@/components/chuni/rating';
|
||||||
|
import { ChuniScoreBadge, getVariantFromLamp, getVariantFromRank } from '@/components/chuni/score-badge';
|
||||||
|
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
|
||||||
|
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
|
||||||
|
import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type ChuniMusicPlaylogProps = {
|
||||||
|
music: Awaited<ReturnType<typeof getMusic>>,
|
||||||
|
playlog: Awaited<ReturnType<typeof getPlaylog>>
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => {
|
||||||
|
type Music = (typeof music)[number];
|
||||||
|
type Playlog = (typeof playlog)['data'][number];
|
||||||
|
const defaultExpanded: Record<string, Set<string>> = {}
|
||||||
|
|
||||||
|
const difficulties: (Music & { playlog: Playlog[] })[] = [];
|
||||||
|
music.forEach(m => {
|
||||||
|
difficulties[m.chartId!] = { ...m, playlog: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
playlog.data.forEach(play => {
|
||||||
|
defaultExpanded[play.chartId!] = new Set();
|
||||||
|
difficulties[play.chartId!].playlog.push(play);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
const badgeClass = 'h-6 sm:h-8';
|
||||||
|
|
||||||
|
return (<div className="flex flex-col w-full px-2 sm:px-0">
|
||||||
|
{difficulties.map((data, i) => {
|
||||||
|
const badges = [
|
||||||
|
!!data.scoreRank && <ChuniScoreBadge variant={getVariantFromRank(data.scoreRank)} className={badgeClass} key="1">
|
||||||
|
{CHUNI_SCORE_RANKS[data.scoreRank]}
|
||||||
|
</ChuniScoreBadge>,
|
||||||
|
!!data.isSuccess && <ChuniScoreBadge variant={getVariantFromLamp(data.isSuccess)} className={badgeClass} key="2">
|
||||||
|
{CHUNI_LAMPS.get(data.isSuccess)}
|
||||||
|
</ChuniScoreBadge>,
|
||||||
|
!!data.isFullCombo && !data.isAllJustice && <ChuniScoreBadge variant="gold" className={badgeClass} key="3">
|
||||||
|
Full Combo
|
||||||
|
</ChuniScoreBadge>,
|
||||||
|
!!data.isAllJustice && <ChuniScoreBadge variant="platinum" className={badgeClass} key="4">
|
||||||
|
All Justice
|
||||||
|
</ChuniScoreBadge>,
|
||||||
|
].filter(x => x);
|
||||||
|
|
||||||
|
const toggleExpanded = () => expanded[i] && setExpanded(e =>
|
||||||
|
({ ...e,
|
||||||
|
[i]: e[i].size ? new Set() : new Set(['1'])
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (<div key={i} className="mb-2 border-b pb-2 border-gray-500 flex flex-row flex-wrap">
|
||||||
|
<div className={`flex items-center gap-2 flex-wrap w-full lg:w-auto lg:flex-grow ${data.playlog.length ? 'cursor-pointer' : ''}`} onClick={toggleExpanded}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-14 mr-2 p-0.5 bg-black">
|
||||||
|
<ChuniLevelBadge className="w-full" music={data} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-semibold">{CHUNI_DIFFICULTIES[i]}</div>
|
||||||
|
</div>
|
||||||
|
{!data.playlog.length && <div className="text-right italic text-gray-500 flex-grow">No Play History</div>}
|
||||||
|
{data.rating ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null}
|
||||||
|
{data.scoreMax ? <div className="ml-2 text-center flex-grow sm:flex-grow-0">
|
||||||
|
<span className="font-semibold">High Score: </span>{data.scoreMax.toLocaleString()}
|
||||||
|
</div> : null}
|
||||||
|
</div>
|
||||||
|
{badges.length ? <div className={`flex-grow lg:flex-grow-0 ml-auto mr-auto sm:ml-0 lg:ml-auto lg:mr-0 mt-2 flex gap-0.5 flex-wrap justify-center sm:justify-start ${data.playlog.length ? 'cursor-pointer' : ''}`} onClick={toggleExpanded}>
|
||||||
|
{badges}
|
||||||
|
</div> : null}
|
||||||
|
{data.playlog.length ? <Accordion selectedKeys={expanded[i]} onSelectionChange={k => setExpanded(e => ({ ...e, [i]: k as any }))}>
|
||||||
|
<AccordionItem key="1" title="Play History">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 5xl:grid-cols-6 6xl:grid-cols-8 gap-2">
|
||||||
|
{data.playlog.map(p => <ChuniPlaylogCard key={p.id} playlog={p} className="h-48" />)}
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion> : null
|
||||||
|
}
|
||||||
|
</div>)
|
||||||
|
})}
|
||||||
|
</div>);
|
||||||
|
};
|
@ -3,6 +3,7 @@ import { getImageUrl } from '@/helpers/assets';
|
|||||||
import { ChuniTrophy } from '@/components/chuni/trophy';
|
import { ChuniTrophy } from '@/components/chuni/trophy';
|
||||||
import { PickNullable } from '@/types/pick-nullable';
|
import { PickNullable } from '@/types/pick-nullable';
|
||||||
import { ChuniRating } from '@/components/chuni/rating';
|
import { ChuniRating } from '@/components/chuni/rating';
|
||||||
|
import { formatJst } from '@/helpers/format-jst';
|
||||||
|
|
||||||
export type Profile = PickNullable<Awaited<ReturnType<typeof getUserData>>,
|
export type Profile = PickNullable<Awaited<ReturnType<typeof getUserData>>,
|
||||||
'trophyName' | 'trophyRareType' | 'nameplateImage' | 'nameplateName' | 'teamName' | 'characterId' | 'level' | 'userName' | 'overPowerRate' | 'overPowerPoint' | 'lastPlayDate' | 'playerRating' | 'highestRating'>;
|
'trophyName' | 'trophyRareType' | 'nameplateImage' | 'nameplateName' | 'teamName' | 'characterId' | 'level' | 'userName' | 'overPowerRate' | 'overPowerPoint' | 'lastPlayDate' | 'playerRating' | 'highestRating'>;
|
||||||
@ -47,13 +48,7 @@ export const ChuniNameplate = ({ className, profile }: ChuniNameplateProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="leading-none py-[1%] border-b border-gray-700 flex items-baseline">
|
<div className="leading-none py-[1%] border-b border-gray-700 flex items-baseline">
|
||||||
<span className="font-normal text-[13cqh]">Last Play Date: </span>
|
<span className="font-normal text-[13cqh]">Last Play Date: </span>
|
||||||
<span className="text-[15cqh]">{profile.lastPlayDate && new Date(`${profile.lastPlayDate} +0900`).toLocaleTimeString(undefined, {
|
<span className="text-[15cqh]">{profile.lastPlayDate && formatJst(profile.lastPlayDate)}</span>
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
year: '2-digit',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="leading-none py-[2%] flex items-baseline">
|
<div className="leading-none py-[2%] flex items-baseline">
|
||||||
<ChuniRating className="text-[12cqh] text-stroke-[0.75cqh]" rating={profile.playerRating}>
|
<ChuniRating className="text-[12cqh] text-stroke-[0.75cqh]" rating={profile.playerRating}>
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { getPlaylog } from '@/actions/chuni/playlog';
|
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||||
import { getJacketUrl } from '@/helpers/assets';
|
import { getJacketUrl } from '@/helpers/assets';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { floorToDp } from '@/helpers/floor-dp';
|
|
||||||
import { ChuniRating } from '@/components/chuni/rating';
|
import { ChuniRating } from '@/components/chuni/rating';
|
||||||
import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
|
import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
|
||||||
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||||
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
||||||
|
import { formatJst } from '@/helpers/format-jst';
|
||||||
|
import { Ticker } from '@/components/ticker';
|
||||||
|
|
||||||
export type ChuniPlaylogCardProps = {
|
export type ChuniPlaylogCardProps = {
|
||||||
playlog: Awaited<ReturnType<typeof getPlaylog>>['data'][number],
|
playlog: Awaited<ReturnType<typeof getPlaylog>>['data'][number],
|
||||||
@ -25,16 +26,22 @@ const getChangeColor = (val: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChuniPlaylogCard = ({ playlog, className }: ChuniPlaylogCardProps) => {
|
export const ChuniPlaylogCard = ({ playlog, className }: ChuniPlaylogCardProps) => {
|
||||||
return (<div className={`rounded-md bg-content1 flex flex-col p-2 border border-black/25 ${className ?? ''}`}>
|
return (<div className={`rounded-md bg-content1 relative flex flex-col p-2 pt-1 border border-black/25 ${className ?? ''}`}>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<ChuniDifficultyContainer difficulty={playlog.chartId ?? 0} className="mr-2 w-28 aspect-square flex-shrink-0 relative p-1">
|
<div className="flex-shrink-0 mr-2 mt-auto">
|
||||||
<ChuniLevelBadge className="absolute -bottom-1.5 -right-1.5 w-12" music={playlog} />
|
<ChuniDifficultyContainer difficulty={playlog.chartId ?? 0} className="w-28 aspect-square relative p-1">
|
||||||
<img className="aspect-square w-full rounded overflow-hidden" src={getJacketUrl(`chuni/jacket/${playlog.jacketPath}`)}
|
<ChuniLevelBadge className="absolute -bottom-1.5 -right-1.5 w-12" music={playlog} />
|
||||||
alt={playlog.title ?? ''} />
|
<img className="aspect-square w-full rounded overflow-hidden" src={getJacketUrl(`chuni/jacket/${playlog.jacketPath}`)}
|
||||||
</ChuniDifficultyContainer>
|
alt={playlog.title ?? ''} />
|
||||||
<div className="flex flex-col leading-tight overflow-hidden text-nowrap">
|
</ChuniDifficultyContainer>
|
||||||
<Link href={`/chuni/music/${playlog.songId}`} lang="ja" className="underline hover:text-secondary transition mb-2 font-semibold">{ playlog.title }</Link>
|
</div>
|
||||||
<span lang="ja" className="text-sm mb-2">{ playlog.artist }</span>
|
|
||||||
|
<div className="flex flex-col leading-tight overflow-hidden text-nowrap w-full">
|
||||||
|
<div className="text-xs text-right -mb-0.5 w-full">{ formatJst(playlog.userPlayDate!) }</div>
|
||||||
|
<Link href={`/chuni/music/${playlog.songId}`} lang="ja" className="hover:text-secondary transition mb-2 font-semibold">
|
||||||
|
<Ticker hoverOnly noDelay><span className="underline">{ playlog.title }</span></Ticker>
|
||||||
|
</Link>
|
||||||
|
<Ticker className="text-sm mb-2">{ playlog.artist }</Ticker>
|
||||||
<span lang="ja" className="text-sm mb-2">{ playlog.genre }</span>
|
<span lang="ja" className="text-sm mb-2">{ playlog.genre }</span>
|
||||||
<div className="text-sm flex items-center">
|
<div className="text-sm flex items-center">
|
||||||
Rating: <ChuniRating className="text-medium" rating={+playlog.rating * 100} />
|
Rating: <ChuniRating className="text-medium" rating={+playlog.rating * 100} />
|
||||||
|
@ -19,6 +19,16 @@ const ChuniScoreBadgeVariant = {
|
|||||||
type Variant = keyof typeof ChuniScoreBadgeVariant | (typeof ChuniScoreBadgeVariant)[keyof typeof ChuniScoreBadgeVariant];
|
type Variant = keyof typeof ChuniScoreBadgeVariant | (typeof ChuniScoreBadgeVariant)[keyof typeof ChuniScoreBadgeVariant];
|
||||||
|
|
||||||
const RANK_VARIANTS = [0, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4, 4] as const;
|
const RANK_VARIANTS = [0, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4, 4] as const;
|
||||||
|
|
||||||
|
export const CHUNI_LAMP_DISPLAY = new Map<number, Variant>([
|
||||||
|
[1, 'gold'],
|
||||||
|
[2, 'gold'],
|
||||||
|
[3, 'gold'],
|
||||||
|
[4, 'platinum'],
|
||||||
|
[5, 'platinum'],
|
||||||
|
[6, 'platinum']
|
||||||
|
]);
|
||||||
|
|
||||||
export const getVariantFromRank = (rank: number): Variant => {
|
export const getVariantFromRank = (rank: number): Variant => {
|
||||||
return RANK_VARIANTS[rank];
|
return RANK_VARIANTS[rank];
|
||||||
}
|
}
|
||||||
@ -35,6 +45,10 @@ export const getVariantFromScore = (score: number): Variant => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getVariantFromLamp = (lamp: number): Variant => {
|
||||||
|
return CHUNI_LAMP_DISPLAY.get(lamp)!
|
||||||
|
};
|
||||||
|
|
||||||
export type ChuniScoreBadgeProps = {
|
export type ChuniScoreBadgeProps = {
|
||||||
children: ReactNode,
|
children: ReactNode,
|
||||||
variant: Variant,
|
variant: Variant,
|
||||||
|
102
src/components/music-player.tsx
Normal file
102
src/components/music-player.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button, Card, CardBody, Slider } from '@nextui-org/react';
|
||||||
|
import { PauseCircleIcon, PlayCircleIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type MusicPlayerProps = {
|
||||||
|
audio: string,
|
||||||
|
image: string,
|
||||||
|
children?: ReactNode,
|
||||||
|
className?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: number) => {
|
||||||
|
if (Number.isNaN(timestamp))
|
||||||
|
return '--:--';
|
||||||
|
|
||||||
|
return `${Math.floor(timestamp / 60).toFixed(0)}:${(timestamp % 60).toFixed(0).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MusicPlayer = ({ audio, image, children, className }: MusicPlayerProps) => {
|
||||||
|
const [duration, setDuration] = useState(NaN);
|
||||||
|
const [progress, setProgress] = useState(NaN);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
if (!Number.isNaN(audio.duration)) {
|
||||||
|
setDuration(audio.duration)
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = () => {
|
||||||
|
if (audio.duration !== undefined)
|
||||||
|
setDuration(audio.duration);
|
||||||
|
setProgress(0);
|
||||||
|
};
|
||||||
|
audio.addEventListener('loadedmetadata', metadata);
|
||||||
|
|
||||||
|
const timeupdate = () => {
|
||||||
|
if (audio.currentTime !== undefined)
|
||||||
|
setProgress(audio.currentTime);
|
||||||
|
};
|
||||||
|
audio.addEventListener('timeupdate', timeupdate);
|
||||||
|
|
||||||
|
const ended = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
audio.addEventListener('ended', ended);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', metadata);
|
||||||
|
audio.removeEventListener('timeupdate', timeupdate);
|
||||||
|
audio.removeEventListener('ended', ended);
|
||||||
|
audio.pause();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing)
|
||||||
|
audioRef.current?.play();
|
||||||
|
else
|
||||||
|
audioRef.current?.pause();
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
const percent = (progress / duration) * 100;
|
||||||
|
|
||||||
|
return (<Card isBlurred radius="none" className={`border-none shadow-lg sm:rounded-2xl w-full max-w-full sm:max-w-[48rem] ${className ?? ''}`}>
|
||||||
|
<CardBody className="sm:rounded-2xl sm:p-4 bg-content1 sm:bg-content2">
|
||||||
|
<audio src={audio} ref={audioRef} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12">
|
||||||
|
<div className="col-span-full sm:col-span-4 h-full flex items-center justify-center sm:justify-start">
|
||||||
|
<img src={image} alt="" className="aspect-square rounded-md shadow-2xl max-w-56 w-full border border-gray-500 sm:border-0" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full sm:col-span-8 h-full flex flex-col pt-4 sm:pt-0 sm:pl-4 text-xl">
|
||||||
|
<div className="mb-2 sm:my-auto flex flex-col gap-1 items-center sm:items-start overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto flex flex-col items-center">
|
||||||
|
<Slider className="cursor-pointer" size="sm" minValue={0} maxValue={100} step={0.0001}
|
||||||
|
value={Number.isNaN(percent) ? 0 : percent}
|
||||||
|
onChange={v => {
|
||||||
|
if (audioRef.current && !Array.isArray(v))
|
||||||
|
audioRef.current.currentTime = v / 100 * duration;
|
||||||
|
}}/>
|
||||||
|
<div className="flex text-medium w-full">
|
||||||
|
<span>{formatTimestamp(Math.min(progress, duration))}</span>
|
||||||
|
<Button isIconOnly radius="full" variant="light" size="lg" className="mx-auto mt-1" onClick={() => setPlaying(p => !p)}>
|
||||||
|
{playing ? <PauseCircleIcon /> : <PlayCircleIcon />}
|
||||||
|
</Button>
|
||||||
|
<span>{formatTimestamp(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>)
|
||||||
|
};
|
47
src/components/ticker.scss
Normal file
47
src/components/ticker.scss
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
@keyframes outer-overflow {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes inner-overflow {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes outer-overflow-nodelay {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes inner-overflow-nodelay {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
23
src/components/ticker.tsx
Normal file
23
src/components/ticker.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import './ticker.scss';
|
||||||
|
|
||||||
|
export type TickerProps = {
|
||||||
|
children?: ReactNode,
|
||||||
|
hoverOnly?: boolean,
|
||||||
|
className?: string,
|
||||||
|
noDelay?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Ticker = ({ children, hoverOnly, className, noDelay }: TickerProps) => {
|
||||||
|
const hoverClass = hoverOnly ? '[&:hover_*]:[animation-play-state:running] [&_*]:[animation-play-state:paused]' : '[&:hover_*]:[animation-play-state:paused]';
|
||||||
|
const outerAnimation = noDelay ? 'animate-[outer-overflow-nodelay_15s_linear_infinite_alternate]' : 'animate-[outer-overflow_15s_linear_infinite_alternate]';
|
||||||
|
const innerAnimation = noDelay ? 'animate-[inner-overflow-nodelay_15s_linear_infinite_alternate]' : 'animate-[inner-overflow_15s_linear_infinite_alternate]';
|
||||||
|
|
||||||
|
return (<div className={`text-nowrap whitespace-nowrap overflow-hidden w-full ${hoverClass} ${className ?? ''}`}>
|
||||||
|
<div className={`${outerAnimation} ticker max-w-full inline-block`}>
|
||||||
|
<div className={`${innerAnimation} ticker inline-block`}>
|
||||||
|
{ children }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
};
|
8
src/helpers/chuni/difficulties.ts
Normal file
8
src/helpers/chuni/difficulties.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export const CHUNI_DIFFICULTIES = [
|
||||||
|
'Basic',
|
||||||
|
'Advanced',
|
||||||
|
'Expert',
|
||||||
|
'Master',
|
||||||
|
'Ultima',
|
||||||
|
'World\'s End'
|
||||||
|
];
|
9
src/helpers/chuni/lamps.ts
Normal file
9
src/helpers/chuni/lamps.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// TODO: check if these are correct
|
||||||
|
export const CHUNI_LAMPS = new Map([
|
||||||
|
[1, 'Clear'],
|
||||||
|
[2, 'Hard'],
|
||||||
|
[3, 'Absolute'],
|
||||||
|
[4, 'Absolute+'],
|
||||||
|
[5, 'Absolute++'],
|
||||||
|
[6, 'Catastrophy']
|
||||||
|
]);
|
1
src/helpers/chuni/score-ranks.ts
Normal file
1
src/helpers/chuni/score-ranks.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const CHUNI_SCORE_RANKS = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'];
|
11
src/helpers/format-jst.ts
Normal file
11
src/helpers/format-jst.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const DEFAULT_OPTIONS = {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const formatJst = (time: string, options: Intl.DateTimeFormatOptions = DEFAULT_OPTIONS) => new Date(`${time} +0900`)
|
||||||
|
.toLocaleTimeString(undefined, DEFAULT_OPTIONS);
|
||||||
|
|
Loading…
Reference in New Issue
Block a user