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 { 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

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 { 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>
</>
) )
} }

View File

@@ -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>