Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/musics/[musicId]/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashboardLayout activeHref="/musics">
<ContentLayout header={<Header variant="h1">楽曲管理</Header>}>
<Container>
<Alert type="error" header="楽曲を取得できませんでした">
楽曲が存在しないか、一時的に取得できません。時間をおいて再試行してください。
</Alert>
<div className="mt-4">
<SpaceBetween direction="horizontal" size="s">
<Button href="/musics">楽曲一覧に戻る</Button>
<Button onClick={reset}>再試行</Button>
</SpaceBetween>
</div>
</Container>
</ContentLayout>
</DashboardLayout>
);
}
5 changes: 5 additions & 0 deletions app/musics/[musicId]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import MusicDetailLoading from "@/components/music-detail-loading";

export default function Loading() {
return <MusicDetailLoading />;
}
15 changes: 15 additions & 0 deletions app/musics/[musicId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <MusicDetail data={data} />;
}
25 changes: 25 additions & 0 deletions components/music-detail-loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashboardLayout activeHref="/musics">
<ContentLayout header={<Header variant="h1">楽曲管理</Header>}>
<Container>
<div
aria-label="楽曲詳細を読み込み中"
className="flex min-h-48 items-center justify-center"
role="status"
>
<Spinner size="large" />
<span className="sr-only">楽曲詳細を読み込み中</span>
</div>
</Container>
</ContentLayout>
</DashboardLayout>
);
}
80 changes: 80 additions & 0 deletions components/music-detail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashboardLayout activeHref="/musics">
<ContentLayout
header={
<Header
variant="h1"
actions={<Button href="/musics">楽曲一覧に戻る</Button>}
>
{music.title}
</Header>
}
>
<SpaceBetween size="l">
<Container header={<Header variant="h2">楽曲情報</Header>}>
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<DetailItem label="タイトル" value={music.title} />
<DetailItem label="アーティスト" value={music.artist} />
<DetailItem label="BPM" value={String(music.bpm)} />
<DetailItem label="ジャンル" value={music.genre} />
<DetailItem label="ジャケット" value={music.jacket} />
<DetailItem
label="登録日時"
value={new Date(music.registrationDate).toISOString()}
/>
<DetailItem
label="テスト楽曲"
value={music.isTest ? "はい" : "いいえ"}
/>
</dl>
</Container>
<Container header={<Header variant="h2">譜面</Header>}>
<MusicSheetsTable
sheets={data.sheets.map((sheet) => ({
...sheet,
difficultyLabel: difficultyLabels[sheet.difficulty],
}))}
/>
</Container>
</SpaceBetween>
</ContentLayout>
</DashboardLayout>
);
}

function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="font-semibold">{label}</dt>
<dd className="break-words">{value}</dd>
</div>
);
}
27 changes: 27 additions & 0 deletions components/music-sheets-table.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Table
columnDefinitions={[
{ header: "難易度", cell: (item) => item.difficultyLabel },
{ header: "レベル", cell: (item) => item.level },
{ header: "譜面制作者", cell: (item) => item.notesDesigner },
]}
items={sheets}
header={<Header counter={`(${sheets.length})`}>譜面一覧</Header>}
empty={<span>譜面がありません。</span>}
/>
);
}
10 changes: 9 additions & 1 deletion components/music-table.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,7 +10,14 @@ export default function MusicTable({ data }: { data: MusicListResponse }) {
return (
<Table
columnDefinitions={[
{ header: "タイトル", cell: (item) => item.music.title },
{
header: "タイトル",
cell: (item) => (
<Link href={`/musics/${encodeURIComponent(item.music.id)}`}>
{item.music.title}
</Link>
),
},
{
header: "アーティスト",
cell: (item) => item.music.artist,
Expand Down
39 changes: 29 additions & 10 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,10 @@ export type MusicListResponse = {
nextCursor: string | null;
};

export async function fetchMusics(
searchParams: { cursor?: string; limit?: number } = {},
): Promise<MusicListResponse> {
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<ReturnType<typeof auth0.getAccessToken>>;
try {
accessToken = await auth0.getAccessToken({ audience });
return await auth0.getAccessToken({ audience });
} catch (error) {
if (
error instanceof AccessTokenError &&
Expand All @@ -64,6 +56,16 @@ export async function fetchMusics(
}
throw error;
}
}

export async function fetchMusics(
searchParams: { cursor?: string; limit?: number } = {},
): Promise<MusicListResponse> {
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()}`,
Expand All @@ -79,3 +81,20 @@ export async function fetchMusics(

return response.json() as Promise<MusicListResponse>;
}

export async function fetchMusic(musicId: string): Promise<MusicWithSheets> {
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<MusicWithSheets>;
}