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);
|
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>
|
||||||
|
|||||||
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,35 +1,58 @@
|
|||||||
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"
|
<ChevronDown className="w-4 h-4" />
|
||||||
onClick={() => setExpanded(true)}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,14 +64,12 @@ 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"
|
<ChevronUp className="w-4 h-4" />
|
||||||
onClick={() => setExpanded(false)}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ?? "";
|
||||||
const song = await getSong(id);
|
try {
|
||||||
if (!song) throw data("Song not found", { status: 404 });
|
const song = await getSong(id);
|
||||||
return { song };
|
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) {
|
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 [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!song) {
|
||||||
|
return (
|
||||||
|
<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);
|
const displayed = transposeSong(song, offset);
|
||||||
|
|
||||||
|
function handleUpdated(summary: SongSummary) {
|
||||||
|
setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
<div className="flex flex-col h-full max-w-lg mx-auto">
|
||||||
<TransposeBar meta={song.meta} offset={offset} onOffsetChange={setOffset} />
|
<TransposeBar
|
||||||
|
meta={song.meta}
|
||||||
|
offset={offset}
|
||||||
|
onOffsetChange={setOffset}
|
||||||
|
onEdit={() => setEditOpen(true)}
|
||||||
|
onDelete={() => setDeleteOpen(true)}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ChordChart sections={displayed.sections} />
|
<ChordChart sections={displayed.sections} />
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user