Skip to content

feat: IoT Hub pages (/iot-hub/)#450

Merged
vvlladd28 merged 40 commits into
developfrom
feature/iot-hub-page
Jun 5, 2026
Merged

feat: IoT Hub pages (/iot-hub/)#450
vvlladd28 merged 40 commits into
developfrom
feature/iot-hub-page

Conversation

@vvlladd28
Copy link
Copy Markdown
Member

Summary

Adds the IoT Hub section to the site — a full set of pages for browsing, searching, and installing ThingsBoard listings (solution templates, dashboards, integrations, rule chains, widgets), built per the Figma designs.

Pages

Route Description
/iot-hub/ Home: hero with debounced API search popup, category tiles, per-category listing sections
/iot-hub/[category]/[...page]/ Category listing with filter panel (build-time facets, mobile drawer), sorting, pagination
/iot-hub/[category]/[slug]/ Listing detail: hero with TB version + CE/PE badges, screenshot carousel (drag + autoplay), markdown description, rule-node chips, meta sidebar, install/connect CTA
/iot-hub/creator/[id]/[...page]/ Creator profile paginating across the creator's whole catalogue
/iot-hub/search/[...page]/ Search results page with dynamic client-side search

Key features

Data layer

  • src/models/iot-hub.ts — typed model for the IoT Hub API: listing types, categories, install instances, URL builders, shared strings
  • Build-time fetch of listings + facets; publishedListingCount used on creator pages
  • Runtime config component for client-side API access

Dynamic search & filtering

  • Hero search popup with debounced API search and custom clear button
  • Dynamic search + filter integration across all listing pages (iot-hub-dynamic-search.ts): results update in place, pagination rewrites, loading states, no-results state
  • Shared client-side template pattern (Pattern C) for listing cards/links/icons rendered from API data — one <template> per component, hydration binds (iot-hub-*-bind.ts)

Install/connect dialog

  • Shared <dialog> for listing cards + detail hero: Cloud / local instance rows with copy-link, deep-link open, editable localhost URL persisted in localStorage (privacy-mode safe)
  • Native showModal() with focus trap, Esc handling that cancels inline edit first, backdrop-click close
  • Fade in/out animation via @starting-style + allow-discrete transitions on display/overlay (instant fallback in older browsers); scroll unlock deferred until the exit fade ends so the returning scrollbar doesn't shift the closing dialog
  • Shared scroll-lock util (document + fixed header + chat widget scrollbar compensation), reused by other modal layers

Content rendering

  • Listing descriptions rendered as markdown with split-and-render support for Code blocks, ImageGallery, Asides, and doc-link buttons
  • Screenshot carousel with pointer-drag, autoplay, and lightbox

Design & a11y

  • Dark mode + responsive layouts across all pages, hairline borders, load animations
  • Reduced-motion support, aria labels on icon-only controls, distinct accessible names per dialog row

Other changes

  • astro.config.ts, tsconfig.json — path aliases / config for the new section
  • Docs guides under src/content/docs/docs/iot-hub/guides/
  • OG image support for the new pages
  • Assorted shared-component touch-ups picked up along the way (Landing, starlight overrides, styles)

~150 files changed: +16k / −4.8k lines (36 commits).

vvlladd28 and others added 30 commits May 26, 2026 18:29
New top-level `/iot-hub/` section that replaces "Device Library" in the
main menu. Six paginated category indexes (devices, solution templates,
widgets, calculated fields, alarm rules, rule chains) and per-listing
detail pages, all populated at build time from
`https://iot-hub.tbqa.cloud` (override via `IOT_HUB_API_URL` env var).

Detail pages fetch `/api/listings/public/by-slug/{slug}` and render
`readmeContent` through Astro's markdown processor. Loader fails the
prod build on API errors and soft-fails to an empty collection in dev
so the dev server always boots.
- DetailHero: <h1> → <p> to avoid double-h1 with Starlight's PageTitle
  (WCAG 1.3.1, was the only Critical from review).
- [...page].astro: filter numeric slugs to match [slug].astro, preventing
  card hrefs that resolve to pagination URLs.
- [slug].astro: hoist createMarkdownProcessor() to module scope so the
  remark/rehype pipeline is built once per build worker, not per render.
- Loader: fetch the 6 categories in parallel via Promise.all instead of
  serially; each category still paginates serially because hasNext is
  only known after the prior page.
- listingViewSchema: drop the redundant `id` field (entry.id already holds
  the slug). `color` now goes through a strict #hex/named-color regex
  with .catch(null) — anything malformed becomes null instead of being
  interpolated into inline style attributes.
- fetchWithRetry: drain the response body on 5xx so the underlying
  connection can be reused before the next retry.
- index.astro: drop the redundant client-side .sort(); the loader already
  hands back installCount DESC per category.
