feat(library): concept as an origin-tagged library entity
Concept/Product Studio + moodboard outputs were one-shot generators whose images never entered the library. Persist them as first-class, origin-tagged entries so the public library stays clean. - Garment.origin (made|ai_concept) + origin_source; rbac self-heal (existing rows default to 'made'). - filter_search excludes ai_concept by default (concepts never leak into the main library, studio pickers, similar, or boards); origin=ai_concept|all param threaded through /garments + /search/filter; origin in serializer. - POST /api/garments/concept: copies a generator image into the library store and creates an origin=ai_concept garment (no style number / QR / production). - Frontend: Library Made/Concepts tab toggle; amber CONCEPT badge on cards; concept cards titled by description; saveConcept client fn; "Save to Library" on Concept Studio + Product Studio results + moodboard starter-kit cards. Validated live: concept created → excluded from Made grid, shown in Concepts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2a5b95e37e
commit
ae5bb90cf0
@@ -44,6 +44,12 @@ class Garment(Base):
|
||||
qr_payload = Column(Text, nullable=True) # the (frozen) string the QR encodes
|
||||
qr_generated_at = Column(DateTime, nullable=True) # set once; re-issue ⇒ a new QR
|
||||
|
||||
# Origin — keeps the public library clean: real made/uploaded items vs
|
||||
# AI-generated concepts (Concept/Product Studio + moodboard). Concepts live in
|
||||
# a separate "Concepts" tab and never enter production flows / style numbering.
|
||||
origin = Column(String, default="made") # made | ai_concept
|
||||
origin_source = Column(String, nullable=True) # concept_studio | product_studio | moodboard
|
||||
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
@@ -263,6 +264,7 @@ def list_garments(
|
||||
macro_family: str | None = None,
|
||||
fabric_family: str | None = None,
|
||||
silhouette: str | None = None,
|
||||
origin: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -283,6 +285,7 @@ def list_garments(
|
||||
"macro_family": macro_family,
|
||||
"fabric_family": fabric_family,
|
||||
"silhouette": silhouette,
|
||||
"origin": origin,
|
||||
}.items() if v is not None}
|
||||
|
||||
from services.rbac import user_scope
|
||||
@@ -291,6 +294,69 @@ def list_garments(
|
||||
scope=user_scope(user, db), department_id=user.department_id)
|
||||
|
||||
|
||||
class SaveConceptBody(BaseModel):
|
||||
image: str # filename returned by a generator
|
||||
source: str # concept_studio | product_studio | moodboard
|
||||
title: str | None = None
|
||||
prompt: str | None = None
|
||||
source_garment_id: str | None = None
|
||||
source_fabric_id: str | None = None
|
||||
decode_id: str | None = None
|
||||
|
||||
|
||||
_CONCEPT_SOURCE_DIRS = {
|
||||
"concept_studio": Path(settings.images_path).parent / "concepts",
|
||||
"product_studio": Path(settings.images_path).parent / "concepts",
|
||||
"moodboard": Path(settings.images_path),
|
||||
}
|
||||
_CONCEPT_SOURCE_LABELS = {
|
||||
"concept_studio": "Concept Studio", "product_studio": "Product Studio", "moodboard": "Moodboard",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/concept", status_code=201)
|
||||
def save_concept(
|
||||
body: SaveConceptBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_permission("moodboard", "edit")),
|
||||
):
|
||||
"""Persist an AI-generated image (Concept/Product Studio or moodboard) as a
|
||||
first-class, origin-tagged library entry — it lands in the Concepts tab,
|
||||
kept out of the main 'made' library and out of production/style numbering."""
|
||||
src_dir = _CONCEPT_SOURCE_DIRS.get(body.source)
|
||||
if src_dir is None:
|
||||
raise HTTPException(400, "Unknown concept source.")
|
||||
fname = Path(body.image).name # strip any path components → no traversal
|
||||
src = src_dir / fname
|
||||
if not src.exists():
|
||||
raise HTTPException(404, "That generated image could not be found.")
|
||||
|
||||
ext = src.suffix.lower() or ".png"
|
||||
dest_name = f"concept_{uuid.uuid4().hex}{ext}"
|
||||
shutil.copyfile(src, Path(settings.images_path) / dest_name)
|
||||
|
||||
label = _CONCEPT_SOURCE_LABELS.get(body.source, "Studio")
|
||||
title = (body.title or "").strip()
|
||||
prompt = (body.prompt or "").strip()
|
||||
description = title or (prompt[:200] if prompt else f"{label} concept")
|
||||
|
||||
g = Garment(
|
||||
origin="ai_concept",
|
||||
origin_source=body.source,
|
||||
image_path=dest_name,
|
||||
description=description,
|
||||
collection_name=label,
|
||||
status="prototype",
|
||||
review_status="ai_tagged_auto",
|
||||
department_id=user.department_id,
|
||||
)
|
||||
db.add(g)
|
||||
db.commit()
|
||||
db.refresh(g)
|
||||
logger.info(f"Saved {body.source} concept → library garment {g.id}")
|
||||
return _garment_to_dict(g, db)
|
||||
|
||||
|
||||
@router.get("/{garment_id}")
|
||||
def get_garment(garment_id: str, db: Session = Depends(get_db)):
|
||||
garment = db.query(Garment).filter(Garment.id == garment_id).first()
|
||||
|
||||
@@ -46,6 +46,7 @@ def filter_search(
|
||||
macro_family: str | None = None,
|
||||
fabric_family: str | None = None,
|
||||
silhouette: str | None = None,
|
||||
origin: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -65,6 +66,7 @@ def filter_search(
|
||||
"macro_family": macro_family,
|
||||
"fabric_family": fabric_family,
|
||||
"silhouette": silhouette,
|
||||
"origin": origin,
|
||||
}.items() if v is not None}
|
||||
|
||||
svc = SearchService(_embedding_svc, db)
|
||||
|
||||
@@ -130,7 +130,7 @@ def can_access_owned(user: User, db: Session, obj, owner_field: str = "created_b
|
||||
# add the missing columns here on boot. Idempotent.
|
||||
_RBAC_COLUMNS = {
|
||||
"users": [("department_id", "TEXT"), ("is_active", "BOOLEAN DEFAULT 1")],
|
||||
"garments": [("department_id", "TEXT")],
|
||||
"garments": [("department_id", "TEXT"), ("origin", "TEXT DEFAULT 'made'"), ("origin_source", "TEXT")],
|
||||
"collections": [("department_id", "TEXT")],
|
||||
"trend_searches": [("department_id", "TEXT"), ("created_by", "TEXT")],
|
||||
"moodboard_decodes": [("department_id", "TEXT"), ("created_by", "TEXT"), ("questions", "TEXT"), ("answers", "TEXT"), ("sapl_grounding", "TEXT"), ("starter_kits", "TEXT")],
|
||||
|
||||
@@ -58,6 +58,14 @@ class SearchService:
|
||||
(Garment.department_id == department_id) | (Garment.department_id.is_(None))
|
||||
)
|
||||
|
||||
# Origin — keep AI concepts OUT of the main library by default; the
|
||||
# Concepts tab passes origin='ai_concept' to see them, 'all' for both.
|
||||
origin = params.get("origin")
|
||||
if origin == "ai_concept":
|
||||
query = query.filter(Garment.origin == "ai_concept")
|
||||
elif origin != "all":
|
||||
query = query.filter((Garment.origin != "ai_concept") | (Garment.origin.is_(None)))
|
||||
|
||||
# Direct garment-table filters
|
||||
if status := params.get("status"):
|
||||
query = query.filter(Garment.status == status)
|
||||
@@ -142,6 +150,8 @@ def _garment_to_dict(garment: Garment, db: Session) -> dict:
|
||||
"is_co_ord_set": garment.is_co_ord_set,
|
||||
"review_status": garment.review_status,
|
||||
"status": garment.status,
|
||||
"origin": garment.origin or "made",
|
||||
"origin_source": garment.origin_source,
|
||||
"sample_available": bool(garment.sample_available),
|
||||
"sample_location": garment.sample_location,
|
||||
"shelf_style_code": garment.shelf_style_code,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**Last updated:** 2026-07-28 (**OpenCode Go — a shared multi-model layer added alongside Gemini; Steps 1–3b SHIPPED + verified live on UAT.** Next direction after the RBAC/notifications work below: give SAPL a **multi-model brain**. **OpenCode Go** is an OpenAI-compatible gateway to ~23 models across families (minimax / deepseek / glm / kimi / qwen / mimo); we adopt it as an **ADDITIVE second engine — Gemini paths are untouched** (`agent.py`, scraper, fabric extraction, mail all unchanged). **The vision is bigger than the source plan** (`docs/plans/hermes-moa-for-sapl-plan.md`, authored by an external "Diablo/Hermes Agent" — and, notably, written WITHOUT running the API, so its response-parsing was wrong): not just a chatbot MOA endpoint but **per-feature model routing by performance** + a **HERMES orchestrator on top** that routes/manages/learns — Scraper→minimax/deepseek · Moodboard decode→deepseek/glm-5 · Chatbot→gemini/deepseek · **Studio stays on AIBrandSolutions (unchanged)** · HERMES→MOA (parallel refs + aggregator) over all of it. **Architecture correction vs the plan:** the plan buried the client under `services/hermes/`, but since scraper+moodboard also call it, it belongs CENTRALLY → new **`services/llm/`** package (`opencode_client.py` now; `routing.py` + `moa_orchestrator.py` next) so every feature reuses one client. **Build order:** (1) ✅ **foundation — DONE** · (2) ✅ **chatbot `/ask-with-moa` MOA agent — DONE + verified green (2/2 live)** · (2b) ✅ **frontend "Deep Research" toggle — DONE** (`AskHermes.tsx` "Deep" pill next to Ask → hits `/ask-with-moa`; loading copy "consulting several models… ~15–60s"; renders a `model_trace` summary "Deep Research · N models consulted · Ns · fallback used"; `hermesAskMoa` + `HermesModelTrace*` types in `client.ts`; `tsc -b` + build clean) · (3a) ✅ **per-feature routing + scraper→minimax — DONE** · (3b) ✅ **moodboard→deepseek + interactive analyze→ask→tailor flow — DONE** · (4) HERMES-on-top orchestrator (routing brain that learns).
|
||||
|
||||
**CONCEPT as an origin-tagged library entity (2026-07-28) — Design-Library Phase-1 gap B.** Concept/Product Studio + moodboard outputs were one-shot generators (images only lived in `data/concepts/` or `moodboard_decodes` JSON) — nothing persisted them as library entities, so the public library couldn't stay clean ("kichdi"). **Decision (Ajay via AskUserQuestion): main Library = real uploaded/made items; a separate CONCEPTS tab collects AI-generated ones from all three generators.** **Backend:** `Garment.origin` (`made` default | `ai_concept`) + `origin_source` columns (rbac self-heal `ADD COLUMN origin TEXT DEFAULT 'made'` — existing 59 rows → made, verified); `SearchService.filter_search` **excludes ai_concept by default** (so concepts never leak into the main library / studio pickers / similar / boards), accepts `origin=ai_concept|all`; `_garment_to_dict` returns origin/origin_source; `origin` param threaded through `GET /api/garments` + `/api/search/filter`. New **`POST /api/garments/concept`** (`SaveConceptBody`: image + source + prompt/title + provenance) — copies the generator image into the library image store (`concept_<uuid>`, served via /images/), creates an `origin=ai_concept` Garment (no style-number/QR, kept out of production); source dirs resolve concept_studio/product_studio→`concepts/`, moodboard→`images/`; filename basename-guarded vs traversal. **Frontend:** Library **Made / Concepts tab toggle** (Concepts tab fetches `origin=ai_concept`); `GarmentCardEditorial` amber **CONCEPT badge**; `VG.origin` + concept cards show description-as-title (no style no.); `saveConcept` client fn + `origin` on `GarmentDict`; **"Save to Library" button on Concept Studio + Product Studio results + moodboard StarterKitCard** (idle→saving→✓ Saved to Concepts). Validated live vs real DB: concept created → excluded from Made grid, included in Concepts grid, origin/source serialized. `tsc -b`+build+py_compile clean.
|
||||
|
||||
**MOODBOARD library-match ACCURACY fix (2026-07-28):** Ajay flagged the "Fabric direction" cards were showing in-house garment matches for fabrics SAPL doesn't stock (satin/suede/tweed) — "make our embedding similarity accurate to the point." **Root-caused via live calibration against the real chromadb + gemini-embedding-2 index:** (1) cosine similarity CANNOT judge fabric availability — a suede query (0 in library) scores 0.69, *higher* than cotton poplin (in library) at 0.66, and pure nonsense scores 0.51; so availability must be a DB fact, not a score. (2) the grounding's fabric matcher split phrases into words and matched generic filler (`blend`→11 hits, `soft`→11, `coloured`→2) against the fabric table's free-text `description`/`construction` → everything read as a match. (3) **a latent batch-embedding bug:** `gemini-embedding-2-preview` returns only ONE embedding for a `contents=[N texts]` call (concatenates them), so `embed_texts` collapsed N per-item queries → 1 vector and a `zip` dropped all but the first item — per-item silhouette/print/fabric matching was silently broken. **Fixes (backend):** new **`_fabric_status`** (vocabulary-driven: extracts real material nouns, drops descriptors, matches STRUCTURED identity fields only — composition/weave/texture/pattern, never free-text) replaces the loose `check_fabric_library` call in `_build_sapl_grounding`; **gap-gate** in `_match_library_per_item` (a sourcing-gap fabric surfaces ZERO in-house garments); **`embed_texts` fixed** to embed per-text (order-aligned) — restoring silhouette/print matching; **calibrated similarity floor** `_enrich_matches(min_sim=…)` (config `moodboard_match_min_similarity=0.58`, env `MOODBOARD_MATCH_MIN_SIM` — nonsense 0.51 vs real 0.58-0.71) on both `_match_library` + per-item. **Frontend:** fabric cards now show a subtle library-status hint (◆ In library / ◈ Similar in library / ◇ Not in library — develop) from `sapl_grounding.fabrics[].status`, so an empty (gap) card doesn't read as broken. Validated live vs real DB: tweed/suede/terry/fleece→gap (no in-house), cotton poplin/twill→have, satin/organza+viscose→similar; per-item now returns fabric/print/silhouette matches correctly; archive grid floor-passes ~0.77-0.79. `tsc -b`+build+py_compile clean. **Note:** SIMILAR fabrics (fibre stocked, not exact construction) still show in-house — flip to have-only if desired. The scraper's `check_fabric_library` has the same loose-match trait (unchanged; scoped to moodboard as asked). **Then fixed a starter-kit gating bug (2026-07-28):** technical flats generated + the whole Design-Starter-Kits section assembled UNCONDITIONALLY, so a designer who picked only "print & fabric swatches" still got garment flats + kits they never asked for. Starter kits are a garment-design deliverable → now gated on the garment-visual opt-in: new `wants_garments = generate_visuals AND "concepts" in visual_types` gates construction-spec drafting, flat-sketch generation, AND starter-kit assembly (empty `starter_kits` → frontend already hides the section). Swatches-only runs now skip all garment-image spend; "garment concepts"/"everything" still get the full kit. py_compile clean.
|
||||
|
||||
**OpenCode Go multi-model layer — shipped & on UAT (2026-07-24→25, commit `4c9486d`; iteration detail archived → STATUS-ARCHIVE.md):** **Step 2 — chatbot MOA agent** (`services/hermes/agent_moa.py`, `POST /api/hermes/ask-with-moa`, opt-in "Deep Research" toggle) live-verified (3 parallel refs → aggregator, graceful degrade + `kimi→glm→minimax` aggregator fallback chain). **Step 3a — per-feature routing** (`services/llm/routing.py`, env-overridable) + **scraper reasoning → `minimax-m3`** (Gemini loop is the automatic fallback; vision stays Gemini). **Scraper overhauled:** all-8-brand sweep (`scrape_all_brands`; user picks via UI tabs), **gender-aware** (women/men/kids category URLs), **grounded in SAPL's own fabric+garment libraries** before/after scraping (make-now vs source vs on-DNA), **Pinterest crash root-caused + fixed** (Camoufox driver `pageError.location` bug → `services/playwright_patch.py`, re-applied on boot), **calm bento report** (`BentoReport.tsx` — colour band → trend signals → what-to-make → feasibility scorecard, dense analysis behind a toggle). ⚠ **Next** stays IP-blocked (Akamai; needs a residential proxy — awaiting Ajay's key). Live-proven: Primark AW26 menswear → 53 items across 5 brands + Pinterest, grounded report.
|
||||
|
||||
@@ -290,6 +290,8 @@ export interface GarmentDict {
|
||||
is_co_ord_set: boolean;
|
||||
review_status: string;
|
||||
status: string;
|
||||
origin?: 'made' | 'ai_concept';
|
||||
origin_source?: string | null;
|
||||
top_tags: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
// New metadata fields
|
||||
@@ -357,6 +359,23 @@ export async function listGarments(params?: Record<string, string>): Promise<Gar
|
||||
return res.data?.data ?? res.data;
|
||||
}
|
||||
|
||||
export interface SaveConceptBody {
|
||||
image: string;
|
||||
source: 'concept_studio' | 'product_studio' | 'moodboard';
|
||||
title?: string;
|
||||
prompt?: string;
|
||||
source_garment_id?: string;
|
||||
source_fabric_id?: string;
|
||||
decode_id?: string;
|
||||
}
|
||||
|
||||
// Persist an AI-generated image (Concept/Product Studio or moodboard) into the
|
||||
// library as an origin-tagged concept — it appears in the Library's Concepts tab.
|
||||
export async function saveConcept(body: SaveConceptBody): Promise<GarmentDict> {
|
||||
const res = await api.post('/garments/concept', body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getGarment(id: string): Promise<GarmentDict> {
|
||||
const res = await api.get(`/garments/${id}`);
|
||||
return res.data;
|
||||
|
||||
@@ -73,6 +73,19 @@ export function ColourStory({ g, size }: { readonly g: VG; readonly size?: 'sm'
|
||||
);
|
||||
}
|
||||
|
||||
function ConceptBadge({ g }: { readonly g: VG }) {
|
||||
if (g.origin !== 'ai_concept') return null;
|
||||
return (
|
||||
<span
|
||||
className="absolute top-2.5 left-2.5 z-10 font-plex text-[8px] tracking-[0.16em] uppercase px-1.5 py-[3px] rounded text-white group-hover:opacity-0 transition-opacity shadow-[0_1px_4px_rgba(27,24,19,.35)]"
|
||||
style={{ background: 'var(--accent, #9a7322)' }}
|
||||
title="AI-generated concept — not a made item"
|
||||
>
|
||||
Concept
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingBadge({ g }: { readonly g: VG }) {
|
||||
if (!g.pending) return null;
|
||||
return (
|
||||
@@ -147,6 +160,7 @@ export default function GarmentCardEditorial({ g, hero, selected, selectMode, on
|
||||
>
|
||||
<GarmentImage g={g} />
|
||||
<SelectDot selected={selected} selectMode={selectMode} onToggle={() => onToggleSelect(g)} />
|
||||
<ConceptBadge g={g} />
|
||||
<PendingBadge g={g} />
|
||||
{!selectMode && <HoverActions g={g} onSave={onSave} onSimilar={onSimilar} />}
|
||||
{selected && <div className="absolute inset-0 ring-2 ring-inset ring-ink/80 bg-paper/10" />}
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface VG {
|
||||
pending: boolean;
|
||||
hero: boolean;
|
||||
img: string;
|
||||
origin: 'made' | 'ai_concept';
|
||||
originSource?: string | null;
|
||||
}
|
||||
|
||||
/** snake_case / lower → Title Case; leaves already-spaced or em-dashed labels intact. */
|
||||
@@ -64,9 +66,11 @@ export function mapGarment(g: GarmentDict, index: number): VG {
|
||||
.map((x) => x && x !== 'none' ? x : '')
|
||||
.filter(Boolean);
|
||||
|
||||
const isConcept = g.origin === 'ai_concept';
|
||||
return {
|
||||
id: g.id,
|
||||
style: g.style_number || g.series_code || '—',
|
||||
// Concepts have no style number — show their title/description instead of a bare em-dash.
|
||||
style: isConcept ? (g.description || g.collection_name || 'Concept') : (g.style_number || g.series_code || '—'),
|
||||
season: g.season || 'AW26',
|
||||
category: titleize(asStr(t.category)) || 'Uncategorised',
|
||||
dna: titleize(asStr(t.design_dna)) || 'Unclassified',
|
||||
@@ -81,6 +85,8 @@ export function mapGarment(g: GarmentDict, index: number): VG {
|
||||
// Editorial rhythm: a stable ~1-in-7 cadence of hero-scale pieces.
|
||||
hero: index % 7 === 3,
|
||||
img: imageUrl(g.image_path),
|
||||
origin: isConcept ? 'ai_concept' : 'made',
|
||||
originSource: g.origin_source,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
listFabrics,
|
||||
getGarment,
|
||||
imageUrl,
|
||||
saveConcept,
|
||||
type ConceptResult,
|
||||
type GarmentDict,
|
||||
type FabricDict,
|
||||
@@ -256,6 +257,27 @@ export default function ConceptStudio() {
|
||||
const [enhancing, setEnhancing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ConceptResult | null>(null);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
|
||||
useEffect(() => { setSaveState('idle'); }, [result]);
|
||||
|
||||
const saveToLibrary = async () => {
|
||||
if (!result || saveState !== 'idle') return;
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await saveConcept({
|
||||
image: result.image,
|
||||
source: 'concept_studio',
|
||||
prompt: result.description,
|
||||
source_garment_id: garment?.kind === 'library' ? garment.garment.id : undefined,
|
||||
source_fabric_id: swatch?.kind === 'fabric' ? swatch.fabricId : undefined,
|
||||
});
|
||||
setSaveState('saved');
|
||||
} catch {
|
||||
setSaveState('idle');
|
||||
setError('Could not save to library. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Arriving from a fabric's "Use in Studio" — load its swatch image into the
|
||||
// swatch slot (fetched into a File so it posts like a normal upload).
|
||||
@@ -512,13 +534,25 @@ export default function ConceptStudio() {
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<span className="font-plex text-[10px] uppercase tracking-[0.22em] text-muted">Generated concept</span>
|
||||
{result && !busy && (
|
||||
<a
|
||||
href={conceptImageUrl(result.image)}
|
||||
download="concept.png"
|
||||
className="h-7 px-3 rounded border border-line bg-ivory text-ink text-[11px] tracking-wide flex items-center hover:border-ink/40 transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveToLibrary}
|
||||
disabled={saveState !== 'idle'}
|
||||
className={'h-7 px-3 rounded text-[11px] tracking-wide flex items-center transition-colors ' + (saveState === 'saved'
|
||||
? 'border border-line bg-ivory text-muted cursor-default'
|
||||
: 'bg-ink text-paper hover:bg-ink/90 disabled:opacity-50')}
|
||||
title="Save this concept to the Library's Concepts tab"
|
||||
>
|
||||
{saveState === 'saved' ? '✓ Saved to Concepts' : saveState === 'saving' ? 'Saving…' : 'Save to Library'}
|
||||
</button>
|
||||
<a
|
||||
href={conceptImageUrl(result.image)}
|
||||
download="concept.png"
|
||||
className="h-7 px-3 rounded border border-line bg-ivory text-ink text-[11px] tracking-wide flex items-center hover:border-ink/40 transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1 min-h-[55vh] lg:min-h-[300px] rounded border border-line bg-ivory overflow-hidden flex items-center justify-center">
|
||||
|
||||
@@ -47,15 +47,22 @@ export default function Library() {
|
||||
const [selection, setSelection] = useState<string[]>([]);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [entered, setEntered] = useState(false); // first-reveal entrance choreography
|
||||
const [libTab, setLibTab] = useState<'made' | 'concept'>('made'); // real made items vs AI concepts
|
||||
|
||||
// ── data ──
|
||||
// Boards load once.
|
||||
useEffect(() => { listBoards().then(setBoards).catch(() => { /* non-blocking */ }); }, []);
|
||||
|
||||
// Garments (re)load when the Made / Concepts tab changes. The main library shows
|
||||
// real made items; the Concepts tab shows AI-generated Studio/moodboard outputs.
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([listGarments({}), listBoards()])
|
||||
.then(([g, b]) => { setRaw(g); setBoards(b); })
|
||||
setEntered(false);
|
||||
listGarments(libTab === 'concept' ? { origin: 'ai_concept' } : {})
|
||||
.then((g) => setRaw(g))
|
||||
.catch(() => { /* keep the page usable on a partial failure rather than blank */ })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [libTab]);
|
||||
|
||||
// After the first grid renders, stop applying the staggered entrance so
|
||||
// later filter changes update instantly (the cascade is a one-time delight).
|
||||
@@ -251,7 +258,20 @@ export default function Library() {
|
||||
return (
|
||||
<div style={{ ['--accent' as string]: ACCENT }} className="min-h-screen bg-paper text-ink font-archivo">
|
||||
<div className="max-w-[1480px] mx-auto px-5 sm:px-8 pt-20">
|
||||
<section className="pt-4 pb-8 anim-fade-up">
|
||||
<div className="flex items-center gap-1.5 pt-4">
|
||||
{([['made', 'Made'], ['concept', 'Concepts']] as const).map(([key, label]) => (
|
||||
<button key={key} onClick={() => setLibTab(key)}
|
||||
className={'h-8 px-4 text-[11.5px] tracking-wide border transition-colors ' + (libTab === key
|
||||
? 'border-ink bg-ink text-paper'
|
||||
: 'border-line bg-ivory text-muted hover:text-ink hover:border-ink/30')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{libTab === 'concept' && (
|
||||
<span className="font-plex text-[10px] uppercase tracking-[0.16em] text-muted ml-2">AI-generated · Concept & Product Studio, Moodboard</span>
|
||||
)}
|
||||
</div>
|
||||
<section className="pt-3 pb-8 anim-fade-up">
|
||||
<EditorialStats total={garments.length} />
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
decodeMoodboard, decodeMoodboardPdf, getMoodboardPdfDecode, listMoodboardPdfDecodes, imageUrl,
|
||||
updateMoodboardItemSelection, updateMoodboardSectionNote, submitMoodboardAnswers, updateMoodboardStarterKit,
|
||||
updateMoodboardItemSelection, updateMoodboardSectionNote, submitMoodboardAnswers, updateMoodboardStarterKit, saveConcept,
|
||||
type MoodboardResponse, type MoodColour, type SimilarItem,
|
||||
type MoodboardPdfDecode, type MoodboardPdfColour, type MoodboardLibraryMatch,
|
||||
type MoodboardQuestion, type MoodboardAnswer, type MoodboardGrounding, type MoodboardStarterKit,
|
||||
@@ -528,11 +528,19 @@ const SPEC_LABELS: Record<string, string> = {
|
||||
function StarterKitCard({ decodeId, index, kit }: {
|
||||
readonly decodeId: string; readonly index: number; readonly kit: MoodboardStarterKit;
|
||||
}) {
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const save = (field: string, value: string) => {
|
||||
if (value === (kit.spec[field] ?? '')) return;
|
||||
kit.spec[field] = value; // keep local in sync
|
||||
updateMoodboardStarterKit(decodeId, index, { [field]: value }).catch(() => { /* non-blocking */ });
|
||||
};
|
||||
const saveToLibrary = () => {
|
||||
if (saveState !== 'idle' || !kit.concept_image) return;
|
||||
setSaveState('saving');
|
||||
saveConcept({ image: kit.concept_image, source: 'moodboard', title: kit.name, decode_id: decodeId })
|
||||
.then(() => setSaveState('saved'))
|
||||
.catch(() => setSaveState('idle'));
|
||||
};
|
||||
return (
|
||||
<div className="border border-line bg-paper rounded overflow-hidden flex flex-col sm:flex-row print:break-inside-avoid">
|
||||
<div className="sm:w-56 shrink-0 flex sm:flex-col bg-[#ece6d9]">
|
||||
@@ -542,9 +550,23 @@ function StarterKitCard({ decodeId, index, kit }: {
|
||||
<div className="flex-1 p-5 flex flex-col gap-3 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-display text-[16px] leading-snug text-ink">{kit.name}</span>
|
||||
<span className={`font-plex text-[9px] uppercase tracking-[0.14em] px-1.5 py-0.5 rounded border shrink-0 ${kit.on_dna ? 'text-[#3f7d4e] bg-[#3f7d4e]/[0.08] border-[#3f7d4e]/25' : 'text-[#9a6a1a] bg-[#c8892b]/[0.1] border-[#c8892b]/30'}`}>
|
||||
{kit.on_dna ? 'on-DNA' : 'new block'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{kit.concept_image && (
|
||||
<button
|
||||
onClick={saveToLibrary}
|
||||
disabled={saveState !== 'idle'}
|
||||
className={'font-plex text-[9px] uppercase tracking-[0.14em] px-2 py-0.5 rounded border transition-colors print:hidden ' + (saveState === 'saved'
|
||||
? 'text-muted border-line cursor-default'
|
||||
: 'text-ink border-ink/40 hover:bg-ink hover:text-paper disabled:opacity-50')}
|
||||
title="Save this concept render to the Library's Concepts tab"
|
||||
>
|
||||
{saveState === 'saved' ? '✓ Saved' : saveState === 'saving' ? 'Saving…' : 'Save to Library'}
|
||||
</button>
|
||||
)}
|
||||
<span className={`font-plex text-[9px] uppercase tracking-[0.14em] px-1.5 py-0.5 rounded border ${kit.on_dna ? 'text-[#3f7d4e] bg-[#3f7d4e]/[0.08] border-[#3f7d4e]/25' : 'text-[#9a6a1a] bg-[#c8892b]/[0.1] border-[#c8892b]/30'}`}>
|
||||
{kit.on_dna ? 'on-DNA' : 'new block'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{kit.suggested_fabric && (
|
||||
<span className="text-[11.5px] text-muted">Suggested fabric — <span className="text-ink/80">{kit.suggested_fabric}</span></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { productStudioGenerate, productStudioEnhance, conceptImageUrl, type ProductStudioResult } from '../api/client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { productStudioGenerate, productStudioEnhance, conceptImageUrl, saveConcept, type ProductStudioResult } from '../api/client';
|
||||
import { DesignerLottie } from '../components/LottieLoader';
|
||||
|
||||
// Native SAPL frontend over AIBrandSolutions' Product Studio Pro backend (our UI,
|
||||
@@ -27,6 +27,21 @@ export default function ProductStudio() {
|
||||
const [enhancing, setEnhancing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ProductStudioResult | null>(null);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
|
||||
useEffect(() => { setSaveState('idle'); }, [result]);
|
||||
|
||||
const saveToLibrary = async () => {
|
||||
if (!result || saveState !== 'idle') return;
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await saveConcept({ image: result.image, source: 'product_studio', prompt: result.prompt });
|
||||
setSaveState('saved');
|
||||
} catch {
|
||||
setSaveState('idle');
|
||||
setError('Could not save to library. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const ready = !!file && prompt.trim().length > 0;
|
||||
@@ -207,13 +222,25 @@ export default function ProductStudio() {
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<span className="font-plex text-[10px] uppercase tracking-[0.22em] text-muted">Generated shot</span>
|
||||
{result && !busy && (
|
||||
<a
|
||||
href={conceptImageUrl(result.image)}
|
||||
download="product-shot.png"
|
||||
className="h-7 px-3 rounded border border-line bg-ivory text-ink text-[11px] tracking-wide flex items-center hover:border-ink/40 transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveToLibrary}
|
||||
disabled={saveState !== 'idle'}
|
||||
className={'h-7 px-3 rounded text-[11px] tracking-wide flex items-center transition-colors ' + (saveState === 'saved'
|
||||
? 'border border-line bg-ivory text-muted cursor-default'
|
||||
: 'bg-ink text-paper hover:bg-ink/90 disabled:opacity-50')}
|
||||
title="Save this shot to the Library's Concepts tab"
|
||||
>
|
||||
{saveState === 'saved' ? '✓ Saved to Concepts' : saveState === 'saving' ? 'Saving…' : 'Save to Library'}
|
||||
</button>
|
||||
<a
|
||||
href={conceptImageUrl(result.image)}
|
||||
download="product-shot.png"
|
||||
className="h-7 px-3 rounded border border-line bg-ivory text-ink text-[11px] tracking-wide flex items-center hover:border-ink/40 transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1 min-h-[55vh] lg:min-h-[300px] rounded border border-line bg-ivory overflow-hidden flex items-center justify-center">
|
||||
|
||||
Reference in New Issue
Block a user