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 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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user