Add rename show functionality: implement API endpoint for renaming shows, update Show model to support name changes, and enhance ShowMetadataCard UI for name input. Update translations for new UI elements to improve user experience.
This commit is contained in:
@@ -7,6 +7,7 @@ using TeleWave.Application.Library.DeleteShow;
|
|||||||
using TeleWave.Application.Library.GetShow;
|
using TeleWave.Application.Library.GetShow;
|
||||||
using TeleWave.Application.Library.ListShows;
|
using TeleWave.Application.Library.ListShows;
|
||||||
using TeleWave.Application.Library.RemoveEpisode;
|
using TeleWave.Application.Library.RemoveEpisode;
|
||||||
|
using TeleWave.Application.Library.RenameShow;
|
||||||
using TeleWave.Application.Library.SetShowOriginalName;
|
using TeleWave.Application.Library.SetShowOriginalName;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ public static class ShowEndpoints
|
|||||||
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
|
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
|
||||||
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
|
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
|
||||||
|
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
@@ -65,6 +67,17 @@ public static class ShowEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Rename(
|
||||||
|
Guid id,
|
||||||
|
RenameShowBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SetOriginalName(
|
private static async Task<IResult> SetOriginalName(
|
||||||
Guid id,
|
Guid id,
|
||||||
SetShowOriginalNameBody body,
|
SetShowOriginalNameBody body,
|
||||||
@@ -116,4 +129,6 @@ public static class ShowEndpoints
|
|||||||
|
|
||||||
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
||||||
|
|
||||||
|
public sealed record RenameShowBody(string Name);
|
||||||
|
|
||||||
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.RenameShow;
|
||||||
|
|
||||||
|
/// <summary>Изменить отображаемое название шоу.</summary>
|
||||||
|
public sealed record RenameShowCommand(Guid Id, string Name) : ICommand<Result>;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.RenameShow;
|
||||||
|
|
||||||
|
public sealed class RenameShowCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<RenameShowCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(RenameShowCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var show = await dbContext.Shows.FirstOrDefaultAsync(
|
||||||
|
s => s.Id == command.Id,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (show is null)
|
||||||
|
return Result.Failure(ShowErrors.NotFound);
|
||||||
|
|
||||||
|
show.SetName(command.Name.Trim());
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.RenameShow;
|
||||||
|
|
||||||
|
public sealed class RenameShowCommandValidator : AbstractValidator<RenameShowCommand>
|
||||||
|
{
|
||||||
|
public RenameShowCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ public class Show
|
|||||||
Description = description;
|
Description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
|
||||||
|
public void SetName(string name) => Name = name;
|
||||||
|
|
||||||
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
|
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
|
||||||
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
|
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
clearMetadata,
|
clearMetadata,
|
||||||
getMetadataProviders,
|
getMetadataProviders,
|
||||||
refreshEpisodesMetadata,
|
refreshEpisodesMetadata,
|
||||||
|
renameShow,
|
||||||
searchMetadata,
|
searchMetadata,
|
||||||
setShowOriginalName,
|
setShowOriginalName,
|
||||||
setShowPoster,
|
setShowPoster,
|
||||||
@@ -26,8 +27,8 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [galleryOpen, setGalleryOpen] = useState(false)
|
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||||
const [provider, setProvider] = useState('')
|
const [provider, setProvider] = useState('')
|
||||||
|
const [name, setName] = useState(show.name)
|
||||||
const [originalName, setOriginalName] = useState(show.originalName ?? '')
|
const [originalName, setOriginalName] = useState(show.originalName ?? '')
|
||||||
const [query, setQuery] = useState(show.originalName || show.name)
|
|
||||||
const [results, setResults] = useState<MetadataCandidate[]>([])
|
const [results, setResults] = useState<MetadataCandidate[]>([])
|
||||||
const [searched, setSearched] = useState(false)
|
const [searched, setSearched] = useState(false)
|
||||||
const [description, setDescription] = useState(show.description ?? '')
|
const [description, setDescription] = useState(show.description ?? '')
|
||||||
@@ -52,9 +53,11 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
})
|
})
|
||||||
|
|
||||||
const effectiveProvider = provider || providers?.[0] || ''
|
const effectiveProvider = provider || providers?.[0] || ''
|
||||||
|
// Метаданные ищем по оригинальному названию (иначе — по обычному).
|
||||||
|
const searchTerm = originalName.trim() || name.trim()
|
||||||
|
|
||||||
const search = useMutation({
|
const search = useMutation({
|
||||||
mutationFn: () => searchMetadata(effectiveProvider, query.trim()),
|
mutationFn: () => searchMetadata(effectiveProvider, searchTerm),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setResults(data)
|
setResults(data)
|
||||||
setSearched(true)
|
setSearched(true)
|
||||||
@@ -76,10 +79,11 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const yearNum = year.trim() ? Number(year) : null
|
const yearNum = year.trim() ? Number(year) : null
|
||||||
const nameChanged = originalName.trim() !== (show.originalName ?? '')
|
|
||||||
const infoChanged =
|
const infoChanged =
|
||||||
(description.trim() || null) !== (show.description ?? null) || yearNum !== (show.year ?? null)
|
(description.trim() || null) !== (show.description ?? null) || yearNum !== (show.year ?? null)
|
||||||
if (nameChanged) await setShowOriginalName(show.id, originalName.trim() || null)
|
if (name.trim() && name.trim() !== show.name) await renameShow(show.id, name.trim())
|
||||||
|
if (originalName.trim() !== (show.originalName ?? ''))
|
||||||
|
await setShowOriginalName(show.id, originalName.trim() || null)
|
||||||
if (infoChanged)
|
if (infoChanged)
|
||||||
await updateMetadata(show.id, { description: description.trim() || null, year: yearNum })
|
await updateMetadata(show.id, { description: description.trim() || null, year: yearNum })
|
||||||
},
|
},
|
||||||
@@ -140,31 +144,29 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Поиск + ручная правка */}
|
{/* Название + поиск + ручная правка */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.metadata.name')}</Label>
|
||||||
|
<Input value={name} maxLength={256} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.metadata.originalName')}</Label>
|
<Label>{t('admin.metadata.originalName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={originalName}
|
value={originalName}
|
||||||
maxLength={256}
|
maxLength={256}
|
||||||
placeholder={t('admin.metadata.originalNamePlaceholder')}
|
placeholder={t('admin.metadata.originalNamePlaceholder')}
|
||||||
onChange={(e) => setOriginalName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setOriginalName(e.target.value)
|
||||||
|
setSearched(false)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{providers && providers.length > 0 && (
|
{providers && providers.length > 0 && (searched || results.length > 0) && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>{t('admin.metadata.searchLabel')}</Label>
|
|
||||||
<Input
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value)
|
|
||||||
setSearched(false)
|
|
||||||
}}
|
|
||||||
placeholder={t('admin.metadata.searchPlaceholder')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{searched && results.length === 0 && (
|
{searched && results.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.nothingFound')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.metadata.nothingFound')}</p>
|
||||||
)}
|
)}
|
||||||
@@ -241,14 +243,14 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={search.isPending || !query.trim()}
|
disabled={search.isPending || !searchTerm}
|
||||||
onClick={() => search.mutate()}
|
onClick={() => search.mutate()}
|
||||||
>
|
>
|
||||||
{t('admin.metadata.searchBtn')}
|
{t('admin.metadata.searchBtn')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
<Button size="sm" disabled={save.isPending || !name.trim()} onClick={() => save.mutate()}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
{linked && (
|
{linked && (
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export function createShow(body: {
|
|||||||
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
|
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameShow(id: string, name: string) {
|
||||||
|
return apiRequest<void>(`/admin/shows/${id}/name`, { method: 'PUT', body: { name } })
|
||||||
|
}
|
||||||
|
|
||||||
export function setShowOriginalName(id: string, originalName: string | null) {
|
export function setShowOriginalName(id: string, originalName: string | null) {
|
||||||
return apiRequest<void>(`/admin/shows/${id}/original-name`, {
|
return apiRequest<void>(`/admin/shows/${id}/original-name`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ const resources = {
|
|||||||
title: 'Метаданные',
|
title: 'Метаданные',
|
||||||
pickFromGallery: 'Выбрать из галереи',
|
pickFromGallery: 'Выбрать из галереи',
|
||||||
pickPoster: 'Из галереи',
|
pickPoster: 'Из галереи',
|
||||||
searchLabel: 'Поиск метаданных',
|
name: 'Название',
|
||||||
originalName: 'Оригинальное название (eng)',
|
originalName: 'Оригинальное название (eng)',
|
||||||
originalNamePlaceholder: 'Например: Family Guy',
|
originalNamePlaceholder: 'Например: Family Guy',
|
||||||
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
|
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
|
||||||
@@ -591,7 +591,7 @@ const resources = {
|
|||||||
title: 'Metadata',
|
title: 'Metadata',
|
||||||
pickFromGallery: 'Pick from gallery',
|
pickFromGallery: 'Pick from gallery',
|
||||||
pickPoster: 'From gallery',
|
pickPoster: 'From gallery',
|
||||||
searchLabel: 'Metadata search',
|
name: 'Name',
|
||||||
originalName: 'Original name (eng)',
|
originalName: 'Original name (eng)',
|
||||||
originalNamePlaceholder: 'e.g. Family Guy',
|
originalNamePlaceholder: 'e.g. Family Guy',
|
||||||
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
|
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
|
||||||
|
|||||||
Reference in New Issue
Block a user