From 149cd153b9787769cb974abd367854db2ee5bd15 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 11:27:34 +0300 Subject: [PATCH] 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. --- CLAUDE.md | 5 + .../TeleWave.Api/Endpoints/ImageEndpoints.cs | 126 +++ backend/src/TeleWave.Api/Program.cs | 1 + .../Common/Interfaces/IAppDbContext.cs | 2 + .../Common/Interfaces/IImageStore.cs | 28 + .../Images/DeleteImage/DeleteImageCommand.cs | 8 + .../DeleteImage/DeleteImageCommandHandler.cs | 24 + .../Images/GetImageFile/GetImageFileQuery.cs | 7 + .../GetImageFile/GetImageFileQueryHandler.cs | 24 + .../TeleWave.Application/Images/ImageDtos.cs | 11 + .../Images/ImageErrors.cs | 16 + .../Images/ListImages/ListImagesQuery.cs | 9 + .../ListImages/ListImagesQueryHandler.cs | 24 + .../Images/UploadImage/UploadImageCommand.cs | 12 + .../UploadImage/UploadImageCommandHandler.cs | 17 + backend/src/TeleWave.Domain/Images/Image.cs | 39 + .../TeleWave.Domain/Images/ImageCategory.cs | 17 + .../DependencyInjection.cs | 1 + .../Media/ImageStore.cs | 48 + .../Media/MediaPathResolver.cs | 9 + .../20260725082032_ImageRegistry.Designer.cs | 889 ++++++++++++++++++ .../20260725082032_ImageRegistry.cs | 42 + .../Migrations/AppDbContextModelSnapshot.cs | 27 + .../Persistence/AppDbContext.cs | 2 + .../Configurations/ImageConfiguration.cs | 15 + .../features/admin/channels/ChannelDetail.tsx | 26 +- .../features/admin/images/GalleryPanel.tsx | 15 + .../features/admin/images/ImageGallery.tsx | 185 ++++ frontend/src/features/admin/images/api.ts | 47 + frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/admin.tsx | 7 + frontend/src/routes/admin/gallery.tsx | 4 + frontend/src/shared/api/types.ts | 9 + frontend/src/shared/lib/i18n.ts | 26 + 34 files changed, 1732 insertions(+), 11 deletions(-) create mode 100644 backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs create mode 100644 backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommand.cs create mode 100644 backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQuery.cs create mode 100644 backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Images/ImageDtos.cs create mode 100644 backend/src/TeleWave.Application/Images/ImageErrors.cs create mode 100644 backend/src/TeleWave.Application/Images/ListImages/ListImagesQuery.cs create mode 100644 backend/src/TeleWave.Application/Images/ListImages/ListImagesQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommand.cs create mode 100644 backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommandHandler.cs create mode 100644 backend/src/TeleWave.Domain/Images/Image.cs create mode 100644 backend/src/TeleWave.Domain/Images/ImageCategory.cs create mode 100644 backend/src/TeleWave.Infrastructure/Media/ImageStore.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/ImageConfiguration.cs create mode 100644 frontend/src/features/admin/images/GalleryPanel.tsx create mode 100644 frontend/src/features/admin/images/ImageGallery.tsx create mode 100644 frontend/src/features/admin/images/api.ts create mode 100644 frontend/src/routes/admin/gallery.tsx diff --git a/CLAUDE.md b/CLAUDE.md index d91963f..1be15d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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}`. ## Команды diff --git a/backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs new file mode 100644 index 0000000..9ffcfcf --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs @@ -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 AllowedExtensions = new HashSet( + 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>(); + admin.MapPost("", UploadImage).Produces(StatusCodes.Status201Created); + admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent); + + // Публичная отдача файла (для у зрителя и в админке). + app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images"); + + return app; + } + + private static async Task ListImages( + ImageCategory category, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListImagesQuery(category), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 DeleteImage( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteImageCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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", + }; +} diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index d9a00b5..8695018 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -119,6 +119,7 @@ app.MapStreamingEndpoints(); app.MapMaintenanceEndpoints(); app.MapSettingsEndpoints(); app.MapMetadataEndpoints(); +app.MapImageEndpoints(); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index 3b83d59..d429af9 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -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 ScheduleEntries { get; } DbSet BumperAssets { get; } DbSet AppSettings { get; } + DbSet Images { get; } Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs b/backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs new file mode 100644 index 0000000..c4080a3 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs @@ -0,0 +1,28 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Файловое хранилище картинок общего реестра: файлы лежат под images/{imageId}{ext} в корне +/// хранилища и отдаются как есть (не режутся). Один файл на запись реестра. +/// +public interface IImageStore +{ + Task SaveAsync( + Guid imageId, + string extension, + Stream content, + CancellationToken cancellationToken + ); + + /// Сохранить готовые байты (например, скачанный из метадаты постер). + Task SaveAsync( + Guid imageId, + string extension, + byte[] content, + CancellationToken cancellationToken + ); + + void Delete(Guid imageId, string extension); + + /// Абсолютный путь к файлу изображения или null, если файла нет. + string? ResolvePath(Guid imageId, string extension); +} diff --git a/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommand.cs b/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommand.cs new file mode 100644 index 0000000..65e5d0d --- /dev/null +++ b/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Images.DeleteImage; + +/// Удаляет изображение из реестра и его файл. Ссылки на него у потребителей могут повиснуть +/// (отдача вернёт 404, UI покажет заглушку). +public sealed record DeleteImageCommand(Guid Id) : ICommand; diff --git a/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs b/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs new file mode 100644 index 0000000..0459306 --- /dev/null +++ b/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQuery.cs b/backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQuery.cs new file mode 100644 index 0000000..2e63660 --- /dev/null +++ b/backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQuery.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Images.GetImageFile; + +/// Абсолютный путь к файлу изображения (для отдачи). Ошибка — если записи/файла нет. +public sealed record GetImageFileQuery(Guid Id) : IQuery>; diff --git a/backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQueryHandler.cs b/backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQueryHandler.cs new file mode 100644 index 0000000..14c234c --- /dev/null +++ b/backend/src/TeleWave.Application/Images/GetImageFile/GetImageFileQueryHandler.cs @@ -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> +{ + public async Task> 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(ImageErrors.NotFound); + + var path = storage.ResolvePath(image.Id, image.FileExtension); + return path is null ? Result.Failure(ImageErrors.NotFound) : Result.Success(path); + } +} diff --git a/backend/src/TeleWave.Application/Images/ImageDtos.cs b/backend/src/TeleWave.Application/Images/ImageDtos.cs new file mode 100644 index 0000000..726435f --- /dev/null +++ b/backend/src/TeleWave.Application/Images/ImageDtos.cs @@ -0,0 +1,11 @@ +using TeleWave.Domain.Images; + +namespace TeleWave.Application.Images; + +/// Запись галереи для показа. Файл отдаётся по /api/images/{Id} (URL строит фронт). +public sealed record ImageDto( + Guid Id, + ImageCategory Category, + string? OriginalFileName, + DateTimeOffset CreatedAt +); diff --git a/backend/src/TeleWave.Application/Images/ImageErrors.cs b/backend/src/TeleWave.Application/Images/ImageErrors.cs new file mode 100644 index 0000000..c7ee5b9 --- /dev/null +++ b/backend/src/TeleWave.Application/Images/ImageErrors.cs @@ -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", + "Недопустимый файл изображения (формат или размер)." + ); +} diff --git a/backend/src/TeleWave.Application/Images/ListImages/ListImagesQuery.cs b/backend/src/TeleWave.Application/Images/ListImages/ListImagesQuery.cs new file mode 100644 index 0000000..1b6d1b8 --- /dev/null +++ b/backend/src/TeleWave.Application/Images/ListImages/ListImagesQuery.cs @@ -0,0 +1,9 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Images; + +namespace TeleWave.Application.Images.ListImages; + +/// Список изображений одной категории (вкладки), новые сверху. +public sealed record ListImagesQuery(ImageCategory Category) + : IQuery>>; diff --git a/backend/src/TeleWave.Application/Images/ListImages/ListImagesQueryHandler.cs b/backend/src/TeleWave.Application/Images/ListImages/ListImagesQueryHandler.cs new file mode 100644 index 0000000..2ecaea2 --- /dev/null +++ b/backend/src/TeleWave.Application/Images/ListImages/ListImagesQueryHandler.cs @@ -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>> +{ + public async Task>> 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>(images); + } +} diff --git a/backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommand.cs b/backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommand.cs new file mode 100644 index 0000000..a69d080 --- /dev/null +++ b/backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommand.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Images; + +namespace TeleWave.Application.Images.UploadImage; + +/// Регистрирует запись изображения (файл кладёт вызывающий эндпоинт по возвращённому Id). +public sealed record UploadImageCommand( + ImageCategory Category, + string Extension, + string? OriginalFileName +) : ICommand>; diff --git a/backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommandHandler.cs b/backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommandHandler.cs new file mode 100644 index 0000000..c9ca74d --- /dev/null +++ b/backend/src/TeleWave.Application/Images/UploadImage/UploadImageCommandHandler.cs @@ -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> +{ + public Task> 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)); + } +} diff --git a/backend/src/TeleWave.Domain/Images/Image.cs b/backend/src/TeleWave.Domain/Images/Image.cs new file mode 100644 index 0000000..6df9a96 --- /dev/null +++ b/backend/src/TeleWave.Domain/Images/Image.cs @@ -0,0 +1,39 @@ +namespace TeleWave.Domain.Images; + +/// +/// Запись общего реестра изображений приложения. Единая точка для всех картинок (постеры шоу, кадры +/// серий, фоны заставок, ручные загрузки) — файл лежит под images/{Id}{FileExtension}, а потребители +/// ссылаются на . Позволяет иметь общую галерею с вкладками по . +/// +public class Image +{ + public Guid Id { get; private set; } + + public ImageCategory Category { get; private set; } + + /// Расширение файла с точкой в нижнем регистре (например «.jpg»). + public string FileExtension { get; private set; } = string.Empty; + + /// Имя исходного файла (для ручных загрузок) или описательная метка — для показа в галерее. + 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; + } +} diff --git a/backend/src/TeleWave.Domain/Images/ImageCategory.cs b/backend/src/TeleWave.Domain/Images/ImageCategory.cs new file mode 100644 index 0000000..70615d7 --- /dev/null +++ b/backend/src/TeleWave.Domain/Images/ImageCategory.cs @@ -0,0 +1,17 @@ +namespace TeleWave.Domain.Images; + +/// Категория изображения в общей галерее (вкладка). Определяет, откуда картинка пришла/где используется. +public enum ImageCategory +{ + /// Загружено напрямую в галерею, не привязано к конкретной сущности. + Library, + + /// Постер шоу. + ShowPoster, + + /// Кадр серии. + EpisodeStill, + + /// Фон блока ТВ-заставки. + BumperBackground, +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 80ffd4e..e2e1f90 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -136,6 +136,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/TeleWave.Infrastructure/Media/ImageStore.cs b/backend/src/TeleWave.Infrastructure/Media/ImageStore.cs new file mode 100644 index 0000000..7e5ebfb --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/ImageStore.cs @@ -0,0 +1,48 @@ +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// Файловое хранилище общего реестра изображений: images/{imageId}{ext}. +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; +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs index 7d6c088..877448b 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -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 /// Картинки метаданных (постеры/кадры), скачанные локально. public string MetadataDir { get; } + /// Общий реестр изображений (галерея): файлы images/{imageId}{ext}. + 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)); + /// Путь к файлу изображения общего реестра (extension — с точкой). + 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)); diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.Designer.cs new file mode 100644 index 0000000..8e4b01b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.Designer.cs @@ -0,0 +1,889 @@ +// +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 + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperNextLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasColumnType("text"); + + b.Property("PosterPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.cs new file mode 100644 index 0000000..403d395 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725082032_ImageRegistry.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ImageRegistry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Images", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Category = table.Column(type: "integer", nullable: false), + FileExtension = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + OriginalFileName = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + CreatedAt = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Images"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index ff92711..383a1e5 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -454,6 +454,33 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("ScheduleEntries"); }); + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("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("Id") diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index 70bc33f..a78fc0b 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -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 options) public DbSet ScheduleEntries => Set(); public DbSet BumperAssets => Set(); public DbSet AppSettings => Set(); + public DbSet Images => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ImageConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ImageConfiguration.cs new file mode 100644 index 0000000..2277874 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ImageConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.FileExtension).IsRequired().HasMaxLength(16); + builder.Property(x => x.OriginalFileName).HasMaxLength(512); + builder.HasIndex(x => new { x.Category, x.CreatedAt }); + } +} diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 0dffc16..9c5880d 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -513,18 +513,10 @@ function BumperCard({ {/* Блоки заставок */} -
+

{t('admin.channels.bumperTemplates')}

- +

{t('admin.channels.bumperTemplatesHint')}

-

{t('admin.channels.bumperTemplatesHint')}

{templates.map((template) => ( ))}
+
+ +
) } @@ -1106,7 +1108,9 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
    {entries.slice(0, 40).map((e) => (
  • - {formatTime(e.startsAtUtc)} + + {formatTime(e.startsAtUtc)} + {e.kind === 'Ad' ? ( {t('air.ad')} ) : e.kind === 'Bumper' ? ( diff --git a/frontend/src/features/admin/images/GalleryPanel.tsx b/frontend/src/features/admin/images/GalleryPanel.tsx new file mode 100644 index 0000000..50e09db --- /dev/null +++ b/frontend/src/features/admin/images/GalleryPanel.tsx @@ -0,0 +1,15 @@ +import { useTranslation } from 'react-i18next' +import { GalleryBrowser } from './ImageGallery' + +/** Отдельная страница «Галерея»: просмотр/загрузка/удаление всех изображений приложения. */ +export function GalleryPanel() { + const { t } = useTranslation() + return ( +
    +

    {t('admin.gallery.title')}

    +
    + +
    +
    + ) +} diff --git a/frontend/src/features/admin/images/ImageGallery.tsx b/frontend/src/features/admin/images/ImageGallery.tsx new file mode 100644 index 0000000..543400f --- /dev/null +++ b/frontend/src/features/admin/images/ImageGallery.tsx @@ -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 } + +/** + * Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан onSelect — + * работает как пикер (клик по картинке или загрузка новой возвращает её и закрывает через 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(category) + const fileInput = useRef(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 ( + <> +
    + {CATEGORIES.map((c) => ( + + ))} +
    + +
    + + {onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')} + + { + const file = e.target.files?.[0] + if (file) upload.mutate(file) + e.target.value = '' + }} + /> + +
    + +
    + {isLoading ? ( +

    {t('common.loading')}

    + ) : images && images.length > 0 ? ( +
    + {images.map((img) => ( +
    + + +
    + ))} +
    + ) : ( +

    + {t('admin.gallery.empty')} +

    + )} +
    + + ) +} + +/** Общая галерея изображений в модальном окне (используется как пикер в местах выбора картинки). */ +export function ImageGallery({ + open, + onOpenChange, + category, + onSelect, +}: { + open: boolean + onOpenChange: (open: boolean) => void + category?: ImageCategory + onSelect?: (image: ImagePick) => void +}) { + const { t } = useTranslation() + return ( + + + + {t('admin.gallery.title')} + + {open && ( + onOpenChange(false)} + /> + )} + + + ) +} diff --git a/frontend/src/features/admin/images/api.ts b/frontend/src/features/admin/images/api.ts new file mode 100644 index 0000000..41536df --- /dev/null +++ b/frontend/src/features/admin/images/api.ts @@ -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(`/admin/images?${q.toString()}`) +} + +export function deleteImage(id: string) { + return apiRequest(`/admin/images/${id}`, { method: 'DELETE' }) +} + +/** Публичный URL файла изображения (для ). */ +export function imageUrl(id: string) { + return `/api/images/${id}` +} + +/** Загрузка изображения в категорию (сырое тело, имя/категория в query — как uploadMedia). */ +export function uploadImage(category: ImageCategory, file: File): Promise { + 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) + }) +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 9bff091..e611b76 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 7ef35fe..dc3a3a2 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -36,6 +36,13 @@ function AdminLayout() { > {t('admin.channels.title')} + + {t('admin.gallery.title')} +