diff --git a/app/musics/[musicId]/edit/page.tsx b/app/musics/[musicId]/edit/page.tsx new file mode 100644 index 0000000..22422dc --- /dev/null +++ b/app/musics/[musicId]/edit/page.tsx @@ -0,0 +1,13 @@ +import MusicForm from "@/components/music-form"; +import { fetchMusic } from "@/lib/api"; + +export default async function EditMusicPage({ + params, +}: { + params: Promise<{ musicId: string }>; +}) { + const { musicId } = await params; + const data = await fetchMusic(musicId); + + return ; +} diff --git a/app/musics/[musicId]/error.tsx b/app/musics/[musicId]/error.tsx index fb4bf90..d516180 100644 --- a/app/musics/[musicId]/error.tsx +++ b/app/musics/[musicId]/error.tsx @@ -8,18 +8,23 @@ import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; import DashboardLayout from "@/components/dashboard-layout"; +import MusicBreadcrumbs from "@/components/music-breadcrumbs"; export default function MusicDetailError({ reset }: { reset: () => void }) { return ( - 楽曲管理}> + + } + header={
楽曲管理
} + > 楽曲が存在しないか、一時的に取得できません。時間をおいて再試行してください。
-
diff --git a/app/musics/actions.ts b/app/musics/actions.ts new file mode 100644 index 0000000..979ae9c --- /dev/null +++ b/app/musics/actions.ts @@ -0,0 +1,22 @@ +"use server"; + +import { + type CreateMusicInput, + createMusic, + type MusicWithSheets, + type UpdateMusicInput, + updateMusic, +} from "@/lib/api"; + +export async function createMusicAction( + input: CreateMusicInput, +): Promise { + return createMusic(input); +} + +export async function updateMusicAction( + musicId: string, + input: UpdateMusicInput, +): Promise { + return updateMusic(musicId, input); +} diff --git a/app/musics/new/page.tsx b/app/musics/new/page.tsx new file mode 100644 index 0000000..ba15e96 --- /dev/null +++ b/app/musics/new/page.tsx @@ -0,0 +1,5 @@ +import MusicForm from "@/components/music-form"; + +export default function NewMusicPage() { + return ; +} diff --git a/components/music-breadcrumbs.tsx b/components/music-breadcrumbs.tsx new file mode 100644 index 0000000..f8ccbb0 --- /dev/null +++ b/components/music-breadcrumbs.tsx @@ -0,0 +1,19 @@ +"use client"; + +import BreadcrumbGroup from "@cloudscape-design/components/breadcrumb-group"; + +export default function MusicBreadcrumbs({ + current, + currentHref, +}: { + current?: string; + currentHref?: string; +}) { + const items = [ + { text: "ホーム", href: "/" }, + { text: "楽曲管理", href: "/musics" }, + ...(current ? [{ text: current, href: currentHref ?? "/musics" }] : []), + ]; + + return ; +} diff --git a/components/music-detail-loading.tsx b/components/music-detail-loading.tsx index 0b673aa..aa4a521 100644 --- a/components/music-detail-loading.tsx +++ b/components/music-detail-loading.tsx @@ -4,11 +4,17 @@ 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 MusicDetailLoading() { return ( - 楽曲管理}> + + } + header={
楽曲管理
} + >
楽曲一覧に戻る} + 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={
{title}
} + > +
+ + + + + } + > + + {submitError ? ( + + {submitError} + + ) : null} + 楽曲情報}> + + + + updateValue("title", detail.value) + } + /> + + + + updateValue("artist", detail.value) + } + /> + + + + updateValue("bpm", detail.value) + } + /> + + + + + + + updateValue("jacket", detail.value) + } + /> + + + + updateValue("registrationDate", detail.value) + } + /> + + + updateValue("isTest", detail.checked) + } + > + テスト楽曲 + + + + 譜面}> + + {difficulties.map(({ key, label }) => { + const sheet = values.sheets[key]; + return ( + {label}} + > + + + + setValues((current) => ({ + ...current, + sheets: { + ...current.sheets, + [key]: { ...sheet, level: detail.value }, + }, + })) + } + /> + + + + setValues((current) => ({ + ...current, + sheets: { + ...current.sheets, + [key]: { + ...sheet, + notesDesigner: detail.value, + }, + }, + })) + } + /> + + + + ); + })} + + + +
+ +
+
+ ); +} 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`, + ); +}