feat: rename podcast episodes
Add a per-episode rename action (pencil, permission-gated) backed by PATCH /api/podcasts/:id/episode/:episodeId; also add deleteEpisode API helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { PodcastEpisode } from '@/types/abs'
|
import type { LibraryItem, PodcastEpisode } from '@/types/abs'
|
||||||
|
|
||||||
/** Check the podcast's RSS feed for new episodes. ABS: POST /api/podcasts/:id/checknew. */
|
/** Check the podcast's RSS feed for new episodes. ABS: POST /api/podcasts/:id/checknew. */
|
||||||
export async function checkNewEpisodes(itemId: string): Promise<PodcastEpisode[]> {
|
export async function checkNewEpisodes(itemId: string): Promise<PodcastEpisode[]> {
|
||||||
@@ -7,6 +7,30 @@ export async function checkNewEpisodes(itemId: string): Promise<PodcastEpisode[]
|
|||||||
return res.data.episodes ?? []
|
return res.data.episodes ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EpisodeUpdate {
|
||||||
|
title?: string
|
||||||
|
subtitle?: string | null
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a single podcast episode (e.g. rename). ABS: PATCH /api/podcasts/:id/episode/:episodeId. */
|
||||||
|
export async function updateEpisode(
|
||||||
|
itemId: string,
|
||||||
|
episodeId: string,
|
||||||
|
payload: EpisodeUpdate,
|
||||||
|
): Promise<LibraryItem> {
|
||||||
|
const res = await api.patch<{ libraryItem?: LibraryItem } | LibraryItem>(
|
||||||
|
`/api/podcasts/${itemId}/episode/${episodeId}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return (res.data as { libraryItem?: LibraryItem }).libraryItem ?? (res.data as LibraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a podcast episode. ABS: DELETE /api/podcasts/:id/episode/:episodeId. */
|
||||||
|
export async function deleteEpisode(itemId: string, episodeId: string): Promise<void> {
|
||||||
|
await api.delete(`/api/podcasts/${itemId}/episode/${episodeId}`)
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedEpisodePreview {
|
export interface FeedEpisodePreview {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { Play, Check } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { Play, Check, Pencil } from 'lucide-react'
|
||||||
import { formatDuration } from '@/lib/format'
|
import { formatDuration } from '@/lib/format'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import { useProgressStore, progressKey } from '@/store/progressStore'
|
import { useProgressStore, progressKey } from '@/store/progressStore'
|
||||||
import { usePlayerStore } from '@/store/playerStore'
|
import { usePlayerStore } from '@/store/playerStore'
|
||||||
|
import { useCan } from '@/store/authStore'
|
||||||
import { htmlToText } from '@/lib/html'
|
import { htmlToText } from '@/lib/html'
|
||||||
|
import { Modal } from '@/components/ui/Modal'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { updateEpisode } from '@/api/podcasts'
|
||||||
|
import { apiErrorMessage } from '@/api/client'
|
||||||
|
import { toast } from '@/store/toastStore'
|
||||||
import type { LibraryItem, Podcast, PodcastEpisode } from '@/types/abs'
|
import type { LibraryItem, Podcast, PodcastEpisode } from '@/types/abs'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: LibraryItem
|
item: LibraryItem
|
||||||
|
onUpdated?: (item: LibraryItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function pubLabel(ep: PodcastEpisode): string {
|
function pubLabel(ep: PodcastEpisode): string {
|
||||||
@@ -25,20 +34,50 @@ function pubLabel(ep: PodcastEpisode): string {
|
|||||||
return ep.pubDate ?? ''
|
return ep.pubDate ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EpisodeList({ item }: Props) {
|
export function EpisodeList({ item, onUpdated }: Props) {
|
||||||
const episodes = (item.media as Podcast).episodes ?? []
|
const episodes = (item.media as Podcast).episodes ?? []
|
||||||
const byKey = useProgressStore((s) => s.byKey)
|
const byKey = useProgressStore((s) => s.byKey)
|
||||||
const play = usePlayerStore((s) => s.play)
|
const play = usePlayerStore((s) => s.play)
|
||||||
const currentEpisodeId = usePlayerStore((s) => s.episodeId)
|
const currentEpisodeId = usePlayerStore((s) => s.episodeId)
|
||||||
|
const canEdit = useCan('update')
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<PodcastEpisode | null>(null)
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function openRename(ep: PodcastEpisode) {
|
||||||
|
setEditing(ep)
|
||||||
|
setTitle(ep.title ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRename() {
|
||||||
|
if (!editing) return
|
||||||
|
const next = title.trim()
|
||||||
|
if (!next) {
|
||||||
|
toast.error('Titel darf nicht leer sein.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await updateEpisode(item.id, editing.id, { title: next })
|
||||||
|
onUpdated?.(updated)
|
||||||
|
toast.success('Episode umbenannt.')
|
||||||
|
setEditing(null)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiErrorMessage(err, 'Umbenennen fehlgeschlagen.'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!episodes.length) {
|
if (!episodes.length) {
|
||||||
return <p className="text-sm text-text-muted">Keine Episoden vorhanden.</p>
|
return <p className="text-sm text-text-muted">Keine Episoden vorhanden.</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
// newest first
|
|
||||||
const sorted = [...episodes].sort((a, b) => (b.publishedAt ?? 0) - (a.publishedAt ?? 0))
|
const sorted = [...episodes].sort((a, b) => (b.publishedAt ?? 0) - (a.publishedAt ?? 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||||
{sorted.map((ep) => {
|
{sorted.map((ep) => {
|
||||||
const prog = byKey[progressKey(item.id, ep.id)]
|
const prog = byKey[progressKey(item.id, ep.id)]
|
||||||
@@ -51,7 +90,7 @@ export function EpisodeList({ item }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
className={cn('flex items-start gap-3 bg-surface px-3 py-3', isCurrent && 'bg-surface-2')}
|
className={cn('group flex items-start gap-3 bg-surface px-3 py-3', isCurrent && 'bg-surface-2')}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => play(item, ep.id, startTime)}
|
onClick={() => play(item, ep.id, startTime)}
|
||||||
@@ -64,6 +103,16 @@ export function EpisodeList({ item }: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="flex-1 truncate text-sm font-medium text-text">{ep.title}</p>
|
<p className="flex-1 truncate text-sm font-medium text-text">{ep.title}</p>
|
||||||
{finished && <Check size={15} className="shrink-0 text-success" />}
|
{finished && <Check size={15} className="shrink-0 text-success" />}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => openRename(ep)}
|
||||||
|
aria-label="Episode umbenennen"
|
||||||
|
title="Umbenennen"
|
||||||
|
className="shrink-0 text-text-muted opacity-0 transition-opacity hover:text-text focus:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
|
||||||
{pubLabel(ep) && <span>{pubLabel(ep)}</span>}
|
{pubLabel(ep) && <span>{pubLabel(ep)}</span>}
|
||||||
@@ -90,5 +139,33 @@ export function EpisodeList({ item }: Props) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
title="Episode umbenennen"
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(null)} disabled={saving}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveRename} loading={saving}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Titel"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') saveRename()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export default function ItemDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isPodcast ? (
|
{isPodcast ? (
|
||||||
<EpisodeList item={item} />
|
<EpisodeList item={item} onUpdated={(updated) => setItem(updated)} />
|
||||||
) : chapters.length > 0 ? (
|
) : chapters.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-3 font-heading text-lg font-semibold">Kapitel</h2>
|
<h2 className="mb-3 font-heading text-lg font-semibold">Kapitel</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user