# Conflicts: # ui-ngx/src/app/modules/home/components/home-components.module.ts # ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts # ui-ngx/src/app/shared/shared.module.tspull/15646/head
@ -0,0 +1,974 @@ |
|||
# IoT Hub `${item-link:uuid}` Markdown Component — Implementation Plan |
|||
|
|||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
|||
|
|||
**Goal:** Add a `${item-link:<uuid>}` markdown placeholder that renders an IoT Hub marketplace item card (thumbnail + name + creator) inline in readmes and install instructions, opening the linked item in a new tab. |
|||
|
|||
**Architecture:** A pure-string utility rewrites `${item-link:<uuid>}` to a `<tb-iot-hub-item-link-card itemId="...">` Angular component tag before the markdown reaches `tb-markdown`. The component is declared in a small `IotHubItemLinkModule` that each `tb-markdown` instance receives as `additionalCompileModules`. The component owns its own fetch (via `IotHubApiService.getPublishedVersion`), state (`loading` / `loaded` / `unavailable`), and click target (`/iot-hub/{itemId}` with `target="_blank"`). |
|||
|
|||
**Tech Stack:** Angular 20 (TypeScript, Material), `tb-markdown` (NgModule-based dynamic compilation), `IotHubApiService` (existing), Tailwind for utility classes, component-scoped SCSS. |
|||
|
|||
**Spec:** `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` |
|||
|
|||
**File structure:** |
|||
|
|||
New files: |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` — `replaceItemLinkPlaceholders` + regex |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` |
|||
|
|||
Modified files: |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — import `IotHubItemLinkModule` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — call utility in `loadReadme()`, expose compile modules |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — bind `[additionalCompileModules]` on readme `<tb-markdown>` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` — add `^item-link:(uuid)$` branch in `resolveVariables()`, expose compile modules |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` — bind `[additionalCompileModules]` |
|||
- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` — pre-process `details`, expose compile modules |
|||
- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` — bind `[additionalCompileModules]` |
|||
- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — add `iot-hub.item-link-unavailable` |
|||
|
|||
--- |
|||
|
|||
## Task 1: Create placeholder utility |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` |
|||
|
|||
- [ ] **Step 1: Create the utility file** |
|||
|
|||
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts`: |
|||
|
|||
```typescript |
|||
/// |
|||
/// Copyright © 2016-2026 The Thingsboard Authors |
|||
/// |
|||
/// Licensed under the Apache License, Version 2.0 (the "License"); |
|||
/// you may not use this file except in compliance with the License. |
|||
/// You may obtain a copy of the License at |
|||
/// |
|||
/// http://www.apache.org/licenses/LICENSE-2.0 |
|||
/// |
|||
/// Unless required by applicable law or agreed to in writing, software |
|||
/// distributed under the License is distributed on an "AS IS" BASIS, |
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
/// See the License for the specific language governing permissions and |
|||
/// limitations under the License. |
|||
/// |
|||
|
|||
export const ITEM_LINK_PLACEHOLDER_REGEX = |
|||
/\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; |
|||
|
|||
export const ITEM_LINK_KEY_REGEX = |
|||
/^item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; |
|||
|
|||
export function itemLinkCardTag(itemId: string): string { |
|||
return `<tb-iot-hub-item-link-card itemId="${itemId}"></tb-iot-hub-item-link-card>`; |
|||
} |
|||
|
|||
export function replaceItemLinkPlaceholders(markdown: string): string { |
|||
if (!markdown) { |
|||
return markdown; |
|||
} |
|||
return markdown.replace(ITEM_LINK_PLACEHOLDER_REGEX, (_match, uuid) => itemLinkCardTag(uuid)); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Sanity-check the regex** |
|||
|
|||
Write a temporary script `/tmp/check-item-link-regex.js` with this exact content: |
|||
|
|||
```javascript |
|||
const re = /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; |
|||
const cases = [ |
|||
['${item-link:11111111-2222-3333-4444-555555555555}', 'valid'], |
|||
['${item-link:not-a-uuid}', 'invalid'], |
|||
['Two: ${item-link:11111111-2222-3333-4444-555555555555} and ${item-link:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}', 'two valid'], |
|||
['```\n${item-link:11111111-2222-3333-4444-555555555555}\n```', 'inside code fence (still replaced — documented behavior)'] |
|||
]; |
|||
for (const [input, label] of cases) { |
|||
console.log(label + ':', input.replace(re, (_m, u) => '<TAG itemId=' + u + '>')); |
|||
} |
|||
``` |
|||
|
|||
Run it: |
|||
```bash |
|||
node /tmp/check-item-link-regex.js |
|||
``` |
|||
|
|||
Expected output: |
|||
``` |
|||
valid: <TAG itemId=11111111-2222-3333-4444-555555555555> |
|||
invalid: ${item-link:not-a-uuid} |
|||
two valid: Two: <TAG itemId=11111111-2222-3333-4444-555555555555> and <TAG itemId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee> |
|||
inside code fence (still replaced — documented behavior): ``` |
|||
<TAG itemId=11111111-2222-3333-4444-555555555555> |
|||
``` |
|||
``` |
|||
|
|||
Then delete the script: `rm /tmp/check-item-link-regex.js`. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts |
|||
git commit -m "feat(iot-hub): add item-link placeholder utility for markdown" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 2: Create item-link card component (skeleton + loaded states, image-thumbnail items) |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` |
|||
|
|||
- [ ] **Step 1: Create the directory** |
|||
|
|||
```bash |
|||
mkdir -p ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card |
|||
``` |
|||
|
|||
- [ ] **Step 2: Create `iot-hub-item-link-card.component.ts`** |
|||
|
|||
```typescript |
|||
/// |
|||
/// Copyright © 2016-2026 The Thingsboard Authors |
|||
/// |
|||
/// Licensed under the Apache License, Version 2.0 (the "License"); |
|||
/// you may not use this file except in compliance with the License. |
|||
/// You may obtain a copy of the License at |
|||
/// |
|||
/// http://www.apache.org/licenses/LICENSE-2.0 |
|||
/// |
|||
/// Unless required by applicable law or agreed to in writing, software |
|||
/// distributed under the License is distributed on an "AS IS" BASIS, |
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
/// See the License for the specific language governing permissions and |
|||
/// limitations under the License. |
|||
/// |
|||
|
|||
import { Component, Input, OnInit } from '@angular/core'; |
|||
import { IotHubApiService } from '@core/http/iot-hub-api.service'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; |
|||
|
|||
type CardState = 'loading' | 'loaded' | 'unavailable'; |
|||
|
|||
const COMPACT_TYPES: ReadonlySet<ItemType> = new Set([ |
|||
ItemType.CALCULATED_FIELD, |
|||
ItemType.ALARM_RULE, |
|||
ItemType.RULE_CHAIN |
|||
]); |
|||
|
|||
@Component({ |
|||
selector: 'tb-iot-hub-item-link-card', |
|||
standalone: false, |
|||
templateUrl: './iot-hub-item-link-card.component.html', |
|||
styleUrls: ['./iot-hub-item-link-card.component.scss'] |
|||
}) |
|||
export class TbIotHubItemLinkCardComponent implements OnInit { |
|||
|
|||
@Input() itemId!: string; |
|||
|
|||
state: CardState = 'loading'; |
|||
item: MpItemVersionView | null = null; |
|||
|
|||
constructor(private iotHubApiService: IotHubApiService) {} |
|||
|
|||
ngOnInit(): void { |
|||
if (!this.itemId) { |
|||
this.state = 'unavailable'; |
|||
return; |
|||
} |
|||
this.iotHubApiService |
|||
.getPublishedVersion(this.itemId, { ignoreErrors: true, ignoreLoading: true }) |
|||
.subscribe({ |
|||
next: item => { |
|||
this.item = item; |
|||
this.state = item ? 'loaded' : 'unavailable'; |
|||
}, |
|||
error: () => { |
|||
this.state = 'unavailable'; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
isCompact(): boolean { |
|||
return !!this.item && COMPACT_TYPES.has(this.item.type); |
|||
} |
|||
|
|||
getImageUrl(): string | null { |
|||
return this.item?.image |
|||
? this.iotHubApiService.resolveResourceUrl(this.item.image) |
|||
: null; |
|||
} |
|||
|
|||
getCompactIcon(): string { |
|||
if (!this.item) { |
|||
return 'category'; |
|||
} |
|||
if (this.item.icon) { |
|||
return this.item.icon; |
|||
} |
|||
switch (this.item.type) { |
|||
case ItemType.CALCULATED_FIELD: return 'functions'; |
|||
case ItemType.ALARM_RULE: return 'notification_important'; |
|||
case ItemType.RULE_CHAIN: return 'account_tree'; |
|||
default: return 'category'; |
|||
} |
|||
} |
|||
|
|||
getTypeIcon(): string { |
|||
if (!this.item) { |
|||
return 'category'; |
|||
} |
|||
switch (this.item.type) { |
|||
case ItemType.WIDGET: return 'widgets'; |
|||
case ItemType.DASHBOARD: return 'dashboard'; |
|||
case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; |
|||
case ItemType.CALCULATED_FIELD: return 'functions'; |
|||
case ItemType.ALARM_RULE: return 'notification_important'; |
|||
case ItemType.RULE_CHAIN: return 'account_tree'; |
|||
case ItemType.DEVICE: return 'memory'; |
|||
default: return 'category'; |
|||
} |
|||
} |
|||
|
|||
getCompactColor(): string { |
|||
return this.item?.color || '#048ad3'; |
|||
} |
|||
|
|||
getHref(): string { |
|||
return `/iot-hub/${this.itemId}`; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Create `iot-hub-item-link-card.component.html`** |
|||
|
|||
```html |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
@switch (state) { |
|||
@case ('loading') { |
|||
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--skeleton" aria-busy="true"> |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-skeleton-block"></div> |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-skeleton-line"></span> |
|||
<span class="tb-iot-hub-item-link-skeleton-line tb-iot-hub-item-link-skeleton-line--short"></span> |
|||
</div> |
|||
</div> |
|||
} |
|||
@case ('loaded') { |
|||
<a [href]="getHref()" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
class="tb-iot-hub-item-link-card"> |
|||
@if (isCompact()) { |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--compact" |
|||
[style.background]="getCompactColor()"> |
|||
<tb-icon class="tb-iot-hub-item-link-thumb-icon">{{ getCompactIcon() }}</tb-icon> |
|||
</div> |
|||
} @else { |
|||
<div class="tb-iot-hub-item-link-thumb"> |
|||
@if (getImageUrl(); as imgUrl) { |
|||
<img [src]="imgUrl" alt=""> |
|||
} @else { |
|||
<mat-icon class="tb-iot-hub-item-link-thumb-fallback">{{ getTypeIcon() }}</mat-icon> |
|||
} |
|||
</div> |
|||
} |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-name">{{ item?.name }}</span> |
|||
<span class="tb-iot-hub-item-link-author"> |
|||
<mat-icon>person</mat-icon> |
|||
{{ item?.creatorDisplayName }} |
|||
</span> |
|||
</div> |
|||
</a> |
|||
} |
|||
@case ('unavailable') { |
|||
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--unavailable"> |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--unavailable"> |
|||
<mat-icon>link_off</mat-icon> |
|||
</div> |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-name">{{ 'iot-hub.item-link-unavailable' | translate }}</span> |
|||
</div> |
|||
</div> |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Create `iot-hub-item-link-card.component.scss`** |
|||
|
|||
```scss |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
|
|||
:host { |
|||
display: block; |
|||
margin: 12px 0; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-card { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
width: 320px; |
|||
max-width: 100%; |
|||
padding: 8px 12px; |
|||
border: 1px solid rgba(0, 0, 0, 0.08); |
|||
border-radius: 8px; |
|||
background: #fff; |
|||
text-decoration: none; |
|||
color: inherit; |
|||
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease; |
|||
|
|||
&:hover:not(.tb-iot-hub-item-link-card--unavailable):not(.tb-iot-hub-item-link-card--skeleton) { |
|||
background: rgba(4, 138, 211, 0.04); |
|||
border-color: rgba(4, 138, 211, 0.32); |
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); |
|||
} |
|||
|
|||
&--unavailable { |
|||
opacity: 0.6; |
|||
cursor: default; |
|||
} |
|||
|
|||
&--skeleton { |
|||
cursor: default; |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb { |
|||
flex: 0 0 48px; |
|||
width: 48px; |
|||
height: 48px; |
|||
border-radius: 6px; |
|||
background: #f4f6f8; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
overflow: hidden; |
|||
|
|||
img { |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: cover; |
|||
display: block; |
|||
} |
|||
|
|||
&--compact { |
|||
color: #fff; |
|||
} |
|||
|
|||
&--unavailable { |
|||
color: rgba(0, 0, 0, 0.45); |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb-icon { |
|||
font-size: 26px; |
|||
width: 26px; |
|||
height: 26px; |
|||
line-height: 26px; |
|||
color: #fff; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb-fallback { |
|||
font-size: 26px; |
|||
width: 26px; |
|||
height: 26px; |
|||
color: rgba(0, 0, 0, 0.45); |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-text { |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-width: 0; |
|||
gap: 2px; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-name { |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
line-height: 1.3; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-author { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
font-size: 12px; |
|||
color: rgba(0, 0, 0, 0.6); |
|||
line-height: 1.3; |
|||
|
|||
mat-icon { |
|||
font-size: 14px; |
|||
width: 14px; |
|||
height: 14px; |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-skeleton-block, |
|||
.tb-iot-hub-item-link-skeleton-line { |
|||
background: linear-gradient( |
|||
90deg, |
|||
rgba(0, 0, 0, 0.06) 0%, |
|||
rgba(0, 0, 0, 0.12) 50%, |
|||
rgba(0, 0, 0, 0.06) 100% |
|||
); |
|||
background-size: 200% 100%; |
|||
animation: tb-iot-hub-item-link-shimmer 1.4s ease-in-out infinite; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-skeleton-line { |
|||
display: block; |
|||
height: 12px; |
|||
width: 180px; |
|||
margin: 4px 0; |
|||
|
|||
&--short { |
|||
width: 96px; |
|||
} |
|||
} |
|||
|
|||
@keyframes tb-iot-hub-item-link-shimmer { |
|||
0% { background-position: 200% 0; } |
|||
100% { background-position: -200% 0; } |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card |
|||
git commit -m "feat(iot-hub): add TbIotHubItemLinkCardComponent for markdown item links" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 3: Create the wrapper module and register it |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` |
|||
|
|||
- [ ] **Step 1: Create `iot-hub-item-link.module.ts`** |
|||
|
|||
```typescript |
|||
/// |
|||
/// Copyright © 2016-2026 The Thingsboard Authors |
|||
/// |
|||
/// Licensed under the Apache License, Version 2.0 (the "License"); |
|||
/// you may not use this file except in compliance with the License. |
|||
/// You may obtain a copy of the License at |
|||
/// |
|||
/// http://www.apache.org/licenses/LICENSE-2.0 |
|||
/// |
|||
/// Unless required by applicable law or agreed to in writing, software |
|||
/// distributed under the License is distributed on an "AS IS" BASIS, |
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
/// See the License for the specific language governing permissions and |
|||
/// limitations under the License. |
|||
/// |
|||
|
|||
import { NgModule } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { SharedModule } from '@shared/shared.module'; |
|||
import { TbIotHubItemLinkCardComponent } from './iot-hub-item-link-card.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [TbIotHubItemLinkCardComponent], |
|||
imports: [CommonModule, SharedModule], |
|||
exports: [TbIotHubItemLinkCardComponent] |
|||
}) |
|||
export class IotHubItemLinkModule {} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Register module in `IotHubComponentsModule`** |
|||
|
|||
Modify `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — add the import and add `IotHubItemLinkModule` to the `imports` array. |
|||
|
|||
After the existing imports near line 32, add: |
|||
|
|||
```typescript |
|||
import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
``` |
|||
|
|||
Then update the `imports` array of the `@NgModule` decorator from: |
|||
|
|||
```typescript |
|||
imports: [ |
|||
CommonModule, |
|||
SharedModule |
|||
], |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```typescript |
|||
imports: [ |
|||
CommonModule, |
|||
SharedModule, |
|||
IotHubItemLinkModule |
|||
], |
|||
``` |
|||
|
|||
- [ ] **Step 3: Build to verify the module wires up** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -40 |
|||
``` |
|||
|
|||
Expected: build succeeds (warnings allowed; no errors mentioning `TbIotHubItemLinkCardComponent` or `IotHubItemLinkModule`). |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts |
|||
git commit -m "feat(iot-hub): wrap item-link card in IotHubItemLinkModule" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 4: Add translation key |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` |
|||
|
|||
- [ ] **Step 1: Add the translation key** |
|||
|
|||
Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`. Locate the `"iot-hub": { ... }` block that begins around line 3701 (the one that opens with `"iot-hub": "IoT Hub",`). Add a new entry next to similar one-liner keys (e.g., right after `"installed-from-iot-hub": "Installed from IoT Hub",` near line 3719): |
|||
|
|||
```json |
|||
"item-link-unavailable": "Item unavailable", |
|||
``` |
|||
|
|||
Make sure the surrounding commas are correct — the new line ends with a comma, and the line above also ends with a comma. |
|||
|
|||
- [ ] **Step 2: Verify JSON is valid** |
|||
|
|||
```bash |
|||
node -e "JSON.parse(require('fs').readFileSync('ui-ngx/src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK');" |
|||
``` |
|||
Expected output: `OK` |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/assets/locale/locale.constant-en_US.json |
|||
git commit -m "feat(iot-hub): add item-link-unavailable translation key" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 5: Wire into item detail dialog (readme) |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` |
|||
|
|||
- [ ] **Step 1: Update the .ts file to pre-process readme and expose compile modules** |
|||
|
|||
In `iot-hub-item-detail-dialog.component.ts`: |
|||
|
|||
Add to the imports near the existing `resolveDocLinkPlaceholders` import: |
|||
|
|||
```typescript |
|||
import { replaceItemLinkPlaceholders } from './iot-hub-markdown.utils'; |
|||
import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
import { Type } from '@angular/core'; |
|||
``` |
|||
|
|||
(`Type` may already be imported via Angular core; merge into the existing `@angular/core` import if so.) |
|||
|
|||
Inside the class, near the other readonly fields (after `readonly ItemType = ItemType;`), add: |
|||
|
|||
```typescript |
|||
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule]; |
|||
``` |
|||
|
|||
Update the existing `loadReadme()` method body (currently at line 307-312): |
|||
|
|||
```typescript |
|||
private loadReadme(): void { |
|||
const versionId = this.item.id as string; |
|||
this.iotHubApiService.getVersionReadme(versionId, { ignoreLoading: true }).subscribe( |
|||
content => this.readmeContent = replaceItemLinkPlaceholders( |
|||
this.resolveDocLinks(this.prefixResourceUrls(content || '')) |
|||
) |
|||
); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Update the template to pass compile modules** |
|||
|
|||
In `iot-hub-item-detail-dialog.component.html` at line 302, change: |
|||
|
|||
```html |
|||
<tb-markdown [data]="readmeContent"></tb-markdown> |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```html |
|||
<tb-markdown [data]="readmeContent" |
|||
[additionalCompileModules]="itemLinkCompileModules"></tb-markdown> |
|||
``` |
|||
|
|||
Leave the changelog `<tb-markdown>` (line 306) unchanged — changelog is out of scope per the spec. |
|||
|
|||
- [ ] **Step 3: Build** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 |
|||
``` |
|||
|
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html |
|||
git commit -m "feat(iot-hub): render \${item-link} cards in item readme" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 6: Wire into device install dialog (instructions) |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` |
|||
|
|||
- [ ] **Step 1: Add imports and compile-modules field** |
|||
|
|||
In `device-install-dialog.component.ts`, add to the imports: |
|||
|
|||
```typescript |
|||
import { IotHubItemLinkModule } from '../iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
import { ITEM_LINK_KEY_REGEX, itemLinkCardTag } from '../iot-hub-markdown.utils'; |
|||
import { Type } from '@angular/core'; |
|||
``` |
|||
|
|||
(Merge `Type` into the existing `@angular/core` import line.) |
|||
|
|||
Inside the class, alongside other readonly fields, add: |
|||
|
|||
```typescript |
|||
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule]; |
|||
``` |
|||
|
|||
- [ ] **Step 2: Add `^item-link:(uuid)$` branch inside `resolveVariables`** |
|||
|
|||
In `resolveVariables()` (currently at line 427-477 in the same file), add a new clause **immediately after** the `gateway.downloadButton` clause (around line 444-446) and before the callout matcher. |
|||
|
|||
Change this section: |
|||
|
|||
```typescript |
|||
// Special action placeholders |
|||
if (key === 'gateway.downloadButton') { |
|||
return '<a href="#" data-action="download-gateway-docker-compose" class="tb-download-btn">⬇ Download docker-compose.yml</a>'; |
|||
} |
|||
// Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```typescript |
|||
// Special action placeholders |
|||
if (key === 'gateway.downloadButton') { |
|||
return '<a href="#" data-action="download-gateway-docker-compose" class="tb-download-btn">⬇ Download docker-compose.yml</a>'; |
|||
} |
|||
// IoT Hub item link card: ${item-link:<uuid>} |
|||
const itemLinkMatch = key.match(ITEM_LINK_KEY_REGEX); |
|||
if (itemLinkMatch) { |
|||
return itemLinkCardTag(itemLinkMatch[1]); |
|||
} |
|||
// Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Update the template** |
|||
|
|||
In `device-install-dialog.component.html` at line 26-28, change: |
|||
|
|||
```html |
|||
<tb-markdown [data]="ws.markdown" |
|||
(ready)="onMarkdownReady(instructionContainer)"> |
|||
</tb-markdown> |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```html |
|||
<tb-markdown [data]="ws.markdown" |
|||
[additionalCompileModules]="itemLinkCompileModules" |
|||
(ready)="onMarkdownReady(instructionContainer)"> |
|||
</tb-markdown> |
|||
``` |
|||
|
|||
- [ ] **Step 4: Build** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 |
|||
``` |
|||
|
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html |
|||
git commit -m "feat(iot-hub): render \${item-link} cards in device install instructions" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 7: Wire into solution install dialog (instructions) |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` |
|||
|
|||
- [ ] **Step 1: Update the .ts file** |
|||
|
|||
Replace the contents of `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` with: |
|||
|
|||
```typescript |
|||
/// |
|||
/// Copyright © 2016-2026 The Thingsboard Authors |
|||
/// |
|||
/// Licensed under the Apache License, Version 2.0 (the "License"); |
|||
/// you may not use this file except in compliance with the License. |
|||
/// You may obtain a copy of the License at |
|||
/// |
|||
/// http://www.apache.org/licenses/LICENSE-2.0 |
|||
/// |
|||
/// Unless required by applicable law or agreed to in writing, software |
|||
/// distributed under the License is distributed on an "AS IS" BASIS, |
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
/// See the License for the specific language governing permissions and |
|||
/// limitations under the License. |
|||
/// |
|||
|
|||
import { Component, Inject, Type } from '@angular/core'; |
|||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
|||
import { Router } from '@angular/router'; |
|||
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; |
|||
import { |
|||
IotHubItemLinkModule |
|||
} from '@home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
import { |
|||
replaceItemLinkPlaceholders |
|||
} from '@home/components/iot-hub/iot-hub-markdown.utils'; |
|||
|
|||
export interface SolutionInstallDialogData { |
|||
descriptor: SolutionTemplateInstalledItemDescriptor; |
|||
instructions?: boolean; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-solution-install-dialog', |
|||
templateUrl: './solution-install-dialog.component.html', |
|||
styleUrls: ['./solution-install-dialog.component.scss'], |
|||
standalone: false |
|||
}) |
|||
export class SolutionInstallDialogComponent { |
|||
|
|||
details: string; |
|||
dashboardId: string | null; |
|||
instructions: boolean; |
|||
|
|||
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule]; |
|||
|
|||
constructor( |
|||
@Inject(MAT_DIALOG_DATA) public data: SolutionInstallDialogData, |
|||
private dialogRef: MatDialogRef<SolutionInstallDialogComponent>, |
|||
private router: Router |
|||
) { |
|||
this.details = replaceItemLinkPlaceholders(data.descriptor.details || ''); |
|||
this.dashboardId = data.descriptor.dashboardId?.id || null; |
|||
this.instructions = !!data.instructions; |
|||
} |
|||
|
|||
gotoMainDashboard(): void { |
|||
if (this.dashboardId) { |
|||
this.dialogRef.close(); |
|||
this.router.navigateByUrl(`/dashboards/${this.dashboardId}`); |
|||
} |
|||
} |
|||
|
|||
close(): void { |
|||
this.dialogRef.close(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Update the template** |
|||
|
|||
In `solution-install-dialog.component.html` at line 29, change: |
|||
|
|||
```html |
|||
<tb-markdown [data]="details" lineNumbers fallbackToPlainMarkdown></tb-markdown> |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```html |
|||
<tb-markdown [data]="details" |
|||
[additionalCompileModules]="itemLinkCompileModules" |
|||
lineNumbers |
|||
fallbackToPlainMarkdown></tb-markdown> |
|||
``` |
|||
|
|||
- [ ] **Step 3: Build** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 |
|||
``` |
|||
|
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts \ |
|||
ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html |
|||
git commit -m "feat(iot-hub): render \${item-link} cards in solution install instructions" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 8: Manual visual QA |
|||
|
|||
**No file changes — verify the feature end-to-end in a browser.** |
|||
|
|||
- [ ] **Step 1: Start the dev server** |
|||
|
|||
```bash |
|||
cd ui-ngx && npm start |
|||
``` |
|||
|
|||
Wait for `Compiled successfully` and `Application bundle generation complete`. |
|||
|
|||
Open `http://localhost:4200` in a browser and log in as a tenant administrator. |
|||
|
|||
- [ ] **Step 2: Verify item readme rendering** |
|||
|
|||
In a separate terminal, query the configured IoT Hub for an item ID you can use as a test target: |
|||
|
|||
```bash |
|||
# Use the same baseUrl that your TB instance uses (typically https://iot-hub.thingsboard.io). |
|||
# Pick any published item you can find via the marketplace UI's network requests in DevTools, |
|||
# e.g. by opening the IoT Hub home page and copying an itemId from a response. |
|||
echo "Pick a known itemId from network responses in the IoT Hub UI" |
|||
``` |
|||
|
|||
Then, for the TEST PASS: |
|||
1. Open IoT Hub in the running app, find an item with a non-empty readme (e.g., a Solution Template), open the detail dialog. |
|||
2. In DevTools, intercept the readme response by editing it (Network → block + replay, or temporarily edit `readmeContent` via the Angular DevTools), and inject `${item-link:<knownItemId>}` somewhere in the readme markdown. |
|||
- Easier alternative: temporarily hardcode a test placeholder in `loadReadme()` by appending `'\n\n${item-link:<knownItemId>}\n'` to the fetched content, just for this QA pass. Revert before committing. |
|||
3. Reload the dialog. Expected: |
|||
- Skeleton card appears briefly with shimmer. |
|||
- Card resolves to: 48-px square thumbnail (image or colored compact icon), item name, person icon + creator name. |
|||
- Hover shows light blue tint and subtle shadow. |
|||
- Click opens `/iot-hub/<itemId>` in a **new tab**, which lands on the item type page with the detail dialog open. |
|||
- Middle-click also opens new tab; ctrl-click does the same. |
|||
|
|||
Revert any hardcoded test data before continuing. |
|||
|
|||
- [ ] **Step 3: Verify unavailable state** |
|||
|
|||
Repeat Step 2 but inject a placeholder with a UUID that does not exist (e.g., `${item-link:00000000-0000-0000-0000-000000000000}`). Expected: |
|||
- Card briefly shows skeleton. |
|||
- Resolves to disabled card: dim opacity, `link_off` icon in the thumbnail slot, "Item unavailable" label. |
|||
- Card is not clickable (no `<a>`, no hover blue). |
|||
|
|||
- [ ] **Step 4: Verify invalid placeholder is left untouched** |
|||
|
|||
Inject `${item-link:not-a-uuid}` into the readme. Expected: the rendered markdown shows the literal text `${item-link:not-a-uuid}` (no card, no error). This proves the regex is strict. |
|||
|
|||
- [ ] **Step 5: Verify device install instructions** |
|||
|
|||
Open any IoT Hub Device item, click Install, and walk to a step whose markdown is dynamically rendered. Inject `${item-link:<knownItemId>}` into one of the markdown templates served by the device package (or temporarily prepend it to `step.markdown` in `device-install-dialog.component.ts` near the existing `resolveVariables` call). Expected: same skeleton → loaded card behavior; click opens `/iot-hub/<itemId>` in a new tab. |
|||
|
|||
Revert temporary edits. |
|||
|
|||
- [ ] **Step 6: Verify solution install instructions** |
|||
|
|||
Install (or reopen the install instructions for) a Solution Template that has a `details` markdown. Inject `${item-link:<knownItemId>}` into `details` (temporarily, in the constructor or via a known solution template whose details you control). Expected: same skeleton → loaded behavior; click opens `/iot-hub/<itemId>` in a new tab. |
|||
|
|||
Revert temporary edits. |
|||
|
|||
- [ ] **Step 7: Confirm no regressions in existing markdown** |
|||
|
|||
In each of the three render sites, verify after the QA edits are reverted: |
|||
- Existing `${gateway.downloadButton}` placeholder still renders correctly in the device install instructions (unchanged behavior). |
|||
- Existing callout boxes (`${note(...)}`, `${warn(...)}`, `${error(...)}`) still render in the device install instructions. |
|||
- Existing `prefixResourceUrls` and `resolveDocLinks` continue to resolve image URLs and doc-link placeholders in readmes. |
|||
- Changelog tab in the item detail dialog still renders without an item-link card (it does not bind `additionalCompileModules`). |
|||
|
|||
- [ ] **Step 8: Stop the dev server** |
|||
|
|||
`Ctrl-C` in the terminal running `npm start`. |
|||
|
|||
- [ ] **Step 9: Final commit (if any QA-driven fix-ups were made)** |
|||
|
|||
If QA surfaced any issues that required code changes, commit them with a focused message. Otherwise this step is a no-op. |
|||
|
|||
```bash |
|||
git status |
|||
# If clean, no commit needed. |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Self-review checklist (for the implementing agent) |
|||
|
|||
Before declaring complete, verify: |
|||
|
|||
1. **Spec coverage** — every decision in `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` has a corresponding task above. |
|||
2. **No leftover test scaffolding** — temporary hardcoded `${item-link:...}` placeholders used for QA are reverted. |
|||
3. **License headers** — every new `.ts` file uses `///` style, every new `.html` uses `<!-- -->`, every new `.scss` uses `/** */`. |
|||
4. **Build clean** — `npx ng build --configuration=development` finishes without errors. |
|||
5. **Three sites work, fourth doesn't** — readme, device install, solution install all render the card; changelog tab does **not** (by design — out of scope per spec). |
|||
@ -0,0 +1,145 @@ |
|||
# IoT Hub `${item-link:uuid}` Markdown Component — Design |
|||
|
|||
**Date:** 2026-05-06 |
|||
**Status:** Approved |
|||
**Branch:** `feature/iot-hub` |
|||
|
|||
## Summary |
|||
|
|||
Add an `${item-link:<uuid>}` markdown placeholder that renders a small card |
|||
(thumbnail + name + creator) for any IoT Hub marketplace item, modeled on |
|||
the cards already shown in the home-page search popup. Used by IoT Hub |
|||
content authors to cross-link items from readme and install instructions. |
|||
|
|||
## Decisions |
|||
|
|||
| Question | Decision | |
|||
|---|---| |
|||
| UUID identifies | Marketplace **item ID** (stable across versions) | |
|||
| Render scope | Item readme + device install instructions + solution install instructions | |
|||
| Click behavior | Plain anchor → `/iot-hub/{itemId}` with `target="_blank"` | |
|||
| Syntax | `${item-link:<uuid>}` — ID only, type derived from API response | |
|||
| Unavailable item handling | Render disabled card labeled "Item unavailable" | |
|||
| Resolution strategy | Render skeleton, fetch async, swap when ready (per card) | |
|||
|
|||
## Architecture |
|||
|
|||
Three building blocks, scoped tightly: |
|||
|
|||
1. **`replaceItemLinkPlaceholders(markdown: string): string`** — pure |
|||
string transform. Rewrites `${item-link:<uuid>}` to |
|||
`<tb-iot-hub-item-link-card itemId="<uuid>"></tb-iot-hub-item-link-card>`. |
|||
2. **`TbIotHubItemLinkCardComponent`** — Angular component that owns |
|||
fetch, state (`loading` / `loaded` / `unavailable`), and visual. |
|||
3. **`IotHubItemLinkModule`** — declares the component; passed as |
|||
`additionalCompileModules` on every `tb-markdown` instance that needs it. |
|||
|
|||
`tb-markdown` already supports compile-time injection of additional |
|||
modules; placeholder rewriting + module registration is all that is |
|||
needed to make the component render inside markdown. |
|||
|
|||
## Placeholder syntax |
|||
|
|||
- Format: `${item-link:<uuid>}` |
|||
- Regex: strict 36-char UUID match |
|||
(`/\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g`) |
|||
- Non-UUID payloads (typos) are left as-is in rendered output, so the |
|||
author sees the issue during preview. |
|||
- Placeholders inside fenced code blocks are still replaced — matches |
|||
the existing `prefixResourceUrls` / `resolveDocLinks` precedent in the |
|||
same files. |
|||
|
|||
## Integration points |
|||
|
|||
| File | Hook | |
|||
|---|---| |
|||
| `iot-hub-item-detail-dialog.component.ts` `loadReadme()` | Add as a step in the existing pipeline next to `prefixResourceUrls` and `resolveDocLinks` | |
|||
| `device-install-dialog.component.ts` `resolveVariables()` | Add an `^item-link:(uuid)$` branch alongside `gateway.downloadButton` and the callout matchers | |
|||
| `solution-install-dialog.component.ts` constructor | New step before assigning `this.details` | |
|||
|
|||
All three call the same backing helper. Each `tb-markdown` instance |
|||
in those templates gets `[additionalCompileModules]="[IotHubItemLinkModule]"`. |
|||
|
|||
## Component spec |
|||
|
|||
**Location:** |
|||
`ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/` |
|||
|
|||
**Inputs:** |
|||
- `@Input() itemId: string` |
|||
|
|||
**Lifecycle:** |
|||
- `ngOnInit()` calls |
|||
`iotHubApiService.getPublishedVersion(itemId, { ignoreErrors: true, ignoreLoading: true })`. |
|||
- Success → `state = 'loaded'`, store item. |
|||
- Error / 404 / network failure → `state = 'unavailable'`. |
|||
|
|||
**Template — three states:** |
|||
|
|||
- **loading:** skeleton card matching the loaded layout (gray thumb + |
|||
two text shimmer lines). |
|||
- **loaded:** anchor to `/iot-hub/{itemId}` (`target="_blank"`, |
|||
`rel="noopener noreferrer"`), thumbnail slot + name + creator row. |
|||
- **unavailable:** non-clickable card, 60 % opacity, `link_off` icon |
|||
in the thumbnail slot, label "Item unavailable" (translated). |
|||
|
|||
**Thumbnail rules** (mirror the search popup at |
|||
`iot-hub-home.component.html:103-115`): |
|||
- Compact types (`CALCULATED_FIELD`, `ALARM_RULE`, `RULE_CHAIN`): |
|||
colored square + `getCompactIcon()` + item color. |
|||
- Other types: image via `iotHubApiService.resolveResourceUrl(item.image)`, |
|||
fallback to a `mat-icon` of the type when no image is present. |
|||
- The same branching exists in `iot-hub-home.component.ts` (search popup) |
|||
and `iot-hub-item-detail-dialog.component.ts`. The new card duplicates |
|||
it locally — extracting a shared helper is out of scope for this PR. |
|||
|
|||
**Styling:** |
|||
- Component-scoped SCSS. |
|||
- Fixed width ~320 px, block-level (each card on its own line in markdown). |
|||
- Subtle CSS-keyframe shimmer for the skeleton state. |
|||
- Hover: matches search-popup card hover. |
|||
|
|||
**Click:** |
|||
- Plain anchor — middle-click / ctrl-click / "open in new tab" all work |
|||
natively. Existing route `/iot-hub/:itemId` |
|||
(`TbIotHubItemResolverComponent`) handles resolution, the |
|||
unpublished-version warning, and opening the detail dialog on the |
|||
type page. |
|||
|
|||
## Translations |
|||
|
|||
Add the following keys (and propagate to other locale files): |
|||
|
|||
| Key | English | |
|||
|---|---| |
|||
| `iot-hub.item-link-unavailable` | "Item unavailable" | |
|||
|
|||
## Out of scope |
|||
|
|||
- Changelog rendering (explicitly excluded). |
|||
- IoT Hub creator-side authoring helpers (placeholder is plain text in |
|||
raw markdown; nothing required on the IoT Hub backend). |
|||
- Batch endpoint for resolving multiple items in one request — N parallel |
|||
requests is fine for the expected 0–5 references per page. |
|||
- A non-block (inline) variant of the card. |
|||
- Hover preview / tooltip / type chip on the card itself. |
|||
|
|||
## Testing |
|||
|
|||
- Component unit tests: state transitions (loading → loaded, loading |
|||
→ unavailable), thumbnail logic for compact vs. non-compact types, |
|||
fallback when image missing. |
|||
- Regex unit test for `replaceItemLinkPlaceholders` covering: valid |
|||
UUID, invalid UUID (left untouched), placeholder inside fenced code |
|||
(still replaced — documented behavior), multiple placeholders in one |
|||
document. |
|||
- Manual visual QA in all three render sites. |
|||
|
|||
## Risks / open items |
|||
|
|||
- `tb-markdown` recompiles on every `data` change; if a parent toggles |
|||
the markdown rapidly, the card re-fetches. Acceptable: readmes / |
|||
instructions don't churn during normal viewing. |
|||
- `getPublishedVersion` returns the **current** published version. If a |
|||
newer published version changes the name/thumbnail, links update |
|||
automatically — that is the documented behavior of the ID-only syntax. |
|||
@ -0,0 +1,119 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
@if (formGroup) { |
|||
<form [formGroup]="formGroup" class="tb-install-form flex flex-col gap-4"> |
|||
@for (field of fields; track field.key; let i = $index) { |
|||
|
|||
<!-- typed widget --> |
|||
@switch (field.type) { |
|||
@case (FormFieldType.BOOLEAN) { |
|||
<mat-checkbox [formControlName]="field.key">{{ field.label }}</mat-checkbox> |
|||
} |
|||
@case (FormFieldType.SELECT) { |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>{{ field.label }}</mat-label> |
|||
<mat-select [formControlName]="field.key"> |
|||
@for (opt of field.options; track opt.value) { |
|||
<mat-option [value]="opt.value">{{ opt.label }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
@if (field.helpText) { |
|||
<mat-hint>{{ field.helpText }}</mat-hint> |
|||
} |
|||
@if (formGroup.controls[field.key]?.hasError('required')) { |
|||
<mat-error>{{ field.label + ' is required' }}</mat-error> |
|||
} |
|||
</mat-form-field> |
|||
} |
|||
@case (FormFieldType.PASSWORD) { |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>{{ field.label }}</mat-label> |
|||
<input matInput [type]="passwordVisible[field.key] ? 'text' : 'password'" |
|||
[formControlName]="field.key"> |
|||
<button mat-icon-button matSuffix type="button" |
|||
(click)="togglePasswordVisible(field.key)"> |
|||
<mat-icon>{{ passwordVisible[field.key] ? 'visibility_off' : 'visibility' }}</mat-icon> |
|||
</button> |
|||
@if (field.randomGenerator) { |
|||
<button mat-icon-button matSuffix type="button" |
|||
[matTooltip]="'iot-hub.generate-random-value' | translate" |
|||
matTooltipPosition="above" |
|||
(click)="regenerate(field)"> |
|||
<mat-icon>autorenew</mat-icon> |
|||
</button> |
|||
} |
|||
@if (field.helpText) { |
|||
<mat-hint>{{ field.helpText }}</mat-hint> |
|||
} |
|||
@if (formGroup.controls[field.key]?.hasError('required')) { |
|||
<mat-error>{{ field.label + ' is required' }}</mat-error> |
|||
} |
|||
@if (formGroup.controls[field.key]?.hasError('pattern')) { |
|||
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error> |
|||
} |
|||
</mat-form-field> |
|||
} |
|||
@case (FormFieldType.INTEGER) { |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>{{ field.label }}</mat-label> |
|||
<input matInput type="number" [formControlName]="field.key"> |
|||
@if (field.helpText) { |
|||
<mat-hint>{{ field.helpText }}</mat-hint> |
|||
} |
|||
@if (formGroup.controls[field.key]?.hasError('required')) { |
|||
<mat-error>{{ field.label + ' is required' }}</mat-error> |
|||
} |
|||
@if (formGroup.controls[field.key]?.hasError('pattern')) { |
|||
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error> |
|||
} |
|||
</mat-form-field> |
|||
} |
|||
@default { |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>{{ field.label }}</mat-label> |
|||
<input matInput [formControlName]="field.key"> |
|||
@if (field.randomGenerator) { |
|||
<button mat-icon-button matSuffix type="button" |
|||
[matTooltip]="'iot-hub.generate-random-value' | translate" |
|||
matTooltipPosition="above" |
|||
(click)="regenerate(field)"> |
|||
<mat-icon>autorenew</mat-icon> |
|||
</button> |
|||
} |
|||
@if (field.helpText) { |
|||
<mat-hint>{{ field.helpText }}</mat-hint> |
|||
} |
|||
@if (formGroup.controls[field.key]?.hasError('required')) { |
|||
<mat-error>{{ field.label + ' is required' }}</mat-error> |
|||
} |
|||
@if (formGroup.controls[field.key]?.hasError('pattern')) { |
|||
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error> |
|||
} |
|||
</mat-form-field> |
|||
} |
|||
} |
|||
|
|||
@if (field.type === FormFieldType.BOOLEAN && field.helpText) { |
|||
<div class="tb-install-form-field-help">{{ field.helpText }}</div> |
|||
} |
|||
@if (field.helpImage) { |
|||
<img class="tb-install-form-field-help-image" [src]="imagePath(field.helpImage)" [alt]="field.label"> |
|||
} |
|||
} |
|||
</form> |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host { |
|||
display: block; |
|||
} |
|||
|
|||
.tb-install-form-field-help { |
|||
font-size: 12px; |
|||
color: rgba(0, 0, 0, 0.6); |
|||
margin-top: 4px; |
|||
} |
|||
|
|||
.tb-install-form-field-help-image { |
|||
max-width: 100%; |
|||
margin-top: 8px; |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; |
|||
import { UntypedFormGroup } from '@angular/forms'; |
|||
import { FormFieldDefinition, FormFieldType } from '@shared/models/iot-hub/device-package.models'; |
|||
import { generateSecret } from '@core/utils'; |
|||
|
|||
const DEFAULT_RANDOM_SIZE = 20; |
|||
|
|||
/** |
|||
* Reusable form renderer for the device install dialog's SHOW_FORM step. Consumes |
|||
* the FormFieldDefinition[] parsed from the package's form.json directly. |
|||
* |
|||
* The renderer is presentation-only. The caller owns the FormGroup and supplies |
|||
* the FormFieldDefinition[] array. Optional resolveImagePath callback maps |
|||
* relative help-image paths to data URIs (the dialog supplies it from the parsed |
|||
* package ZIP). |
|||
*/ |
|||
@Component({ |
|||
selector: 'tb-install-form-renderer', |
|||
templateUrl: './install-form-renderer.component.html', |
|||
styleUrls: ['./install-form-renderer.component.scss'], |
|||
standalone: false |
|||
}) |
|||
export class InstallFormRendererComponent implements OnChanges { |
|||
|
|||
@Input() fields: FormFieldDefinition[] = []; |
|||
@Input() formGroup!: UntypedFormGroup; |
|||
@Input() resolveImagePath?: (path: string) => string; |
|||
/** When true, PASSWORD inputs render unmasked by default (used by review mode). */ |
|||
@Input() reviewMode = false; |
|||
|
|||
passwordVisible: Record<string, boolean> = {}; |
|||
|
|||
readonly FormFieldType = FormFieldType; |
|||
|
|||
ngOnChanges(changes: SimpleChanges): void { |
|||
if (changes.fields || changes.reviewMode) { |
|||
this.passwordVisible = {}; |
|||
if (this.reviewMode) { |
|||
for (const f of this.fields) { |
|||
if (f.type === FormFieldType.PASSWORD) { |
|||
this.passwordVisible[f.key] = true; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
togglePasswordVisible(key: string): void { |
|||
this.passwordVisible[key] = !this.passwordVisible[key]; |
|||
} |
|||
|
|||
regenerate(field: FormFieldDefinition): void { |
|||
const control = this.formGroup.controls[field.key]; |
|||
if (!control) return; |
|||
control.patchValue(generateSecret(field.randomSize ?? DEFAULT_RANDOM_SIZE)); |
|||
control.markAsDirty(); |
|||
} |
|||
|
|||
getPatternErrorMessage(field: FormFieldDefinition): string { |
|||
return field.validators?.[0]?.message || 'Invalid format'; |
|||
} |
|||
|
|||
imagePath(path: string): string { |
|||
return this.resolveImagePath ? this.resolveImagePath(path) : path; |
|||
} |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
@switch (state) { |
|||
@case ('loading') { |
|||
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--skeleton" aria-busy="true"> |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-skeleton-block"></div> |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-skeleton-line"></span> |
|||
<span class="tb-iot-hub-item-link-skeleton-line tb-iot-hub-item-link-skeleton-line--short"></span> |
|||
</div> |
|||
</div> |
|||
} |
|||
@case ('loaded') { |
|||
<a [href]="getHref()" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
class="tb-iot-hub-item-link-card"> |
|||
@if (isCompact()) { |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--compact" |
|||
[style.background]="getCompactColor()"> |
|||
<tb-icon class="tb-iot-hub-item-link-thumb-icon">{{ getCompactIcon() }}</tb-icon> |
|||
</div> |
|||
} @else { |
|||
<div class="tb-iot-hub-item-link-thumb"> |
|||
@if (getImageUrl(); as imgUrl) { |
|||
<img [src]="imgUrl" alt=""> |
|||
} @else { |
|||
<mat-icon class="tb-iot-hub-item-link-thumb-fallback">{{ getTypeIcon() }}</mat-icon> |
|||
} |
|||
</div> |
|||
} |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-name">{{ item.name }}</span> |
|||
<span class="tb-iot-hub-item-link-author"> |
|||
<mat-icon>person</mat-icon> |
|||
{{ item.creatorDisplayName }} |
|||
</span> |
|||
</div> |
|||
</a> |
|||
} |
|||
@case ('unavailable') { |
|||
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--unavailable"> |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--unavailable"> |
|||
<mat-icon>link_off</mat-icon> |
|||
</div> |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-name">{{ 'iot-hub.item-link-unavailable' | translate }}</span> |
|||
</div> |
|||
</div> |
|||
} |
|||
} |
|||
@ -0,0 +1,160 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
|
|||
:host { |
|||
display: block; |
|||
margin: 12px 0; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-card { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
width: 360px; |
|||
max-width: 100%; |
|||
padding: 8px 12px; |
|||
border: 1px solid rgba(0, 0, 0, 0.08); |
|||
border-radius: 8px; |
|||
background: #fff; |
|||
text-decoration: none; |
|||
color: inherit; |
|||
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease; |
|||
|
|||
&:hover:not(.tb-iot-hub-item-link-card--unavailable):not(.tb-iot-hub-item-link-card--skeleton) { |
|||
background: rgba(4, 138, 211, 0.04); |
|||
border-color: rgba(4, 138, 211, 0.32); |
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); |
|||
} |
|||
|
|||
&--unavailable { |
|||
opacity: 0.6; |
|||
cursor: default; |
|||
} |
|||
|
|||
&--skeleton { |
|||
cursor: default; |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb { |
|||
flex-shrink: 0; |
|||
width: 99px; |
|||
height: 56px; |
|||
border-radius: 4px; |
|||
border: 1px solid rgba(0, 0, 0, 0.12); |
|||
background: rgba(0, 0, 0, 0.04); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
overflow: hidden; |
|||
|
|||
img { |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: contain; |
|||
display: block; |
|||
} |
|||
|
|||
&--compact { |
|||
width: 56px; |
|||
height: 56px; |
|||
border: none; |
|||
border-radius: 4.667px; |
|||
color: #fff; |
|||
} |
|||
|
|||
&--unavailable { |
|||
width: 56px; |
|||
height: 56px; |
|||
border: none; |
|||
color: rgba(0, 0, 0, 0.45); |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb-icon { |
|||
font-size: 28px; |
|||
width: 28px; |
|||
height: 28px; |
|||
line-height: 28px; |
|||
color: #fff; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb-fallback { |
|||
font-size: 28px; |
|||
width: 28px; |
|||
height: 28px; |
|||
color: rgba(0, 0, 0, 0.45); |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-text { |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-width: 0; |
|||
gap: 2px; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-name { |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
line-height: 1.3; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-author { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
font-size: 12px; |
|||
color: rgba(0, 0, 0, 0.6); |
|||
line-height: 1.3; |
|||
|
|||
mat-icon { |
|||
font-size: 14px; |
|||
width: 14px; |
|||
height: 14px; |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-skeleton-block, |
|||
.tb-iot-hub-item-link-skeleton-line { |
|||
background: linear-gradient( |
|||
90deg, |
|||
rgba(0, 0, 0, 0.06) 0%, |
|||
rgba(0, 0, 0, 0.12) 50%, |
|||
rgba(0, 0, 0, 0.06) 100% |
|||
); |
|||
background-size: 200% 100%; |
|||
animation: tb-iot-hub-item-link-shimmer 1.4s ease-in-out infinite; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-skeleton-line { |
|||
display: block; |
|||
height: 12px; |
|||
width: 180px; |
|||
margin: 4px 0; |
|||
|
|||
&--short { |
|||
width: 96px; |
|||
} |
|||
} |
|||
|
|||
@keyframes tb-iot-hub-item-link-shimmer { |
|||
0% { background-position: 200% 0; } |
|||
100% { background-position: -200% 0; } |
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, Input, OnInit } from '@angular/core'; |
|||
import { IotHubApiService } from '@core/http/iot-hub-api.service'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; |
|||
|
|||
type CardState = 'loading' | 'loaded' | 'unavailable'; |
|||
|
|||
const COMPACT_TYPES: ReadonlySet<ItemType> = new Set([ |
|||
ItemType.CALCULATED_FIELD, |
|||
ItemType.ALARM_RULE, |
|||
ItemType.RULE_CHAIN |
|||
]); |
|||
|
|||
@Component({ |
|||
selector: 'tb-iot-hub-item-link-card', |
|||
standalone: false, |
|||
templateUrl: './iot-hub-item-link-card.component.html', |
|||
styleUrls: ['./iot-hub-item-link-card.component.scss'] |
|||
}) |
|||
export class TbIotHubItemLinkCardComponent implements OnInit { |
|||
|
|||
@Input() itemId!: string; |
|||
|
|||
state: CardState = 'loading'; |
|||
item: MpItemVersionView | null = null; |
|||
|
|||
constructor(private iotHubApiService: IotHubApiService) {} |
|||
|
|||
ngOnInit(): void { |
|||
if (!this.itemId) { |
|||
this.state = 'unavailable'; |
|||
return; |
|||
} |
|||
this.iotHubApiService |
|||
.getPublishedVersion(this.itemId, { ignoreErrors: true, ignoreLoading: true }) |
|||
.subscribe({ |
|||
next: item => { |
|||
this.item = item; |
|||
this.state = item ? 'loaded' : 'unavailable'; |
|||
}, |
|||
error: () => { |
|||
this.state = 'unavailable'; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
isCompact(): boolean { |
|||
return !!this.item && COMPACT_TYPES.has(this.item.type); |
|||
} |
|||
|
|||
getImageUrl(): string | null { |
|||
const item = this.item!; |
|||
return item.image ? this.iotHubApiService.resolveResourceUrl(item.image) : null; |
|||
} |
|||
|
|||
getCompactIcon(): string { |
|||
const item = this.item!; |
|||
if (item.icon) { |
|||
return item.icon; |
|||
} |
|||
switch (item.type) { |
|||
case ItemType.CALCULATED_FIELD: return 'functions'; |
|||
case ItemType.ALARM_RULE: return 'notification_important'; |
|||
case ItemType.RULE_CHAIN: return 'account_tree'; |
|||
default: return 'category'; |
|||
} |
|||
} |
|||
|
|||
getTypeIcon(): string { |
|||
const item = this.item!; |
|||
switch (item.type) { |
|||
case ItemType.WIDGET: return 'widgets'; |
|||
case ItemType.DASHBOARD: return 'dashboard'; |
|||
case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; |
|||
case ItemType.CALCULATED_FIELD: return 'functions'; |
|||
case ItemType.ALARM_RULE: return 'notification_important'; |
|||
case ItemType.RULE_CHAIN: return 'account_tree'; |
|||
case ItemType.DEVICE: return 'memory'; |
|||
default: return 'category'; |
|||
} |
|||
} |
|||
|
|||
getCompactColor(): string { |
|||
return this.item!.color || '#048ad3'; |
|||
} |
|||
|
|||
getHref(): string { |
|||
return `/iot-hub/${this.itemId}`; |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { NgModule } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { SharedModule } from '@shared/shared.module'; |
|||
import { TbIotHubItemLinkCardComponent } from './iot-hub-item-link-card.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [TbIotHubItemLinkCardComponent], |
|||
imports: [CommonModule, SharedModule], |
|||
exports: [TbIotHubItemLinkCardComponent] |
|||
}) |
|||
export class IotHubItemLinkModule {} |
|||
@ -0,0 +1,25 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<tb-markdown [data]="parsedData" |
|||
[applyDefaultMarkdownStyle]="false" |
|||
[additionalStyles]="additionalStyles" |
|||
[additionalCompileModules]="itemLinkCompileModules" |
|||
[lineNumbers]="lineNumbers" |
|||
[fallbackToPlainMarkdown]="fallbackToPlainMarkdown" |
|||
(ready)="onReady()"> |
|||
</tb-markdown> |
|||
@ -0,0 +1,461 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
|
|||
$text-color: rgba(0,0,0,0.76); |
|||
$link-color: #2a7dec; |
|||
$copy-button-color: #2a7dec; |
|||
|
|||
$code-block-border-color: rgba(42, 125, 236, .2); |
|||
$code-default-color: #212529; |
|||
$code-text-color: #eb5757; |
|||
$code-keyword-color: #2a7dec; |
|||
|
|||
$table-border-color: rgba(42, 125, 236, .2); |
|||
$table-header-background-color: #f9fbff; |
|||
$table-header-text-color: rgba(33, 37, 41, .6); |
|||
$table-text-color: rgba(15, 22, 29, 0.8); |
|||
|
|||
$gallery-border-hover-color: #2a7dec; |
|||
|
|||
:host { |
|||
display: block; |
|||
font-size: 14px; |
|||
line-height: 24px; |
|||
letter-spacing: 0.2px; |
|||
color: $text-color; |
|||
|
|||
--mat-button-outlined-label-text-color: #{$text-color}; |
|||
|
|||
::ng-deep tb-markdown .tb-markdown-view { |
|||
padding: 16px 24px 24px; |
|||
&.table-wrapper { |
|||
padding: 0; |
|||
} |
|||
&:not(.table-wrapper) { |
|||
> :first-child { |
|||
margin-top: 0; |
|||
} |
|||
} |
|||
|
|||
// Headings: design 20px SemiBold, line-height 24, tracking 0.1. |
|||
h1, h2, h3, h4, h5, h6 { |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
line-height: 24px; |
|||
letter-spacing: 0.1px; |
|||
color: $text-color; |
|||
margin: 0; |
|||
padding: 0; |
|||
} |
|||
|
|||
> h1, > h2, > h3, > h4, > h5, > h6 { |
|||
padding: 0 0 6px; |
|||
margin-top: 20px; |
|||
} |
|||
|
|||
h1 { |
|||
font-size: 24px; |
|||
line-height: 32px; |
|||
padding-right: 0; |
|||
} |
|||
|
|||
// Paragraphs: design 14px Regular, tracking 0.2, line-height 24. |
|||
p { |
|||
font-size: 14px; |
|||
font-weight: 400; |
|||
line-height: 24px; |
|||
letter-spacing: 0.2px; |
|||
color: $text-color; |
|||
margin: 0; |
|||
} |
|||
|
|||
> p, > div { |
|||
padding-left: 0; |
|||
padding-right: 0; |
|||
} |
|||
|
|||
p + p { |
|||
margin-top: 8px; |
|||
} |
|||
|
|||
h1 + p, h2 + p, h3 + p, |
|||
h1 + ul, h2 + ul, h3 + ul, |
|||
h1 + ol, h2 + ol, h3 + ol { |
|||
margin-top: 8px; |
|||
} |
|||
|
|||
// Lists: design ms-21 per li, 14px, line-height 24. |
|||
ul, ol { |
|||
padding-left: 21px; |
|||
padding-right: 0; |
|||
margin: 0; |
|||
font-size: 14px; |
|||
line-height: 24px; |
|||
letter-spacing: 0.2px; |
|||
color: $text-color; |
|||
|
|||
+ h1, + h2, + h3, + h4, + h5, + h6 { |
|||
padding-top: 0; |
|||
margin-top: 20px; |
|||
} |
|||
} |
|||
|
|||
ul { |
|||
margin-top: 8px; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
li { |
|||
padding-bottom: 4px; |
|||
margin-bottom: 0; |
|||
line-height: 24px; |
|||
} |
|||
|
|||
img { |
|||
max-width: 100%; |
|||
} |
|||
|
|||
a:not(.mdc-button) { |
|||
font-weight: 500; |
|||
color: $link-color; |
|||
text-decoration: none; |
|||
border: none; |
|||
|
|||
&:hover { |
|||
color: $link-color; |
|||
text-decoration: underline; |
|||
border: none; |
|||
} |
|||
} |
|||
|
|||
code:not([class*=language-]) { |
|||
color: $code-text-color; |
|||
font-family: monospace; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
// Code-wrapper + clipboard button |
|||
div.code-wrapper { |
|||
position: relative; |
|||
|
|||
button.clipboard-btn { |
|||
pointer-events: none; |
|||
outline: none; |
|||
position: absolute; |
|||
width: 206px; |
|||
height: 32px; |
|||
top: 0; |
|||
right: 0; |
|||
background: 0 0; |
|||
border: none; |
|||
user-select: none; |
|||
|
|||
&.multiline { |
|||
right: 6px; |
|||
} |
|||
|
|||
p { |
|||
padding: 6px 8px 0; |
|||
top: 1px; |
|||
transition: .2s; |
|||
color: $copy-button-color; |
|||
background: rgba(255, 255, 255, .85); |
|||
backdrop-filter: blur(4px); |
|||
opacity: 0; |
|||
font-weight: 500; |
|||
right: 32px; |
|||
position: absolute; |
|||
} |
|||
|
|||
div { |
|||
background-color: #fff; |
|||
position: absolute; |
|||
width: 38px; |
|||
height: 28px; |
|||
top: 3px; |
|||
right: 3px; |
|||
padding: 8px 10px 0; |
|||
|
|||
img { |
|||
position: initial; |
|||
width: 18px; |
|||
height: 18px; |
|||
filter: invert(51%) sepia(6%) saturate(172%) hue-rotate(177deg) brightness(94%) contrast(92%); |
|||
} |
|||
} |
|||
} |
|||
|
|||
&:hover { |
|||
cursor: pointer; |
|||
|
|||
pre[class*="language-"] { |
|||
border: solid 1px $copy-button-color; |
|||
} |
|||
|
|||
button.clipboard-btn { |
|||
p { |
|||
opacity: 1; |
|||
} |
|||
|
|||
div img { |
|||
filter: invert(49%) sepia(97%) saturate(3730%) hue-rotate(200deg) brightness(95%) contrast(95%); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
th, td { |
|||
div.code-wrapper { |
|||
display: inline-block; |
|||
width: 100%; |
|||
|
|||
button.clipboard-btn { |
|||
top: -6px; |
|||
padding: 0 3px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Code blocks (Prism) |
|||
pre[class*="language-"] { |
|||
font-size: 14px; |
|||
border: 1px solid $code-block-border-color; |
|||
border-radius: 4px; |
|||
background: 0 0; |
|||
padding: 8px 16px; |
|||
color: $code-default-color; |
|||
|
|||
.token.atrule, .token.attr-value, .token.keyword { |
|||
color: $code-keyword-color; |
|||
} |
|||
|
|||
.token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { |
|||
color: $code-text-color; |
|||
} |
|||
|
|||
.token.punctuation { |
|||
color: $code-default-color; |
|||
} |
|||
|
|||
&.line-numbers { |
|||
padding-left: 66px; |
|||
|
|||
> code span.line-numbers-rows { |
|||
top: -11px; |
|||
bottom: -11px; |
|||
left: -66px; |
|||
width: 50px; |
|||
border: none; |
|||
padding: 8px 12px 8px 18px; |
|||
text-align: right; |
|||
background: #f9fbff; |
|||
|
|||
> span:before { |
|||
color: rgba(33, 37, 41, .6); |
|||
padding-right: 0; |
|||
} |
|||
} |
|||
|
|||
&.no-line-numbers { |
|||
padding-left: 16px; |
|||
|
|||
> code span.line-numbers-rows { |
|||
display: none; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Tables |
|||
> table { |
|||
width: 100%; |
|||
border: 1px solid $table-border-color; |
|||
border-radius: 4px; |
|||
border-collapse: unset; |
|||
border-spacing: 0; |
|||
margin-top: 20px; |
|||
margin-bottom: 20px; |
|||
overflow: hidden; |
|||
table-layout: fixed; |
|||
|
|||
&.auto { |
|||
table-layout: auto; |
|||
} |
|||
|
|||
> thead { |
|||
background-color: $table-header-background-color; |
|||
color: $table-header-text-color; |
|||
|
|||
> tr > th { |
|||
border-bottom: 1px solid $table-border-color; |
|||
font-size: 14px; |
|||
padding: 8px 12px; |
|||
text-align: left; |
|||
margin: 0; |
|||
@media screen and (max-width: 400px) { |
|||
font-size: 12px; |
|||
padding: 8px 4px; |
|||
code:not([class*=language-]) { |
|||
font-size: 12px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
> tbody { |
|||
> tr:not(:last-child) > td { |
|||
border-bottom: 1px solid $table-border-color; |
|||
} |
|||
|
|||
> tr > td { |
|||
font-size: 14px; |
|||
padding: 8px 12px; |
|||
text-align: left; |
|||
margin: 0; |
|||
color: $table-text-color; |
|||
@media screen and (max-width: 400px) { |
|||
font-size: 12px; |
|||
padding: 8px 4px; |
|||
code:not([class*=language-]) { |
|||
font-size: 12px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
th, td { |
|||
font-size: 14px; |
|||
padding: 8px; |
|||
margin: 0; |
|||
text-align: left; |
|||
} |
|||
|
|||
td[align=center], th[align=center] { |
|||
text-align: center; |
|||
} |
|||
|
|||
td[align=right], th[align=right] { |
|||
text-align: right; |
|||
} |
|||
|
|||
tr td div { |
|||
padding-left: 0; |
|||
padding-right: 0; |
|||
} |
|||
} |
|||
|
|||
.tb-callout { |
|||
display: flex; |
|||
align-items: flex-start; |
|||
gap: 10px; |
|||
padding: 12px 16px; |
|||
border-radius: 4px; |
|||
border: 1px solid; |
|||
margin: 12px 0; |
|||
font-size: 14px; |
|||
line-height: 20px; |
|||
|
|||
.tb-callout-icon { |
|||
font-size: 20px; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
&.tb-callout-note { |
|||
background: #e3f2fd; |
|||
border-color: #90caf9; |
|||
color: #1565c0; |
|||
} |
|||
|
|||
&.tb-callout-warn { |
|||
background: #fff8e1; |
|||
border-color: #ffe082; |
|||
color: #f57f17; |
|||
} |
|||
|
|||
&.tb-callout-error { |
|||
background: #fce4ec; |
|||
border-color: #ef9a9a; |
|||
color: #c62828; |
|||
} |
|||
} |
|||
|
|||
.tb-gallery-images { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
|||
gap: 12px; |
|||
margin-top: 8px; |
|||
margin-bottom: 8px; |
|||
|
|||
.tb-gallery-image { |
|||
position: relative; |
|||
cursor: pointer; |
|||
border: 2px solid #c1c3c8; |
|||
border-radius: 8px; |
|||
overflow: hidden; |
|||
margin: 0; |
|||
padding: 0; |
|||
background: #fff; |
|||
transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; |
|||
display: block; |
|||
&:hover { |
|||
border-color: $gallery-border-hover-color; |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 6px 18px #0000002e; |
|||
outline: none; |
|||
.tb-image-tooltip { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
.tb-image-container { |
|||
display: block; |
|||
height: 115px; |
|||
overflow: hidden; |
|||
background: #fff; |
|||
img { |
|||
width: 100%; |
|||
max-width: 100%; |
|||
height: 100%; |
|||
object-fit: contain; |
|||
object-position: top center; |
|||
display: block; |
|||
margin: 0; |
|||
padding: 0; |
|||
} |
|||
} |
|||
.tb-image-tooltip { |
|||
position: absolute; |
|||
bottom: 0; |
|||
left: 0; |
|||
right: 0; |
|||
padding: .4rem .6rem; |
|||
background: #000000b8; |
|||
color: #fff; |
|||
font-size: .75rem; |
|||
line-height: 1.3; |
|||
text-align: center; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
opacity: 0; |
|||
transform: translateY(4px); |
|||
transition: opacity .2s ease, transform .2s ease; |
|||
pointer-events: none; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,212 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
Component, |
|||
ElementRef, |
|||
EventEmitter, |
|||
Input, |
|||
OnChanges, |
|||
OnInit, |
|||
Output, |
|||
SimpleChanges, |
|||
Type |
|||
} from '@angular/core'; |
|||
import { coerceBoolean } from '@shared/decorators/coercion'; |
|||
import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
import { IotHubApiService } from '@core/http/iot-hub-api.service'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
import { |
|||
escapeHtmlAttr, |
|||
replaceItemLinkPlaceholders, |
|||
resolveDocLinkPlaceholders, |
|||
sanitizeInlineHtml |
|||
} from '@home/components/iot-hub/iot-hub-markdown.utils'; |
|||
import { DevicePackageInfo } from '@shared/models/iot-hub/device-package.models'; |
|||
import { isNotEmptyStr } from '@core/utils'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-iot-hub-markdown', |
|||
standalone: false, |
|||
templateUrl: './iot-hub-markdown.component.html', |
|||
styleUrls: ['./iot-hub-markdown.component.scss'] |
|||
}) |
|||
export class TbIotHubMarkdownComponent implements OnInit, OnChanges { |
|||
|
|||
@Input() data: string | undefined; |
|||
|
|||
@Input() item: MpItemVersionView | undefined; |
|||
|
|||
@Input() packageInfo: DevicePackageInfo | undefined; |
|||
|
|||
@Input() imageMap: Map<string, string> | undefined; |
|||
|
|||
@Input() onResolveVariable: (key: string) => string | undefined = () => undefined; |
|||
|
|||
@Input() |
|||
@coerceBoolean() |
|||
lineNumbers = false; |
|||
|
|||
@Input() |
|||
@coerceBoolean() |
|||
fallbackToPlainMarkdown = false; |
|||
|
|||
@Input() |
|||
codeBlockMaxHeightPx: number; |
|||
|
|||
@Output() ready = new EventEmitter<HTMLElement>(); |
|||
|
|||
additionalStyles: string[] = []; |
|||
|
|||
parsedData: string; |
|||
|
|||
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule]; |
|||
|
|||
constructor( |
|||
private iotHubApiService: IotHubApiService, |
|||
private elementRef: ElementRef<HTMLElement> |
|||
) {} |
|||
|
|||
ngOnInit(): void { |
|||
if (this.codeBlockMaxHeightPx) { |
|||
const codeBlockMaxHeightStyle = `pre[class*="language-"] \n{
|
|||
max-height: ${this.codeBlockMaxHeightPx}px;\n |
|||
}`;
|
|||
this.additionalStyles.push(codeBlockMaxHeightStyle); |
|||
} |
|||
} |
|||
|
|||
ngOnChanges(changes: SimpleChanges): void { |
|||
for (const propName of Object.keys(changes)) { |
|||
const change = changes[propName]; |
|||
if (propName === 'data' && change.currentValue !== change.previousValue) { |
|||
this.parsedData = this.parseData(this.data); |
|||
} |
|||
} |
|||
} |
|||
|
|||
onReady() { |
|||
const container = this.elementRef.nativeElement; |
|||
this.ready.emit(container); |
|||
} |
|||
|
|||
private parseData(content: string | undefined): string { |
|||
let parsed = this.prefixResourceUrls(content || ''); |
|||
parsed = this.resolveDocLinks(parsed); |
|||
parsed = replaceItemLinkPlaceholders(parsed); |
|||
parsed = this.resolveImages(parsed); |
|||
parsed = this.resolveVariables(parsed); |
|||
return parsed; |
|||
} |
|||
|
|||
private prefixResourceUrls(markdown: string): string { |
|||
const baseUrl = this.iotHubApiService.baseUrl; |
|||
return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); |
|||
} |
|||
|
|||
private resolveDocLinks(markdown: string): string { |
|||
if (this.item || this.packageInfo) { |
|||
const dd = this.item ? this.item.dataDescriptor : this.packageInfo; |
|||
return resolveDocLinkPlaceholders( |
|||
markdown, |
|||
this.item?.name || dd?.name || '', |
|||
{ productURL: dd?.productURL, datasheetURL: dd?.datasheetURL }, |
|||
{ productPage: 'Product page', datasheet: 'Datasheet' } |
|||
); |
|||
} else { |
|||
return markdown; |
|||
} |
|||
} |
|||
|
|||
private resolveImages(content: string): string { |
|||
if (this.imageMap) { |
|||
return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => { |
|||
if (path.startsWith('data:') || path.startsWith('http')) { |
|||
return match; |
|||
} |
|||
const dataUri = this.imageMap.get(path); |
|||
return dataUri ? `` : match; |
|||
}); |
|||
} else { |
|||
return content; |
|||
} |
|||
} |
|||
|
|||
private resolveImage(uri: string): string { |
|||
if (this.imageMap && uri) { |
|||
if (uri.startsWith('data:') || uri.startsWith('http')) { |
|||
return uri; |
|||
} |
|||
const dataUri = this.imageMap.get(uri); |
|||
return dataUri ? dataUri : uri; |
|||
} else { |
|||
return uri; |
|||
} |
|||
} |
|||
|
|||
private resolveVariables(content: string): string { |
|||
// Image gallery is handled first because its inner ${...} contents
|
|||
// may include nested braces and span multiple lines, which the
|
|||
// generic ${key} regex below cannot parse.
|
|||
//
|
|||
// Format: ${images.gallery({src: 'p1', alt: 'a1', caption: 'c1'}, {src: 'p2'}, ...)}
|
|||
// Each image entry is a JS object literal with a required `src`
|
|||
// and optional `alt` / `caption` string fields.
|
|||
content = content.replace(/\$\{\s*images\.gallery\(([\s\S]*?)\)\s*}/g, (_match, inner: string) => { |
|||
const objects: string[] = inner.match(/\{[\s\S]*?}/g) || []; |
|||
const items = objects |
|||
.map((obj: string) => { |
|||
const src = (obj.match(/src\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; |
|||
const alt = (obj.match(/alt\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; |
|||
const caption = (obj.match(/caption\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; |
|||
return { src: this.resolveImage(src), alt, caption }; |
|||
}) |
|||
.filter((item: { src?: string }) => !!item.src); |
|||
const images = items |
|||
.map(item => { |
|||
let galleryImageHtml = `<button class="tb-gallery-image">
|
|||
<span class="tb-image-container"> |
|||
<img src="${item.src}" alt="${escapeHtmlAttr(item.alt)}"/> |
|||
</span>`;
|
|||
if (isNotEmptyStr(item.caption)) { |
|||
galleryImageHtml += `<span class="tb-image-tooltip">${sanitizeInlineHtml(item.caption)}</span>`; |
|||
} |
|||
galleryImageHtml += `</button>`; |
|||
return galleryImageHtml; |
|||
}) |
|||
.join(''); |
|||
return `<div class="tb-gallery-images" tbPhotoSwipeGallery galleryChildrenSelector=".tb-gallery-image" imageCaptionSelector=".tb-image-tooltip">${images}</div>`; |
|||
}); |
|||
|
|||
return content.replace(/\$\{([^}]+)}/g, (_match, key) => { |
|||
// Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)}
|
|||
const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); |
|||
if (calloutMatch) { |
|||
const type = calloutMatch[1]; |
|||
const text = calloutMatch[2]; |
|||
const icons: Record<string, string> = { note: 'info_outline', warn: 'warning_amber', error: 'error_outline' }; |
|||
return `<div class="tb-callout tb-callout-${type}"><i class="material-icons tb-callout-icon">${icons[type]}</i><span class="tb-callout-text">${text}</span></div>`; |
|||
} |
|||
|
|||
// Special variables
|
|||
const res = this.onResolveVariable(key); |
|||
if (res) { |
|||
return res; |
|||
} |
|||
return '${' + key + '}'; |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
export const ITEM_LINK_PLACEHOLDER_REGEX = |
|||
/\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})}/g; |
|||
|
|||
export function itemLinkCardTag(itemId: string): string { |
|||
return `<tb-iot-hub-item-link-card itemId="${itemId}"></tb-iot-hub-item-link-card>`; |
|||
} |
|||
|
|||
export function replaceItemLinkPlaceholders(markdown: string): string { |
|||
if (!markdown) { |
|||
return markdown; |
|||
} |
|||
return markdown.replace(ITEM_LINK_PLACEHOLDER_REGEX, (_match, uuid) => itemLinkCardTag(uuid)); |
|||
} |
|||
|
|||
export interface DocLinks { |
|||
productURL?: string; |
|||
datasheetURL?: string; |
|||
} |
|||
|
|||
export interface DocLinkLabels { |
|||
productPage: string; |
|||
datasheet: string; |
|||
} |
|||
|
|||
export function resolveDocLinkPlaceholders( |
|||
markdown: string, |
|||
name: string, |
|||
links: DocLinks, |
|||
labels: DocLinkLabels |
|||
): string { |
|||
return markdown |
|||
.replace(/\$\{product\.button}/g, () => |
|||
links.productURL ? buildDocLinkButton(links.productURL, `${name} ${labels.productPage}`, 'open_in_new') : '') |
|||
.replace(/\$\{datasheet\.button}/g, () => |
|||
links.datasheetURL ? buildDocLinkButton(links.datasheetURL, `${name} ${labels.datasheet}`, 'description') : ''); |
|||
} |
|||
|
|||
function buildDocLinkButton(url: string, text: string, icon: string): string { |
|||
const safeUrl = escapeHtmlAttr(url); |
|||
const safeText = escapeHtml(text); |
|||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" class="mr-2" mat-stroked-button>` + |
|||
`<tb-icon matButtonIcon>${icon}</tb-icon><span>${safeText}</span></a>`; |
|||
} |
|||
|
|||
export function escapeHtml(value: string): string { |
|||
return value.replace(/[&<>]/g, ch => ch === '&' ? '&' : ch === '<' ? '<' : '>'); |
|||
} |
|||
|
|||
export function escapeHtmlAttr(value: string): string { |
|||
return value.replace(/[&<>"']/g, ch => { |
|||
switch (ch) { |
|||
case '&': return '&'; |
|||
case '<': return '<'; |
|||
case '>': return '>'; |
|||
case '"': return '"'; |
|||
default: return '''; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// Inline-only tags + a small attribute whitelist that are safe to keep
|
|||
// in caption strings (or other small bits of user-authored HTML inside
|
|||
// generated markdown). Anything else is dropped — disallowed tags are
|
|||
// replaced by their text content and disallowed attributes are removed.
|
|||
const SAFE_INLINE_TAGS: ReadonlySet<string> = new Set([ |
|||
'B', 'STRONG', 'I', 'EM', 'U', 'S', 'MARK', |
|||
'SMALL', 'SUB', 'SUP', 'BR', 'CODE', 'SPAN' |
|||
]); |
|||
|
|||
const SAFE_INLINE_ATTRS: ReadonlySet<string> = new Set(['class', 'style']); |
|||
|
|||
export function sanitizeInlineHtml(value: string): string { |
|||
if (!value) { |
|||
return ''; |
|||
} |
|||
const doc = new DOMParser().parseFromString(`<div>${value}</div>`, 'text/html'); |
|||
const root = doc.body.firstElementChild as HTMLElement | null; |
|||
if (!root) { |
|||
return ''; |
|||
} |
|||
const sanitize = (parent: Element): void => { |
|||
const children = Array.from(parent.childNodes); |
|||
for (const node of children) { |
|||
if (node.nodeType !== Node.ELEMENT_NODE) { |
|||
continue; |
|||
} |
|||
const el = node as Element; |
|||
if (!SAFE_INLINE_TAGS.has(el.tagName)) { |
|||
// Replace disallowed elements with their plain text content.
|
|||
parent.replaceChild(doc.createTextNode(el.textContent || ''), el); |
|||
continue; |
|||
} |
|||
for (const attr of Array.from(el.attributes)) { |
|||
if (!SAFE_INLINE_ATTRS.has(attr.name)) { |
|||
el.removeAttribute(attr.name); |
|||
continue; |
|||
} |
|||
if (attr.name === 'style' && /(expression\s*\(|javascript:|url\s*\()/i.test(attr.value)) { |
|||
el.removeAttribute(attr.name); |
|||
} |
|||
} |
|||
sanitize(el); |
|||
} |
|||
}; |
|||
sanitize(root); |
|||
return root.innerHTML; |
|||
} |
|||
|
|||
@ -0,0 +1,38 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="tb-pe-connectivity-prompt"> |
|||
<button mat-icon-button class="tb-pe-connectivity-prompt-close" (click)="close()" tabindex="-1"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
<img class="tb-pe-connectivity-prompt-illustration" |
|||
src="assets/iot-hub/pe-only-illustration.svg" alt=""> |
|||
<h2 class="tb-pe-connectivity-prompt-title"> |
|||
<span class="tb-pe-connectivity-prompt-connector">{{ data.connectorName }}</span> |
|||
{{ 'iot-hub.pe-connectivity-prompt-title-suffix' | translate }} |
|||
</h2> |
|||
<div class="tb-pe-connectivity-prompt-actions"> |
|||
<a mat-flat-button color="primary" |
|||
href="https://thingsboard.io/installations/" |
|||
target="_blank" rel="noopener"> |
|||
{{ 'iot-hub.pe-connectivity-prompt-try-pe' | translate }} |
|||
</a> |
|||
<button mat-button (click)="close()"> |
|||
{{ 'action.close' | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,65 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
|
|||
@import "../../../../../scss/constants"; |
|||
|
|||
:host { |
|||
display: block; |
|||
width: 500px; |
|||
max-width: 100%; |
|||
} |
|||
|
|||
.tb-pe-connectivity-prompt { |
|||
position: relative; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
text-align: center; |
|||
gap: 20px; |
|||
padding: 40px; |
|||
background: white; |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
.tb-pe-connectivity-prompt-close { |
|||
position: absolute; |
|||
top: 8px; |
|||
right: 8px; |
|||
} |
|||
|
|||
.tb-pe-connectivity-prompt-illustration { |
|||
width: 140px; |
|||
height: 140px; |
|||
} |
|||
|
|||
.tb-pe-connectivity-prompt-title { |
|||
font-size: 24px; |
|||
font-weight: 500; |
|||
line-height: 32px; |
|||
letter-spacing: 0.15px; |
|||
color: rgba(0, 0, 0, 0.87); |
|||
margin: 0; |
|||
} |
|||
|
|||
.tb-pe-connectivity-prompt-connector { |
|||
color: $tb-primary-color; |
|||
} |
|||
|
|||
.tb-pe-connectivity-prompt-actions { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, Inject } from '@angular/core'; |
|||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
|||
import { Router } from '@angular/router'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { DialogComponent } from '@shared/components/dialog.component'; |
|||
|
|||
export interface PeConnectivityMethodPromptData { |
|||
connectorName: string; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-pe-connectivity-method-prompt', |
|||
standalone: false, |
|||
templateUrl: './pe-connectivity-method-prompt.component.html', |
|||
styleUrls: ['./pe-connectivity-method-prompt.component.scss'] |
|||
}) |
|||
export class TbPeConnectivityMethodPromptComponent extends DialogComponent<TbPeConnectivityMethodPromptComponent> { |
|||
|
|||
constructor( |
|||
protected store: Store<AppState>, |
|||
protected router: Router, |
|||
protected dialogRef: MatDialogRef<TbPeConnectivityMethodPromptComponent>, |
|||
@Inject(MAT_DIALOG_DATA) public data: PeConnectivityMethodPromptData |
|||
) { |
|||
super(store, router, dialogRef); |
|||
} |
|||
|
|||
close(): void { |
|||
this.dialogRef.close(); |
|||
} |
|||
} |
|||
@ -0,0 +1,219 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; |
|||
import PhotoSwipeLightbox from 'photoswipe/lightbox'; |
|||
import PhotoSwipe from 'photoswipe'; |
|||
import cssjs from '@core/css/css'; |
|||
|
|||
const PHOTO_GALLERY_STYLE_ID = 'photoswipe-gallery-style'; |
|||
const PHOTO_GALLERY_CLASS = 'tb-photoswipe-gallery'; |
|||
const PHOTO_GALLERY_STYLE = |
|||
'{\n'+ |
|||
' background: rgba(10, 10, 20, 0.55);\n' + |
|||
' backdrop-filter: blur(18px);\n' + |
|||
' opacity: 1;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__tb-photoswipe-caption {\n' + |
|||
' position: fixed;\n' + |
|||
' bottom: 1.5rem;\n' + |
|||
' left: 50%;\n' + |
|||
' transform: translate(-50%);\n' + |
|||
' z-index: 10000;\n' + |
|||
' display: flex;\n' + |
|||
' flex-direction: column;\n' + |
|||
' align-items: center;\n' + |
|||
' gap: .25rem;\n' + |
|||
' max-width: 80vw;\n' + |
|||
' text-align: center;\n' + |
|||
' pointer-events: none;\n' + |
|||
' line-height: 1.75;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__tb-photoswipe-caption .tb-gallery-caption {\n' + |
|||
' color: #fff;\n' + |
|||
' font-size: 1.125rem;\n' + |
|||
' line-height: 1.5;\n' + |
|||
' background: #000000a6;\n' + |
|||
' padding: .5rem 1.25rem;\n' + |
|||
' border-radius: 8px;\n' + |
|||
' backdrop-filter: blur(8px);\n' + |
|||
' -webkit-backdrop-filter: blur(8px);\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__tb-photoswipe-caption .tb-gallery-counter {\n' + |
|||
' color: #fff9;\n' + |
|||
' font-size: .8rem;\n' + |
|||
'}\n'+ |
|||
'\n' + |
|||
'.pswp__item img.pswp__img {\n' + |
|||
' display: block;\n' + |
|||
' max-width: 90vw;\n' + |
|||
' max-height: 78vh;\n' + |
|||
' object-fit: contain;\n' + |
|||
' border-radius: 4px; \n' + |
|||
' box-shadow: 0 20px 60px #00000080;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__item .pswp__img--placeholder {\n' + |
|||
' border-radius: 4px; \n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button {\n' + |
|||
' border-radius: 50%;\n' + |
|||
' border: 1px solid rgba(255, 255, 255, .2);\n' + |
|||
' background: #1e1e2899;\n' + |
|||
' backdrop-filter: blur(8px);\n' + |
|||
' -webkit-backdrop-filter: blur(8px);\n' + |
|||
' color: #fff;\n' + |
|||
' transition: background .18s ease, transform .18s ease;\n' + |
|||
' outline: none;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button:hover {\n' + |
|||
' background: #3c3c50d9;\n' + |
|||
' transform: scale(1.08);\n' + |
|||
' outline: none;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--close {\n' + |
|||
' width: 40px;\n' + |
|||
' height: 40px;\n' + |
|||
' margin-top: 16px;\n' + |
|||
' margin-right: 16px;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--close .pswp__icn {\n' + |
|||
' width: 24px;\n' + |
|||
' height: 24px;\n' + |
|||
' top: 7px;\n' + |
|||
' left: 7px;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--arrow {\n' + |
|||
' width: 48px;\n' + |
|||
' height: 48px;\n' + |
|||
' margin-top: -24px;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--arrow .pswp__icn {\n' + |
|||
' width: 32px;\n' + |
|||
' height: 32px;\n' + |
|||
' margin-top: 0;\n' + |
|||
' top: 7px;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--arrow.pswp__button--arrow--prev {\n' + |
|||
' left: 16px;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--arrow.pswp__button--arrow--prev .pswp__icn {\n' + |
|||
' left: 12px;\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--arrow.pswp__button--arrow--next {\n' + |
|||
' right: 16px\n' + |
|||
'}\n' + |
|||
'\n' + |
|||
'.pswp__button.pswp__button--arrow.pswp__button--arrow--next .pswp__icn { \n' + |
|||
' right: 12px;\n' + |
|||
'}'; |
|||
|
|||
@Directive({ |
|||
selector: '[tbPhotoSwipeGallery]', |
|||
standalone: false |
|||
}) |
|||
export class PhotoSwipeGalleryDirective implements OnInit, OnDestroy { |
|||
|
|||
@Input() galleryChildrenSelector = '.tb-image'; |
|||
@Input() imageCaptionSelector = '.tb-image-tooltip'; |
|||
|
|||
private lightbox: PhotoSwipeLightbox; |
|||
|
|||
constructor( |
|||
private elementRef: ElementRef<HTMLElement> |
|||
) {} |
|||
|
|||
ngOnInit(): void { |
|||
this.initPhotoSwipeGalleryStyle(); |
|||
this.lightbox = new PhotoSwipeLightbox({ |
|||
gallery: this.elementRef.nativeElement, |
|||
children: this.galleryChildrenSelector, |
|||
pswpModule: PhotoSwipe, |
|||
counter: false, |
|||
bgOpacity: 0, |
|||
mainClass: PHOTO_GALLERY_CLASS |
|||
}); |
|||
this.lightbox.addFilter('domItemData', (itemData, element) => { |
|||
let image: HTMLImageElement; |
|||
if (element instanceof HTMLImageElement) { |
|||
image = element; |
|||
} else { |
|||
image = element.querySelector('img'); |
|||
} |
|||
itemData.src = image.src; |
|||
itemData.width = image.naturalWidth; |
|||
itemData.height = image.naturalHeight; |
|||
itemData.thumbCropped = true; |
|||
return itemData; |
|||
}); |
|||
this.lightbox.on('uiRegister', () => { |
|||
this.lightbox.pswp.ui.registerElement({ |
|||
name: 'tb-photoswipe-caption', |
|||
order: 9, |
|||
isButton: false, |
|||
appendTo: 'root', |
|||
html: '<span class="tb-gallery-caption"></span><span class="tb-gallery-counter"></span>', |
|||
onInit: (el, pswp) => { |
|||
const caption = el.querySelector<HTMLElement>('.tb-gallery-caption'); |
|||
const counter = el.querySelector<HTMLElement>('.tb-gallery-counter'); |
|||
this.lightbox.pswp.on('change', () => { |
|||
counter.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems(); |
|||
const currSlideElement = this.lightbox.pswp.currSlide.data.element; |
|||
let imageTooltip: Element; |
|||
if (currSlideElement) { |
|||
imageTooltip = currSlideElement.querySelector(this.imageCaptionSelector); |
|||
} |
|||
if (imageTooltip) { |
|||
caption.style.display = 'block'; |
|||
caption.innerHTML = imageTooltip.innerHTML || ''; |
|||
} else { |
|||
caption.style.display = 'none'; |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
}); |
|||
this.lightbox.init(); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.lightbox) { |
|||
this.lightbox.destroy(); |
|||
} |
|||
} |
|||
|
|||
private initPhotoSwipeGalleryStyle(): void { |
|||
const existingElement = document.getElementById(PHOTO_GALLERY_STYLE_ID); |
|||
if (!existingElement) { |
|||
const cssParser = new cssjs(); |
|||
cssParser.testMode = false; |
|||
cssParser.cssPreviewNamespace = PHOTO_GALLERY_CLASS; |
|||
cssParser.createStyleElement(PHOTO_GALLERY_STYLE_ID, PHOTO_GALLERY_STYLE); |
|||
} |
|||
} |
|||
} |
|||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 684 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 991 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 955 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 853 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 313 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 398 B |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 980 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 8.0 KiB |