187 lines
9.6 KiB
C#
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());
|
|
}
|
|
}
|