diff --git a/src/actions/chuni/music.ts b/src/actions/chuni/music.ts index 40e6fe5..6662cc0 100644 --- a/src/actions/chuni/music.ts +++ b/src/actions/chuni/music.ts @@ -1,7 +1,10 @@ -import { getUser } from '@/actions/auth'; +'use server'; + +import { getUser, requireUser } from '@/actions/auth'; import { db } from '@/db'; import { chuniRating } from '@/helpers/chuni/rating'; import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music'; +import { UserPayload } from '@/types/user'; export const getMusic = async (musicId?: number) => { const user = await getUser(); @@ -12,8 +15,16 @@ export const getMusic = async (musicId?: number) => { .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()]) + .leftJoin('chuni_item_favorite as favorite', join => + join.onRef('music.songId', '=', 'favorite.favId') + .onRef('music.version', '=', 'favorite.version') + .on('favorite.favKind', '=', 1) + .on('favorite.user', '=', user?.id!) + ) + .select(({ fn }) => [...CHUNI_MUSIC_PROPERTIES, + 'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess', 'score.scoreRank', 'score.scoreMax', + fn('NOT ISNULL', ['favorite.favId']).as('favorite'), + chuniRating()]) .where(({ selectFrom, eb, and, or }) => and([ eb('music.version', '=', selectFrom('chuni_static_music') .select(({ fn }) => fn.max('version').as('latest'))), @@ -27,3 +38,61 @@ export const getMusic = async (musicId?: number) => { .orderBy(['music.songId asc', 'music.chartId asc']) .execute(); }; + +const getMusicById = async (user: UserPayload, musicId: number) => { + if (isNaN(musicId)) + return { error: true, message: 'Invalid music ID.' }; + + const music = await db.selectFrom('chuni_static_music as music') + .select('music.version') + .where(({ selectFrom, eb }) => eb('music.version', '=', selectFrom('chuni_static_music') + .select(({ fn }) => fn.max('version').as('latest')))) + .executeTakeFirst(); + + if (!music) + return { error: true, message: `Unknown music ID ${musicId}.` }; + + return { error: false, music }; +} + +export const addFavoriteMusic = async (musicId: number) => { + const user = await requireUser(); + const data = await getMusicById(user, musicId); + if (data.error) return data; + + const existingFavorite = await db.selectFrom('chuni_item_favorite') + .where(({ eb, and }) => and([ + eb('version', '=', data.music?.version!), + eb('user', '=', user.id), + eb('favId', '=', musicId), + eb('favKind', '=', 1) + ])) + .select('favId') + .executeTakeFirst(); + + if (existingFavorite) return; + + await db.insertInto('chuni_item_favorite') + .values({ + version: data.music?.version!, + user: user.id, + favId: musicId, + favKind: 1 + }) + .executeTakeFirst(); +}; + +export const removeFavoriteMusic = async (musicId: number) => { + const user = await requireUser(); + const data = await getMusicById(user, musicId); + if (data.error) return data; + + await db.deleteFrom('chuni_item_favorite') + .where(({ eb, and }) => and([ + eb('version', '=', data.music?.version!), + eb('user', '=', user.id), + eb('favId', '=', musicId), + eb('favKind', '=', 1) + ])) + .executeTakeFirst(); +} diff --git a/src/app/(with-header)/chuni/music/[musicId]/page.tsx b/src/app/(with-header)/chuni/music/[musicId]/page.tsx index b6919d4..613c940 100644 --- a/src/app/(with-header)/chuni/music/[musicId]/page.tsx +++ b/src/app/(with-header)/chuni/music/[musicId]/page.tsx @@ -1,13 +1,10 @@ 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 } }) { +import { ChuniMusicDetail } from '@/components/chuni/music-detail'; + +export default async function ChuniMusicDetailPage({ params }: { params: { musicId: string } }) { const musicId = parseInt(params.musicId); if (Number.isNaN(musicId)) return notFound(); @@ -21,16 +18,5 @@ export default async function ChuniMusicDetail({ params }: { params: { musicId: if (!music.length) return notFound(); - - const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1]; - - return (
- - { music[0].title } - { music[0].artist } - { music[0].genre } - - -
); + return () } diff --git a/src/components/chuni/music-detail.tsx b/src/components/chuni/music-detail.tsx new file mode 100644 index 0000000..56091ba --- /dev/null +++ b/src/components/chuni/music-detail.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { addFavoriteMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music'; +import { getPlaylog } from '@/actions/chuni/playlog'; +import { MusicPlayer } from '@/components/music-player'; +import { getJacketUrl, getMusicUrl } from '@/helpers/assets'; +import { Ticker } from '@/components/ticker'; +import { ChuniMusicPlaylog } from '@/components/chuni/music-playlog'; +import { Button } from '@nextui-org/react'; +import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid'; +import { HeartIcon as OutlineHeartIcon } from '@heroicons/react/24/outline'; +import React, { useState } from 'react'; +import { useErrorModal } from '@/components/error-modal'; + +type ChuniMusicDetailProps = { + music: Awaited>, + playlog: Awaited> +}; + +export const ChuniMusicDetail = ({ music, playlog }: ChuniMusicDetailProps) => { + const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1]; + const [favorite, setFavorite] = useState(music[0].favorite); + const [pendingFavorite, setPendingFavorite] = useState(false); + const setError = useErrorModal(); + + return (
+ + { music[0].title } + { music[0].artist } + { music[0].genre } + + + +
); +}; diff --git a/src/components/chuni/music-list.tsx b/src/components/chuni/music-list.tsx index 6bd0aa3..c4fe189 100644 --- a/src/components/chuni/music-list.tsx +++ b/src/components/chuni/music-list.tsx @@ -2,21 +2,23 @@ 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 { Button, SelectItem } from '@nextui-org/react'; +import { addFavoriteMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music'; +import React, { useEffect, useMemo, useRef, useState } 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, ChuniLampSuccessBadge, getVariantFromLamp, getVariantFromRank, ChuniLampComboBadge } from '@/components/chuni/score-badge'; +import { ChuniScoreBadge, ChuniLampSuccessBadge, getVariantFromRank, ChuniLampComboBadge } from '@/components/chuni/score-badge'; import { ChuniRating } from '@/components/chuni/rating'; import Link from 'next/link'; -import { Squares2X2Icon } from '@heroicons/react/24/outline'; +import { HeartIcon as OutlineHeartIcon, Squares2X2Icon } from '@heroicons/react/24/outline'; +import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid'; 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'; +import { useErrorModal } from '@/components/error-modal'; const getLevelFromStop = (n: number) => { if (n < 7) @@ -59,12 +61,16 @@ const searcher = (query: string, data: ChuniMusicListProps['music'][number]) => return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query); }; -const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) => { +const MusicGrid = ({ music, size, setMusicList }: ChuniMusicListProps & { size: 'sm' | 'lg' | 'xs', setMusicList: (m: typeof music) => void }) => { let itemWidth = 0; let itemHeight = 0; let itemClass = ''; - if (size === 'sm') { + if (size === 'xs') { + itemWidth = 125; + itemHeight = 180; + itemClass = 'w-[125px] h-[180px] py-0.5 px-0.5'; + } else if (size === 'sm') { itemWidth = 175; itemHeight = 235; itemClass = 'w-[175px] h-[235px] py-1.5 px-1'; @@ -75,6 +81,8 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) } const listRef = useRef(null); + const setError = useErrorModal(); + const [pendingFavorite, setPendingFavorite] = useState(false); useEffect(() => { listRef.current?.recomputeRowHeights(0); @@ -94,33 +102,60 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
{item.title - {item.rating && !item.worldsEndTag &&
+ {item.rating && !item.worldsEndTag &&
{item.rating.slice(0, item.rating.indexOf('.') + 3)}
} - + + +
{size === 'lg' &&
{item.isSuccess ? : null}
} -
+
{item.scoreRank !== null && {item.scoreMax!.toLocaleString()} }
-
+
+ className={`${size === 'lg' ? 'text-lg' : 'text-xs'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}> {item.title} - {item.artist} + {item.artist}
)}
} />) @@ -128,7 +163,27 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) )} ); }; + +const DISPLAY_MODES = [{ + name: 'Extra Small Grid', + icon: +}, { + name: 'Small Grid', + icon: +}, { + name: 'Large Grid', + icon: +}]; + +const DISPLAY_IDS = { + 'Extra Small Grid': 'xs', + 'Small Grid': 'sm', + 'Large Grid': 'lg' +} as const; + export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { + const [localMusic, setLocalMusic] = useState(music); + const { filterers } = useMemo(() => { const genres = new Set(); const worldsEndTags = new Set(); @@ -275,16 +330,10 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { }, [music]); return ( - - }, { - name: 'Large Grid', - icon: - }]} searcher={searcher}> + {(displayMode, data) =>
- +
}
); }; diff --git a/src/helpers/chuni/music.ts b/src/helpers/chuni/music.ts index ce2ab0c..7d92711 100644 --- a/src/helpers/chuni/music.ts +++ b/src/helpers/chuni/music.ts @@ -7,6 +7,7 @@ export const CHUNI_MUSIC_PROPERTIES = ['music.songId', 'music.jacketPath', 'music.worldsEndTag', 'music.genre', + 'music.version', 'music.level' // sql`CAST(music.level AS DECIMAL(3, 1))`.as('level') ] as const;