Add image management functionality: introduce Image entity and related API endpoints, update database schema to support image storage, and enhance UI with a new gallery feature for image selection and upload. Update translations for gallery-related terms.

This commit is contained in:
Leonid Pershin
2026-07-25 11:27:34 +03:00
parent 72451f89a8
commit 149cd153b9
34 changed files with 1732 additions and 11 deletions
+5
View File
@@ -103,6 +103,11 @@
`Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`.
- Секреты не логировать; логи — Serilog.
- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`).
- **Изображения — через общий реестр `Image`** (домен `Domain/Images`, галерея `features/admin/images`).
Любой новый функционал, где загружается или выбирается картинка, должен использовать контрол
`ImageGallery`/`GalleryBrowser` (пикер по категориям `ImageCategory`) и хранить ссылку на `ImageId`,
а не заводить своё файловое поле. Автоматически полученные картинки (например постер из метадаты)
тоже регистрируются как `Image`. Файлы лежат под `images/{id}{ext}`, отдаются по `/api/images/{id}`.
## Команды
@@ -0,0 +1,126 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.GetImageFile;
using TeleWave.Application.Images.ListImages;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ImageEndpoints
{
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
};
public static IEndpointRouteBuilder MapImageEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/images")
.WithTags("Admin.Images")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListImages).Produces<IReadOnlyList<ImageDto>>();
admin.MapPost("", UploadImage).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent);
// Публичная отдача файла (для <img> у зрителя и в админке).
app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images");
return app;
}
private static async Task<IResult> ListImages(
ImageCategory category,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListImagesQuery(category), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadImage(
ImageCategory category,
string fileName,
HttpRequest request,
IImageStore storage,
ISender sender,
CancellationToken cancellationToken
)
{
if (request.ContentLength is > MaxBytes or 0 or null)
return ImageErrors.InvalidFile.ToProblem();
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return ImageErrors.InvalidFile.ToProblem();
var created = await sender.Send(
new UploadImageCommand(category, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await storage.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
// Файл не сохранился — не оставляем висячую запись реестра.
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
return Results.Created($"/api/images/{created.Value}", new CreatedIdResponse(created.Value));
}
private static async Task<IResult> DeleteImage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteImageCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ServeImage(
Guid id,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetImageFileQuery(id), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=86400";
return Results.File(result.Value, ContentTypeFor(Path.GetExtension(result.Value)));
}
private static string ContentTypeFor(string extension) =>
extension.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".bmp" => "image/bmp",
_ => "image/jpeg",
};
}
+1
View File
@@ -119,6 +119,7 @@ app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Settings;
@@ -16,6 +17,7 @@ public interface IAppDbContext
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,28 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Файловое хранилище картинок общего реестра: файлы лежат под images/{imageId}{ext} в корне
/// хранилища и отдаются как есть (не режутся). Один файл на запись реестра.
/// </summary>
public interface IImageStore
{
Task SaveAsync(
Guid imageId,
string extension,
Stream content,
CancellationToken cancellationToken
);
/// <summary>Сохранить готовые байты (например, скачанный из метадаты постер).</summary>
Task SaveAsync(
Guid imageId,
string extension,
byte[] content,
CancellationToken cancellationToken
);
void Delete(Guid imageId, string extension);
/// <summary>Абсолютный путь к файлу изображения или null, если файла нет.</summary>
string? ResolvePath(Guid imageId, string extension);
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Images.DeleteImage;
/// <summary>Удаляет изображение из реестра и его файл. Ссылки на него у потребителей могут повиснуть
/// (отдача вернёт 404, UI покажет заглушку).</summary>
public sealed record DeleteImageCommand(Guid Id) : ICommand<Result>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Images.DeleteImage;
public sealed class DeleteImageCommandHandler(IAppDbContext dbContext, IImageStore storage)
: ICommandHandler<DeleteImageCommand, Result>
{
public async Task<Result> Handle(DeleteImageCommand command, CancellationToken cancellationToken)
{
var image = await dbContext.Images.FirstOrDefaultAsync(
i => i.Id == command.Id,
cancellationToken
);
if (image is null)
return Result.Failure(ImageErrors.NotFound);
storage.Delete(image.Id, image.FileExtension);
dbContext.Images.Remove(image);
return Result.Success();
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Images.GetImageFile;
/// <summary>Абсолютный путь к файлу изображения (для отдачи). Ошибка — если записи/файла нет.</summary>
public sealed record GetImageFileQuery(Guid Id) : IQuery<Result<string>>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Images.GetImageFile;
public sealed class GetImageFileQueryHandler(IAppDbContext dbContext, IImageStore storage)
: IQueryHandler<GetImageFileQuery, Result<string>>
{
public async Task<Result<string>> Handle(
GetImageFileQuery query,
CancellationToken cancellationToken
)
{
var image = await dbContext.Images.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == query.Id, cancellationToken);
if (image is null)
return Result.Failure<string>(ImageErrors.NotFound);
var path = storage.ResolvePath(image.Id, image.FileExtension);
return path is null ? Result.Failure<string>(ImageErrors.NotFound) : Result.Success(path);
}
}
@@ -0,0 +1,11 @@
using TeleWave.Domain.Images;
namespace TeleWave.Application.Images;
/// <summary>Запись галереи для показа. Файл отдаётся по /api/images/{Id} (URL строит фронт).</summary>
public sealed record ImageDto(
Guid Id,
ImageCategory Category,
string? OriginalFileName,
DateTimeOffset CreatedAt
);
@@ -0,0 +1,16 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Images;
public static class ImageErrors
{
public static readonly Error NotFound = Error.NotFound(
"Images.NotFound",
"Изображение не найдено."
);
public static readonly Error InvalidFile = Error.Validation(
"Images.InvalidFile",
"Недопустимый файл изображения (формат или размер)."
);
}
@@ -0,0 +1,9 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Images;
namespace TeleWave.Application.Images.ListImages;
/// <summary>Список изображений одной категории (вкладки), новые сверху.</summary>
public sealed record ListImagesQuery(ImageCategory Category)
: IQuery<Result<IReadOnlyList<ImageDto>>>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Images.ListImages;
public sealed class ListImagesQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListImagesQuery, Result<IReadOnlyList<ImageDto>>>
{
public async Task<Result<IReadOnlyList<ImageDto>>> Handle(
ListImagesQuery query,
CancellationToken cancellationToken
)
{
var images = await dbContext.Images.AsNoTracking()
.Where(i => i.Category == query.Category)
.OrderByDescending(i => i.CreatedAt)
.Select(i => new ImageDto(i.Id, i.Category, i.OriginalFileName, i.CreatedAt))
.ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<ImageDto>>(images);
}
}
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Images;
namespace TeleWave.Application.Images.UploadImage;
/// <summary>Регистрирует запись изображения (файл кладёт вызывающий эндпоинт по возвращённому Id).</summary>
public sealed record UploadImageCommand(
ImageCategory Category,
string Extension,
string? OriginalFileName
) : ICommand<Result<Guid>>;
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Images;
namespace TeleWave.Application.Images.UploadImage;
public sealed class UploadImageCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UploadImageCommand, Result<Guid>>
{
public Task<Result<Guid>> Handle(UploadImageCommand command, CancellationToken cancellationToken)
{
var image = Image.Create(command.Category, command.Extension, command.OriginalFileName);
dbContext.Images.Add(image);
return Task.FromResult(Result.Success(image.Id));
}
}
@@ -0,0 +1,39 @@
namespace TeleWave.Domain.Images;
/// <summary>
/// Запись общего реестра изображений приложения. Единая точка для всех картинок (постеры шоу, кадры
/// серий, фоны заставок, ручные загрузки) — файл лежит под images/{Id}{FileExtension}, а потребители
/// ссылаются на <see cref="Id"/>. Позволяет иметь общую галерею с вкладками по <see cref="Category"/>.
/// </summary>
public class Image
{
public Guid Id { get; private set; }
public ImageCategory Category { get; private set; }
/// <summary>Расширение файла с точкой в нижнем регистре (например «.jpg»).</summary>
public string FileExtension { get; private set; } = string.Empty;
/// <summary>Имя исходного файла (для ручных загрузок) или описательная метка — для показа в галерее.</summary>
public string? OriginalFileName { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private Image() { }
public static Image Create(ImageCategory category, string fileExtension, string? originalFileName) =>
new()
{
Id = Guid.NewGuid(),
Category = category,
FileExtension = Normalize(fileExtension),
OriginalFileName = string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName.Trim(),
CreatedAt = DateTimeOffset.UtcNow,
};
private static string Normalize(string extension)
{
var ext = extension.Trim().ToLowerInvariant();
return ext.StartsWith('.') ? ext : "." + ext;
}
}
@@ -0,0 +1,17 @@
namespace TeleWave.Domain.Images;
/// <summary>Категория изображения в общей галерее (вкладка). Определяет, откуда картинка пришла/где используется.</summary>
public enum ImageCategory
{
/// <summary>Загружено напрямую в галерею, не привязано к конкретной сущности.</summary>
Library,
/// <summary>Постер шоу.</summary>
ShowPoster,
/// <summary>Кадр серии.</summary>
EpisodeStill,
/// <summary>Фон блока ТВ-заставки.</summary>
BumperBackground,
}
@@ -136,6 +136,7 @@ public static class DependencyInjection
services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
@@ -0,0 +1,48 @@
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Файловое хранилище общего реестра изображений: images/{imageId}{ext}.</summary>
public sealed class ImageStore(MediaPathResolver paths) : IImageStore
{
public async Task SaveAsync(
Guid imageId,
string extension,
Stream content,
CancellationToken cancellationToken
)
{
Directory.CreateDirectory(paths.ImagesDir);
var path = paths.ImagePath(imageId, Normalize(extension));
await using var fs = File.Create(path);
await content.CopyToAsync(fs, cancellationToken);
}
public async Task SaveAsync(
Guid imageId,
string extension,
byte[] content,
CancellationToken cancellationToken
)
{
Directory.CreateDirectory(paths.ImagesDir);
var path = paths.ImagePath(imageId, Normalize(extension));
await File.WriteAllBytesAsync(path, content, cancellationToken);
}
public void Delete(Guid imageId, string extension)
{
var path = paths.ImagePath(imageId, Normalize(extension));
if (File.Exists(path))
File.Delete(path);
}
public string? ResolvePath(Guid imageId, string extension)
{
var path = paths.ImagePath(imageId, Normalize(extension));
return File.Exists(path) ? path : null;
}
private static string Normalize(string extension) =>
extension.StartsWith('.') ? extension : "." + extension;
}
@@ -19,6 +19,7 @@ public sealed class MediaPathResolver
AssetsDir = Path.Combine(_root, "assets");
BumpersDir = Path.Combine(_root, "bumpers");
MetadataDir = Path.Combine(_root, "metadata");
ImagesDir = Path.Combine(_root, "images");
}
public string InboxDir { get; }
@@ -32,6 +33,9 @@ public sealed class MediaPathResolver
/// <summary>Картинки метаданных (постеры/кадры), скачанные локально.</summary>
public string MetadataDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; }
public void EnsureDirectories()
{
Directory.CreateDirectory(InboxDir);
@@ -40,6 +44,7 @@ public sealed class MediaPathResolver
Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(MetadataDir);
Directory.CreateDirectory(ImagesDir);
}
public string MetadataShowDir(Guid showId) =>
@@ -86,6 +91,10 @@ public sealed class MediaPathResolver
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
public string ImagePath(Guid imageId, string extension) =>
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
public string OriginalPath(Guid assetId, string extension) =>
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
@@ -0,0 +1,889 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeleWave.Infrastructure.Persistence;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260725082032_ImageRegistry")]
partial class ImageRegistry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundImageExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("TextColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("BumperTemplate");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
b.ToTable("ProgrammingOverride");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("Category", "CreatedAt");
b.ToTable("Images");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasColumnType("text");
b.Property<string>("PosterPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<string>("StillPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("BumperTemplates")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany("Episodes")
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("BumperTemplates");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ImageRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Images",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Category = table.Column<int>(type: "integer", nullable: false),
FileExtension = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
OriginalFileName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Images", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Images_Category_CreatedAt",
table: "Images",
columns: new[] { "Category", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Images");
}
}
}
@@ -454,6 +454,33 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("Category", "CreatedAt");
b.ToTable("Images");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Metadata;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Settings;
@@ -26,6 +27,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Images;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ImageConfiguration : IEntityTypeConfiguration<Image>
{
public void Configure(EntityTypeBuilder<Image> builder)
{
builder.Property(x => x.FileExtension).IsRequired().HasMaxLength(16);
builder.Property(x => x.OriginalFileName).HasMaxLength(512);
builder.HasIndex(x => new { x.Category, x.CreatedAt });
}
}
@@ -513,18 +513,10 @@ function BumperCard({
</div>
{/* Блоки заставок */}
<div className="flex items-center justify-between border-t border-border pt-4">
<div className="border-t border-border pt-4">
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
<Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')}
</Button>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
<div className="flex flex-col gap-3">
{templates.map((template) => (
<BumperTemplateEditor
@@ -536,6 +528,16 @@ function BumperCard({
/>
))}
</div>
<div className="flex justify-center">
<Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')}
</Button>
</div>
</CollapsibleCard>
)
}
@@ -1106,7 +1108,9 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="flex items-center gap-3 py-1.5">
<span className="w-28 shrink-0 text-muted-foreground">{formatTime(e.startsAtUtc)}</span>
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)}
</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next'
import { GalleryBrowser } from './ImageGallery'
/** Отдельная страница «Галерея»: просмотр/загрузка/удаление всех изображений приложения. */
export function GalleryPanel() {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.gallery.title')}</h2>
<div className="crt-panel rounded-md p-4">
<GalleryBrowser />
</div>
</div>
)
}
@@ -0,0 +1,185 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { ImageCategory } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { toast } from '@/shared/ui/toast-store'
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
export type ImagePick = { id: string; url: string }
/**
* Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан <c>onSelect</c> —
* работает как пикер (клик по картинке или загрузка новой возвращает её и закрывает через onClose).
* Новые загрузки идут в активную вкладку (по умолчанию — категорию вызова).
*/
export function GalleryBrowser({
category = 'Library',
onSelect,
onClose,
}: {
category?: ImageCategory
onSelect?: (image: ImagePick) => void
onClose?: () => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [active, setActive] = useState<ImageCategory>(category)
const fileInput = useRef<HTMLInputElement>(null)
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data: images, isLoading } = useQuery({
queryKey: ['admin', 'images', active],
queryFn: () => listImages(active),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
const pick = (id: string) => {
if (!onSelect) return
onSelect({ id, url: imageUrl(id) })
onClose?.()
}
const upload = useMutation({
mutationFn: (file: File) => uploadImage(active, file),
onSuccess: (created) => {
invalidate()
// В режиме пикера загрузка = «загрузить и выбрать»: возвращаем новую картинку и закрываем.
if (onSelect) pick(created.id)
},
onError,
})
const remove = useMutation({
mutationFn: (id: string) => deleteImage(id),
onSuccess: invalidate,
onError,
})
return (
<>
<div className="flex flex-wrap gap-1.5">
{CATEGORIES.map((c) => (
<button
key={c}
type="button"
onClick={() => setActive(c)}
className={`rounded-md px-3 py-1.5 text-sm ${
active === c
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted'
}`}
>
{t(`admin.gallery.categories.${c}`)}
</button>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')}
</span>
<input
ref={fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) upload.mutate(file)
e.target.value = ''
}}
/>
<Button
size="sm"
variant="outline"
disabled={upload.isPending}
onClick={() => fileInput.current?.click()}
>
<Upload className="h-4 w-4" />
{t('admin.gallery.upload')}
</Button>
</div>
<div className="mt-3 max-h-[55vh] overflow-y-auto">
{isLoading ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
) : images && images.length > 0 ? (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
{images.map((img) => (
<div key={img.id} className="group relative">
<button
type="button"
onClick={() => pick(img.id)}
className={`block aspect-square w-full overflow-hidden rounded-md border border-border bg-muted/30 ${
onSelect ? 'cursor-pointer hover:border-primary' : 'cursor-default'
}`}
title={img.originalFileName ?? ''}
>
<img
src={imageUrl(img.id)}
alt={img.originalFileName ?? ''}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
<button
type="button"
disabled={remove.isPending}
onClick={() => remove.mutate(img.id)}
aria-label={t('common.delete')}
className="absolute right-1 top-1 rounded bg-black/60 p-1 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
{t('admin.gallery.empty')}
</p>
)}
</div>
</>
)
}
/** Общая галерея изображений в модальном окне (используется как пикер в местах выбора картинки). */
export function ImageGallery({
open,
onOpenChange,
category,
onSelect,
}: {
open: boolean
onOpenChange: (open: boolean) => void
category?: ImageCategory
onSelect?: (image: ImagePick) => void
}) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('admin.gallery.title')}</DialogTitle>
</DialogHeader>
{open && (
<GalleryBrowser
category={category}
onSelect={onSelect}
onClose={() => onOpenChange(false)}
/>
)}
</DialogContent>
</Dialog>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type { CreatedIdResponse, ImageCategory, ImageDto } from '@/shared/api/types'
export function listImages(category: ImageCategory) {
const q = new URLSearchParams({ category })
return apiRequest<ImageDto[]>(`/admin/images?${q.toString()}`)
}
export function deleteImage(id: string) {
return apiRequest<void>(`/admin/images/${id}`, { method: 'DELETE' })
}
/** Публичный URL файла изображения (для <img>). */
export function imageUrl(id: string) {
return `/api/images/${id}`
}
/** Загрузка изображения в категорию (сырое тело, имя/категория в query — как uploadMedia). */
export function uploadImage(category: ImageCategory, file: File): Promise<CreatedIdResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const q = new URLSearchParams({ fileName: file.name, category })
xhr.open('POST', `/api/admin/images?${q.toString()}`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
} catch {
reject(new HttpError({ title: 'Bad response' }, xhr.status))
}
} else {
let detail = `HTTP ${xhr.status}`
try {
const p = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = p.detail ?? p.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
+21
View File
@@ -17,6 +17,7 @@ import { Route as RegisterRouteImport } from './routes/register'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
import { Route as AdminGalleryRouteImport } from './routes/admin/gallery'
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
import { Route as AdminMediaRouteImport } from './routes/admin/media'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
@@ -68,6 +69,11 @@ const AdminChannelsRoute = AdminChannelsRouteImport.update({
path: '/channels',
getParentRoute: () => AdminRoute,
} as any)
const AdminGalleryRoute = AdminGalleryRouteImport.update({
id: '/gallery',
path: '/gallery',
getParentRoute: () => AdminRoute,
} as any)
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
id: '/maintenance',
path: '/maintenance',
@@ -127,6 +133,7 @@ export interface FileRoutesByFullPath {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/gallery': typeof AdminGalleryRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
@@ -145,6 +152,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/gallery': typeof AdminGalleryRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
@@ -165,6 +173,7 @@ export interface FileRoutesById {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/gallery': typeof AdminGalleryRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
@@ -187,6 +196,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/gallery'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
@@ -205,6 +215,7 @@ export interface FileRouteTypes {
| '/login'
| '/register'
| '/settings'
| '/admin/gallery'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
@@ -224,6 +235,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/gallery'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
@@ -304,6 +316,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminChannelsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/gallery': {
id: '/admin/gallery'
path: '/gallery'
fullPath: '/admin/gallery'
preLoaderRoute: typeof AdminGalleryRouteImport
parentRoute: typeof AdminRoute
}
'/admin/maintenance': {
id: '/admin/maintenance'
path: '/maintenance'
@@ -407,6 +426,7 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
interface AdminRouteChildren {
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
AdminGalleryRoute: typeof AdminGalleryRoute
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
AdminMediaRoute: typeof AdminMediaRoute
AdminRolesRoute: typeof AdminRolesRoute
@@ -418,6 +438,7 @@ interface AdminRouteChildren {
const AdminRouteChildren: AdminRouteChildren = {
AdminChannelsRoute: AdminChannelsRouteWithChildren,
AdminGalleryRoute: AdminGalleryRoute,
AdminMaintenanceRoute: AdminMaintenanceRoute,
AdminMediaRoute: AdminMediaRoute,
AdminRolesRoute: AdminRolesRoute,
+7
View File
@@ -36,6 +36,13 @@ function AdminLayout() {
>
{t('admin.channels.title')}
</Link>
<Link
to="/admin/gallery"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.gallery.title')}
</Link>
<Link
to="/admin/roles"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { GalleryPanel } from '@/features/admin/images/GalleryPanel'
export const Route = createFileRoute('/admin/gallery')({ component: GalleryPanel })
+9
View File
@@ -99,6 +99,15 @@ export type EpisodeDto = {
airDate: string | null
}
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
export type ImageDto = {
id: string
category: ImageCategory
originalFileName: string | null
createdAt: string
}
export type ShowDto = {
id: string
name: string
+26
View File
@@ -149,6 +149,19 @@ const resources = {
Failed: 'Ошибка',
},
},
gallery: {
title: 'Галерея',
upload: 'Загрузить',
empty: 'В этой категории пока нет изображений',
pickHint: 'Выберите изображение или загрузите новое',
browseHint: 'Все изображения приложения по категориям',
categories: {
Library: 'Библиотека',
ShowPoster: 'Постеры шоу',
EpisodeStill: 'Кадры серий',
BumperBackground: 'Фоны заставок',
},
},
shows: {
title: 'Шоу',
name: 'Название',
@@ -438,6 +451,19 @@ const resources = {
Failed: 'Failed',
},
},
gallery: {
title: 'Gallery',
upload: 'Upload',
empty: 'No images in this category yet',
pickHint: 'Pick an image or upload a new one',
browseHint: 'All app images by category',
categories: {
Library: 'Library',
ShowPoster: 'Show posters',
EpisodeStill: 'Episode stills',
BumperBackground: 'Bumper backgrounds',
},
},
shows: {
title: 'Shows',
name: 'Name',