Files
podcastdistributiona/components/app/episode-actions.tsx
T

229 lines
7.3 KiB
TypeScript
Raw Normal View History

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
MoreVertical,
ImageIcon,
RefreshCw,
Trash2,
Loader2,
Share2,
FileArchive,
Copy,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
regenerateAction,
deleteEpisodeAction,
setEpisodeShareAction,
} from "@/app/(app)/episodes/actions";
export function EpisodeActions({
episodeId,
initialShareId = null,
}: {
episodeId: string;
initialShareId?: string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [shareId, setShareId] = useState<string | null>(initialShareId);
const [shareToggling, setShareToggling] = useState(false);
const [copied, setCopied] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const shareUrl =
shareId && typeof window !== "undefined" ? `${window.location.origin}/p/${shareId}` : "";
async function regen(type: "art" | "full") {
setBusy(true);
const res = await regenerateAction(episodeId, type);
setBusy(false);
if (res.ok) {
toast.success(type === "art" ? "Regenerating cover art…" : "Regenerating episode…");
router.refresh();
} else {
toast.error(res.error ?? "Could not regenerate");
}
}
async function toggleShare(enabled: boolean) {
setShareToggling(true);
const res = await setEpisodeShareAction(episodeId, enabled);
setShareToggling(false);
if (!res.ok) {
toast.error(res.error ?? "Could not update sharing");
return;
}
setShareId(res.shareId ?? null);
toast.success(enabled ? "Public link enabled" : "Sharing turned off");
router.refresh();
}
function copyLink() {
if (!shareUrl) return;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
setCopied(true);
toast.success("Link copied");
setTimeout(() => setCopied(false), 1500);
})
.catch(() => toast.error("Could not copy"));
}
async function confirmDelete() {
setDeleting(true);
const res = await deleteEpisodeAction(episodeId);
setDeleting(false);
if (res.ok) {
toast.success("Episode deleted");
setDeleteOpen(false);
router.push("/episodes");
router.refresh();
} else {
toast.error(res.error ?? "Could not delete");
}
}
return (
<>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
<Share2 className="h-4 w-4" /> Share
</Button>
<Button asChild variant="outline" size="icon" title="Download everything (.zip)">
<a href={`/api/episodes/${episodeId}/export`} aria-label="Export ZIP">
<FileArchive className="h-4 w-4" />
</a>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" disabled={busy}>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => regen("art")}>
<ImageIcon className="h-4 w-4" /> Regenerate cover art
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => regen("full")}>
<RefreshCw className="h-4 w-4" /> Regenerate everything
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`/api/episodes/${episodeId}/export`}>
<FileArchive className="h-4 w-4" /> Export ZIP
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" /> Delete episode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Share dialog */}
<Dialog open={shareOpen} onOpenChange={setShareOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share this episode</DialogTitle>
<DialogDescription>
Turn on a public link to let anyone listen no account needed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-xl border bg-secondary/40 px-4 py-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">Public link</p>
<p className="text-xs text-muted-foreground">
{shareId ? "Anyone with the link can listen." : "Currently private."}
</p>
</div>
<div className="flex items-center gap-2">
{shareToggling && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<Switch
checked={!!shareId}
disabled={shareToggling}
onCheckedChange={toggleShare}
aria-label="Toggle public link"
/>
</div>
</div>
{shareId && (
<div className="space-y-2">
<Label htmlFor="share-url">Public URL</Label>
<div className="flex gap-2">
<Input id="share-url" readOnly value={shareUrl} className="font-mono text-xs" />
<Button type="button" variant="outline" size="icon" onClick={copyLink}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this episode?</DialogTitle>
<DialogDescription>
This permanently removes the script, audio, cover art and all repurposed content. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={deleting}>
Cancel
</Button>
</DialogClose>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Loader2 className="h-4 w-4 animate-spin" />}
Delete episode
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}