From 521ccbe025d35e3ae3fe1b6dc9e64f6e728628d3 Mon Sep 17 00:00:00 2001 From: bubulux Date: Tue, 27 Oct 2026 14:53:44 +0100 Subject: [PATCH 1/2] Projects: bake compositions for the phone only once approved (#275) A project setting (migration 0031, carried on every shot payload) makes the Studio's bakery skip shots that are not approved; the phone tags their steps "Baked once approved". Existing bakes stay and show while fresh. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/api.ts | 2 ++ apps/mobile/src/components/ShotParts.tsx | 4 ++-- apps/studio/src/Framed.tsx | 4 ++-- apps/studio/src/Projects.tsx | 20 ++++++++++++++++++- apps/studio/src/api.ts | 8 +++++--- apps/studio/src/compose/baker.ts | 7 ++++--- .../migrations/0031_bake_approved_only.sql | 3 +++ apps/worker/src/projects.ts | 13 ++++++++---- apps/worker/src/shots.ts | 5 ++++- apps/worker/test/routes/layers.test.ts | 13 ++++++++++++ docs/api.md | 2 +- docs/data-model.md | 1 + docs/features/composition.md | 4 ++++ docs/features/projects.md | 2 +- docs/features/review-and-tagging.md | 2 +- packages/vocab/src/layers.test.ts | 9 ++++++++- packages/vocab/src/layers.ts | 7 +++++++ 17 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 apps/worker/migrations/0031_bake_approved_only.sql diff --git a/apps/mobile/src/api.ts b/apps/mobile/src/api.ts index a6d8b94..73d053e 100644 --- a/apps/mobile/src/api.ts +++ b/apps/mobile/src/api.ts @@ -164,6 +164,8 @@ export interface Shot { id: string; project_id: string; project_name: string | null; + /** The project bakes approved shots only (epic #275); absent on queued shots. */ + bake_approved_only?: boolean; location_id: string | null; location_name: string | null; spot_id: string | null; diff --git a/apps/mobile/src/components/ShotParts.tsx b/apps/mobile/src/components/ShotParts.tsx index 7d6e73c..9b9b90e 100644 --- a/apps/mobile/src/components/ShotParts.tsx +++ b/apps/mobile/src/components/ShotParts.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Modal, Text, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { label } from "@openclapper/vocab"; +import { bakeWaits, label } from "@openclapper/vocab"; import { api, positionOf, steps, stepStale, type Photo, type Shot } from "../api"; import { editShot, isWaiting } from "../localShots"; import { isOfflineMode, isOnline } from "../net"; @@ -116,7 +116,7 @@ export function ShotPhoto({ shot, index, mode, settings, maxW, maxH, badges = tr return ( - {stepStale(shot, step) && Layers not baked yet} + {stepStale(shot, step) && {bakeWaits(shot) ? "Baked once approved" : "Layers not baked yet"}} {badges && n > 1 && } {badges && n > 1 && {stepTag(shot, index, n)}} diff --git a/apps/studio/src/Framed.tsx b/apps/studio/src/Framed.tsx index 2786663..72701eb 100644 --- a/apps/studio/src/Framed.tsx +++ b/apps/studio/src/Framed.tsx @@ -1,5 +1,5 @@ import type { ComponentChildren, JSX } from "preact"; -import { withCentre } from "@openclapper/vocab"; +import { bakeWaits, withCentre } from "@openclapper/vocab"; import type { Photo } from "./api"; import { frameLayout, frameOf, imageAspect, stepSrc, stepStale, type FrameGeometry, type FrameMode, type PctRect, type Step } from "./format"; import type { Shot } from "./api"; @@ -64,7 +64,7 @@ export function Framed({ photo, mode, className, maxHeight, frame, imgStyle, src export function StepFramed({ shot, step, mode, className, maxHeight, children }: { shot: Shot; step: Step; mode: MaskMode; className?: string; maxHeight?: string; children?: ComponentChildren }) { return ( - {stepStale(shot, step) && ◌} + {stepStale(shot, step) && ◌} {children} ); diff --git a/apps/studio/src/Projects.tsx b/apps/studio/src/Projects.tsx index 128cedc..f462cad 100644 --- a/apps/studio/src/Projects.tsx +++ b/apps/studio/src/Projects.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "preact/hooks"; import { deleteProject, existingIdOf, putProject, type FieldDefinition, type Project } from "./api"; -import { Button, confirmDialog, Empty, Field, Input, ListRow, Mark, MenuItem, Panel, PanelBody, PanelHead, ProjectTag, promptDialog, SaveStatus, toast, Toolbar, ToolbarSpacer, ToolbarTitle, type SaveState } from "@openclapper/ui"; +import { Button, confirmDialog, Empty, Field, Input, ListRow, Mark, MenuItem, Panel, PanelBody, PanelHead, ProjectTag, promptDialog, SaveStatus, Seg, toast, Toolbar, ToolbarSpacer, ToolbarTitle, type SaveState } from "@openclapper/ui"; +import * as data from "./data"; import { useUser } from "./user"; const sortByName = (list: Project[]) => [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); @@ -89,6 +90,16 @@ function ProjectDetail({ project, projects, onChange, fields, active, onActivate try { const saved = await putProject({ id: project.id, name: n.trim(), notes: t.trim() || null }); replace({ ...saved, field_ids: project.field_ids }); setSave("saved"); } catch (err) { setSave("error"); setNameError(existingIdOf(err) ? `A project called “${n.trim()}” already exists. Names are unique, ignoring case.` : (err as Error).message); } } + /** Bake for the phone only once approved (epic #275); the loaded shots carry the setting too, so the baker follows at once. */ + async function setBakeApproved(on: boolean) { + setSave("saving"); + try { + const saved = await putProject({ id: project.id, name: project.name, notes: project.notes, bake_approved_only: on }); + replace({ ...saved, field_ids: project.field_ids }); + data.setShots((cur) => (cur ?? []).map((x) => (x.project_id === project.id ? { ...x, bake_approved_only: on } : x))); + setSave("saved"); + } catch (e) { setSave("error"); toast(`Not saved: ${(e as Error).message}`, "danger"); } + } async function remove() { const ok = await confirmDialog({ title: `Delete “${project.name}”?`, body: "The project is empty; nothing else is removed.", confirmLabel: "Delete project", danger: true }); if (!ok) return; @@ -110,6 +121,13 @@ function ProjectDetail({ project, projects, onChange, fields, active, onActivate setName((e.target as HTMLInputElement).value)} onBlur={() => void saveMeta()} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />