Detailseite (Rennmausakte): - Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen, Charakter als Chips, getabte Fotos/Gesundheit/Gewicht. - Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen). - Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular). Listen: - Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge, Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button. - Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header. Zucht-Nachname: - Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix". - „Eigene Zucht" ist die Standard-Herkunft neuer Tiere. Weiteres: - Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage). - Toast-Rückmeldungen für alle Speichern-Aktionen. - Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
8.5 KiB
C#
164 lines
8.5 KiB
C#
using GerbilManagerWebAPI.Dtos;
|
|
using GerbilManagerWebAPI.Models;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GerbilManagerWebAPI.Endpoints
|
|
{
|
|
/// <summary>
|
|
/// Gerbil photo endpoints (FEAT-1b phase 2), per the contract agreed with Oscar:
|
|
/// GET /gerbils/{id}/photos -> plain array (not paged)
|
|
/// POST /gerbils/{id}/photos -> multipart 'file' + optional 'caption' -> 201 PhotoDto
|
|
/// DELETE /photos/{id} -> 204
|
|
/// GET /photos/files/{fileName} -> the image bytes
|
|
/// url = "/photos/files/{fileName}"; profile photo = first by SortOrder.
|
|
/// </summary>
|
|
public static class PhotoEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapPhotoEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
app.MapGet("/gerbils/{id:guid}/photos",
|
|
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
|
{
|
|
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
|
|
var photos = await db.GerbilPhotos.AsNoTracking()
|
|
.Where(p => p.GerbilId == id)
|
|
.OrderBy(p => p.SortOrder)
|
|
.ToListAsync();
|
|
return TypedResults.Ok(photos.Select(ToDto).ToList());
|
|
}).WithTags("Photos");
|
|
|
|
app.MapPost("/gerbils/{id:guid}/photos",
|
|
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
|
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
|
|
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
|
|
|
var ext = Path.GetExtension(file.FileName);
|
|
var fileName = $"{Guid.NewGuid():N}{ext}";
|
|
var root = PhotoRoot(config, env);
|
|
Directory.CreateDirectory(root);
|
|
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
|
await file.CopyToAsync(stream);
|
|
|
|
int nextSort = (await db.GerbilPhotos.Where(p => p.GerbilId == id)
|
|
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
|
|
|
var photo = new GerbilPhoto
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
GerbilId = id,
|
|
FileName = fileName,
|
|
Caption = caption,
|
|
SortOrder = nextSort,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
};
|
|
db.GerbilPhotos.Add(photo);
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.Created($"/photos/{photo.Id}", ToDto(photo));
|
|
}).WithTags("Photos").DisableAntiforgery();
|
|
|
|
app.MapDelete("/photos/{id:guid}",
|
|
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
var photo = await db.GerbilPhotos.FirstOrDefaultAsync(p => p.Id == id);
|
|
if (photo is null) return TypedResults.NotFound();
|
|
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
|
if (File.Exists(path)) File.Delete(path);
|
|
db.GerbilPhotos.Remove(photo);
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.NoContent();
|
|
}).WithTags("Photos");
|
|
|
|
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
|
|
app.MapGet("/enclosures/{id:guid}/photos",
|
|
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
|
{
|
|
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
|
var photos = await db.EnclosurePhotos.AsNoTracking()
|
|
.Where(p => p.EnclosureId == id)
|
|
.OrderBy(p => p.SortOrder)
|
|
.ToListAsync();
|
|
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
|
|
}).WithTags("Photos");
|
|
|
|
app.MapPost("/enclosures/{id:guid}/photos",
|
|
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
|
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
|
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
|
|
|
var ext = Path.GetExtension(file.FileName);
|
|
var fileName = $"{Guid.NewGuid():N}{ext}";
|
|
var root = PhotoRoot(config, env);
|
|
Directory.CreateDirectory(root);
|
|
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
|
await file.CopyToAsync(stream);
|
|
|
|
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
|
|
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
|
|
|
var photo = new EnclosurePhoto
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EnclosureId = id,
|
|
FileName = fileName,
|
|
Caption = caption,
|
|
SortOrder = nextSort,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
};
|
|
db.EnclosurePhotos.Add(photo);
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
|
|
}).WithTags("Photos").DisableAntiforgery();
|
|
|
|
app.MapDelete("/enclosure-photos/{id:guid}",
|
|
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
|
|
if (photo is null) return TypedResults.NotFound();
|
|
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
|
if (File.Exists(path)) File.Delete(path);
|
|
db.EnclosurePhotos.Remove(photo);
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.NoContent();
|
|
}).WithTags("Photos");
|
|
|
|
app.MapGet("/photos/files/{fileName}",
|
|
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
// guard against path traversal: only a bare file name is allowed
|
|
if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains(".."))
|
|
return TypedResults.BadRequest("Invalid file name.");
|
|
var path = Path.Combine(PhotoRoot(config, env), fileName);
|
|
if (!File.Exists(path)) return TypedResults.NotFound();
|
|
return TypedResults.PhysicalFile(path, ContentType(fileName));
|
|
}).WithTags("Photos");
|
|
|
|
return app;
|
|
}
|
|
|
|
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
|
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
|
|
|
private static string ContentType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch
|
|
{
|
|
".png" => "image/png",
|
|
".gif" => "image/gif",
|
|
".webp" => "image/webp",
|
|
".bmp" => "image/bmp",
|
|
_ => "image/jpeg",
|
|
};
|
|
|
|
private static PhotoDto ToDto(GerbilPhoto p) =>
|
|
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
|
|
|
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
|
|
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
|
}
|
|
}
|