feat: IoT Hub pages (/iot-hub/)#450
Merged
Merged
Conversation
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
…o-empty/no-unused-vars)
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.
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)
vvlladd28
commented
Jun 4, 2026
Member
Author
vvlladd28
left a comment
There was a problem hiding this comment.
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 (
cssColorSchemaetc.), but several paths bypass it with bareascasts ([slug].astro,iot-hub-listing-link-cache.ts, the dynamic-search fetch), and creator-submitted markdown/SVG is injected viaset:htmlwith 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.allon creator profiles, and a single listing-detail fetch failure aborting the whole prod build. - Site-wide shared-file changes — the new
@layer resetwrap in_base.scss/_reset.scsshas no layer-order declaration (cascade change for every page), and theThemeProviderremoval inBaseLayoutchanges how a storedautotheme 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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/iot-hub//iot-hub/[category]/[...page]//iot-hub/[category]/[slug]//iot-hub/creator/[id]/[...page]//iot-hub/search/[...page]/Key features
Data layer
src/models/iot-hub.ts— typed model for the IoT Hub API: listing types, categories, install instances, URL builders, shared stringspublishedListingCountused on creator pagesDynamic search & filtering
iot-hub-dynamic-search.ts): results update in place, pagination rewrites, loading states, no-results state<template>per component, hydration binds (iot-hub-*-bind.ts)Install/connect dialog
<dialog>for listing cards + detail hero: Cloud / local instance rows with copy-link, deep-link open, editable localhost URL persisted inlocalStorage(privacy-mode safe)showModal()with focus trap, Esc handling that cancels inline edit first, backdrop-click close@starting-style+allow-discretetransitions ondisplay/overlay(instant fallback in older browsers); scroll unlock deferred until the exit fade ends so the returning scrollbar doesn't shift the closing dialogscroll-lockutil (document + fixed header + chat widget scrollbar compensation), reused by other modal layersContent rendering
Codeblocks,ImageGallery, Asides, and doc-link buttonsDesign & a11y
Other changes
astro.config.ts,tsconfig.json— path aliases / config for the new sectionsrc/content/docs/docs/iot-hub/guides/~150 files changed: +16k / −4.8k lines (36 commits).