feat(app): add song edit and delete with dropdown menu
This commit is contained in:
@@ -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>
|
||||
|
||||
56
app/app/components/delete-song-dialog.tsx
Normal file
56
app/app/components/delete-song-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
app/app/components/edit-song-sheet.tsx
Normal file
82
app/app/components/edit-song-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user