Appearance
Entity gallery population (#360)
How system pages get real imagery: by turning the media library's system tags into entity attachments (media_links), which the existing media-presentation wiring (recommendation.ts) renders as a hero + gallery.
Source of truth: entity-media-attach.ts.
The pipeline
Drupal media/image ──#400 sync──▶ media_assets (referenced) + media_tags (system)
│
#360 population (this doc)
▼
media_links (entity_type='system')
│
recommendation.ts (existing)
▼
system hero + gallery on the pageIngestion and tagging are the media library sync. This step only attaches — no fetch, no new assets.
Rules
- Static-first hero. Per system, tagged assets are ordered static images first, then stably by id. Since
attachMediamakes the first attachment to an empty gallery the primary, the hero is a real photo, not a Wistia video still. - Bounded. Never exceeds
MAX_GALLERY_ITEMS(12) per system, counting links already present. - Idempotent. Already-linked assets are skipped; a system already full or fully-attached yields no change. Re-running never duplicates.
- Graceful fallback. A system with no tagged media is left untouched;
recommendation.tsalready rendersmedia: nullas a neutral state.
Running it
Admin → Drupal sync → Populate entity galleries: Preview shows the systems that would gain attachments and how many; Apply attaches them and records the run in the audit trail. Programmatically: runEntityMediaAttach(db, actor).
System main images come from the PDP hero (#1009)
The gallery population above decides a reasonable hero (static-first, stable order); the PDP hero population then makes each system's main image the hero its public nanawall.com PDP renders — the on-brand choice a human made for the product page. Source of truth: pdp-hero.ts.
- Resolved via JSON:API. Each mapped system's hero is resolved through the shallow chain
taxonomy_term/products/<uuid>(fromdrupal_system_mappings) →node--product_detail(field_product_node, orfield_nodeon some terms) →field_hero_media→paragraph--media_block→field_images_media[0]= the hero media--image UUID plus its file URL. The originally-planned public-page fetch is unworkable server-side — the www.nanawall.com WAF 403s every non-browser client on page routes at the TLS-fingerprint level (the #395 behaviour) while JSON:API passes; see the decision record on #1009. - Reconciled with synced media first. The hero is matched against
media_assetsby media UUID (source_ref— an exact key), falling back to remote-URL path tail, then a unique filename match. A match is associated, never re-downloaded — and a Wistia-backed hero keeps its embed, so it stays a playable gallery item. - Imported only when absent. A hero missing from the library is created as a referenced asset — carrying the real media UUID as
source_ref(so a later media sync recognises it instead of duplicating) and its Wistia embed when present — and its bytes imported to R2 immediately. The import doubles as the liveness check (#1008): if the bytes can't be fetched, the asset is rolled back and nothing is attached — no dead URL is ever stored. - One-time, re-runnable — not a live sync. Re-running re-resolves the pages and plans only what changed; an unchanged hero that is already primary is a no-op. No scheduled or field-bound sync exists.
- Cap-aware. When a full (12-item) gallery must admit its hero, the last non-primary item is detached to make room; evictions are counted and surfaced.
Run it from Admin → Drupal sync → Populate system hero images from PDPs (Preview resolves the PDPs and lists the per-system actions; Apply re-resolves and populates, recording the run in the audit trail). Programmatically: runPdpHeroPopulate(db, bucket, actor, { baseUrl }).
Full-library sync — progress, cancel, resumability (#796)
The upstream media library and projects syncs (Admin → Drupal sync → Sync tab) can ingest the entire Drupal collection. Each provides two paths:
- Preview + a bounded Apply — inspect the pending changes, or ingest a capped sample (a limit, and for media a newest/oldest order).
- Sync entire library / Sync all projects (chunked) — the recommended path for a whole-library run. Rather than one opaque synchronous POST, the browser loops a bounded resume-chunk at a time against
POST /admin/drupal-sync/chunk, showing live progress (items processed across N chunks) and a Cancel button.
Resumability. The ingest is idempotent and resume-from-last-position by construction: each chunk skips already-ingested rows and pulls the next batch, and commits as it completes. So:
- Cancel stops the loop after the current chunk — everything already synced is kept. A cancelled run is a valid partial run, not a rollback.
- Re-running (chunked or bounded) picks up where the last run left off; a run over unchanged data is a no-op.
- A chunk whose Drupal fetch is incomplete aborts before writing anything and stops the loop; earlier chunks are unaffected.
The loop's stop condition (hasMore from a full-batch fetch, plus a safety cap) and count accumulation are the pure, unit-tested helpers in src/lib/admin/chunk-progress.ts; the UI is ChunkedSyncRunner.svelte.
Scope
Systems only. Attribute imagery is deferred — attributes are not media-tagged today (only system and project tag types exist, #377/#400). When an attribute-tag type is added, the same planner extends to it.