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;
+}