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);
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
</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"}
</Button>
</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 { 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) ? (
<DropdownMenu>
<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) {
return (
<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>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setExpanded(true)}
>
<div className="flex items-center gap-1">
{menuButton}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(true)}>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
</div>
);
}
@@ -41,15 +64,13 @@ export function TransposeBar({ meta, offset, onOffsetChange }: Props) {
<span className="font-bold text-base">{meta.title}</span>
<span className="text-sm text-muted-foreground">{meta.artist}</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setExpanded(false)}
>
<div className="flex items-center gap-1">
{menuButton}
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setExpanded(false)}>
<ChevronUp className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between">
<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.tuning && <span>{meta.tuning}</span>}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
>
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
<Minus className="w-4 h-4" />
</Button>
<span className="w-8 text-center text-sm font-mono font-semibold">
{label}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
>
<span className="w-8 text-center text-sm font-mono font-semibold">{label}</span>
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => onOffsetChange(Math.min(11, offset + 1))}>
<Plus className="w-4 h-4" />
</Button>
</div>

View File

@@ -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 ?? "";
try {
const song = await getSong(id);
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) {
const { song } = loaderData;
const { song: initialSong, id } = loaderData;
const [song, setSong] = useState<Song | null>(initialSong ?? null);
const [offset, setOffset] = useState(0);
const displayed = transposeSong(song, offset);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (!song) {
return (
<div className="flex flex-col h-dvh max-w-lg mx-auto">
<TransposeBar meta={song.meta} offset={offset} onOffsetChange={setOffset} />
<div className="flex-1 overflow-y-auto">
<ChordChart sections={displayed.sections} />
</div>
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
Back to library
</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>
);
}