- ogContext: add /iot-hub/* to MARKETING_ALLOWLIST so OG cards generate.
- package.json: move @astrojs/markdown-remark from devDependencies to
  dependencies (it's imported by a page source file).
- Components: replace hardcoded font-weight numerics with the
  $font-weight-* SCSS tokens already imported via _variables.scss.
- Rebuild hero with title, animated highlight subtitle, search input
- Add 3×2 colored category tiles using PE PNG assets with hover scale
  + chevron slide-in animation (200ms ease-out, scale 1.054)
- Add big-card and small-card listing variants, dispatched per category
  type (Devices/Solution Templates/Widgets → big, Calculated Fields/
  Alarm Rules/Rule Chains → small) on both home and category index pages
- Add subtle dot pattern + soft indigo blob background covering hero
  and tiles, with light/dark theme variants
- Add Become a Creator CTA matching Figma spec (indigo gradient,
  semi-transparent border, primary button with chevron)
- Section headers now use indicator bar + chevron link to category page
- Move src/config/iot-hub.ts -> src/models/iot-hub.ts
- Rename src/config/utils.ts -> src/util/fetch-utils.ts (remove empty src/config/)
- Migrate BaseLayout.astro and its full dependency tree to @ path aliases
- Document @ alias convention in CLAUDE.md
- Bump astro, starlight, sitemap, and @astrojs/check (pre-existing)
…cents

- Set hero title 180px below the fixed header (Figma 1169:2767)
- Make hero + tiles sections transparent so the dotted pattern/blobs show
  through (override the global :where(section) white fill)
- Blobs: solid #4c63cc base, opacity 0.16, blur(120px) (drop radial gradients)
- Drive a single animated --iot-hub-active-accent so the highlighted word and
  background blobs cycle the 6 category colors in sync (Figma 1158:14803)
- Highlights are now anchors linking to /iot-hub/{slug}/ (same routing as
  the category tiles); hover/focus pauses the auto-cycle and immediately
  becomes the active accent, matching tb-iot-hub-home in the platform
- Switch the accent cycle from a CSS @Keyframes animation to a small
  inline script driving --iot-hub-active-accent on .iot-hub-home__top so
  highlight color, blob color, and active cluster stay in lockstep
- Port 6 hero cluster SVGs from the platform; render them layered over
  the hero with fade + scale entry/exit per active state, positioned per
  Figma (per-slug top offsets via --cluster-top inline var)
- Subtitle: lead text -> --sl-color-gray-2 (#353841); off-state highlight
  -> --sl-color-gray-5 (#c1c3c8); reduced-motion keeps text-color for
  contrast
- Hero bottom padding 80px and .iot-hub-home__top-inner bottom 80px per
  Figma spacing
…th Figma

- Hero bottom 80px; .iot-hub-home__top-inner bottom 80px; tiles grid gap 16px
- Heading -> first section gap 64px (via padding so it doesn't collapse with
  the section's 4rem margin); section-to-section 64px
- Move section horizontal gutter onto .iot-hub-grid so the section header's
  indicator sits flush left while cards stay inset 16px
- Move bottom divider + CreatorCTA outside .iot-hub-home__container; wrap
  CTA in its own .iot-hub-home__container so dividers span full width
- Drop .iot-hub-home padding-bottom; add hairline borders to top-inner +
  container; divider + section indicator colors per Figma tokens
- CreatorCTA "Contribute" links to ${IOT_HUB_API_URL}/signup, matching
  tb-iot-hub-home's openSignup()
- Page-load animation ported from tb-iot-hub-home (hero fade-slide-down,
  tiles fade-slide-up, 1s gentle easing, 0.2s delay), respects
  prefers-reduced-motion
- Double rAF for the initial cluster activation so the Devices cluster
  actually transitions in from scale(0.8) instead of snapping to scale(1)
- Collection: replace `iotHubListings` (1 entry per item) with
  `iotHubCategories` (1 entry per category, items kept inside `data.items`).
  Preserves the API installCount-DESC order, which `getCollection()` loses
  when re-sorting flat entries by id.
- ListingCard absorbs ListingCardSmall: a single component switches between
  big/compact via class:list. Compact tile renders `getPlaceholderIcon(item)`
  — `mdi:*` icons load from the iot-hub `/assets/mdi/` directory, bare
  names render through the self-hosted Material Icons font
  (`@fontsource/material-icons`).
- Author (`verified`/`person`) and install-count (`download`) glyphs switch
  from inline SVG to the same Material Icons font.
- CTA span becomes a real `<button>` with an isolated hover state.
- DEVICE preview gets a white background; `<img>` carries `width`/`height`
  attributes so the aspect-ratio is reserved before pixels arrive.
- Trim redundant style rules and stale comments.
Move the Install button out of the footer for the compact variant so it
sits as a direct sibling of the icon/body and is vertically centered on
the card. Adjust icon radius and muted text colors to match the design.
…h styling

- Dark mode: drive `--iot-hub-hairline` per theme on .iot-hub-home so
  side borders and dividers swap to #23262f in dark (Figma)
- Category tiles: add tileColorDark per category and swap background +
  white text + transparent border in dark via :global([data-theme=dark])
- CreatorCTA Contribute button: dark-mode #b3c7ff bg / #17181c text per
  Figma node 1240:40372
- Hero search border + box-shadow per Figma node 1169:2786 (full 5-layer
  shadow; gray-4 border with light/dark variants)
- Fix Astro scoping on dark-theme overrides — switch [data-theme='dark']
  to :global([data-theme='dark']) where ancestor lives on <html>
- Responsive + layout tweaks (sm/lg/xl breakpoints, center wrapper,
  container max-width and inline margins, top-inner padding adjustments,
  hero spacing on small screens, listing grid home truncation)
- Add CategoryHero with breadcrumb, title, description, contribute CTA,
  and per-category hero artwork (devices, solution-templates, widgets,
  calculated-fields, alarm-rules, rule-chains).
- Hide hero image at lg-down and load it on desktop only via
  <picture> + <source media> so mobile never fetches the asset.
- Add ListingsFilterBar (filter + search + results count + sort
  visual stubs) and restructure category page with hairline side
  borders, dividers, and the bottom Creator CTA.
- Rewrite PaginationLink to add a "Page X of Y" compact mode at
  md-down and a disabled "Items per page" selector at md-up.
- Extract IotHubChevron and PaginationChevron to remove duplicated
  inline SVG markup across components.
- Drop the unused CategoryHeader stub.
Astro 6.3.8's dev-mode style hoisting was inserting every page's scoped
<style> block INSIDE Starlight's `<template id="theme-icons">` (emitted
by `<ThemeProvider>`). Browsers put <template> content in a separate
DocumentFragment, so those styles were never in the live DOM — the page
rendered unstyled after any navigation.

- BaseLayout: drop `<ThemeProvider>` (we don't use Starlight's docs
  theme picker on non-docs pages) and replace with a minimal inline
  script that reads `localStorage['starlight-theme']` (same key) and
  falls back to OS preference, preserving cross-page theme consistency
- astro.config: drop the custom Sass `findFileUrl` importer — Vite 7's
  built-in resolver handles `~/` SCSS via vite.resolve.alias and tracks
  files in its CSS dep graph correctly
Adds a category-page filter panel that mirrors the in-app Angular
`iot-hub-browse` filters. Sections, ordering, defaults, search threshold,
"Most popular"/"All" popularity grouping, and the single-visible-scrollbar
hover UX all match the Angular reference. Facet counts come from a
build-time fetch of `/api/item-listing/listingFilterInfo/{itemType}`,
typed against the Angular DTOs.

Desktop: inline left sidebar, `position: relative` + absolute `__inner`
so a tall filter list scrolls internally without stretching the listings
column. Mobile (<lg): slide-in drawer from the left below the site
header, with backdrop, body scroll lock that compensates for the
disappearing scrollbar (so the fixed header doesn't jump), and focus
restoration to the Filter button on close.

UI shell strings live in a single `IOT_HUB_STRINGS` dictionary in
`@models/iot-hub.ts` so they're easy to swap for a `t(...)` call if
marketing-side i18n ever lands. The search icon is served from
`/public/icons/iot-hub-search.svg` via CSS-mask, deduplicating it
across every search field on the page.

Checkboxes are disabled (display-only) — filtering wiring will land in
a follow-up.
- Tighten `itemTypeFilterInfoSchema` with a `.transform(v => v ?? [])`
  after `.nullable().default([])` so the API's explicit `null` (e.g.
  `categories: null` for DEVICE) coerces to `[]` at the type level —
  `.default()` alone only swaps `undefined`. Drops a `?? {}` fallback
  in `FilterPanel` that's no longer needed.
- Swap `z.string().uuid()` → `z.uuid()` (Zod v4 API).
- Replace `onsubmit=\"event.preventDefault()\"` in `IotHubHero` with a
  `submit` listener on a `data-*`-marked form — the inline `return`
  syntax was tripping TS into flagging subsequent script as
  unreachable.
- `BaseLayout`: `var` → `const` in the inline pre-hydration theme
  script (eslint `no-var`).
Page composition matches the category page (centered container with hairline
side borders, full-width divider, CreatorCTA in its own container).

Components:
- New IotHubMarkdown — accepts raw markdown, processes via a module-level
  cached Astro processor (@util/markdown-processor), renders with the Figma
  readme typography (lead paragraph, h2 sections, body lists, specs table).
  Rewrites `/api/resources/...` URLs to absolute (resolveImage) before
  processing so embedded images load.
- New DetailRuleNodes — chip row for `tags[]` on CALCULATED_FIELD /
  RULE_CHAIN / ALARM_RULE (Figma 1286:31922).
- DetailHero rewritten with breadcrumbs (style + tileLabel match
  CategoryHero) + variant-aware body:
    * DEVICE / WIDGET / SOLUTION_TEMPLATE -> preview column + description
      column. DEVICE gets a white frame + hairline border. SOLUTION_TEMPLATE
      renders DetailGallery (featured + thumbnails) when screenshots exist.
    * CALCULATED_FIELD / RULE_CHAIN / ALARM_RULE -> 80x80 colored icon tile
      (same renderer as compact ListingCard) next to the title stack.
- DetailMeta rewritten as the 4-column tags row (Creator avatar + name,
  Type, Connectivity/Category, Use Cases) with Starlight low/high color
  tints per column. Layout uses display:flex/row + flex-shrink:0 on
  non-wide columns; mobile wraps via media-down(md). Class renamed from
  __col to __group to avoid collision with the global [class*='col']
  Bootstrap-style rule in _layout.scss.
- DetailGallery refactored to a featured + thumbnail strip layout for use
  inside the SOLUTION_TEMPLATE hero preview slot.
- New IotHubIcon (mirrors Angular hub-icon): `icon` + `size` props. Bare
  names render as Material Icons font glyphs; `mdi:` names fetch the SVG
  from `${IOT_HUB_API_URL}/assets/mdi/{name}.svg` and inline it. Server-
  side fetch cached per-build-worker (@util/mdi-icons), with normalization
  that strips XML prologue and injects `fill="currentColor"` so parent
  `color` drives the rendered hue.
- ListingCard: compact-card icon slot now uses IotHubIcon. Dropped the
  `--mdi` filter:brightness/invert hack (currentColor handles inversion).
- DetailHero: compact-variant 80x80 icon + the sub-title meta row icons
  (sub-type / download / event) all routed through IotHubIcon. Removed
  the local mdi-prefix parsing and the duplicated `.material-icons`
  font-face rule.
- DetailMeta: render creator avatar from `creatorAvatarUrl` (resolveImage
  applied when path starts with `/api/resources`); falls back to initials.
- iot-hub model: add `creatorAvatarUrl` to listingViewSchema, and
  `dataDescriptor` + `ceOnly` / `peOnly` / `minTbVersion` / `maxTbVersion`
  to listingDetailSchema (propagated from MpListingView / MpListingDetail
  DTOs).
…icon

- IotHubIcon: add in-memory SVG registry (ported from the platform's
  shared/models/icon.models.ts svgIcons). Resolution precedence is now:
  registry hit -> mdi: fetch -> Material Icons font glyph. The `isSvgIcon`
  helper mirrors the Angular equivalent.
- Merge @util/mdi-icons and the new registry into a single @util/icons.ts
  so icon resolution lives in one file.
- Add `thingsboard` brand glyph to the registry (the only entry for now).
- DetailHero meta row:
    * after Apache-2.0 license, show the supported ThingsBoard version
      envelope using a new model helper `getTbVersionLabel` that mirrors
      the platform helper (`v{min}` / `v{min} - {max}` / `v{min}+`). The
      icon is the new `thingsboard` glyph from the registry.
    * append CE / PE edition badges at the end of the row when
      detail.ceOnly / detail.peOnly is true. Fixed brand hues — CE
      `#305680`, PE `#00695c` — white text for contrast on both themes.
- Model: add `version` field to listingDetailSchema (matches
  MpListingDetail#version).
Add a reusable InstallButton that opens a shared, auto-mounting dialog
(NA/EU cloud + editable Local instance persisted to localStorage) using
the upstream preview-links URL logic. Extract the scroll-lock logic into
a shared @util/scroll-lock used by the dialog, ImageGallery, and the
listings filter drawer.
- DetailRuleNodes: render `dataDescriptor.nodes` as colored pill chips
  (8 NodeComponentType variants), ported 1:1 from the platform's
  .dlg-node-chip styling. Gated to RULE_CHAIN only — calculated fields
  and alarm rules no longer get the row. Chip font-weight bumped to 500.
- DetailMeta: route widgetType/cfType/ruleChainType values through
  getSubtypeLabel for human-readable values; ALARM_RULE shares the
  CALCULATED_FIELD cfType path (matches the platform's
  getCompactSubtypeLabel).
- iot-hub model: add IotHubNodeInfo interface mirroring the platform
  NodeInfo type used by RULE_CHAIN data descriptors.
- Comments cleanup across 19 files: strip Figma-tool references and
  node IDs (`Figma 1234:5678`, `Figma node X:Y`, etc.) — replace with
  "the design" / "per the design" so comments stand on their own.
- CLAUDE.md: add a Code Style rule prohibiting Figma references and
  tool-specific node IDs in source comments.
Rewrite DetailGallery as a self-contained interactive carousel matching
the platform's .dlg-hero-carousel UX, with no third-party deps.

- Single framed surface (rounded corners + light bg + hairline border)
  wraps both the slide viewport and the bottom controls row, so the
  background covers controls too.
- Controls row at the bottom: [prev chevron] [centred dots, flex:1]
  [next chevron] — justify-content: space-between, padding 12px 16px.
  Both chevrons sit on the same level as the dots.
- Slides driven by a single `--idx` custom property + a CSS
  `translateX(calc(var(--idx) * -100%))` transform with a 0.4s ease
  transition. The script just sets `--idx`.
- Autoplay: 5s interval, pauses on hover / focus, suppressed under
  prefers-reduced-motion.
- Pointer-events drag/swipe: one handler covers mouse, touch, and pen.
  During the gesture --idx is set to a fractional value so the track
  follows the finger; on release we snap to the nearest integer slot
  (or advance one when delta passes max(50px, 15% of viewport width)).
  touch-action:pan-y keeps vertical scroll working; user-select:none
  and -webkit-user-drag:none keep images from being native-dragged or
  selected mid-swipe. Click pass-through on buttons is preserved.
- Keyboard nav: ← / → step, Home / End jump.
- Dark-mode chevron + dot palette via :global([data-theme='dark']) &.
- Drops gracefully when only one screenshot exists — controls hidden.
…ariants

- DetailHero now renders `detail.description` via IotHubMarkdown in both
  compact and big variants (was a plain <p>).
- InstallButton gets a dark-mode override (#b3c7ff bg / #17181c text).
- getInstallVerb takes a `variant` arg so DEVICE listings read
  "Connect device" in the hero context but stay "Connect" on cards.
- IotHubMarkdown internals updated.
- New /iot-hub/creator/{id} paginated route (per-category, 16/page) with a
  hero (avatar, verified badge, bio, website + social links) sourced from the
  public /api/creators/{id}/profile endpoint
- Make the creator clickable on listing cards and the item detail meta
- Extract shared _iot-hub.scss mixins (page shell + Material Icons reset) and
  apply across the IoT Hub home/category/detail/creator pages
- Add creatorId to the listing schema; PaginationLink gains a perPage prop
IotHubMarkdown switched from a single set:html dump to a split-and-render
pipeline that mixes HTML chunks with real Astro components. Pattern
detectors run on the raw markdown before processing, push entries into a
typed `Slot` registry, and leave HTML-comment sentinels behind that get
spliced back out after markdown→HTML so the corresponding components
render at the right spot.

Two patterns registered:

- Fenced code blocks (```lang …meta\n…\n```) → Starlight's <Code>.
  Author-supplied meta (e.g. title="…") is preserved; appends
  `showLineNumbers` for multi-line snippets and always `maxLines=20`.
  Strips legacy Kramdown `{:copy-code}` residue from code bodies.

- `\${images.gallery({src,alt,caption}, …)}` → <ImageGallery>, parsed
  with the same regexes the platform's resolveVariables uses.

Pipeline (now numbered 1–5): extract code → rewrite resource URLs →
extract galleries → markdown→HTML → walk sentinels.

Also: wrap the reset selector in `@layer reset {}` so it loses to
Starlight's default layer cascade, restoring the headings/paragraphs
margins set by `@astrojs/starlight/style/markdown.css` (imported by
IotHubMarkdown).
IotHubMarkdown grew three new pattern handlers and a `listingDetail`
prop so detectors can read per-listing context:

- `\${note(…)}` / `\${warn(…)}` / `\${error(…)}` → Starlight <Aside>
  with type note / caution / danger. Body allows inline HTML
  (set:html into the Aside slot) to match the platform's behaviour.
- `\${product.button}` / `\${datasheet.button}` → Starlight
  <LinkButton variant="secondary" iconPlacement="start">, label
  `<listing.name> Product page` / `<listing.name> Datasheet`, icons
  external / document. Resolved from
  `listingDetail.dataDescriptor.{productURL,datasheetURL}`; missing
  URLs drop the placeholder. Opens in _blank with rel noopener.
- New optional `listingDetail` prop, threaded by the detail page so
  the doc-link detector (and future ones) can read the listing DTO.

Also:
- Single-line code snippets no longer get `showLineNumbers` (user
  refinement on the previous code-block change).
- DetailHero preview <img> sizing: explicit 100% / 100% with
  max-height bound so small icons don't stretch but tall previews
  cap inside the frame.
- `_base.scss`: pulled the reset + base typography defaults into
  the existing `@layer reset {}` so Starlight's later cascade can
  override them without specificity gymnastics.
… slot

Comment hygiene pass across the IoT Hub components and model:

- Stripped all internal-repo path leakage (28 refs to ui-ngx /
  iot-hub/ui sources, `tb-*` directive paths, Angular component
  filenames). Comments now stand on their own without referring to
  upstream-app source files that aren't shipped with this repo.
- Tightened verbose explanatory blocks to keep only the *why* (e.g.
  the double-rAF trick for first-paint transitions, the
  ALARM_RULE→CALCULATED_FIELD cfType reuse, the dark-text-on-
  saturated-bg rationale for rule-node chips). Dropped "this is the
  X" comments where the class / function name already says it.

Substantive changes folded in:

- IotHubListingLink.astro + iot-hub-listing-link-cache.ts — new
  build-time inline card for `${listing-link:<slug>}` placeholders
  in listing readmes. Fetches the referenced listing via a
  module-scoped Promise cache so the same slug referenced from
  multiple readmes hits the API once. Compact / non-compact thumb
  split mirrors DetailHero; falls back to "Listing unavailable"
  when the API returns no detail.
- IotHubMarkdown.astro — `listingLink` slot variant + detector for
  `${listing-link:<slug>}`, wired into the render switch.
- DetailMeta.astro — Use Cases column now renders ALL chips with a
  runtime chip-overflow script that measures available width, hides
  what doesn't fit, and surfaces overflow via a `+N` counter chip
  (ResizeObserver-driven, re-runs on `astro:page-load` and after
  webfont load). Parent meta row switches to `flex-wrap: wrap` with
  the wide column at `flex: 1 1 14em` so when the column can't fit
  alongside fixed-content columns it drops to its own row instead
  of being clipped by `overflow: hidden`.
- _reset.scss — selectors wrapped in `@layer reset {}` to match the
  cascade layering used by `_base.scss`.
Backend's CreatorView ships two count fields with very different meanings
(see /data/git/iot-hub/common/data/.../CreatorView.java):

- publishedCount       — total PUBLISHED item *versions* across the creator's items
- publishedListingCount — count of the creator's listings whose status is PUBLISHED

Creator page surfaces "X items" where X should be the latter: users care
about how many distinct listings a creator ships, not how many versions
sit inside them. Schema now propagates both fields; the creator page
headline switches from publishedCount to publishedListingCount, with the
existing `?? total` fallback unchanged.
…logue

Previously the creator page bucketed items per category and paginated
each bucket in parallel — page N showed [(N-1)*size, N*size) of every
category at once, so a single page could surface up to
categories.length × CREATOR_PAGE_SIZE items.

Switch to a single global ranking per creator:

- Flatten the per-category collection into a per-creator stream
  carrying (item, categorySlug) and sort by installCount DESC across
  categories.
- Slice CREATOR_PAGE_SIZE items per page from that flat list.
- Re-group the slice by category in IOT_HUB_CATEGORIES priority order
  for display, so the visual section layout is unchanged.
- totalPages is now ceil(total / CREATOR_PAGE_SIZE) — a single global
  count, not the largest category's.

No new collection or API call needed: the existing iotHubCategories
loader already paginates the full catalogue (loops until hasNext is
false), so the per-creator re-shape is a single O(N) pass over
data already in memory.
New routes/components:

- pages/iot-hub/search/[...page].astro — search results page. Same
  layout as the creator page minus the hero; flattens the existing
  iotHubCategories collection (no extra API call), sorts by
  installCount DESC across categories, paginates SEARCH_PAGE_SIZE
  items per page and re-groups the page slice by category in
  IOT_HUB_CATEGORIES priority order. Pure-numeric slugs excluded.
- components/IotHub/SearchFilterBar.astro — heading + search input +
  sort/count/pagination row. Heading is "Search results for "{q}""
  or "Search results" when empty. Inline hydrate script reads ?q=
  from the URL, populates the input, keeps the heading in sync as
  the user types (no refetch yet — dynamic pipeline lands later).
- components/IotHub/IotHubBreadcrumbs.astro — shared row used by
  CreatorHero, CategoryHero, DetailHero and the search page. Owns
  only the inline chrome (font, gap, chevron sep, link/current
  colors, dark-mode override); parents own the outer layout and
  fixed-header clearance.

Refactors / wiring:

- CreatorHero / CategoryHero / DetailHero — replaced ~30 lines of
  duplicate breadcrumb markup and ~80 lines of duplicate SCSS with
  one <IotHubBreadcrumbs items=[…] /> invocation each. DetailHero
  passes `wrap` so its 3-crumb row still wraps on narrow viewports.
- IotHubHero — search form action moved from /iot-hub/ (unhandled)
  to /iot-hub/search/, preventDefault submit-handler removed so the
  hero search actually navigates.
- models/iot-hub.ts — new SEARCH_PAGE_SIZE constant (kept separate
  from CREATOR_PAGE_SIZE so the eventual dynamic page-size control
  can vary it), and IOT_HUB_STRINGS.searchPage block.
ikulikov and others added 6 commits June 2, 2026 17:58
Replaces the browser's tiny default search-cancel button with a custom
40×40 clear affordance that mirrors the search submit button's chrome.

- ::-webkit-search-cancel-button / ::-webkit-search-decoration set to
  appearance: none so the platform "X" stops drawing on top.
- New <button data-iot-hub-hero-clear> inserted between input and
  submit, sharing the submit's icon-button styles via a comma rule.
- Pure-CSS show/hide: input:placeholder-shown ~ .__search-clear is
  display: none — the button appears only when the input has a value.
- input:not(:placeholder-shown) ~ .__search-button gets margin-left:
  -4px to tighten the clear→submit gap to 4px (form gap is 8px);
  collapses back to 8px between input and submit when the clear is
  hidden.
- Tiny click handler in the existing is:inline script wipes the input
  value, dispatches an `input` event, and refocuses. instanceof
  HTMLInputElement narrows the type for the editor.
- Glyph: X path sized to ~12px on screen (M5 5 19 19), close to the
  search magnifier glyph's visual weight.
Opens below the hero search input on focus; fetches
/api/listings/published?pageSize=10&page=0&sortProperty=installCount
&sortOrder=DESC[&textSearch=…] and renders the results client-side,
grouped by category in IOT_HUB_CATEGORIES priority order. Re-fetches
on input (300ms debounce); the initial focus search runs with the
input's current value (even when empty) to surface the top 10 by
installCount. AbortController cancels in-flight requests on the next
keystroke / close.

- Sticky "See all results" footer pinned to the popup bottom; its
  href is kept in sync with the input on every keystroke so it
  jumps to the same /iot-hub/search/?q=… as the submit button would.
- Card chrome (99×56 thumb / 56×56 compact tile / name / author)
  mirrors IotHubListingLink 1:1; class names are declared :global
  in the component's <style> so the JS-built DOM picks them up.
  iot-hub-material-icon mixin reused for the popup's Material Icons
  glyphs (person, type fallbacks, search_off).
- Popup hides on click-outside-form / Escape. Result links navigate
  via plain anchors (full reload), so no special hide-on-select logic
  needed.
- IotHubListingLink gained optional `item: ListingView` alongside
  `slug` — when set, the build-time slug→detail fetch is skipped and
  the card renders directly from the view. ListingView and
  ListingDetail share every field the card consumes.
- IotHubHero imports @fontsource/material-icons (Vite dedupes
  against the other components that already pull it).
- getPlaceholderIcon can return mdi:* which isn't in the Material
  Icons font; the popup substitutes the type fallback so we never
  render a junk glyph.
… template)

The hero search popup previously duplicated the listing-card markup as DOM
construction in its bundled script. Switch to a single source of truth:

- IotHubIcon: optional `icon` prop. With it, renders the existing
  three-path output (registry SVG / MDI SVG / Material Icons font);
  without it, renders an empty `<span data-icon-root>` template-mode
  wrapper. New `hidden?: boolean` prop forwarded to the wrapper, plus
  a specificity-raised `&[hidden] { display: none }` so the attribute
  actually beats the `display: inline-flex` rule.
- IotHubListingLink: universal markup with `data-listing-link-*` slot
  attributes. Three rendering paths (slug, item, no-args) share the
  same DOM shape. New `variant` prop — 'card' (default 360px bordered)
  or 'row' (full-width, no border, hover tint) for the popup. Row
  variant uses --color-text + font-weight-semibold for the name and
  suppresses the underline-on-hover. Author glyph swaps between
  `verified` (brand-accent) and `person` based on creatorVerified.
- IotHubListingLinkTemplate.astro: wraps an empty IotHubListingLink
  in <template data-iot-hub-listing-link-tmpl> as the clone source.
- iot-hub-icon-bind.ts: bindIotHubIcon(root, icon, size) mirrors the
  static three-path resolution; async because MDI fetches the SVG.
- iot-hub-listing-link-bind.ts: bindListingCard(root, item) mirrors
  the static card render branches. Sync for everything except the
  thumb icon (delegates to bindIotHubIcon, fire-and-forget so MDI
  fetches don't block). Author icon swaps verified/person + tinting.

Hero popup:

- Drops local renderCard DOM construction (~120 LOC).
- Drops popup-specific card styles in IotHubHero (~140 LOC) —
  cloned cards inherit IotHubListingLink's scoped styles via the
  Astro scope-hash attribute carried on every cloned element.
- Result rendering: clone tmpl.content.firstElementChild,
  bindListingCard, append.

Runtime API URL plumbing:

- IOT_HUB_API_URL in @models/iot-hub.ts is now runtime-aware:
  server-side uses import.meta.env.IOT_HUB_API_URL; client-side
  reads window.__IOT_HUB_API_URL. Same import works in both
  contexts — no PUBLIC_* env-var duplicate needed.
- New IotHubRuntimeConfig.astro emits one inline <script> that
  publishes the server's IOT_HUB_API_URL value to the global. Dropped
  on any page with runtime fetches (currently IotHubHero).
- Inline script runs during HTML parse; bundled module scripts defer
  until DOMContentLoaded → the global is set before any client
  module evaluates the model.

Fixes a bug where the client bundle's `import.meta.env.IOT_HUB_API_URL`
was always undefined (Vite strips non-PUBLIC env vars from the client
bundle), so runtime fetches always hit the fallback origin.
Search-page results now refetch live from
/api/listings/published as the user types / sorts / changes
page size / clicks pagination. Triggers:

- SearchFilterBar input (300ms debounce)         — resets page to 1
- IotHubSort selection change                    — resets page to 1
- PaginationLink items-per-page change           — resets page to 1
- PaginationLink page change                     — keeps page index
- Initial `?q=` on page load                     — immediate fetch

After the first dynamic fetch:
- Pagination switches to dynamic mode (page anchors dispatch
  iot-hub-page:change events instead of navigating).
- totalPages / totalElements driven by PageData response.
- "No items found" panel renders when data.length === 0.
- Loading overlay (absolute, semi-opaque scrim + spinner) sits
  above results so page height stays stable across fetches.

Pattern C foundations to support dynamic rendering:

- ListingCard.astro: gained data-card-* slot attrs (root, title,
  thumb img+fallback, icon-tile, author-wrap+author+name+icon,
  installs). Author block grouped under a wrap so the binder hides
  the whole author+dot pair in one operation. Image/fallback
  always render; hidden attr toggles which is visible (specificity
  guard added on both).
- iot-hub-listing-card-bind.ts: bindListingCard(root, item,
  categorySlug, showCreator) mirrors the static render — both
  big and compact variants — including verified/person glyph swap
  and InstallButton data-attr update. Compact thumb delegates to
  bindIotHubIcon (async for MDI, fire-and-forget).
- ListingCardTemplate.astro: emits <template data-variant="big|
  compact"> clone sources; binder picks the variant per item.
- CategorySection.astro: <style> switched to is:global so JS-built
  sections inherit the same chrome.
- CategorySectionTemplate.astro: empty section skeleton wrapped in
  <template> with data-category-section-* slot attrs.
- ListingGrid.astro: grid-layout <style> switched to is:global so
  JS-built grids pick up the column rules.
- ListingCard creator-click script switched to delegation on
  document so cloned cards' role="link" creator spans navigate
  to the creator page (not the card's href).

Stale-slug protection:

- src/pages/iot-hub-known-slugs.json.ts: static endpoint that
  emits /iot-hub-known-slugs.json with every slug known at build
  time. Cache-Control: max-age=60, stale-while-revalidate=86400.
- iot-hub-known-slugs.ts: lazy fetch + module-scoped Promise
  cache. Fail-open (returns has()=>true) if the manifest fails to
  load.
- Search-page and hero-popup refetches filter API results
  against the known-slugs set so users can't click through to a
  detail page that doesn't exist statically yet.

Pagination dynamic mode + supporting bits:

- PaginationLink.astro: new variant: 'centered' | 'right' prop.
  Right variant uses justify-content: flex-end + inline items-per-
  page (no absolute pin) — used by the search page's filter bar.
  Nav gained data-iot-hub-pagination + data-dynamic-mode hook.
  Block switched to is:global; redundant :global() wrappers
  removed. @mixin pagination-btn replaces %placeholder + @extend
  (Astro SCSS pipeline didn't reliably extend placeholders through
  :global()). Ellipsis matches page-button cell shape per figma.
- iot-hub-pagination-update.ts: updatePaginationDynamic(nav,
  state) rebuilds the page list as anchor-based buttons that
  preventDefault + dispatch iot-hub-page:change. Anchor-based
  (not <button>) keeps dynamic-mode controls visually identical
  to static anchors with no UA-button defaults to fight.
  updateResultsCount(el, n) keeps the "N results" line in sync.
- SearchFilterBar.astro: input listener also dispatches
  iot-hub-search-text:change so the search page can debounce +
  refetch. Replaces inline sort button with <IotHubSort />.
  Pagination uses variant="right".

Sort selector + no-results illustration:

- IotHubSort.astro + iot-hub.ts: shared sort model
  (IOT_HUB_SORT_OPTIONS) with three options carrying their API
  query mapping. Dropdown chrome per figma — primary-cta text +
  gray-6 hover/open background, panel surface bg + hairline + 5px
  inner rows + brand-accent + check for selected, 120ms enter
  animation matching the hero autocomplete.
- IotHubNoResults.astro + src/assets/iot-hub/search-no-results.svg
  (fetched from Figma): hidden by default; toggled by the search
  script when data.length === 0. "Clear all filters" CTA reloads
  to /iot-hub/search/ (full unfiltered SSR).

Model:

- listingViewSchema gained createdTime so the static sort can
  tie-break installCount DESC by createdTime DESC, matching the
  backend's DB-level order.
Extracts the search/sort/page pipeline into a shared module and wires it
through the category and creator pages. FilterPanel selections now flow
into both the API query and the URL, with full persist/restore.

- iot-hub-dynamic-search.ts: shared pipeline driven by data attributes
  on `[data-iot-hub-search-root]` (`data-creator-id`, `data-item-type`,
  `data-page-size`, `data-base-path`). Listens for sort, search,
  page-size, page and filter events. Mirrors all state into the URL
  via `history.replaceState`; on initial load, parses q/sort/page/
  pageSize and filter params, restores them into the FilterPanel
  checkboxes, then refetches if any non-default value is present.
- iot-hub-search-bar-init.ts: shared search-input hydrate.
- FilterPanel: editable per-section search inputs with clear button,
  text filtering of options, toggleable checkboxes, `iot-hub-filter:
  change` event with `{ value, label }[]` entries per section.
- ListingsFilterBar: replaces CreatorFilterBar; chip strip with X to
  remove individual filters + Clear all, IotHubSort dropdown, editable
  search input.
- ListingGrid: when the panel is open, big-variant grid drops one
  column at each breakpoint to keep cards comfortably wide.
- PaginationLink: dynamic mode uses `<button>` with UA resets so the
  cloned controls match the static `<a>` instances.
- IotHubSearchLoading: absolute-positioned spinner overlay so the
  results section keeps its height during fetches.
- content.config.ts: `fetchFilterInfo` drops facets with
  `totalItems === 0` (and any connectivity bucket that ends up empty)
  so the panel never shows zero-result checkboxes.
- Filter section → API/URL param mapping: vendor→vendors,
  hardwareType→hardwareTypes, connectivity→connectivity,
  category→categories, useCase→useCases, and type→
  widgetTypes | cfTypes | ruleChainTypes (resolved from itemType).
- Fade the dialog and ::backdrop on open/close via @starting-style +
  allow-discrete transitions on display/overlay (instant show/hide
  fallback in browsers without support)
- Defer unlockScroll until the exit fade ends so the returning page
  scrollbar no longer shifts the still-visible dialog left on close
  (transitionend + timeout fallback; pending unlock cancelled on reopen)
Copy link
Copy Markdown
Member Author

@vvlladd28 vvlladd28 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Reviewed 110 changed files in feat: IoT Hub pages (/iot-hub/). Left 46 comments inline.

The overall structure is solid — the build-time/runtime API split, the Pattern C template hydration, and the numeric-slug collision handling are all carefully done. The findings cluster into a few themes:

  • Trust boundary with the IoT Hub API — the SSR path validates listings through zod (cssColorSchema etc.), but several paths bypass it with bare as casts ([slug].astro, iot-hub-listing-link-cache.ts, the dynamic-search fetch), and creator-submitted markdown/SVG is injected via set:html with no sanitization. The markdown one is the most important finding in the review.
  • Runtime API URL/iot-hub/ (home) never renders <IotHubRuntimeConfig />, so the hero search popup always falls back to the hardcoded staging host that's baked into the client bundle.
  • Build robustness — unbounded pagination loop in the loader, Promise.all on creator profiles, and a single listing-detail fetch failure aborting the whole prod build.
  • Site-wide shared-file changes — the new @layer reset wrap in _base.scss/_reset.scss has no layer-order declaration (cascade change for every page), and the ThemeProvider removal in BaseLayout changes how a stored auto theme resolves.
  • Smaller items: init-guard consistency across the client scripts, hardcoded English strings bypassing IOT_HUB_STRINGS, a couple of legacy ~/ imports, and carousel/listbox ARIA contracts.

This is an auto-generated review. Findings may contain errors — please verify before applying changes.

Comment thread src/components/IotHub/IotHubMarkdown.astro
Comment thread src/util/markdown-processor.ts Outdated
Comment thread src/pages/iot-hub/[category]/[slug].astro
Comment thread src/components/IotHub/iot-hub-dynamic-search.ts
Comment thread src/components/IotHub/IotHubIcon.astro
Comment thread astro.config.ts
Comment thread src/data/navigation.ts
Comment thread src/layouts/BaseLayout.astro
Comment thread src/styles/_base.scss
Comment thread src/util/icons.ts
vvlladd28 and others added 4 commits June 5, 2026 00:49
Vite 7 / Astro 6 regressed SCSS path-alias resolution, so 58
`@use '~/styles/...'` imports started failing builds with
"Can't find stylesheet to import".

Tried in order:
- sass `loadPaths` + bare 'styles/...' — works at build, but
  IDEs cannot follow the imports.
- `@`-aliases via tsconfig + custom sass `findFileUrl` importer —
  works, but adds config and reads tsconfig on every build.
- Vite `resolve.alias` derived from tsconfig — the canonical
  recommendation but still broken by vitejs/vite#20292 (fix
  #20300 merged in 7.0.1, reverted in 7.0.2, not re-landed as
  of 7.3.2).
- Relative paths (chosen): IDE-friendly across platforms, zero
  config, survives future tool changes.

Refs: vitejs/vite#20292, withastro/astro#15897 (still open).
Excludes pre-existing relative imports in Landing/CaseStudy/etc.
which were already working. `src/styles/_iot-hub.scss` switched
to sibling-relative `@use 'variables'` to match its peers.
When the listings API call fails (network down, 5xx, etc.) the dynamic-
search pipeline now surfaces a recoverable error state instead of
silently keeping stale results visible.

- IotHubFetchError.astro: new empty-state panel that mirrors the
  no-results layout. Wifi-with-slash illustration in the brand blue,
  heading + subtitle, and a real <button data-iot-hub-fetch-error-
  retry> styled like the no-results "Clear all filters" CTA.
- search-fetch-error.svg: 140×140 asset matching the no-results
  backdrop and dot pattern for visual continuity.
- IOT_HUB_STRINGS.fetchError: heading, subtitle, ctaLabel.
- iot-hub-dynamic-search.ts:
    * Captures the fetch-error panel + retry button from the root.
    * showFetchError() clears the results grid and hides the no-
      results panel + pagination when raising the error, restores
      the pagination when lowering it.
    * refetch() shows the panel on both non-OK responses and catch
      branch (skipping AbortError so superseded requests don't flash
      the error). Aborts still short-circuit cleanly.
    * The panel is only ever taken down by a successful response —
      filter changes, sort changes, search, and page navigation
      leave it in place while the next request is in flight.
    * lastRefetchOpts tracks the resetPage flag from the failing
      call so Try again retries on the same page index.
    * Try again click flips the loading overlay on synchronously
      then debounces the actual fetch by 350ms so rapid clicks
      collapse into a single API call.
- Category, creator, and search pages render <IotHubFetchError /> in
  the results container next to the no-results panel.
- fetchWithRetry: linear backoff between retry attempts
- category page: derive card variant from itemType prop; hide pagination
  on single-page categories (parity with search page)
- dynamic search: idempotency guard on setupDynamicSearch
- a11y: drop misleading listbox/option roles (hero popup, sort,
  per-page popup) in favor of group + aria-pressed; carousel dots are
  plain buttons with aria-current instead of tablist/tab
- installs: shared formatInstallCount/formatInstalls helpers (thousands
  separator + pluralization) across card, binder and detail hero
- pagination/result-count strings centralized in IOT_HUB_STRINGS
- rename bindListingCard -> bindListingLink in listing-link binder
- migrate legacy ~/ imports to @ aliases (CategoryTile(s), ListingGrid)
- placeholder item: satisfies ListingView to track schema drift
- rule-node unknown chip: theme-aware neutral colors
- BaseLayout theme script: stored 'auto' resolves via OS preference
- hero: move misplaced highlight-cycle comment to its IIFE
…ite-relative form

Listing readmes (and creator profiles) link to thingsboard.io with
absolute URLs. New @util/site-links normalizes them so they pass the
same linkcheck rules as authored content: site-relative pathname,
canonical trailing slash, and remapping of moved paths through the
generated redirects table.

- normalization runs as a rehype plugin inside the shared markdown
  processor (element hrefs in the AST + regex over raw-HTML nodes), so
  literal href="..." text in code spans stays untouched
- trailing slash skips only real file extensions (must contain a
  letter), so dotted page slugs like release-before-v1.7 still get the
  slash and hit the redirects table
- redirect targets carrying their own ?query/#fragment win over the
  source's parts instead of emitting a malformed double ?/#
- callout bodies now render through the same processor, so markdown
  links/emphasis inside ${note(...)} work and get the same href
  normalization
@vvlladd28 vvlladd28 merged commit a4c7d11 into develop Jun 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants