diff --git a/app/musics/page.tsx b/app/musics/page.tsx
new file mode 100644
index 0000000..a5a20b3
--- /dev/null
+++ b/app/musics/page.tsx
@@ -0,0 +1,14 @@
+import MusicList from "@/components/music-list";
+import { fetchMusics } from "@/lib/api";
+
+type MusicPageProps = {
+ searchParams: Promise<{ cursor?: string; limit?: string }>;
+};
+
+export default async function MusicPage({ searchParams }: MusicPageProps) {
+ const params = await searchParams;
+ const limit = params.limit ? Number(params.limit) : undefined;
+ const data = await fetchMusics({ cursor: params.cursor, limit });
+
+ return ;
+}
diff --git a/components/dashboard-home.tsx b/components/dashboard-home.tsx
index 3a07830..ed8d0cc 100644
--- a/components/dashboard-home.tsx
+++ b/components/dashboard-home.tsx
@@ -24,6 +24,7 @@ export default function DashboardHome({ userName }: { userName: string }) {
管理者用ダッシュボードへようこそ、{userName} さん。
+
diff --git a/components/music-list.tsx b/components/music-list.tsx
new file mode 100644
index 0000000..b3f707c
--- /dev/null
+++ b/components/music-list.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import AppLayout from "@cloudscape-design/components/app-layout";
+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 Table from "@cloudscape-design/components/table";
+import TopNavigation from "@cloudscape-design/components/top-navigation";
+
+import type { MusicListResponse } from "@/lib/api";
+
+export default function MusicList({
+ data,
+ limit,
+}: {
+ data: MusicListResponse;
+ limit?: number;
+}) {
+ const nextPageHref = data.nextCursor
+ ? `/musics?${new URLSearchParams({
+ cursor: data.nextCursor,
+ ...(limit ? { limit: String(limit) } : {}),
+ }).toString()}`
+ : undefined;
+
+ return (
+
+
+
ホーム} variant="h1">
+ 楽曲管理
+
+ }
+ >
+
+
+ item.music.title },
+ {
+ header: "アーティスト",
+ cell: (item) => item.music.artist,
+ },
+ { header: "BPM", cell: (item) => item.music.bpm },
+ { header: "譜面", cell: (item) => item.sheets.length },
+ {
+ header: "登録日時",
+ cell: (item) =>
+ new Date(item.music.registrationDate).toLocaleString(
+ "ja-JP",
+ ),
+ },
+ ]}
+ items={data.items}
+ header={
+
+ }
+ empty={楽曲がありません。}
+ />
+
+
+
+
+
+
+ }
+ />
+
+ );
+}
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 0000000..71f4348
--- /dev/null
+++ b/lib/api.ts
@@ -0,0 +1,54 @@
+import { auth0 } from "@/lib/auth0";
+
+export type Sheet = {
+ id: string;
+ musicId: string;
+ difficulty: "easy" | "normal" | "hard";
+ level: number;
+ notesDesigner: string;
+};
+
+export type Music = {
+ id: string;
+ title: string;
+ artist: string;
+ bpm: number;
+ genre: string;
+ jacket: string;
+ registrationDate: string;
+ isTest: boolean;
+};
+
+export type MusicWithSheets = {
+ music: Music;
+ sheets: Sheet[];
+};
+
+export type MusicListResponse = {
+ items: MusicWithSheets[];
+ nextCursor: string | null;
+};
+
+export async function fetchMusics(
+ searchParams: { cursor?: string; limit?: number } = {},
+): Promise {
+ const audience = process.env.AUTH0_AUDIENCE ?? "https://api.xlair.dev";
+ const accessToken = await auth0.getAccessToken({ audience });
+ const params = new URLSearchParams();
+ if (searchParams.cursor) params.set("cursor", searchParams.cursor);
+ if (searchParams.limit) params.set("limit", String(searchParams.limit));
+
+ const response = await fetch(
+ `${process.env.API_BASE_URL}/admin/musics?${params.toString()}`,
+ {
+ headers: { Authorization: `Bearer ${accessToken.token}` },
+ cache: "no-store",
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch musics: ${response.status}`);
+ }
+
+ return response.json() as Promise;
+}