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:
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
+889
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 })
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user