diff --git a/services/content/FlatRender.ContentSvc/Application/Services/SceneColorService.cs b/services/content/FlatRender.ContentSvc/Application/Services/SceneColorService.cs new file mode 100644 index 0000000..f063bf0 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Application/Services/SceneColorService.cs @@ -0,0 +1,190 @@ +using FlatRender.ContentSvc.Domain.Entities; +using FlatRender.ContentSvc.Domain.Enums; +using FlatRender.ContentSvc.Infrastructure.Data; +using FlatRender.ContentSvc.Models; +using Microsoft.EntityFrameworkCore; + +namespace FlatRender.ContentSvc.Application.Services; + +/// CRUD for project-scoped scenes, shared colors and colour presets. +public class SceneColorService(ContentDbContext db) +{ + // ── Scenes ────────────────────────────────────────────────────────────── + + public async Task> GetScenesAsync(Guid projectId) => + await db.Scenes.Where(s => s.ProjectId == projectId && s.DeletedAt == null) + .OrderBy(s => s.Sort) + .Select(s => ToSceneResponse(s)).ToListAsync(); + + public async Task CreateSceneAsync(CreateSceneRequest req) + { + var scene = new Scene + { + ProjectId = req.ProjectId, + Key = req.Key, + Title = req.Title, + LocalizedTitle = req.LocalizedTitle, + SceneType = ParseSceneKind(req.SceneType), + Image = req.Image, + Demo = req.Demo, + SceneColorSvg = req.SceneColorSvg, + SnapshotUrl = req.SnapshotUrl, + GenerateKf = req.GenerateKf, + DefaultDurationSec = req.DefaultDurationSec, + MinDurationSec = req.MinDurationSec, + MaxDurationSec = req.MaxDurationSec, + OverlapAtEndSec = req.OverlapAtEndSec, + CanHandleDuration = req.CanHandleDuration, + ManualColorSelection = req.ManualColorSelection, + Sort = req.Sort, + IsActive = req.IsActive, + }; + db.Scenes.Add(scene); + await db.SaveChangesAsync(); + return ToSceneResponse(scene); + } + + public async Task UpdateSceneAsync(Guid id, UpdateSceneRequest req) + { + var scene = await db.Scenes.FindAsync(id) ?? throw new KeyNotFoundException($"Scene {id} not found"); + scene.Key = req.Key; + scene.Title = req.Title; + scene.LocalizedTitle = req.LocalizedTitle; + scene.SceneType = ParseSceneKind(req.SceneType); + scene.Image = req.Image; + scene.Demo = req.Demo; + scene.SceneColorSvg = req.SceneColorSvg; + scene.SnapshotUrl = req.SnapshotUrl; + scene.GenerateKf = req.GenerateKf; + scene.DefaultDurationSec = req.DefaultDurationSec; + scene.MinDurationSec = req.MinDurationSec; + scene.MaxDurationSec = req.MaxDurationSec; + scene.OverlapAtEndSec = req.OverlapAtEndSec; + scene.CanHandleDuration = req.CanHandleDuration; + scene.ManualColorSelection = req.ManualColorSelection; + scene.Sort = req.Sort; + scene.IsActive = req.IsActive; + scene.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return ToSceneResponse(scene); + } + + public async Task DeleteSceneAsync(Guid id) + { + var scene = await db.Scenes.FindAsync(id) ?? throw new KeyNotFoundException($"Scene {id} not found"); + scene.DeletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + + // ── Shared colours ────────────────────────────────────────────────────── + + public async Task> GetSharedColorsAsync(Guid projectId) => + await db.SharedColors.Where(c => c.ProjectId == projectId).OrderBy(c => c.Sort) + .Select(c => new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon, + c.AttrValue.ToString(), c.DefaultColor, c.Sort)).ToListAsync(); + + public async Task CreateSharedColorAsync(SaveSharedColorRequest req) + { + var c = new SharedColor + { + ProjectId = req.ProjectId, + ElementKey = req.ElementKey, + Title = req.Title, + Icon = req.Icon, + AttrValue = ParseAttrValue(req.AttrValue), + DefaultColor = req.DefaultColor, + Sort = req.Sort, + }; + db.SharedColors.Add(c); + await db.SaveChangesAsync(); + return new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon, c.AttrValue.ToString(), c.DefaultColor, c.Sort); + } + + public async Task UpdateSharedColorAsync(Guid id, SaveSharedColorRequest req) + { + var c = await db.SharedColors.FindAsync(id) ?? throw new KeyNotFoundException($"Color {id} not found"); + c.ElementKey = req.ElementKey; + c.Title = req.Title; + c.Icon = req.Icon; + c.AttrValue = ParseAttrValue(req.AttrValue); + c.DefaultColor = req.DefaultColor; + c.Sort = req.Sort; + c.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon, c.AttrValue.ToString(), c.DefaultColor, c.Sort); + } + + public async Task DeleteSharedColorAsync(Guid id) + { + var c = await db.SharedColors.FindAsync(id) ?? throw new KeyNotFoundException($"Color {id} not found"); + db.SharedColors.Remove(c); + await db.SaveChangesAsync(); + } + + // ── Colour presets (palettes) ─────────────────────────────────────────── + + public async Task> GetColorPresetsAsync(Guid projectId) => + await db.SharedColorPresets.Where(p => p.ProjectId == projectId).OrderBy(p => p.Sort) + .Select(p => new ColorPresetResponse(p.Id, p.ProjectId, p.Name, p.Sort, + p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList())) + .ToListAsync(); + + public async Task CreateColorPresetAsync(SaveColorPresetRequest req) + { + var preset = new SharedColorPreset + { + ProjectId = req.ProjectId, + Name = req.Name, + Sort = req.Sort, + Items = (req.Items ?? []).Select(i => new SharedColorPresetItem + { + ElementKey = i.ElementKey, Value = i.Value, Sort = i.Sort, + }).ToList(), + }; + db.SharedColorPresets.Add(preset); + await db.SaveChangesAsync(); + return await GetPresetAsync(preset.Id); + } + + public async Task UpdateColorPresetAsync(Guid id, SaveColorPresetRequest req) + { + var preset = await db.SharedColorPresets.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id) + ?? throw new KeyNotFoundException($"Preset {id} not found"); + preset.Name = req.Name; + preset.Sort = req.Sort; + db.SharedColorPresetItems.RemoveRange(preset.Items); + preset.Items = (req.Items ?? []).Select(i => new SharedColorPresetItem + { + PresetId = preset.Id, ElementKey = i.ElementKey, Value = i.Value, Sort = i.Sort, + }).ToList(); + await db.SaveChangesAsync(); + return await GetPresetAsync(preset.Id); + } + + public async Task DeleteColorPresetAsync(Guid id) + { + var preset = await db.SharedColorPresets.FindAsync(id) ?? throw new KeyNotFoundException($"Preset {id} not found"); + db.SharedColorPresets.Remove(preset); + await db.SaveChangesAsync(); + } + + private async Task GetPresetAsync(Guid id) => + await db.SharedColorPresets.Where(p => p.Id == id) + .Select(p => new ColorPresetResponse(p.Id, p.ProjectId, p.Name, p.Sort, + p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList())) + .FirstAsync(); + + // ── helpers ───────────────────────────────────────────────────────────── + + private static SceneResponse ToSceneResponse(Scene s) => new( + s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(), + s.Image, s.Demo, s.SceneColorSvg, s.SnapshotUrl, s.GenerateKf, + s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec, + s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive); + + private static SceneKind ParseSceneKind(string? v) => + Enum.TryParse(v, true, out var k) ? k : SceneKind.Normal; + + private static AttrValueKind ParseAttrValue(string? v) => + Enum.TryParse(v, true, out var a) ? a : AttrValueKind.fill; +} diff --git a/services/content/FlatRender.ContentSvc/Controllers/SceneColorController.cs b/services/content/FlatRender.ContentSvc/Controllers/SceneColorController.cs new file mode 100644 index 0000000..c2bd2df --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Controllers/SceneColorController.cs @@ -0,0 +1,87 @@ +using FlatRender.ContentSvc.Application.Services; +using FlatRender.ContentSvc.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace FlatRender.ContentSvc.Controllers; + +[ApiController] +[Route("v1/scenes")] +public class ScenesController(SceneColorService svc) : ControllerBase +{ + [HttpGet] + public async Task List([FromQuery(Name = "project_id")] Guid projectId) => + Ok(await svc.GetScenesAsync(projectId)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task Create([FromBody] CreateSceneRequest req) => + Ok(await svc.CreateSceneAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdateSceneRequest req) => + Ok(await svc.UpdateSceneAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + await svc.DeleteSceneAsync(id); + return NoContent(); + } +} + +[ApiController] +[Route("v1/shared-colors")] +public class SharedColorsController(SceneColorService svc) : ControllerBase +{ + [HttpGet] + public async Task List([FromQuery(Name = "project_id")] Guid projectId) => + Ok(await svc.GetSharedColorsAsync(projectId)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task Create([FromBody] SaveSharedColorRequest req) => + Ok(await svc.CreateSharedColorAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] SaveSharedColorRequest req) => + Ok(await svc.UpdateSharedColorAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + await svc.DeleteSharedColorAsync(id); + return NoContent(); + } +} + +[ApiController] +[Route("v1/color-presets")] +public class ColorPresetsController(SceneColorService svc) : ControllerBase +{ + [HttpGet] + public async Task List([FromQuery(Name = "project_id")] Guid projectId) => + Ok(await svc.GetColorPresetsAsync(projectId)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task Create([FromBody] SaveColorPresetRequest req) => + Ok(await svc.CreateColorPresetAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] SaveColorPresetRequest req) => + Ok(await svc.UpdateColorPresetAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + await svc.DeleteColorPresetAsync(id); + return NoContent(); + } +} diff --git a/services/content/FlatRender.ContentSvc/Models/SceneColor.cs b/services/content/FlatRender.ContentSvc/Models/SceneColor.cs new file mode 100644 index 0000000..3599e65 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Models/SceneColor.cs @@ -0,0 +1,50 @@ +namespace FlatRender.ContentSvc.Models; + +// ── Scenes (project-scoped scene templates) ────────────────────────────────── + +public record SceneResponse( + Guid Id, Guid ProjectId, string Key, string Title, string? LocalizedTitle, + string SceneType, string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl, + bool GenerateKf, decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec, + decimal OverlapAtEndSec, bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive +); + +public record CreateSceneRequest( + Guid ProjectId, string Key, string Title, string? LocalizedTitle, string? SceneType, + string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl, bool GenerateKf, + decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec, decimal OverlapAtEndSec, + bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive +); + +public record UpdateSceneRequest( + string Key, string Title, string? LocalizedTitle, string? SceneType, + string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl, bool GenerateKf, + decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec, decimal OverlapAtEndSec, + bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive +); + +// ── Shared colors (project-scoped named colors) ────────────────────────────── + +public record SharedColorResponse( + Guid Id, Guid ProjectId, string ElementKey, string Title, string? Icon, + string AttrValue, string DefaultColor, int Sort +); + +public record SaveSharedColorRequest( + Guid ProjectId, string ElementKey, string Title, string? Icon, + string? AttrValue, string DefaultColor, int Sort +); + +// ── Color presets (named palettes of colors) ───────────────────────────────── + +public record ColorPresetItemResponse(Guid Id, string ElementKey, string Value, int Sort); + +public record ColorPresetResponse( + Guid Id, Guid ProjectId, string? Name, int Sort, List Items +); + +public record ColorPresetItemInput(string ElementKey, string Value, int Sort); + +public record SaveColorPresetRequest( + Guid ProjectId, string? Name, int Sort, List Items +); diff --git a/services/content/FlatRender.ContentSvc/Program.cs b/services/content/FlatRender.ContentSvc/Program.cs index 40b00bc..0f7c034 100644 --- a/services/content/FlatRender.ContentSvc/Program.cs +++ b/services/content/FlatRender.ContentSvc/Program.cs @@ -64,6 +64,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config). builder.Services.AddHttpClient("openai"); diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index ebb3377..c547f51 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -116,6 +116,9 @@ func main() { v1.Any("/comments/*path", apiRL, auth, content.Handler()) v1.Any("/favorites/*path", apiRL, auth, content.Handler()) v1.Any("/ai/*path", apiRL, auth, content.Handler()) + v1.Any("/scenes/*path", apiRL, optionalAuth, content.Handler()) + v1.Any("/shared-colors/*path", apiRL, optionalAuth, content.Handler()) + v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler()) // ── File Service ───────────────────────────────────────────────────────── v1.Any("/files/*path", apiRL, auth, file.Handler())