From c0d04fa855dc230901f5b002ba38320ea3d912dd Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 23 Jun 2026 17:04:47 +0330 Subject: [PATCH] =?UTF-8?q?feat(studio+render):=20wire=20theme=20picker=20?= =?UTF-8?q?=E2=86=92=20saved=5Fshared=5Fcolors=20=E2=86=92=20FlexStory=20r?= =?UTF-8?q?ender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the theme→render gap: the studio theme picker now actually drives a FlexStory render's colours. GetFlexStoryProps reads saved_shared_colors by element_key (accentColor/secondaryColor/backgroundColor/textColor), but the studio only wrote the theme into scene_data — so the picker never reached the MP4. - studio-svc: UpdateSharedColorsAsync upserts saved_shared_colors by (project, element_key) + PATCH /v1/saved-projects/{id}/shared-colors endpoint + UpdateColorsRequest/UpdateColorItem. Mirrors UpdateSceneContentsAsync. (dotnet build: 0 errors.) - gateway already wildcard-routes /v1/saved-projects/*path → studio-svc (no change). - Next: /api/projects/[id]/colors route → gateway; project-api patchProjectColors + themeColorsFromSceneData (maps scene_data sceneAccentColor… → the colorSchema keys); performSave best-effort pushes the 4 colours alongside contents. Chain: theme picker → store → scene_data → performSave → patchProjectColors → gateway → studio-svc upsert → saved_shared_colors → GetFlexStoryProps → render. Verified: Next build + dotnet build both clean; theme presets render cohesively across all 6 (incl. dark Midnight). End-to-end studio→render needs the live stack. Co-Authored-By: Claude Opus 4.8 --- .../Application/Services/StudioService.cs | 34 ++++++++++++++ .../Controllers/StudioController.cs | 6 +++ .../Models/Requests/Requests.cs | 6 +++ .../api/projects/[projectId]/colors/route.ts | 46 +++++++++++++++++++ src/hooks/useStudioProjectPersistence.ts | 8 ++++ src/lib/project-api.ts | 43 +++++++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 src/app/api/projects/[projectId]/colors/route.ts diff --git a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs index 0efd64c..50f53a1 100644 --- a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs +++ b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs @@ -321,6 +321,40 @@ public class StudioService(StudioDbContext db) return updated; } + /// + /// Update the project's theme colours by element key (accentColor/secondaryColor/ + /// backgroundColor/textColor). Upserts saved_shared_colors so the FlexStory render + /// binder (GetFlexStoryProps) reads the studio theme picker's colours. + /// + public async Task UpdateSharedColorsAsync(Guid projectId, Guid userId, List items) + { + var owns = await db.SavedProjects.AnyAsync(x => x.Id == projectId && x.UserId == userId); + if (!owns) throw new KeyNotFoundException($"Project {projectId} not found"); + + var updated = 0; + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.Key) || string.IsNullOrWhiteSpace(item.Value)) continue; + // Upsert by (project, element_key): the row usually exists (copied from the + // template's shared colours); insert if a FlexStory key is missing. + var n = await db.Database.ExecuteSqlInterpolatedAsync($@" + UPDATE studio.saved_shared_colors + SET value = {item.Value} + WHERE saved_project_id = {projectId} AND element_key = {item.Key}"); + if (n == 0) + { + n = await db.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO studio.saved_shared_colors + (saved_project_id, element_key, attr_value, value, is_selected, sort) + VALUES ({projectId}, {item.Key}, 'fill', {item.Value}, true, 0)"); + } + updated += n; + } + await db.Database.ExecuteSqlInterpolatedAsync( + $"UPDATE studio.saved_projects SET last_edit_date = now(), updated_at = now() WHERE id = {projectId}"); + return updated; + } + public async Task DeleteProjectAsync(Guid id, Guid userId) { var project = await db.SavedProjects diff --git a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs index 84bfd68..a5aa2c5 100644 --- a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs +++ b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs @@ -60,6 +60,12 @@ public class StudioController(StudioService svc) : ControllerBase public async Task UpdateContents(Guid id, [FromBody] UpdateContentsRequest req) => Ok(new { updated = await svc.UpdateSceneContentsAsync(id, UserId, req.Items ?? new List()) }); + /// Update the project's theme colours (accentColor/secondaryColor/ + /// backgroundColor/textColor) so the FlexStory render reads the theme picker. + [HttpPatch("{id:guid}/shared-colors")] + public async Task UpdateColors(Guid id, [FromBody] UpdateColorsRequest req) => + Ok(new { updated = await svc.UpdateSharedColorsAsync(id, UserId, req.Items ?? new List()) }); + /// Internal endpoint: get project for render service (no user-ownership check). [HttpGet("{id:guid}/render-payload")] [Authorize(Roles = "Service")] diff --git a/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs b/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs index bcd86eb..19762a3 100644 --- a/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs +++ b/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs @@ -128,3 +128,9 @@ public record SavedProjectListRequest( /// writes the user's edits here (by content key) so the render binder picks them up. public record UpdateContentsRequest(List Items); public record UpdateContentItem(string Key, string? Value, Guid? ValueFileId); + +/// Update the project-wide theme colours (the studio theme picker) by +/// element key (accentColor/secondaryColor/backgroundColor/textColor) so the +/// FlexStory render binder reads them from saved_shared_colors. +public record UpdateColorsRequest(List Items); +public record UpdateColorItem(string Key, string Value); diff --git a/src/app/api/projects/[projectId]/colors/route.ts b/src/app/api/projects/[projectId]/colors/route.ts new file mode 100644 index 0000000..f55a898 --- /dev/null +++ b/src/app/api/projects/[projectId]/colors/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; + +import { gatewayFetch } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; + +/** + * Persist the studio theme picker's brand colours to the saved project's shared + * colours (studio-svc PATCH /v1/saved-projects/{id}/shared-colors). The FlexStory + * render binder (GetFlexStoryProps) reads saved_shared_colors by element_key, so this + * is what makes the theme picker reach the MP4. + * Body: { items: [{ key, value }] } — key ∈ accentColor/secondaryColor/backgroundColor/textColor + */ +export async function PATCH( + req: Request, + ctx: { params: Promise<{ projectId: string }> } +) { + const { projectId } = await ctx.params; + const token = await getAccessToken(); + if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = (await req.json().catch(() => null)) as { + items?: Array<{ key?: string; value?: string }>; + } | null; + const items = Array.isArray(body?.items) + ? body!.items.filter( + (i) => typeof i?.key === "string" && i.key.length > 0 && typeof i?.value === "string" + ) + : []; + if (items.length === 0) return NextResponse.json({ updated: 0 }); + + const res = await gatewayFetch(`/v1/saved-projects/${projectId}/shared-colors`, { + method: "PATCH", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ items }), + }); + if (!res.ok) { + const d = await res.json().catch(() => null); + return NextResponse.json( + { error: d?.message ?? "Could not save colours" }, + { status: res.status } + ); + } + return NextResponse.json(await res.json().catch(() => ({ updated: items.length }))); +} diff --git a/src/hooks/useStudioProjectPersistence.ts b/src/hooks/useStudioProjectPersistence.ts index 6260ea4..ad223e1 100644 --- a/src/hooks/useStudioProjectPersistence.ts +++ b/src/hooks/useStudioProjectPersistence.ts @@ -12,8 +12,10 @@ import { contentValuesFromSceneData, fetchProject, isProjectNotFoundError, + patchProjectColors, patchProjectContents, patchProjectSceneData, + themeColorsFromSceneData, } from "@/lib/project-api"; import { isDevProjectId } from "@/lib/project-ids"; import { @@ -159,6 +161,12 @@ export function useStudioProjectPersistence( projectId, contentValuesFromSceneData(scene_data) ).catch(() => {}); + // Persist the theme colours to saved_shared_colors so the FlexStory render + // reads the theme picker (best-effort). + void patchProjectColors( + projectId, + themeColorsFromSceneData(scene_data) + ).catch(() => {}); setUsingLocalStorage(false); markSaved(payloadJson, false); } catch (error) { diff --git a/src/lib/project-api.ts b/src/lib/project-api.ts index 662eb2a..759a909 100644 --- a/src/lib/project-api.ts +++ b/src/lib/project-api.ts @@ -98,6 +98,31 @@ export async function patchProjectContents( } } +export interface ColorValueUpdate { + key: string; + value: string; +} + +/** + * Persist the project's theme colours (accentColor/secondaryColor/backgroundColor/ + * textColor) to saved_shared_colors so the FlexStory render binder reads them. + * Best-effort (callers may ignore failures). + */ +export async function patchProjectColors( + projectId: string, + items: ColorValueUpdate[] +): Promise { + if (items.length === 0) return; + const response = await fetch(`/api/projects/${projectId}/colors`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items }), + }); + if (!response.ok) { + throw new ProjectApiError("Failed to save colours", response.status); + } +} + /** Extract editable input values from studio scene_data (bridged `c-` layers). */ export function contentValuesFromSceneData( sceneData: Record @@ -126,3 +151,21 @@ export function contentValuesFromSceneData( } return items; } + +/** Map studio scene_data theme colours → the FlexStory colorSchema shared-colour keys. */ +export function themeColorsFromSceneData( + sceneData: Record +): ColorValueUpdate[] { + const map: Array<[string, string]> = [ + ["accentColor", "sceneAccentColor"], + ["secondaryColor", "sceneSecondaryColor"], + ["backgroundColor", "sceneBackgroundColor"], + ["textColor", "sceneTextColor"], + ]; + const items: ColorValueUpdate[] = []; + for (const [key, field] of map) { + const v = sceneData[field]; + if (typeof v === "string" && v) items.push({ key, value: v }); + } + return items; +}