diff --git a/app/musics/[musicId]/error.tsx b/app/musics/[musicId]/error.tsx new file mode 100644 index 0000000..fb4bf90 --- /dev/null +++ b/app/musics/[musicId]/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import Alert from "@cloudscape-design/components/alert"; +import Button from "@cloudscape-design/components/button"; +import Container from "@cloudscape-design/components/container"; +import ContentLayout from "@cloudscape-design/components/content-layout"; +import Header from "@cloudscape-design/components/header"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + +import DashboardLayout from "@/components/dashboard-layout"; + +export default function MusicDetailError({ reset }: { reset: () => void }) { + return ( + + 楽曲管理}> + + + 楽曲が存在しないか、一時的に取得できません。時間をおいて再試行してください。 + +
+ + + + +
+
+
+
+ ); +} diff --git a/app/musics/[musicId]/loading.tsx b/app/musics/[musicId]/loading.tsx new file mode 100644 index 0000000..de04790 --- /dev/null +++ b/app/musics/[musicId]/loading.tsx @@ -0,0 +1,5 @@ +import MusicDetailLoading from "@/components/music-detail-loading"; + +export default function Loading() { + return ; +} diff --git a/app/musics/[musicId]/page.tsx b/app/musics/[musicId]/page.tsx new file mode 100644 index 0000000..c0dbddf --- /dev/null +++ b/app/musics/[musicId]/page.tsx @@ -0,0 +1,15 @@ +import MusicDetail from "@/components/music-detail"; +import { fetchMusic } from "@/lib/api"; + +type MusicDetailPageProps = { + params: Promise<{ musicId: string }>; +}; + +export default async function MusicDetailPage({ + params, +}: MusicDetailPageProps) { + const { musicId } = await params; + const data = await fetchMusic(musicId); + + return ; +} diff --git a/components/music-detail-loading.tsx b/components/music-detail-loading.tsx new file mode 100644 index 0000000..0b673aa --- /dev/null +++ b/components/music-detail-loading.tsx @@ -0,0 +1,25 @@ +import Container from "@cloudscape-design/components/container"; +import ContentLayout from "@cloudscape-design/components/content-layout"; +import Header from "@cloudscape-design/components/header"; +import Spinner from "@cloudscape-design/components/spinner"; + +import DashboardLayout from "@/components/dashboard-layout"; + +export default function MusicDetailLoading() { + return ( + + 楽曲管理}> + +
+ + 楽曲詳細を読み込み中 +
+
+
+
+ ); +} diff --git a/components/music-detail.tsx b/components/music-detail.tsx new file mode 100644 index 0000000..b030d49 --- /dev/null +++ b/components/music-detail.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Button from "@cloudscape-design/components/button"; +import Container from "@cloudscape-design/components/container"; +import ContentLayout from "@cloudscape-design/components/content-layout"; +import Header from "@cloudscape-design/components/header"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import dynamic from "next/dynamic"; + +import DashboardLayout from "@/components/dashboard-layout"; +import type { MusicWithSheets } from "@/lib/api"; + +const MusicSheetsTable = dynamic( + () => import("@/components/music-sheets-table"), + { + ssr: false, + }, +); + +const difficultyLabels = { + easy: "Easy", + normal: "Normal", + hard: "Hard", +} as const; + +export default function MusicDetail({ data }: { data: MusicWithSheets }) { + const { music } = data; + + return ( + + 楽曲一覧に戻る} + > + {music.title} + + } + > + + 楽曲情報}> +
+ + + + + + + +
+
+ 譜面}> + ({ + ...sheet, + difficultyLabel: difficultyLabels[sheet.difficulty], + }))} + /> + +
+
+
+ ); +} + +function DetailItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/components/music-sheets-table.tsx b/components/music-sheets-table.tsx new file mode 100644 index 0000000..0cc55b6 --- /dev/null +++ b/components/music-sheets-table.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Header from "@cloudscape-design/components/header"; +import Table from "@cloudscape-design/components/table"; + +import type { Sheet } from "@/lib/api"; + +type SheetWithLabel = Sheet & { difficultyLabel: string }; + +export default function MusicSheetsTable({ + sheets, +}: { + sheets: SheetWithLabel[]; +}) { + return ( + item.difficultyLabel }, + { header: "レベル", cell: (item) => item.level }, + { header: "譜面制作者", cell: (item) => item.notesDesigner }, + ]} + items={sheets} + header={
譜面一覧
} + empty={譜面がありません。} + /> + ); +} diff --git a/components/music-table.tsx b/components/music-table.tsx index dbe191a..ce9073c 100644 --- a/components/music-table.tsx +++ b/components/music-table.tsx @@ -1,6 +1,7 @@ "use client"; import Header from "@cloudscape-design/components/header"; +import Link from "@cloudscape-design/components/link"; import Table from "@cloudscape-design/components/table"; import type { MusicListResponse } from "@/lib/api"; @@ -9,7 +10,14 @@ export default function MusicTable({ data }: { data: MusicListResponse }) { return (
item.music.title }, + { + header: "タイトル", + cell: (item) => ( + + {item.music.title} + + ), + }, { header: "アーティスト", cell: (item) => item.music.artist, diff --git a/lib/api.ts b/lib/api.ts index b6bb8eb..ef43a46 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -35,18 +35,10 @@ export type MusicListResponse = { nextCursor: string | null; }; -export async function fetchMusics( - searchParams: { cursor?: string; limit?: number } = {}, -): Promise { +async function getAccessToken(returnTo: string) { const audience = process.env.AUTH0_AUDIENCE ?? "https://api.xlair.dev"; - const params = new URLSearchParams(); - if (searchParams.cursor) params.set("cursor", searchParams.cursor); - if (searchParams.limit) params.set("limit", String(searchParams.limit)); - const returnTo = `/musics${params.size ? `?${params.toString()}` : ""}`; - - let accessToken: Awaited>; try { - accessToken = await auth0.getAccessToken({ audience }); + return await auth0.getAccessToken({ audience }); } catch (error) { if ( error instanceof AccessTokenError && @@ -64,6 +56,16 @@ export async function fetchMusics( } throw error; } +} + +export async function fetchMusics( + searchParams: { cursor?: string; limit?: number } = {}, +): Promise { + const params = new URLSearchParams(); + if (searchParams.cursor) params.set("cursor", searchParams.cursor); + if (searchParams.limit) params.set("limit", String(searchParams.limit)); + const returnTo = `/musics${params.size ? `?${params.toString()}` : ""}`; + const accessToken = await getAccessToken(returnTo); const response = await fetch( `${process.env.API_BASE_URL}/admin/musics?${params.toString()}`, @@ -79,3 +81,20 @@ export async function fetchMusics( return response.json() as Promise; } + +export async function fetchMusic(musicId: string): Promise { + const accessToken = await getAccessToken(`/musics/${musicId}`); + const response = await fetch( + `${process.env.API_BASE_URL}/admin/musics/${encodeURIComponent(musicId)}`, + { + headers: { Authorization: `Bearer ${accessToken.token}` }, + cache: "no-store", + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch music: ${response.status}`); + } + + return response.json() as Promise; +}