Enhance user and media management with sorting and statistics: add sorting options to user and media listing endpoints, implement media statistics retrieval, and update frontend components for sorting and displaying media processing times. Refactor related query handlers and API types to support new features.
This commit is contained in:
@@ -40,6 +40,8 @@ public static class AdminUserEndpoints
|
|||||||
string? search,
|
string? search,
|
||||||
Guid? roleId,
|
Guid? roleId,
|
||||||
bool? isBlocked,
|
bool? isBlocked,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
@@ -50,7 +52,9 @@ public static class AdminUserEndpoints
|
|||||||
pageSize <= 0 ? 20 : pageSize,
|
pageSize <= 0 ? 20 : pageSize,
|
||||||
search,
|
search,
|
||||||
roleId,
|
roleId,
|
||||||
isBlocked
|
isBlocked,
|
||||||
|
sort,
|
||||||
|
desc
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using TeleWave.Application.Media.Delete;
|
|||||||
using TeleWave.Application.Media.GetMedia;
|
using TeleWave.Application.Media.GetMedia;
|
||||||
using TeleWave.Application.Media.ListMedia;
|
using TeleWave.Application.Media.ListMedia;
|
||||||
using TeleWave.Application.Media.Register;
|
using TeleWave.Application.Media.Register;
|
||||||
|
using TeleWave.Application.Media.Stats;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
@@ -24,6 +25,7 @@ public static class MediaEndpoints
|
|||||||
|
|
||||||
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||||
|
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||||
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
|
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
|
||||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
@@ -100,6 +102,8 @@ public static class MediaEndpoints
|
|||||||
int pageSize,
|
int pageSize,
|
||||||
MediaAssetStatus[]? status,
|
MediaAssetStatus[]? status,
|
||||||
string? search,
|
string? search,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
@@ -109,13 +113,21 @@ public static class MediaEndpoints
|
|||||||
page <= 0 ? 1 : page,
|
page <= 0 ? 1 : page,
|
||||||
pageSize <= 0 ? 20 : pageSize,
|
pageSize <= 0 ? 20 : pageSize,
|
||||||
status ?? [],
|
status ?? [],
|
||||||
search
|
search,
|
||||||
|
sort,
|
||||||
|
desc
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Get(
|
private static async Task<IResult> Get(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ public sealed record ListUsersQuery(
|
|||||||
int PageSize,
|
int PageSize,
|
||||||
string? Search,
|
string? Search,
|
||||||
Guid? RoleId,
|
Guid? RoleId,
|
||||||
bool? IsBlocked
|
bool? IsBlocked,
|
||||||
|
string? Sort = null,
|
||||||
|
bool Desc = false
|
||||||
) : IQuery<PagedList<UserSummaryDto>>;
|
) : IQuery<PagedList<UserSummaryDto>>;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
|||||||
query.Search,
|
query.Search,
|
||||||
query.RoleId,
|
query.RoleId,
|
||||||
query.IsBlocked,
|
query.IsBlocked,
|
||||||
|
query.Sort,
|
||||||
|
query.Desc,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ public interface IIdentityService
|
|||||||
string? search,
|
string? search,
|
||||||
Guid? roleId,
|
Guid? roleId,
|
||||||
bool? isBlocked,
|
bool? isBlocked,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ public sealed record ListMediaAssetsQuery(
|
|||||||
int Page,
|
int Page,
|
||||||
int PageSize,
|
int PageSize,
|
||||||
IReadOnlyList<MediaAssetStatus> Statuses,
|
IReadOnlyList<MediaAssetStatus> Statuses,
|
||||||
string? Search
|
string? Search,
|
||||||
|
string? Sort = null,
|
||||||
|
bool Desc = false
|
||||||
) : IQuery<PagedList<MediaAssetDto>>;
|
) : IQuery<PagedList<MediaAssetDto>>;
|
||||||
|
|||||||
@@ -30,9 +30,33 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
|
|||||||
|
|
||||||
var total = await q.CountAsync(cancellationToken);
|
var total = await q.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Сортировка по выбранному столбцу (в SQL, до пагинации); вторичный ключ — Id для стабильного
|
||||||
|
// порядка при равенстве значений (иначе страницы «дрожат»).
|
||||||
|
var ordered = (query.Sort?.ToLowerInvariant()) switch
|
||||||
|
{
|
||||||
|
"name" => query.Desc
|
||||||
|
? q.OrderByDescending(x => x.OriginalFileName)
|
||||||
|
: q.OrderBy(x => x.OriginalFileName),
|
||||||
|
"status" => query.Desc ? q.OrderByDescending(x => x.Status) : q.OrderBy(x => x.Status),
|
||||||
|
"duration" => query.Desc
|
||||||
|
? q.OrderByDescending(x => x.Duration)
|
||||||
|
: q.OrderBy(x => x.Duration),
|
||||||
|
"resolution" => query.Desc
|
||||||
|
? q.OrderByDescending(x => x.Width)
|
||||||
|
: q.OrderBy(x => x.Width),
|
||||||
|
"processing" => query.Desc
|
||||||
|
? q.OrderByDescending(x => x.ProcessingDuration)
|
||||||
|
: q.OrderBy(x => x.ProcessingDuration),
|
||||||
|
"created" => query.Desc
|
||||||
|
? q.OrderByDescending(x => x.CreatedAt)
|
||||||
|
: q.OrderBy(x => x.CreatedAt),
|
||||||
|
_ => q.OrderByDescending(x => x.CreatedAt),
|
||||||
|
};
|
||||||
|
|
||||||
// Маппинг в памяти: MediaAssetDto.From обращается к TimeSpan.TotalSeconds, который EF в SQL
|
// Маппинг в памяти: MediaAssetDto.From обращается к TimeSpan.TotalSeconds, который EF в SQL
|
||||||
// не переводит. Страница ограничена pageSize, поэтому материализация сущностей безопасна.
|
// не переводит. Страница ограничена pageSize, поэтому материализация сущностей безопасна.
|
||||||
var entities = await q.OrderByDescending(x => x.CreatedAt)
|
var entities = await ordered
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
.Skip((query.Page - 1) * query.PageSize)
|
.Skip((query.Page - 1) * query.PageSize)
|
||||||
.Take(query.PageSize)
|
.Take(query.PageSize)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed record MediaAssetDto(
|
|||||||
string? VideoCodec,
|
string? VideoCodec,
|
||||||
string? AudioCodec,
|
string? AudioCodec,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
|
double? ProcessingSeconds,
|
||||||
DateTimeOffset CreatedAt
|
DateTimeOffset CreatedAt
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -30,6 +31,7 @@ public sealed record MediaAssetDto(
|
|||||||
asset.VideoCodec,
|
asset.VideoCodec,
|
||||||
asset.AudioCodec,
|
asset.AudioCodec,
|
||||||
asset.ErrorMessage,
|
asset.ErrorMessage,
|
||||||
|
asset.ProcessingDuration?.TotalSeconds,
|
||||||
asset.CreatedAt
|
asset.CreatedAt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Media.Stats;
|
||||||
|
|
||||||
|
/// <summary>Сводка по обработке медиа: сколько сейчас в очереди/в обработке и среднее время обработки.</summary>
|
||||||
|
public sealed record GetMediaStatsQuery : IQuery<MediaStatsDto>;
|
||||||
|
|
||||||
|
/// <param name="Queued">Ассетов в статусе Pending (ждут обработки) сейчас.</param>
|
||||||
|
/// <param name="Processing">Ассетов в статусе Processing (обрабатываются) сейчас.</param>
|
||||||
|
/// <param name="AverageProcessingSeconds">Среднее время обработки по недавним завершённым (Ready), сек; null — если нет данных.</param>
|
||||||
|
public sealed record MediaStatsDto(int Queued, int Processing, double? AverageProcessingSeconds);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Media.Stats;
|
||||||
|
|
||||||
|
public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<GetMediaStatsQuery, MediaStatsDto>
|
||||||
|
{
|
||||||
|
/// <summary>По скольким последним завершённым ассетам усредняем время обработки.</summary>
|
||||||
|
private const int AverageSample = 500;
|
||||||
|
|
||||||
|
public async Task<MediaStatsDto> Handle(
|
||||||
|
GetMediaStatsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Сгенерированные (ТВ-заставки) в статистику библиотеки не входят.
|
||||||
|
var assets = dbContext.MediaAssets.AsNoTracking().Where(x => x.Source != MediaSource.Generated);
|
||||||
|
|
||||||
|
var queued = await assets.CountAsync(x => x.Status == MediaAssetStatus.Pending, cancellationToken);
|
||||||
|
var processing = await assets.CountAsync(
|
||||||
|
x => x.Status == MediaAssetStatus.Processing,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Среднее — по недавним завершённым (ограничение выборки, чтобы не тянуть всю историю). EF не
|
||||||
|
// усредняет TimeSpan в SQL, поэтому берём длительности последних N и усредняем в памяти.
|
||||||
|
var recent = await assets
|
||||||
|
.Where(x => x.Status == MediaAssetStatus.Ready && x.ProcessingDuration != null)
|
||||||
|
.OrderByDescending(x => x.UpdatedAt)
|
||||||
|
.Select(x => x.ProcessingDuration!.Value)
|
||||||
|
.Take(AverageSample)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
double? average = recent.Count > 0 ? recent.Average(d => d.TotalSeconds) : null;
|
||||||
|
|
||||||
|
return new MediaStatsDto(queued, processing, average);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,12 @@ public class MediaAsset
|
|||||||
/// <summary>Текст ошибки, если <see cref="Status"/> == <see cref="MediaAssetStatus.Failed"/>.</summary>
|
/// <summary>Текст ошибки, если <see cref="Status"/> == <see cref="MediaAssetStatus.Failed"/>.</summary>
|
||||||
public string? ErrorMessage { get; private set; }
|
public string? ErrorMessage { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Когда началась обработка (переход в Processing) — для расчёта её длительности.</summary>
|
||||||
|
public DateTimeOffset? ProcessingStartedAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Сколько заняла обработка завершённого ассета (Ready) = момент готовности − старт обработки.</summary>
|
||||||
|
public TimeSpan? ProcessingDuration { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
public DateTimeOffset UpdatedAt { get; private set; }
|
public DateTimeOffset UpdatedAt { get; private set; }
|
||||||
|
|
||||||
@@ -79,6 +85,8 @@ public class MediaAsset
|
|||||||
{
|
{
|
||||||
Status = MediaAssetStatus.Processing;
|
Status = MediaAssetStatus.Processing;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
|
ProcessingStartedAt = DateTimeOffset.UtcNow;
|
||||||
|
ProcessingDuration = null;
|
||||||
Touch();
|
Touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +111,11 @@ public class MediaAsset
|
|||||||
AudioCodec = audioCodec;
|
AudioCodec = audioCodec;
|
||||||
RelativePath = relativePath;
|
RelativePath = relativePath;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
|
if (ProcessingStartedAt is { } startedAt)
|
||||||
|
{
|
||||||
|
var elapsed = DateTimeOffset.UtcNow - startedAt;
|
||||||
|
ProcessingDuration = elapsed > TimeSpan.Zero ? elapsed : TimeSpan.Zero;
|
||||||
|
}
|
||||||
Touch();
|
Touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +131,7 @@ public class MediaAsset
|
|||||||
{
|
{
|
||||||
Status = MediaAssetStatus.Pending;
|
Status = MediaAssetStatus.Pending;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
|
ProcessingStartedAt = null;
|
||||||
Touch();
|
Touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ internal sealed class IdentityService(
|
|||||||
string? search,
|
string? search,
|
||||||
Guid? roleId,
|
Guid? roleId,
|
||||||
bool? isBlocked,
|
bool? isBlocked,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -225,8 +227,25 @@ internal sealed class IdentityService(
|
|||||||
|
|
||||||
var total = await query.CountAsync(cancellationToken);
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
var items = await query
|
var ordered = (sort?.ToLowerInvariant()) switch
|
||||||
.OrderByDescending(x => x.user.CreatedAt)
|
{
|
||||||
|
"username" => desc
|
||||||
|
? query.OrderByDescending(x => x.user.UserName)
|
||||||
|
: query.OrderBy(x => x.user.UserName),
|
||||||
|
"role" => desc
|
||||||
|
? query.OrderByDescending(x => x.RoleName)
|
||||||
|
: query.OrderBy(x => x.RoleName),
|
||||||
|
"blocked" => desc
|
||||||
|
? query.OrderByDescending(x => x.user.IsBlocked)
|
||||||
|
: query.OrderBy(x => x.user.IsBlocked),
|
||||||
|
"created" => desc
|
||||||
|
? query.OrderByDescending(x => x.user.CreatedAt)
|
||||||
|
: query.OrderBy(x => x.user.CreatedAt),
|
||||||
|
_ => query.OrderByDescending(x => x.user.CreatedAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
var items = await ordered
|
||||||
|
.ThenBy(x => x.user.Id)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(x => new UserSummaryDto(
|
.Select(x => new UserSummaryDto(
|
||||||
|
|||||||
Generated
+1023
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MediaProcessingTiming : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<TimeSpan>(
|
||||||
|
name: "ProcessingDuration",
|
||||||
|
table: "MediaAssets",
|
||||||
|
type: "interval",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ProcessingStartedAt",
|
||||||
|
table: "MediaAssets",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProcessingDuration",
|
||||||
|
table: "MediaAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProcessingStartedAt",
|
||||||
|
table: "MediaAssets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -703,6 +703,12 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("character varying(512)");
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("ProcessingDuration")
|
||||||
|
.HasColumnType("interval");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ProcessingStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("RelativePath")
|
b.Property<string>("RelativePath")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using TeleWave.Application.Media.Stats;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Media;
|
||||||
|
|
||||||
|
public class MediaStatsTests
|
||||||
|
{
|
||||||
|
private static MediaAsset Pending(string name) =>
|
||||||
|
MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||||
|
|
||||||
|
private static MediaAsset Processing(string name)
|
||||||
|
{
|
||||||
|
var a = Pending(name);
|
||||||
|
a.MarkProcessing();
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MediaAsset Ready(string name)
|
||||||
|
{
|
||||||
|
var a = Pending(name);
|
||||||
|
a.MarkProcessing();
|
||||||
|
a.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stats_CountsQueuedAndProcessing_AndAveragesReady_ExcludingGenerated()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.MediaAssets.AddRange(
|
||||||
|
Pending("a.mkv"),
|
||||||
|
Pending("b.mkv"),
|
||||||
|
Processing("c.mkv"),
|
||||||
|
Ready("d.mkv"),
|
||||||
|
MediaAsset.RegisterGenerated("Заставка") // Generated — не входит в статистику
|
||||||
|
);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var stats = await new GetMediaStatsQueryHandler(db).Handle(
|
||||||
|
new GetMediaStatsQuery(),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(2, stats.Queued); // два Pending (Generated исключён)
|
||||||
|
Assert.Equal(1, stats.Processing);
|
||||||
|
Assert.NotNull(stats.AverageProcessingSeconds);
|
||||||
|
Assert.True(stats.AverageProcessingSeconds >= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,31 @@ public class MediaAssetTests
|
|||||||
Assert.Null(asset.ErrorMessage);
|
Assert.Null(asset.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkProcessing_RecordsStart_AndMarkReady_ComputesProcessingDuration()
|
||||||
|
{
|
||||||
|
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
|
||||||
|
|
||||||
|
asset.MarkProcessing();
|
||||||
|
Assert.NotNull(asset.ProcessingStartedAt);
|
||||||
|
Assert.Null(asset.ProcessingDuration);
|
||||||
|
|
||||||
|
asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc");
|
||||||
|
|
||||||
|
Assert.NotNull(asset.ProcessingDuration);
|
||||||
|
Assert.True(asset.ProcessingDuration >= TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkReady_WithoutProcessingStart_LeavesDurationNull()
|
||||||
|
{
|
||||||
|
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
|
||||||
|
|
||||||
|
asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc");
|
||||||
|
|
||||||
|
Assert.Null(asset.ProcessingDuration);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MarkFailed_StoresError()
|
public void MarkFailed_StoresError()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Trash2, Upload } from 'lucide-react'
|
import { Trash2, Upload } from 'lucide-react'
|
||||||
import { useRef, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { ImageCategory } from '@/shared/api/types'
|
import type { ImageCategory } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
|
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
|
||||||
|
|
||||||
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
|
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
|
||||||
|
|
||||||
|
type ImageOrder = 'new' | 'old' | 'az' | 'za'
|
||||||
|
|
||||||
export type ImagePick = { id: string; url: string }
|
export type ImagePick = { id: string; url: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,11 +38,30 @@ export function GalleryBrowser({
|
|||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
|
|
||||||
|
const [order, setOrder] = useState<ImageOrder>('new')
|
||||||
|
|
||||||
const { data: images, isLoading } = useQuery({
|
const { data: images, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'images', active],
|
queryKey: ['admin', 'images', active],
|
||||||
queryFn: () => listImages(active),
|
queryFn: () => listImages(active),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const arr = [...(images ?? [])]
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
switch (order) {
|
||||||
|
case 'old':
|
||||||
|
return a.createdAt.localeCompare(b.createdAt)
|
||||||
|
case 'az':
|
||||||
|
return (a.originalFileName ?? '').localeCompare(b.originalFileName ?? '')
|
||||||
|
case 'za':
|
||||||
|
return (b.originalFileName ?? '').localeCompare(a.originalFileName ?? '')
|
||||||
|
default:
|
||||||
|
return b.createdAt.localeCompare(a.createdAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return arr
|
||||||
|
}, [images, order])
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
|
||||||
|
|
||||||
const pick = (id: string) => {
|
const pick = (id: string) => {
|
||||||
@@ -87,6 +109,17 @@ export function GalleryBrowser({
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')}
|
{onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')}
|
||||||
</span>
|
</span>
|
||||||
|
<Select value={order} onValueChange={(v) => setOrder(v as ImageOrder)}>
|
||||||
|
<SelectTrigger className="ml-auto h-8 w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="new">{t('admin.gallery.sort.newest')}</SelectItem>
|
||||||
|
<SelectItem value="old">{t('admin.gallery.sort.oldest')}</SelectItem>
|
||||||
|
<SelectItem value="az">{t('admin.gallery.sort.nameAsc')}</SelectItem>
|
||||||
|
<SelectItem value="za">{t('admin.gallery.sort.nameDesc')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<input
|
<input
|
||||||
ref={fileInput}
|
ref={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -114,7 +147,7 @@ export function GalleryBrowser({
|
|||||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||||
) : images && images.length > 0 ? (
|
) : images && images.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
||||||
{images.map((img) => (
|
{sorted.map((img) => (
|
||||||
<div key={img.id} className="group relative">
|
<div key={img.id} className="group relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Pager } from '@/shared/ui/pager'
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { deleteMedia, listMedia } from './api'
|
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
type MediaFilter = 'active' | 'all' | 'Ready' | 'Failed'
|
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
|
||||||
|
|
||||||
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
||||||
active: ['Pending', 'Processing'],
|
active: ['Pending', 'Processing'],
|
||||||
|
Pending: ['Pending'],
|
||||||
|
Processing: ['Processing'],
|
||||||
all: [],
|
all: [],
|
||||||
Ready: ['Ready'],
|
Ready: ['Ready'],
|
||||||
Failed: ['Failed'],
|
Failed: ['Failed'],
|
||||||
@@ -48,12 +51,25 @@ export function MediaPanel() {
|
|||||||
const fileInputShow = useRef<HTMLInputElement>(null)
|
const fileInputShow = useRef<HTMLInputElement>(null)
|
||||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
|
const sortColumn = (key: string) => {
|
||||||
|
setPage(1)
|
||||||
|
toggle(key)
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'media', filter, page],
|
queryKey: ['admin', 'media', filter, page, sort.key, sort.desc],
|
||||||
queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
|
queryFn: () =>
|
||||||
|
listMedia({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
statuses: filterStatuses[filter],
|
||||||
|
sort: sort.key,
|
||||||
|
desc: sort.desc,
|
||||||
|
}),
|
||||||
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
||||||
@@ -61,6 +77,14 @@ export function MediaPanel() {
|
|||||||
: false,
|
: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['admin', 'media', 'stats'],
|
||||||
|
queryFn: getMediaStats,
|
||||||
|
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
|
||||||
|
})
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
@@ -84,11 +108,32 @@ export function MediaPanel() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
|
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
|
||||||
|
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
|
||||||
|
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
|
||||||
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
|
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
|
||||||
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
|
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
|
||||||
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
|
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span title={t('admin.media.stats.queued')}>
|
||||||
|
{t('admin.media.stats.queuedShort')}:{' '}
|
||||||
|
<span className="text-foreground tabular-nums">{stats.queued}</span>
|
||||||
|
</span>
|
||||||
|
<span title={t('admin.media.stats.processing')}>
|
||||||
|
{t('admin.media.stats.processingShort')}:{' '}
|
||||||
|
<span className="text-foreground tabular-nums">{stats.processing}</span>
|
||||||
|
</span>
|
||||||
|
<span title={t('admin.media.stats.average')}>
|
||||||
|
{t('admin.media.stats.averageShort')}:{' '}
|
||||||
|
<span className="text-foreground tabular-nums">
|
||||||
|
{formatDuration(stats.averageProcessingSeconds)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -134,17 +179,43 @@ export function MediaPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.name')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
|
label={t('admin.media.name')}
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
|
sortKey="name"
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.resolution')}</th>
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.status')}
|
||||||
|
sortKey="status"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.duration')}
|
||||||
|
sortKey="duration"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.resolution')}
|
||||||
|
sortKey="resolution"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.processingTime')}
|
||||||
|
sortKey="processing"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||||
{t('common.loading')}
|
{t('common.loading')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -158,7 +229,7 @@ export function MediaPanel() {
|
|||||||
))}
|
))}
|
||||||
{data && data.items.length === 0 && !isLoading && (
|
{data && data.items.length === 0 && !isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||||
{t('admin.media.empty')}
|
{t('admin.media.empty')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -190,6 +261,9 @@ function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => v
|
|||||||
<td className="px-4 py-2 text-muted-foreground">
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
|
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground tabular-nums">
|
||||||
|
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
MediaAssetDto,
|
MediaAssetDto,
|
||||||
MediaAssetStatus,
|
MediaAssetStatus,
|
||||||
|
MediaStatsDto,
|
||||||
PagedList,
|
PagedList,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ export type ListMediaParams = {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
statuses?: MediaAssetStatus[]
|
statuses?: MediaAssetStatus[]
|
||||||
search?: string
|
search?: string
|
||||||
|
sort?: string
|
||||||
|
desc?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMedia(params: ListMediaParams) {
|
export function listMedia(params: ListMediaParams) {
|
||||||
@@ -20,9 +23,15 @@ export function listMedia(params: ListMediaParams) {
|
|||||||
})
|
})
|
||||||
for (const status of params.statuses ?? []) query.append('status', status)
|
for (const status of params.statuses ?? []) query.append('status', status)
|
||||||
if (params.search) query.set('search', params.search)
|
if (params.search) query.set('search', params.search)
|
||||||
|
if (params.sort) query.set('sort', params.sort)
|
||||||
|
if (params.desc) query.set('desc', 'true')
|
||||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMediaStats() {
|
||||||
|
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@/shared/ui/dialog'
|
} from '@/shared/ui/dialog'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ export function RolesPanel() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||||
|
const { sort, toggle } = useTableSort('name', false)
|
||||||
|
const sortedRoles = sortRows(roles ?? [], sort, {
|
||||||
|
name: (r) => r.name.toLowerCase(),
|
||||||
|
system: (r) => r.isSystem,
|
||||||
|
})
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
|
||||||
|
|
||||||
@@ -97,8 +103,18 @@ export function RolesPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.roles.name')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.roles.system')}</th>
|
label={t('admin.roles.name')}
|
||||||
|
sortKey="name"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.roles.system')}
|
||||||
|
sortKey="system"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -110,7 +126,7 @@ export function RolesPanel() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{roles?.map((role) => (
|
{sortedRoles.map((role) => (
|
||||||
<tr key={role.id} className="border-b border-border last:border-0">
|
<tr key={role.id} className="border-b border-border last:border-0">
|
||||||
<td className="px-4 py-2">{role.name}</td>
|
<td className="px-4 py-2">{role.name}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Pager } from '@/shared/ui/pager'
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { createShow, deleteShow, listShows } from './api'
|
import { createShow, deleteShow, listShows } from './api'
|
||||||
|
|
||||||
@@ -22,19 +23,31 @@ export function ShowsPanel() {
|
|||||||
const [kind, setKind] = useState<ShowKind>('Series')
|
const [kind, setKind] = useState<ShowKind>('Series')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const { sort, toggle } = useTableSort('name', false)
|
||||||
|
const sortColumn = (key: string) => {
|
||||||
|
setPage(1)
|
||||||
|
toggle(key)
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||||
|
|
||||||
// Список шоу обычно умещается в одну загрузку — фильтруем и листаем на клиенте (пикеры берут всё).
|
// Список шоу обычно умещается в одну загрузку — фильтруем, сортируем и листаем на клиенте.
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
const all = data ?? []
|
const all = data ?? []
|
||||||
if (!q) return all
|
const matched = q
|
||||||
return all.filter(
|
? all.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
|
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
|
||||||
)
|
)
|
||||||
}, [data, query])
|
: all
|
||||||
|
return sortRows(matched, sort, {
|
||||||
|
name: (s) => s.name.toLowerCase(),
|
||||||
|
kind: (s) => s.kind,
|
||||||
|
seasons: (s) => s.seasonCount,
|
||||||
|
episodes: (s) => s.episodeCount,
|
||||||
|
})
|
||||||
|
}, [data, query, sort])
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||||
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
||||||
@@ -102,10 +115,30 @@ export function ShowsPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.name')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th>
|
label={t('admin.shows.name')}
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.seasons')}</th>
|
sortKey="name"
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</th>
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.shows.kind')}
|
||||||
|
sortKey="kind"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.shows.seasons')}
|
||||||
|
sortKey="seasons"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.shows.episodes')}
|
||||||
|
sortKey="episodes"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import type { UserSummaryDto } from '@/shared/api/types'
|
import type { UserSummaryDto } from '@/shared/api/types'
|
||||||
import { changeUserRole } from '@/features/admin/roles/api'
|
import { changeUserRole } from '@/features/admin/roles/api'
|
||||||
@@ -29,6 +30,11 @@ export function UsersPanel() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [roleId, setRoleId] = useState<string>('')
|
const [roleId, setRoleId] = useState<string>('')
|
||||||
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
|
const sortColumn = (key: string) => {
|
||||||
|
setPage(1)
|
||||||
|
toggle(key)
|
||||||
|
}
|
||||||
const [newUserName, setNewUserName] = useState('')
|
const [newUserName, setNewUserName] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [newRoleId, setNewRoleId] = useState('')
|
const [newRoleId, setNewRoleId] = useState('')
|
||||||
@@ -36,8 +42,16 @@ export function UsersPanel() {
|
|||||||
|
|
||||||
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'users', page, search, roleId],
|
queryKey: ['admin', 'users', page, search, roleId, sort.key, sort.desc],
|
||||||
queryFn: () => listUsers({ page, pageSize: PAGE_SIZE, search: search || undefined, roleId: roleId || undefined }),
|
queryFn: () =>
|
||||||
|
listUsers({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
search: search || undefined,
|
||||||
|
roleId: roleId || undefined,
|
||||||
|
sort: sort.key,
|
||||||
|
desc: sort.desc,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
@@ -167,10 +181,30 @@ export function UsersPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.userName')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.role')}</th>
|
label={t('admin.users.userName')}
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.status')}</th>
|
sortKey="username"
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.createdAt')}</th>
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.users.role')}
|
||||||
|
sortKey="role"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.users.status')}
|
||||||
|
sortKey="blocked"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.users.createdAt')}
|
||||||
|
sortKey="created"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type ListUsersParams = {
|
|||||||
search?: string
|
search?: string
|
||||||
roleId?: string
|
roleId?: string
|
||||||
isBlocked?: boolean
|
isBlocked?: boolean
|
||||||
|
sort?: string
|
||||||
|
desc?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listUsers(params: ListUsersParams) {
|
export function listUsers(params: ListUsersParams) {
|
||||||
@@ -17,6 +19,8 @@ export function listUsers(params: ListUsersParams) {
|
|||||||
if (params.search) query.set('search', params.search)
|
if (params.search) query.set('search', params.search)
|
||||||
if (params.roleId) query.set('roleId', params.roleId)
|
if (params.roleId) query.set('roleId', params.roleId)
|
||||||
if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked))
|
if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked))
|
||||||
|
if (params.sort) query.set('sort', params.sort)
|
||||||
|
if (params.desc) query.set('desc', 'true')
|
||||||
|
|
||||||
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${query.toString()}`)
|
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${query.toString()}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,16 @@ export type MediaAssetDto = {
|
|||||||
videoCodec: string | null
|
videoCodec: string | null
|
||||||
audioCodec: string | null
|
audioCodec: string | null
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
|
processingSeconds: number | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaStatsDto = {
|
||||||
|
queued: number
|
||||||
|
processing: number
|
||||||
|
averageProcessingSeconds: number | null
|
||||||
|
}
|
||||||
|
|
||||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||||
export type ShowKind = 'Series' | 'Single'
|
export type ShowKind = 'Series' | 'Single'
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const resources = {
|
|||||||
status: 'Статус',
|
status: 'Статус',
|
||||||
duration: 'Длительность',
|
duration: 'Длительность',
|
||||||
resolution: 'Разрешение',
|
resolution: 'Разрешение',
|
||||||
|
processingTime: 'Время обработки',
|
||||||
empty: 'Пока нет загруженных файлов',
|
empty: 'Пока нет загруженных файлов',
|
||||||
statuses: {
|
statuses: {
|
||||||
Pending: 'В очереди',
|
Pending: 'В очереди',
|
||||||
@@ -161,6 +162,14 @@ const resources = {
|
|||||||
Ready: 'Готов',
|
Ready: 'Готов',
|
||||||
Failed: 'Ошибка',
|
Failed: 'Ошибка',
|
||||||
},
|
},
|
||||||
|
stats: {
|
||||||
|
queued: 'Сейчас в очереди',
|
||||||
|
queuedShort: 'В очереди',
|
||||||
|
processing: 'Сейчас в обработке',
|
||||||
|
processingShort: 'В обработке',
|
||||||
|
average: 'Среднее время обработки (по недавним)',
|
||||||
|
averageShort: 'Ср. время',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
gallery: {
|
gallery: {
|
||||||
title: 'Галерея',
|
title: 'Галерея',
|
||||||
@@ -168,6 +177,12 @@ const resources = {
|
|||||||
empty: 'В этой категории пока нет изображений',
|
empty: 'В этой категории пока нет изображений',
|
||||||
pickHint: 'Выберите изображение или загрузите новое',
|
pickHint: 'Выберите изображение или загрузите новое',
|
||||||
browseHint: 'Все изображения приложения по категориям',
|
browseHint: 'Все изображения приложения по категориям',
|
||||||
|
sort: {
|
||||||
|
newest: 'Сначала новые',
|
||||||
|
oldest: 'Сначала старые',
|
||||||
|
nameAsc: 'Имя: А–Я',
|
||||||
|
nameDesc: 'Имя: Я–А',
|
||||||
|
},
|
||||||
categories: {
|
categories: {
|
||||||
Library: 'Библиотека',
|
Library: 'Библиотека',
|
||||||
ShowPoster: 'Постеры шоу',
|
ShowPoster: 'Постеры шоу',
|
||||||
@@ -516,6 +531,7 @@ const resources = {
|
|||||||
status: 'Status',
|
status: 'Status',
|
||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
resolution: 'Resolution',
|
resolution: 'Resolution',
|
||||||
|
processingTime: 'Processing time',
|
||||||
empty: 'No uploaded files yet',
|
empty: 'No uploaded files yet',
|
||||||
statuses: {
|
statuses: {
|
||||||
Pending: 'Queued',
|
Pending: 'Queued',
|
||||||
@@ -523,6 +539,14 @@ const resources = {
|
|||||||
Ready: 'Ready',
|
Ready: 'Ready',
|
||||||
Failed: 'Failed',
|
Failed: 'Failed',
|
||||||
},
|
},
|
||||||
|
stats: {
|
||||||
|
queued: 'Currently queued',
|
||||||
|
queuedShort: 'Queued',
|
||||||
|
processing: 'Currently processing',
|
||||||
|
processingShort: 'Processing',
|
||||||
|
average: 'Average processing time (recent)',
|
||||||
|
averageShort: 'Avg time',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
gallery: {
|
gallery: {
|
||||||
title: 'Gallery',
|
title: 'Gallery',
|
||||||
@@ -530,6 +554,12 @@ const resources = {
|
|||||||
empty: 'No images in this category yet',
|
empty: 'No images in this category yet',
|
||||||
pickHint: 'Pick an image or upload a new one',
|
pickHint: 'Pick an image or upload a new one',
|
||||||
browseHint: 'All app images by category',
|
browseHint: 'All app images by category',
|
||||||
|
sort: {
|
||||||
|
newest: 'Newest first',
|
||||||
|
oldest: 'Oldest first',
|
||||||
|
nameAsc: 'Name: A–Z',
|
||||||
|
nameDesc: 'Name: Z–A',
|
||||||
|
},
|
||||||
categories: {
|
categories: {
|
||||||
Library: 'Library',
|
Library: 'Library',
|
||||||
ShowPoster: 'Show posters',
|
ShowPoster: 'Show posters',
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export type SortState = { key: string; desc: boolean }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же —
|
||||||
|
* переключает направление. Для серверных списков `sort`/`desc` передаются в API (и в queryKey), для
|
||||||
|
* клиентских — в {@link sortRows}.
|
||||||
|
*/
|
||||||
|
export function useTableSort(defaultKey: string, defaultDesc = false) {
|
||||||
|
const [sort, setSort] = useState<SortState>({ key: defaultKey, desc: defaultDesc })
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setSort((s) => (s.key === key ? { key, desc: !s.desc } : { key, desc: false }))
|
||||||
|
return { sort, toggle }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заголовок-кнопка столбца со стрелкой сортировки. */
|
||||||
|
export function SortHeader({
|
||||||
|
label,
|
||||||
|
sortKey,
|
||||||
|
sort,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
sortKey: string
|
||||||
|
sort: SortState
|
||||||
|
onToggle: (key: string) => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const active = sort.key === sortKey
|
||||||
|
const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp
|
||||||
|
return (
|
||||||
|
<th className={cn('px-4 py-2 font-medium', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(sortKey)}
|
||||||
|
aria-sort={active ? (sort.desc ? 'descending' : 'ascending') : 'none'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 hover:text-foreground',
|
||||||
|
active && 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', !active && 'opacity-40')} />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Comparable = string | number | boolean | null | undefined
|
||||||
|
|
||||||
|
/** Клиентская сортировка строк по выбранному ключу (для непагинированных списков). nulls — в конец. */
|
||||||
|
export function sortRows<T>(
|
||||||
|
rows: T[],
|
||||||
|
sort: SortState,
|
||||||
|
accessors: Record<string, (row: T) => Comparable>,
|
||||||
|
): T[] {
|
||||||
|
const accessor = accessors[sort.key]
|
||||||
|
if (!accessor) return rows
|
||||||
|
const dir = sort.desc ? -1 : 1
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = accessor(a)
|
||||||
|
const bv = accessor(b)
|
||||||
|
if (av == null && bv == null) return 0
|
||||||
|
if (av == null) return 1
|
||||||
|
if (bv == null) return -1
|
||||||
|
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
||||||
|
return (av < bv ? -1 : av > bv ? 1 : 0) * dir
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user