- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav, site-footer-nav mit Impressum/Datenschutz-Links) - AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung, Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote) - SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote erweitert - Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als Platzhalter-Seiten (footer-only, kein navOrder-Eintrag) - SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests) - Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier, Impressum/Datenschutz, Footer-Links) Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
131 lines
5.2 KiB
C#
131 lines
5.2 KiB
C#
using System.Text.Json.Nodes;
|
|
using GerbilManagerWebAPI.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GerbilManagerWebAPI.Cms
|
|
{
|
|
/// <summary>
|
|
/// Builds the full CMS snapshot consumed by the static site renderer (WEB-1). The
|
|
/// abgabetiere page's auto AbgabetiereList block is resolved here from live ForSale
|
|
/// animals — nothing private is ever exposed (publish = snapshot of public content).
|
|
/// </summary>
|
|
public sealed class SiteSnapshotService
|
|
{
|
|
private readonly ApplicationContext _db;
|
|
|
|
public SiteSnapshotService(ApplicationContext db) => _db = db;
|
|
|
|
public async Task<JsonObject> BuildAsync()
|
|
{
|
|
var site = await _db.Sites.AsNoTracking().FirstOrDefaultAsync()
|
|
?? new Site { Id = Site.SingletonId };
|
|
var pages = await _db.Pages.AsNoTracking().Include(p => p.Blocks).ToListAsync();
|
|
var pageById = pages.ToDictionary(p => p.Id);
|
|
|
|
var navOrder = new JsonArray();
|
|
foreach (var id in site.NavOrder)
|
|
if (pageById.TryGetValue(id, out var p))
|
|
navOrder.Add(p.Slug);
|
|
|
|
JsonArray? forSale = null; // resolved lazily, reused across blocks
|
|
|
|
int NavIndex(Guid id) { var i = site.NavOrder.IndexOf(id); return i < 0 ? int.MaxValue : i; }
|
|
|
|
var pagesArr = new JsonArray();
|
|
foreach (var page in pages.OrderBy(p => NavIndex(p.Id)).ThenBy(p => p.Slug))
|
|
{
|
|
var blocks = new JsonArray();
|
|
foreach (var b in page.Blocks.OrderBy(b => b.Order))
|
|
{
|
|
var data = JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data) as JsonObject
|
|
?? new JsonObject();
|
|
if (b.Type == BlockType.AbgabetiereList && AsString(data["mode"]) == "auto")
|
|
{
|
|
forSale ??= await BuildForSaleAsync();
|
|
data["animals"] = forSale.DeepClone();
|
|
}
|
|
blocks.Add(new JsonObject
|
|
{
|
|
["id"] = b.Id.ToString(),
|
|
["order"] = b.Order,
|
|
["type"] = b.Type.ToString(),
|
|
["data"] = data,
|
|
});
|
|
}
|
|
pagesArr.Add(new JsonObject
|
|
{
|
|
["slug"] = page.Slug,
|
|
["title"] = page.Title,
|
|
["seoDescription"] = page.SeoDescription,
|
|
["status"] = page.Status.ToString(),
|
|
["blocks"] = blocks,
|
|
});
|
|
}
|
|
|
|
return new JsonObject
|
|
{
|
|
["site"] = new JsonObject
|
|
{
|
|
["defaultLocale"] = site.DefaultLocale,
|
|
["navOrder"] = navOrder,
|
|
},
|
|
["pages"] = pagesArr,
|
|
};
|
|
}
|
|
|
|
private async Task<JsonArray> BuildForSaleAsync()
|
|
{
|
|
var animals = await _db.Gerbils.AsNoTracking()
|
|
.Where(g => g.Status == GerbilStatus.ForSale)
|
|
.OrderBy(g => g.Name)
|
|
.Select(g => new
|
|
{
|
|
g.Id,
|
|
g.Name,
|
|
g.Gender,
|
|
g.DateOfBirth,
|
|
g.CharacterNote,
|
|
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
|
// group = Becken (enclosure) name — matches how the Abgabe composer
|
|
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
|
Group = g.Enclosure != null ? g.Enclosure.Name : null,
|
|
})
|
|
.ToListAsync();
|
|
|
|
var ids = animals.Select(a => a.Id).ToList();
|
|
var photos = await _db.GerbilPhotos.AsNoTracking()
|
|
.Where(p => ids.Contains(p.GerbilId))
|
|
.OrderBy(p => p.SortOrder)
|
|
.Select(p => new { p.GerbilId, p.FileName })
|
|
.ToListAsync();
|
|
var photosByGerbil = photos
|
|
.GroupBy(p => p.GerbilId)
|
|
.ToDictionary(g => g.Key, g => g.Select(x => $"/photos/files/{x.FileName}").ToList());
|
|
|
|
var arr = new JsonArray();
|
|
foreach (var a in animals)
|
|
{
|
|
var photoArr = new JsonArray();
|
|
if (photosByGerbil.TryGetValue(a.Id, out var urls))
|
|
foreach (var u in urls) photoArr.Add(u);
|
|
|
|
arr.Add(new JsonObject
|
|
{
|
|
["name"] = a.Name,
|
|
["gender"] = a.Gender.ToString(),
|
|
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
|
["characterNote"] = a.CharacterNote,
|
|
["farbschlag"] = a.Farbschlag,
|
|
["group"] = a.Group,
|
|
["photos"] = photoArr,
|
|
["aiSaleText"] = null, // populated by WEB-5 once a Gemini key exists
|
|
});
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
private static string? AsString(JsonNode? node) =>
|
|
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
|
}
|
|
}
|