Files
GerbilManager/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs

187 lines
9.6 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// WEB-0: CMS for the public website. GET /api/site-snapshot is the publish-time export
/// (with Abgabetiere resolved from live ForSale animals); the /api/pages + /api/blocks
/// CRUD edits content (hand-edit admin UI in WEB-0b, AI tools later). LAN-only like the
/// rest of the API.
/// </summary>
public static class CmsEndpoints
{
public static IEndpointRouteBuilder MapCmsEndpoints(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api").WithTags("CMS");
// ---- snapshot (consumed by the static renderer) ----
api.MapGet("/site-snapshot", async (ApplicationContext db) =>
TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync()));
// ---- static render dry-run (WEB-1: returns rendered file map as JSON) ----
api.MapGet("/render-site", async (ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
});
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
if (!files.TryGetValue(key, out var content) &&
!files.TryGetValue($"{key}/index.html", out content))
{
return Results.NotFound();
}
return Results.Content(content, PreviewContentType(key));
});
// ---- pages ----
api.MapGet("/pages", async (ApplicationContext db) =>
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
.Select(p => new PageSummaryDto(p.Id, p.Slug, p.Title, p.SeoDescription, p.Status))
.ToListAsync()));
api.MapGet("/pages/{slug}", async Task<Results<Ok<PageDto>, NotFound>> (string slug, ApplicationContext db) =>
{
var p = await db.Pages.AsNoTracking().Include(x => x.Blocks)
.FirstOrDefaultAsync(x => x.Slug == slug);
return p is null ? TypedResults.NotFound() : TypedResults.Ok(ToPageDto(p));
});
api.MapPost("/pages", async Task<Results<Created<PageDto>, ValidationProblem, Conflict<string>>> (PageInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["slug"] = ["Slug and title are required."] });
if (await db.Pages.AnyAsync(p => p.Slug == input.Slug))
return TypedResults.Conflict($"A page with slug '{input.Slug}' already exists.");
var page = new Page
{
Id = Guid.NewGuid(),
Slug = input.Slug,
Title = input.Title,
SeoDescription = input.SeoDescription,
Status = input.Status ?? PageStatus.Draft,
};
db.Pages.Add(page);
await db.SaveChangesAsync();
return TypedResults.Created($"/api/pages/{page.Slug}", ToPageDto(page));
});
api.MapPut("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, PageInput input, ApplicationContext db) =>
{
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
if (page is null) return TypedResults.NotFound();
page.Slug = input.Slug;
page.Title = input.Title;
page.SeoDescription = input.SeoDescription;
if (input.Status is PageStatus s) page.Status = s;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
api.MapDelete("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
if (page is null) return TypedResults.NotFound();
db.Pages.Remove(page); // cascades blocks
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// ---- blocks ----
api.MapPost("/pages/{pageId:guid}/blocks", async Task<Results<Created<BlockDto>, NotFound, BadRequest<string>>> (
Guid pageId, BlockInput input, ApplicationContext db) =>
{
if (!await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound();
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
return TypedResults.BadRequest("Block data must be a JSON object.");
int order = input.Order ?? ((await db.Blocks.Where(b => b.PageId == pageId)
.Select(b => (int?)b.Order).MaxAsync() ?? -1) + 1);
var block = new Block
{
Id = Guid.NewGuid(),
PageId = pageId,
Order = order,
Type = input.Type,
Data = input.Data.ValueKind == JsonValueKind.Object ? input.Data.GetRawText() : "{}",
};
db.Blocks.Add(block);
await db.SaveChangesAsync();
return TypedResults.Created($"/api/blocks/{block.Id}", ToBlockDto(block));
});
api.MapPut("/blocks/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
Guid id, BlockInput input, ApplicationContext db) =>
{
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
if (block is null) return TypedResults.NotFound();
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
return TypedResults.BadRequest("Block data must be a JSON object.");
block.Type = input.Type;
if (input.Data.ValueKind == JsonValueKind.Object) block.Data = input.Data.GetRawText();
if (input.Order is int o) block.Order = o;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
api.MapDelete("/blocks/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
if (block is null) return TypedResults.NotFound();
db.Blocks.Remove(block);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// reorder: body = ordered block ids; Order set to the list index
api.MapPut("/pages/{pageId:guid}/blocks/order", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
Guid pageId, BlockOrderInput input, ApplicationContext db) =>
{
var blocks = await db.Blocks.Where(b => b.PageId == pageId).ToListAsync();
if (blocks.Count == 0 && !await db.Pages.AnyAsync(p => p.Id == pageId))
return TypedResults.NotFound();
var byId = blocks.ToDictionary(b => b.Id);
if (input.BlockIds.Count != blocks.Count || input.BlockIds.Any(bid => !byId.ContainsKey(bid)))
return TypedResults.BadRequest("blockIds must list exactly the page's block ids.");
for (int i = 0; i < input.BlockIds.Count; i++)
byId[input.BlockIds[i]].Order = i;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
: "text/html; charset=utf-8";
private static BlockDto ToBlockDto(Block b) =>
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
private static PageDto ToPageDto(Page p) => new(
p.Id, p.Slug, p.Title, p.SeoDescription, p.Status,
p.Blocks.OrderBy(b => b.Order).Select(ToBlockDto).ToList());
}
}