feat(app): add song edit and delete with dropdown menu

This commit is contained in:
2026-04-08 03:46:56 +02:00
parent 3a576f0018
commit 8fc6e48aac
5 changed files with 257 additions and 50 deletions

View File

@@ -60,9 +60,13 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
setLoading(true); setLoading(true);
try { try {
const stored = await createSong( 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); onOpenChange(false);
reset(); reset();
navigate(`/songs/${stored.id}`); navigate(`/songs/${stored.id}`);
@@ -127,12 +131,19 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
type="button" type="button"
variant="outline" variant="outline"
className="flex-1" className="flex-1"
onClick={() => { onOpenChange(false); reset(); }} onClick={() => {
onOpenChange(false);
reset();
}}
disabled={loading} disabled={loading}
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" className="flex-1" disabled={loading || (!url.trim() && !fileHtml)}> <Button
type="submit"
className="flex-1"
disabled={loading || (!url.trim() && !fileHtml)}
>
{loading ? "Importing..." : "Import"} {loading ? "Importing..." : "Import"}
</Button> </Button>
</div> </div>

View File

@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{title}"?</AlertDialogTitle>
<AlertDialogDescription>
This cannot be undone. The song will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="rounded-t-xl">
<SheetHeader className="mb-4">
<SheetTitle>Edit Song</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground uppercase tracking-wide">Title</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} disabled={loading} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground uppercase tracking-wide">Artist</label>
<Input value={artist} onChange={(e) => setArtist(e.target.value)} disabled={loading} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground uppercase tracking-wide">Key</label>
<Input
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="e.g. Em, G, Bb"
disabled={loading}
/>
</div>
<div className="flex gap-2 pt-1">
<Button type="button" variant="outline" className="flex-1"
onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -1,36 +1,59 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "~/components/ui/button"; 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"; import type { SongMeta } from "~/lib/types";
interface Props { interface Props {
meta: SongMeta; meta: SongMeta;
offset: number; offset: number;
onOffsetChange: (offset: number) => void; 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 [expanded, setExpanded] = useState(true);
const label = offset === 0 const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
? "±0"
: offset > 0 const menuButton = (onEdit || onDelete) ? (
? `+${offset}` <DropdownMenu>
: `${offset}`; <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null;
if (!expanded) { if (!expanded) {
return ( return (
<div className="flex items-center justify-between px-4 py-2 border-b bg-background sticky top-0"> <div className="flex items-center justify-between px-4 py-2 border-b bg-background sticky top-0">
<span className="text-sm font-semibold truncate">{meta.title}</span> <span className="text-sm font-semibold truncate">{meta.title}</span>
<Button <div className="flex items-center gap-1">
variant="ghost" {menuButton}
size="icon" <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(true)}>
className="h-7 w-7"
onClick={() => setExpanded(true)}
>
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div>
); );
} }
@@ -41,15 +64,13 @@ export function TransposeBar({ meta, offset, onOffsetChange }: Props) {
<span className="font-bold text-base">{meta.title}</span> <span className="font-bold text-base">{meta.title}</span>
<span className="text-sm text-muted-foreground">{meta.artist}</span> <span className="text-sm text-muted-foreground">{meta.artist}</span>
</div> </div>
<Button <div className="flex items-center gap-1">
variant="ghost" {menuButton}
size="icon" <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setExpanded(false)}>
className="h-7 w-7 shrink-0"
onClick={() => setExpanded(false)}
>
<ChevronUp className="w-4 h-4" /> <ChevronUp className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-3 text-xs text-muted-foreground"> <div className="flex gap-3 text-xs text-muted-foreground">
@@ -57,25 +78,14 @@ export function TransposeBar({ meta, offset, onOffsetChange }: Props) {
{meta.capo != null && <span>Capo: {meta.capo}</span>} {meta.capo != null && <span>Capo: {meta.capo}</span>}
{meta.tuning && <span>{meta.tuning}</span>} {meta.tuning && <span>{meta.tuning}</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button variant="ghost" size="icon" className="h-8 w-8"
variant="ghost" onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
>
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
</Button> </Button>
<span className="w-8 text-center text-sm font-mono font-semibold"> <span className="w-8 text-center text-sm font-mono font-semibold">{label}</span>
{label} <Button variant="ghost" size="icon" className="h-8 w-8"
</span> onClick={() => onOffsetChange(Math.min(11, offset + 1))}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>

View File

@@ -1,10 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { data } from "react-router"; import { data, Link } from "react-router";
import type { Route } from "./+types/songs.$id"; import type { Route } from "./+types/songs.$id";
import { TransposeBar } from "~/components/transpose-bar"; import { TransposeBar } from "~/components/transpose-bar";
import { ChordChart } from "~/components/chord-chart"; 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 { transposeSong } from "~/lib/transpose";
import { getSong } from "~/lib/api"; import { getSong } from "~/lib/api";
import type { Song, SongSummary } from "~/lib/types";
export function meta({ data }: Route.MetaArgs) { export function meta({ data }: Route.MetaArgs) {
if (!data?.song) return [{ title: "PocketChords" }]; if (!data?.song) return [{ title: "PocketChords" }];
@@ -16,22 +19,67 @@ export function meta({ data }: Route.MetaArgs) {
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const id = params.id ?? ""; const id = params.id ?? "";
try {
const song = await getSong(id); const song = await getSong(id);
if (!song) throw data("Song not found", { status: 404 }); if (!song) throw data("Song not found", { status: 404 });
return { song }; 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) { export default function SongDetail({ loaderData }: Route.ComponentProps) {
const { song } = loaderData; const { song: initialSong, id } = loaderData;
const [song, setSong] = useState<Song | null>(initialSong ?? null);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const displayed = transposeSong(song, offset); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (!song) {
return ( return (
<div className="flex flex-col h-dvh max-w-lg mx-auto"> <div className="flex flex-col items-center justify-center h-full gap-4">
<TransposeBar meta={song.meta} offset={offset} onOffsetChange={setOffset} /> <p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
<div className="flex-1 overflow-y-auto"> <Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
<ChordChart sections={displayed.sections} /> Back to library
</div> </Link>
</div>
);
}
const displayed = transposeSong(song, offset);
function handleUpdated(summary: SongSummary) {
setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
}
return (
<div className="flex flex-col h-full max-w-lg mx-auto">
<TransposeBar
meta={song.meta}
offset={offset}
onOffsetChange={setOffset}
onEdit={() => setEditOpen(true)}
onDelete={() => setDeleteOpen(true)}
/>
<div className="flex-1 overflow-y-auto">
<ChordChart sections={displayed.sections} />
</div>
<EditSongSheet
id={id}
meta={song.meta}
open={editOpen}
onOpenChange={setEditOpen}
onUpdated={handleUpdated}
/>
<DeleteSongDialog
id={id}
title={song.meta.title}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div> </div>
); );
} }