From 8fc6e48aac51289635cdc604556d4974640f5378 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 8 Apr 2026 03:46:56 +0200 Subject: [PATCH] feat(app): add song edit and delete with dropdown menu --- app/app/components/add-song-sheet.tsx | 19 +++-- app/app/components/delete-song-dialog.tsx | 56 +++++++++++++++ app/app/components/edit-song-sheet.tsx | 82 +++++++++++++++++++++ app/app/components/transpose-bar.tsx | 88 +++++++++++++---------- app/app/routes/songs.$id.tsx | 62 ++++++++++++++-- 5 files changed, 257 insertions(+), 50 deletions(-) create mode 100644 app/app/components/delete-song-dialog.tsx create mode 100644 app/app/components/edit-song-sheet.tsx diff --git a/app/app/components/add-song-sheet.tsx b/app/app/components/add-song-sheet.tsx index bee36d1..ca55d36 100644 --- a/app/app/components/add-song-sheet.tsx +++ b/app/app/components/add-song-sheet.tsx @@ -60,9 +60,13 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { setLoading(true); try { const stored = await createSong( - fileHtml ? { html: fileHtml } : { source: url.trim() } + fileHtml ? { html: fileHtml } : { source: url.trim() }, ); - onSongAdded({ id: stored.id, meta: stored.song.meta, preview_chords: previewChords(stored.song) }); + onSongAdded({ + id: stored.id, + meta: stored.song.meta, + preview_chords: previewChords(stored.song), + }); onOpenChange(false); reset(); navigate(`/songs/${stored.id}`); @@ -127,12 +131,19 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { type="button" variant="outline" className="flex-1" - onClick={() => { onOpenChange(false); reset(); }} + onClick={() => { + onOpenChange(false); + reset(); + }} disabled={loading} > Cancel - diff --git a/app/app/components/delete-song-dialog.tsx b/app/app/components/delete-song-dialog.tsx new file mode 100644 index 0000000..5e7f660 --- /dev/null +++ b/app/app/components/delete-song-dialog.tsx @@ -0,0 +1,56 @@ +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, + AlertDialogContent, AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { toast } from "sonner"; +import { deleteSong } from "~/lib/api"; +import { useNavigate } from "react-router"; +import { useState } from "react"; + +interface Props { + id: string; + title: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteSongDialog({ id, title, open, onOpenChange }: Props) { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + + async function handleDelete() { + setLoading(true); + try { + await deleteSong(id); + navigate("/"); + } catch { + toast.error("Failed to delete song"); + setLoading(false); + onOpenChange(false); + } + } + + return ( + + + + Delete "{title}"? + + This cannot be undone. The song will be permanently removed. + + + + Cancel + + {loading ? "Deleting..." : "Delete"} + + + + + ); +} diff --git a/app/app/components/edit-song-sheet.tsx b/app/app/components/edit-song-sheet.tsx new file mode 100644 index 0000000..2c91ddc --- /dev/null +++ b/app/app/components/edit-song-sheet.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { + Sheet, SheetContent, SheetHeader, SheetTitle, +} from "~/components/ui/sheet"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui/button"; +import { toast } from "sonner"; +import { updateSong } from "~/lib/api"; +import type { SongMeta, SongSummary } from "~/lib/types"; + +interface Props { + id: string; + meta: SongMeta; + open: boolean; + onOpenChange: (open: boolean) => void; + onUpdated: (summary: SongSummary) => void; +} + +export function EditSongSheet({ id, meta, open, onOpenChange, onUpdated }: Props) { + const [title, setTitle] = useState(meta.title); + const [artist, setArtist] = useState(meta.artist); + const [key, setKey] = useState(meta.original_key ?? ""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const updated = await updateSong(id, { + title: title.trim() || undefined, + artist: artist.trim() || undefined, + original_key: key.trim() || undefined, + }); + onUpdated(updated); + onOpenChange(false); + } catch (err) { + toast.error("Failed to save changes", { + description: err instanceof Error ? err.message : undefined, + }); + } finally { + setLoading(false); + } + } + + return ( + + + + Edit Song + +
+
+ + setTitle(e.target.value)} disabled={loading} /> +
+
+ + setArtist(e.target.value)} disabled={loading} /> +
+
+ + setKey(e.target.value)} + placeholder="e.g. Em, G, Bb" + disabled={loading} + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/app/app/components/transpose-bar.tsx b/app/app/components/transpose-bar.tsx index e16c782..3b90aea 100644 --- a/app/app/components/transpose-bar.tsx +++ b/app/app/components/transpose-bar.tsx @@ -1,35 +1,58 @@ import { useState } from "react"; import { Button } from "~/components/ui/button"; -import { ChevronUp, ChevronDown, Minus, Plus } from "lucide-react"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import type { SongMeta } from "~/lib/types"; interface Props { meta: SongMeta; offset: number; onOffsetChange: (offset: number) => void; + onEdit?: () => void; + onDelete?: () => void; } -export function TransposeBar({ meta, offset, onOffsetChange }: Props) { +export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }: Props) { const [expanded, setExpanded] = useState(true); - const label = offset === 0 - ? "±0" - : offset > 0 - ? `+${offset}` - : `${offset}`; + const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; + + const menuButton = (onEdit || onDelete) ? ( + + + + + + {onEdit && ( + + + Edit + + )} + {onDelete && ( + + + Delete + + )} + + + ) : null; if (!expanded) { return (
{meta.title} - +
+ {menuButton} + +
); } @@ -41,14 +64,12 @@ export function TransposeBar({ meta, offset, onOffsetChange }: Props) { {meta.title} {meta.artist} - +
+ {menuButton} + +
@@ -57,25 +78,14 @@ export function TransposeBar({ meta, offset, onOffsetChange }: Props) { {meta.capo != null && Capo: {meta.capo}} {meta.tuning && {meta.tuning}}
-
- - - {label} - -
diff --git a/app/app/routes/songs.$id.tsx b/app/app/routes/songs.$id.tsx index 5745799..587a15e 100644 --- a/app/app/routes/songs.$id.tsx +++ b/app/app/routes/songs.$id.tsx @@ -1,10 +1,13 @@ import { useState } from "react"; -import { data } from "react-router"; +import { data, Link } from "react-router"; import type { Route } from "./+types/songs.$id"; import { TransposeBar } from "~/components/transpose-bar"; import { ChordChart } from "~/components/chord-chart"; +import { EditSongSheet } from "~/components/edit-song-sheet"; +import { DeleteSongDialog } from "~/components/delete-song-dialog"; import { transposeSong } from "~/lib/transpose"; import { getSong } from "~/lib/api"; +import type { Song, SongSummary } from "~/lib/types"; export function meta({ data }: Route.MetaArgs) { if (!data?.song) return [{ title: "PocketChords" }]; @@ -16,22 +19,67 @@ export function meta({ data }: Route.MetaArgs) { export async function loader({ params }: Route.LoaderArgs) { const id = params.id ?? ""; - const song = await getSong(id); - if (!song) throw data("Song not found", { status: 404 }); - return { song }; + try { + const song = await getSong(id); + if (!song) throw data("Song not found", { status: 404 }); + return { song, id }; + } catch (err: unknown) { + if (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404) { + throw err; + } + return { song: null as unknown as Song, id }; + } } export default function SongDetail({ loaderData }: Route.ComponentProps) { - const { song } = loaderData; + const { song: initialSong, id } = loaderData; + const [song, setSong] = useState(initialSong ?? null); const [offset, setOffset] = useState(0); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + if (!song) { + return ( +
+

Song not found or unavailable.

+ + ← Back to library + +
+ ); + } + const displayed = transposeSong(song, offset); + function handleUpdated(summary: SongSummary) { + setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); + } + return ( -
- +
+ setEditOpen(true)} + onDelete={() => setDeleteOpen(true)} + />
+ +
); }