feat(studio+render): wire theme picker → saved_shared_colors → FlexStory render
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 <noreply@anthropic.com>
This commit is contained in:
@@ -321,6 +321,40 @@ public class StudioService(StudioDbContext db)
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> UpdateSharedColorsAsync(Guid projectId, Guid userId, List<UpdateColorItem> 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)
|
public async Task DeleteProjectAsync(Guid id, Guid userId)
|
||||||
{
|
{
|
||||||
var project = await db.SavedProjects
|
var project = await db.SavedProjects
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ public class StudioController(StudioService svc) : ControllerBase
|
|||||||
public async Task<IActionResult> UpdateContents(Guid id, [FromBody] UpdateContentsRequest req) =>
|
public async Task<IActionResult> UpdateContents(Guid id, [FromBody] UpdateContentsRequest req) =>
|
||||||
Ok(new { updated = await svc.UpdateSceneContentsAsync(id, UserId, req.Items ?? new List<UpdateContentItem>()) });
|
Ok(new { updated = await svc.UpdateSceneContentsAsync(id, UserId, req.Items ?? new List<UpdateContentItem>()) });
|
||||||
|
|
||||||
|
/// <summary>Update the project's theme colours (accentColor/secondaryColor/
|
||||||
|
/// backgroundColor/textColor) so the FlexStory render reads the theme picker.</summary>
|
||||||
|
[HttpPatch("{id:guid}/shared-colors")]
|
||||||
|
public async Task<IActionResult> UpdateColors(Guid id, [FromBody] UpdateColorsRequest req) =>
|
||||||
|
Ok(new { updated = await svc.UpdateSharedColorsAsync(id, UserId, req.Items ?? new List<UpdateColorItem>()) });
|
||||||
|
|
||||||
/// <summary>Internal endpoint: get project for render service (no user-ownership check).</summary>
|
/// <summary>Internal endpoint: get project for render service (no user-ownership check).</summary>
|
||||||
[HttpGet("{id:guid}/render-payload")]
|
[HttpGet("{id:guid}/render-payload")]
|
||||||
[Authorize(Roles = "Service")]
|
[Authorize(Roles = "Service")]
|
||||||
|
|||||||
@@ -128,3 +128,9 @@ public record SavedProjectListRequest(
|
|||||||
/// writes the user's edits here (by content key) so the render binder picks them up.</summary>
|
/// writes the user's edits here (by content key) so the render binder picks them up.</summary>
|
||||||
public record UpdateContentsRequest(List<UpdateContentItem> Items);
|
public record UpdateContentsRequest(List<UpdateContentItem> Items);
|
||||||
public record UpdateContentItem(string Key, string? Value, Guid? ValueFileId);
|
public record UpdateContentItem(string Key, string? Value, Guid? ValueFileId);
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public record UpdateColorsRequest(List<UpdateColorItem> Items);
|
||||||
|
public record UpdateColorItem(string Key, string Value);
|
||||||
|
|||||||
@@ -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 })));
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
contentValuesFromSceneData,
|
contentValuesFromSceneData,
|
||||||
fetchProject,
|
fetchProject,
|
||||||
isProjectNotFoundError,
|
isProjectNotFoundError,
|
||||||
|
patchProjectColors,
|
||||||
patchProjectContents,
|
patchProjectContents,
|
||||||
patchProjectSceneData,
|
patchProjectSceneData,
|
||||||
|
themeColorsFromSceneData,
|
||||||
} from "@/lib/project-api";
|
} from "@/lib/project-api";
|
||||||
import { isDevProjectId } from "@/lib/project-ids";
|
import { isDevProjectId } from "@/lib/project-ids";
|
||||||
import {
|
import {
|
||||||
@@ -159,6 +161,12 @@ export function useStudioProjectPersistence(
|
|||||||
projectId,
|
projectId,
|
||||||
contentValuesFromSceneData(scene_data)
|
contentValuesFromSceneData(scene_data)
|
||||||
).catch(() => {});
|
).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);
|
setUsingLocalStorage(false);
|
||||||
markSaved(payloadJson, false);
|
markSaved(payloadJson, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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-<key>` layers). */
|
/** Extract editable input values from studio scene_data (bridged `c-<key>` layers). */
|
||||||
export function contentValuesFromSceneData(
|
export function contentValuesFromSceneData(
|
||||||
sceneData: Record<string, unknown>
|
sceneData: Record<string, unknown>
|
||||||
@@ -126,3 +151,21 @@ export function contentValuesFromSceneData(
|
|||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map studio scene_data theme colours → the FlexStory colorSchema shared-colour keys. */
|
||||||
|
export function themeColorsFromSceneData(
|
||||||
|
sceneData: Record<string, unknown>
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user