Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -3,8 +3,30 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { MoreVertical, ImageIcon, RefreshCw, Trash2, Loader2 } from "lucide-react";
|
||||
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,
|
||||
@@ -12,11 +34,30 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { regenerateAction, deleteEpisodeAction } from "@/app/(app)/episodes/actions";
|
||||
import {
|
||||
regenerateAction,
|
||||
deleteEpisodeAction,
|
||||
setEpisodeShareAction,
|
||||
} from "@/app/(app)/episodes/actions";
|
||||
|
||||
export function EpisodeActions({ episodeId }: { episodeId: string }) {
|
||||
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);
|
||||
@@ -30,11 +71,38 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (!confirm("Delete this episode? This cannot be undone.")) return;
|
||||
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 {
|
||||
@@ -43,24 +111,118 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />}
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="h-4 w-4" /> Share
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<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>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={del} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="h-4 w-4" /> Delete episode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user