forked from sk1982/actaeon
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) {
|
||||
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
|
||||
.selectFrom('chuni_score_playlog as playlog')
|
||||
@ -41,8 +43,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
|
||||
)
|
||||
.selectFrom('p')
|
||||
.where(({ and, eb }) => and([
|
||||
...('musicId' in opts ? [eb('p.songId', '=', opts.musicId)] : []),
|
||||
...('chartId' in opts ? [eb('p.chartId', '=', opts.chartId)] : []),
|
||||
...(!Number.isNaN(musicId) ? [eb('p.songId', '=', musicId)] : []),
|
||||
...(!Number.isNaN(chartId) ? [eb('p.chartId', '=', chartId)] : []),
|
||||
]))
|
||||
.selectAll()
|
||||
.limit(+opts.limit)
|
||||
@ -54,8 +56,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
|
||||
.where(({ and, eb }) => and([
|
||||
eb('playlog.user', '=', user.id),
|
||||
eb('playlog.id', '<', playlog.at(-1)!.id),
|
||||
...('musicId' in opts ? [eb('playlog.musicId', '=', opts.musicId)] : []),
|
||||
...('chartId' in opts ? [eb('playlog.level', '=', opts.chartId)] : []),
|
||||
...(!Number.isNaN(musicId) ? [eb('playlog.musicId', '=', musicId)] : []),
|
||||
...(!Number.isNaN(chartId) ? [eb('playlog.level', '=', chartId)] : []),
|
||||
]))
|
||||
.select(({ fn }) => fn.countAll().as('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 { getJacketUrl } from '@/helpers/assets';
|
||||
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 Link from 'next/link';
|
||||
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) => {
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
@ -81,12 +66,12 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
||||
|
||||
if (size === 'sm') {
|
||||
itemWidth = 175;
|
||||
itemHeight = 225;
|
||||
itemClass = 'w-[175px] h-[230px] py-1.5 px-1';
|
||||
itemHeight = 235;
|
||||
itemClass = 'w-[175px] h-[235px] py-1.5 px-1';
|
||||
} else {
|
||||
itemWidth = 285;
|
||||
itemHeight = 360;
|
||||
itemClass = 'w-[285px] h-[360px] py-1.5 px-1';
|
||||
itemHeight = 375;
|
||||
itemClass = 'w-[285px] h-[375px] py-1.5 px-1';
|
||||
}
|
||||
|
||||
const listRef = useRef<List | null>(null);
|
||||
@ -106,7 +91,7 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
||||
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">
|
||||
<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">
|
||||
<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`}>
|
||||
@ -118,8 +103,8 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
||||
</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)}
|
||||
{item.isSuccess ? <ChuniScoreBadge variant={getVariantFromLamp(item.isSuccess)} className="h-full">
|
||||
{CHUNI_LAMPS.get(item.isSuccess)}
|
||||
</ChuniScoreBadge> : null}
|
||||
</div>}
|
||||
|
||||
@ -136,13 +121,10 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
|
||||
</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}
|
||||
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`}>
|
||||
<Ticker hoverOnly noDelay>{item.title}</Ticker>
|
||||
</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>
|
||||
<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>
|
||||
</ChuniDifficultyContainer>
|
||||
</div>)}
|
||||
</div>} />)
|
||||
@ -167,14 +149,9 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
||||
value: new Set<string>(),
|
||||
className: 'col-span-6 md:col-span-3 5xl:col-span-1',
|
||||
props: {
|
||||
children: [
|
||||
<SelectItem key="0" value="0">Basic</SelectItem>,
|
||||
<SelectItem key="1" value="1">Advanced</SelectItem>,
|
||||
<SelectItem key="2" value="2">Expert</SelectItem>,
|
||||
<SelectItem key="3" value="3">Master</SelectItem>,
|
||||
<SelectItem key="4" value="4">Ultima</SelectItem>,
|
||||
<SelectItem key="5" value="5">World's End</SelectItem>,
|
||||
],
|
||||
children: CHUNI_DIFFICULTIES.map((name, i) => <SelectItem key={i.toString()} value={i.toString()}>
|
||||
{name}
|
||||
</SelectItem>),
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => !val.size || val.has(data.chartId?.toString()!)
|
||||
@ -200,14 +177,14 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
||||
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>)
|
||||
...[...CHUNI_LAMPS].map(([id, name]) => <SelectItem key={id.toString()} value={id.toString()}>{name}</SelectItem>)
|
||||
],
|
||||
selectionMode: 'multiple'
|
||||
},
|
||||
filter: (val: Set<string>, data) => {
|
||||
if (!val.size) return true;
|
||||
|
||||
const checkLamps = [...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())))
|
||||
return false
|
||||
|
||||
@ -238,7 +215,7 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
|
||||
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
|
||||
children: CHUNI_SCORE_RANKS
|
||||
.map((s, i) => <SelectItem key={i.toString()} value={i.toString()}>{s}</SelectItem>)
|
||||
.reverse(),
|
||||
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 { PickNullable } from '@/types/pick-nullable';
|
||||
import { ChuniRating } from '@/components/chuni/rating';
|
||||
import { formatJst } from '@/helpers/format-jst';
|
||||
|
||||
export type Profile = PickNullable<Awaited<ReturnType<typeof getUserData>>,
|
||||
'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 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="text-[15cqh]">{profile.lastPlayDate && new Date(`${profile.lastPlayDate} +0900`).toLocaleTimeString(undefined, {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})}</span>
|
||||
<span className="text-[15cqh]">{profile.lastPlayDate && formatJst(profile.lastPlayDate)}</span>
|
||||
</div>
|
||||
<div className="leading-none py-[2%] flex items-baseline">
|
||||
<ChuniRating className="text-[12cqh] text-stroke-[0.75cqh]" rating={profile.playerRating}>
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { getPlaylog } from '@/actions/chuni/playlog';
|
||||
import { getJacketUrl } from '@/helpers/assets';
|
||||
import Link from 'next/link';
|
||||
import { floorToDp } from '@/helpers/floor-dp';
|
||||
import { ChuniRating } from '@/components/chuni/rating';
|
||||
import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
|
||||
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
|
||||
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
|
||||
import { formatJst } from '@/helpers/format-jst';
|
||||
import { Ticker } from '@/components/ticker';
|
||||
|
||||
export type ChuniPlaylogCardProps = {
|
||||
playlog: Awaited<ReturnType<typeof getPlaylog>>['data'][number],
|
||||
@ -25,16 +26,22 @@ const getChangeColor = (val: number) => {
|
||||
}
|
||||
|
||||
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">
|
||||
<ChuniDifficultyContainer difficulty={playlog.chartId ?? 0} className="mr-2 w-28 aspect-square flex-shrink-0 relative p-1">
|
||||
<ChuniLevelBadge className="absolute -bottom-1.5 -right-1.5 w-12" music={playlog} />
|
||||
<img className="aspect-square w-full rounded overflow-hidden" src={getJacketUrl(`chuni/jacket/${playlog.jacketPath}`)}
|
||||
alt={playlog.title ?? ''} />
|
||||
</ChuniDifficultyContainer>
|
||||
<div className="flex flex-col leading-tight overflow-hidden text-nowrap">
|
||||
<Link href={`/chuni/music/${playlog.songId}`} lang="ja" className="underline hover:text-secondary transition mb-2 font-semibold">{ playlog.title }</Link>
|
||||
<span lang="ja" className="text-sm mb-2">{ playlog.artist }</span>
|
||||
<div className="flex-shrink-0 mr-2 mt-auto">
|
||||
<ChuniDifficultyContainer difficulty={playlog.chartId ?? 0} className="w-28 aspect-square relative p-1">
|
||||
<ChuniLevelBadge className="absolute -bottom-1.5 -right-1.5 w-12" music={playlog} />
|
||||
<img className="aspect-square w-full rounded overflow-hidden" src={getJacketUrl(`chuni/jacket/${playlog.jacketPath}`)}
|
||||
alt={playlog.title ?? ''} />
|
||||
</ChuniDifficultyContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col leading-tight overflow-hidden text-nowrap w-full">
|
||||
<div className="text-xs text-right -mb-0.5 w-full">{ formatJst(playlog.userPlayDate!) }</div>
|
||||
<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>
|
||||
<div className="text-sm flex items-center">
|
||||
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];
|
||||
|
||||
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 => {
|
||||
return RANK_VARIANTS[rank];
|
||||
}
|
||||
@ -35,6 +45,10 @@ export const getVariantFromScore = (score: number): Variant => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const getVariantFromLamp = (lamp: number): Variant => {
|
||||
return CHUNI_LAMP_DISPLAY.get(lamp)!
|
||||
};
|
||||
|
||||
export type ChuniScoreBadgeProps = {
|
||||
children: ReactNode,
|
||||
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