楽曲一覧に戻る}
+ actions={
}
>
{music.title}
}
+ breadcrumbs={
+
+ }
>
楽曲情報}>
@@ -48,7 +55,9 @@ export default function MusicDetail({ data }: { data: MusicWithSheets }) {
- 譜面}>
- ({
- ...sheet,
- difficultyLabel: difficultyLabels[sheet.difficulty],
- }))}
- />
-
+ ({
+ ...sheet,
+ difficultyLabel: difficultyLabels[sheet.difficulty],
+ }))}
+ />
diff --git a/components/music-form.tsx b/components/music-form.tsx
new file mode 100644
index 0000000..ad5862f
--- /dev/null
+++ b/components/music-form.tsx
@@ -0,0 +1,321 @@
+"use client";
+
+import Alert from "@cloudscape-design/components/alert";
+import Button from "@cloudscape-design/components/button";
+import Checkbox from "@cloudscape-design/components/checkbox";
+import Container from "@cloudscape-design/components/container";
+import ContentLayout from "@cloudscape-design/components/content-layout";
+import DatePicker from "@cloudscape-design/components/date-picker";
+import Form from "@cloudscape-design/components/form";
+import FormField from "@cloudscape-design/components/form-field";
+import Header from "@cloudscape-design/components/header";
+import Input from "@cloudscape-design/components/input";
+import SpaceBetween from "@cloudscape-design/components/space-between";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+import { createMusicAction, updateMusicAction } from "@/app/musics/actions";
+import DashboardLayout from "@/components/dashboard-layout";
+import MusicBreadcrumbs from "@/components/music-breadcrumbs";
+import type {
+ CreateMusicInput,
+ MusicWithSheets,
+ UpdateMusicInput,
+} from "@/lib/api";
+
+type Difficulty = "easy" | "normal" | "hard";
+type SheetDraft = { id?: string; level: string; notesDesigner: string };
+type FormValues = {
+ title: string;
+ artist: string;
+ bpm: string;
+ jacket: string;
+ registrationDate: string;
+ isTest: boolean;
+ sheets: Record
;
+};
+
+const difficulties: Array<{ key: Difficulty; label: string }> = [
+ { key: "easy", label: "Easy" },
+ { key: "normal", label: "Normal" },
+ { key: "hard", label: "Hard" },
+];
+
+function isPositiveSingleDecimal(value: string) {
+ return /^\d+(\.\d)?$/.test(value) && Number(value) > 0;
+}
+
+function initialValues(data?: MusicWithSheets): FormValues {
+ const sheets = Object.fromEntries(
+ difficulties.map(({ key }) => {
+ const sheet = data?.sheets.find((item) => item.difficulty === key);
+ return [
+ key,
+ {
+ id: sheet?.id,
+ level: sheet ? String(sheet.level) : "",
+ notesDesigner: sheet?.notesDesigner ?? "",
+ },
+ ];
+ }),
+ ) as Record;
+ return {
+ title: data?.music.title ?? "",
+ artist: data?.music.artist ?? "",
+ bpm: data ? String(data.music.bpm) : "",
+ jacket: data?.music.jacket ?? "",
+ registrationDate: data?.music.registrationDate.slice(0, 10) ?? "",
+ isTest: data?.music.isTest ?? false,
+ sheets,
+ };
+}
+
+export default function MusicForm({
+ data,
+ title,
+}: {
+ data?: MusicWithSheets;
+ title: string;
+}) {
+ const [values, setValues] = useState(() => initialValues(data));
+ const [errors, setErrors] = useState>({});
+ const [submitError, setSubmitError] = useState();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const router = useRouter();
+
+ const isEdit = Boolean(data);
+ const updateValue = (
+ key: keyof Omit,
+ value: string | boolean,
+ ) => setValues((current) => ({ ...current, [key]: value }));
+
+ function validate() {
+ const nextErrors: Record = {};
+ if (!values.title.trim()) nextErrors.title = "タイトルを入力してください。";
+ if (!values.artist.trim())
+ nextErrors.artist = "アーティストを入力してください。";
+ if (!values.jacket.trim())
+ nextErrors.jacket = "ジャケットを入力してください。";
+ if (!values.registrationDate)
+ nextErrors.registrationDate = "登録日を入力してください。";
+ else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.registrationDate))
+ nextErrors.registrationDate =
+ "登録日は YYYY-MM-DD 形式で入力してください。";
+ if (!isPositiveSingleDecimal(values.bpm))
+ nextErrors.bpm = "BPM は正の数値(小数第1位まで)で入力してください。";
+ for (const { key, label } of difficulties) {
+ const sheet = values.sheets[key];
+ if (!isPositiveSingleDecimal(sheet.level))
+ nextErrors[`${key}.level`] =
+ `${label} のレベルは正の数値(小数第1位まで)で入力してください。`;
+ if (!sheet.notesDesigner.trim())
+ nextErrors[`${key}.notesDesigner`] =
+ `${label} の譜面制作者を入力してください。`;
+ if (isEdit && !sheet.id)
+ nextErrors[`${key}.id`] = `${label} の譜面 ID がありません。`;
+ }
+ setErrors(nextErrors);
+ return nextErrors;
+ }
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setSubmitError(undefined);
+ if (Object.keys(validate()).length > 0) return;
+ setIsSubmitting(true);
+ try {
+ const fields = {
+ title: values.title.trim(),
+ artist: values.artist.trim(),
+ bpm: Number(values.bpm),
+ genre: "ORIGINAL" as const,
+ jacket: values.jacket.trim(),
+ registrationDate: `${values.registrationDate}T00:00:00.000Z`,
+ isTest: values.isTest,
+ };
+ if (data) {
+ await updateMusicAction(data.music.id, {
+ ...fields,
+ sheets: difficulties.map(({ key }) => ({
+ id: values.sheets[key].id as string,
+ difficulty: key,
+ level: Number(values.sheets[key].level),
+ notesDesigner: values.sheets[key].notesDesigner.trim(),
+ })),
+ } satisfies UpdateMusicInput);
+ } else {
+ await createMusicAction({
+ ...fields,
+ sheets: difficulties.map(({ key }) => ({
+ difficulty: key,
+ level: Number(values.sheets[key].level),
+ notesDesigner: values.sheets[key].notesDesigner.trim(),
+ })),
+ } satisfies CreateMusicInput);
+ }
+ router.push(data ? `/musics/${data.music.id}` : "/musics");
+ } catch (error) {
+ setSubmitError(
+ error instanceof Error ? error.message : "保存に失敗しました。",
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+ }
+ header={}
+ >
+
+
+
+
+ );
+}
diff --git a/components/music-list-loading.tsx b/components/music-list-loading.tsx
index fb30409..c30ed12 100644
--- a/components/music-list-loading.tsx
+++ b/components/music-list-loading.tsx
@@ -4,11 +4,15 @@ import Header from "@cloudscape-design/components/header";
import Spinner from "@cloudscape-design/components/spinner";
import DashboardLayout from "@/components/dashboard-layout";
+import MusicBreadcrumbs from "@/components/music-breadcrumbs";
export default function MusicListLoading() {
return (
- 楽曲管理}>
+ }
+ header={}
+ >
import("@/components/music-table"), {
@@ -30,17 +30,29 @@ export default function MusicList({
return (
- 楽曲管理}>
-
-
-
- {nextPageHref ? (
-
-
-
- ) : null}
-
-
+ }
+ header={
+
+ 楽曲を追加
+
+ }
+ >
+ 楽曲管理
+
+ }
+ >
+
+
+ {nextPageHref ? (
+
+
+
+ ) : null}
+
);
diff --git a/components/music-sheets-table.tsx b/components/music-sheets-table.tsx
index 0cc55b6..93cebcc 100644
--- a/components/music-sheets-table.tsx
+++ b/components/music-sheets-table.tsx
@@ -14,13 +14,15 @@ export default function MusicSheetsTable({
}) {
return (
譜面一覧}
columnDefinitions={[
{ header: "難易度", cell: (item) => 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 ce9073c..5415937 100644
--- a/components/music-table.tsx
+++ b/components/music-table.tsx
@@ -9,6 +9,9 @@ import type { MusicListResponse } from "@/lib/api";
export default function MusicTable({ data }: { data: MusicListResponse }) {
return (
楽曲一覧}
columnDefinitions={[
{
header: "タイトル",
@@ -22,16 +25,17 @@ export default function MusicTable({ data }: { data: MusicListResponse }) {
header: "アーティスト",
cell: (item) => item.music.artist,
},
- { header: "BPM", cell: (item) => item.music.bpm },
- { header: "譜面", cell: (item) => item.sheets.length },
+ {
+ header: "BPM",
+ cell: (item) => item.music.bpm,
+ },
{
header: "登録日時",
cell: (item) =>
- new Date(item.music.registrationDate).toLocaleString("ja-JP"),
+ new Date(item.music.registrationDate).toLocaleDateString("ja-JP"),
},
]}
items={data.items}
- header={}
empty={楽曲がありません。}
/>
);
diff --git a/lib/api.ts b/lib/api.ts
index ef43a46..019d1ec 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -35,6 +35,33 @@ export type MusicListResponse = {
nextCursor: string | null;
};
+export type MusicFields = {
+ title: string;
+ artist: string;
+ bpm: number;
+ genre: "ORIGINAL";
+ jacket: string;
+ registrationDate: string;
+ isTest: boolean;
+};
+
+export type CreateMusicInput = MusicFields & {
+ sheets: Array<{
+ difficulty: Sheet["difficulty"];
+ level: number;
+ notesDesigner: string;
+ }>;
+};
+
+export type UpdateMusicInput = MusicFields & {
+ sheets: Array<{
+ id: string;
+ difficulty: Sheet["difficulty"];
+ level: number;
+ notesDesigner: string;
+ }>;
+};
+
async function getAccessToken(returnTo: string) {
const audience = process.env.AUTH0_AUDIENCE ?? "https://api.xlair.dev";
try {
@@ -98,3 +125,42 @@ export async function fetchMusic(musicId: string): Promise {
return response.json() as Promise;
}
+
+async function writeMusic(
+ path: string,
+ method: "POST",
+ body: CreateMusicInput | UpdateMusicInput,
+ returnTo: string,
+): Promise {
+ const accessToken = await getAccessToken(returnTo);
+ const response = await fetch(`${process.env.API_BASE_URL}${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${accessToken.token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to ${method === "POST" ? "create" : "update"} music: ${response.status}`,
+ );
+ }
+
+ return response.json() as Promise;
+}
+
+export function createMusic(input: CreateMusicInput) {
+ return writeMusic("/admin/musics", "POST", input, "/musics/new");
+}
+
+export function updateMusic(musicId: string, input: UpdateMusicInput) {
+ return writeMusic(
+ `/admin/musics/${encodeURIComponent(musicId)}`,
+ "POST",
+ input,
+ `/musics/${musicId}/edit`,
+ );
+}