2026-06-07 03:58:32 -04:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
import { toast } from "sonner";
|
2026-06-07 17:54:30 -04:00
|
|
|
import {
|
|
|
|
|
MoreVertical,
|
|
|
|
|
ImageIcon,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Trash2,
|
|
|
|
|
Loader2,
|
|
|
|
|
Share2,
|
|
|
|
|
FileArchive,
|
|
|
|
|
Copy,
|
|
|
|
|
Check,
|
|
|
|
|
} from "lucide-react";
|
2026-06-07 03:58:32 -04:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-06-07 17:54:30 -04:00
|
|
|
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";
|
2026-06-07 03:58:32 -04:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
2026-06-07 17:54:30 -04:00
|
|
|
import {
|
|
|
|
|
regenerateAction,
|
|
|
|
|
deleteEpisodeAction,
|
|
|
|
|
setEpisodeShareAction,
|
|
|
|
|
} from "@/app/(app)/episodes/actions";
|
2026-06-07 03:58:32 -04:00
|
|
|
|
2026-06-07 17:54:30 -04:00
|
|
|
export function EpisodeActions({
|
|
|
|
|
episodeId,
|
|
|
|
|
initialShareId = null,
|
|
|
|
|
}: {
|
|
|
|
|
episodeId: string;
|
|
|
|
|
initialShareId?: string | null;
|
|
|
|
|
}) {
|
2026-06-07 03:58:32 -04:00
|
|
|
const router = useRouter();
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
2026-06-07 17:54:30 -04:00
|
|
|
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}` : "";
|
2026-06-07 03:58:32 -04:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 17:54:30 -04:00
|
|
|
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);
|
2026-06-07 03:58:32 -04:00
|
|
|
const res = await deleteEpisodeAction(episodeId);
|
2026-06-07 17:54:30 -04:00
|
|
|
setDeleting(false);
|
2026-06-07 03:58:32 -04:00
|
|
|
if (res.ok) {
|
|
|
|
|
toast.success("Episode deleted");
|
2026-06-07 17:54:30 -04:00
|
|
|
setDeleteOpen(false);
|
2026-06-07 03:58:32 -04:00
|
|
|
router.push("/episodes");
|
|
|
|
|
router.refresh();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(res.error ?? "Could not delete");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-07 17:54:30 -04:00
|
|
|
<>
|
|
|
|
|
<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>
|
2026-06-07 03:58:32 -04:00
|
|
|
</Button>
|
2026-06-07 17:54:30 -04:00
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
</>
|
2026-06-07 03:58:32 -04:00
|
|
|
);
|
|
|
|
|
}
|