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

This commit is contained in:
Leonid Pershin
2026-07-25 11:27:34 +03:00
parent 72451f89a8
commit 149cd153b9
34 changed files with 1732 additions and 11 deletions
@@ -0,0 +1,126 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.GetImageFile;
using TeleWave.Application.Images.ListImages;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ImageEndpoints
{
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
};
public static IEndpointRouteBuilder MapImageEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/images")
.WithTags("Admin.Images")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListImages).Produces<IReadOnlyList<ImageDto>>();
admin.MapPost("", UploadImage).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent);
// Публичная отдача файла (для <img> у зрителя и в админке).
app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images");
return app;
}
private static async Task<IResult> ListImages(
ImageCategory category,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListImagesQuery(category), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadImage(
ImageCategory category,
string fileName,
HttpRequest request,
IImageStore storage,
ISender sender,
CancellationToken cancellationToken
)
{
if (request.ContentLength is > MaxBytes or 0 or null)
return ImageErrors.InvalidFile.ToProblem();
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return ImageErrors.InvalidFile.ToProblem();
var created = await sender.Send(
new UploadImageCommand(category, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await storage.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
// Файл не сохранился — не оставляем висячую запись реестра.
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
return Results.Created($"/api/images/{created.Value}", new CreatedIdResponse(created.Value));
}
private static async Task<IResult> DeleteImage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteImageCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ServeImage(
Guid id,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetImageFileQuery(id), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=86400";
return Results.File(result.Value, ContentTypeFor(Path.GetExtension(result.Value)));
}
private static string ContentTypeFor(string extension) =>
extension.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".bmp" => "image/bmp",
_ => "image/jpeg",
};
}
+1
View File
@@ -119,6 +119,7 @@ app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();