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:
Scarriffle
2026-06-03 11:39:19 +02:00
parent ded05e18f4
commit 4086c132cb
3 changed files with 152 additions and 51 deletions

View File

@@ -1,5 +1,5 @@
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. */
export async function checkNewEpisodes(itemId: string): Promise<PodcastEpisode[]> {
@@ -7,6 +7,30 @@ export async function checkNewEpisodes(itemId: string): Promise<PodcastEpisode[]
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 {
title: string
description?: string

View File

@@ -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 { cn } from '@/lib/cn'
import { useProgressStore, progressKey } from '@/store/progressStore'
import { usePlayerStore } from '@/store/playerStore'
import { useCan } from '@/store/authStore'
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'
interface Props {
item: LibraryItem
onUpdated?: (item: LibraryItem) => void
}
function pubLabel(ep: PodcastEpisode): string {
@@ -25,70 +34,138 @@ function pubLabel(ep: PodcastEpisode): string {
return ep.pubDate ?? ''
}
export function EpisodeList({ item }: Props) {
export function EpisodeList({ item, onUpdated }: Props) {
const episodes = (item.media as Podcast).episodes ?? []
const byKey = useProgressStore((s) => s.byKey)
const play = usePlayerStore((s) => s.play)
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) {
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))
return (
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{sorted.map((ep) => {
const prog = byKey[progressKey(item.id, ep.id)]
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
const finished = prog?.isFinished ?? false
const isCurrent = currentEpisodeId === ep.id
const duration = ep.duration ?? ep.audioFile?.duration ?? 0
const startTime = prog && !prog.isFinished ? prog.currentTime : 0
<>
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{sorted.map((ep) => {
const prog = byKey[progressKey(item.id, ep.id)]
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
const finished = prog?.isFinished ?? false
const isCurrent = currentEpisodeId === ep.id
const duration = ep.duration ?? ep.audioFile?.duration ?? 0
const startTime = prog && !prog.isFinished ? prog.currentTime : 0
return (
<div
key={ep.id}
className={cn('flex items-start gap-3 bg-surface px-3 py-3', isCurrent && 'bg-surface-2')}
>
<button
onClick={() => play(item, ep.id, startTime)}
aria-label="Episode abspielen"
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-text transition-colors hover:bg-accent hover:text-on-accent"
return (
<div
key={ep.id}
className={cn('group flex items-start gap-3 bg-surface px-3 py-3', isCurrent && 'bg-surface-2')}
>
<Play size={16} />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="flex-1 truncate text-sm font-medium text-text">{ep.title}</p>
{finished && <Check size={15} className="shrink-0 text-success" />}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
{pubLabel(ep) && <span>{pubLabel(ep)}</span>}
{duration > 0 && (
<>
<span>·</span>
<span className="tnum">{formatDuration(duration)}</span>
</>
)}
{pct > 0 && (
<>
<span>·</span>
<span className="tnum text-accent">{pct}%</span>
</>
<button
onClick={() => play(item, ep.id, startTime)}
aria-label="Episode abspielen"
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-text transition-colors hover:bg-accent hover:text-on-accent"
>
<Play size={16} />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="flex-1 truncate text-sm font-medium text-text">{ep.title}</p>
{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 className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
{pubLabel(ep) && <span>{pubLabel(ep)}</span>}
{duration > 0 && (
<>
<span>·</span>
<span className="tnum">{formatDuration(duration)}</span>
</>
)}
{pct > 0 && (
<>
<span>·</span>
<span className="tnum text-accent">{pct}%</span>
</>
)}
</div>
{ep.description && (
<p className="mt-1.5 line-clamp-2 text-xs text-text-muted">
{htmlToText(ep.description)}
</p>
)}
</div>
{ep.description && (
<p className="mt-1.5 line-clamp-2 text-xs text-text-muted">
{htmlToText(ep.description)}
</p>
)}
</div>
</div>
)
})}
</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>
</>
)
}

View File

@@ -208,7 +208,7 @@ export default function ItemDetail() {
)}
{isPodcast ? (
<EpisodeList item={item} />
<EpisodeList item={item} onUpdated={(updated) => setItem(updated)} />
) : chapters.length > 0 ? (
<div>
<h2 className="mb-3 font-heading text-lg font-semibold">Kapitel</h2>