Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions nuxt/components/integrations/Card.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { IntegrationCatalogEntry } from '../../types/integrations'
import { COLLECTION_LABELS } from '../../types/integrations'
// `generatedIds` is the set of node IDs that have a prerendered detail page.
const props = defineProps<{
Expand All @@ -8,17 +9,24 @@ const props = defineProps<{
}>()
const hasGeneratedPage = computed(() => props.generatedIds.has(props.node._id))
const href = computed(() =>
hasGeneratedPage.value
const href = computed<string | null>(() => {
if (props.node.docsUrl) return props.node.docsUrl
if (props.node.tier === 'certified') return null
return hasGeneratedPage.value
? `/integrations/${props.node._id}/`
: `https://flows.nodered.org/node/${props.node._id}`
)
})
const isInternal = computed(() => props.node.docsUrl || hasGeneratedPage.value)
const isExternalLink = computed(() => href.value && !isInternal.value)
const externalAttrs = computed(() =>
hasGeneratedPage.value
? {}
: { target: '_blank', rel: 'noopener noreferrer' }
isExternalLink.value
? { target: '_blank', rel: 'noopener noreferrer' }
: {}
)
const scope = computed(() => props.node.npmScope || props.node.npmOwners?.[0] || '')
const collectionLabel = computed(() =>
props.node.collection ? COLLECTION_LABELS[props.node.collection] : ''
)
const shortDescription = computed(() => {
if (!props.node.description) return ''
const words = props.node.description.split(' ')
Expand All @@ -30,13 +38,13 @@ const shortDescription = computed(() => {

<template>
<li class="integration-card group border border-gray-300 rounded-xl bg-white drop-shadow-md">
<a :href="href" v-bind="externalAttrs" class="h-48 flex flex-col">
<div class="integration-card--details p-3 grow min-h-0">
<component :is="href ? 'a' : 'div'" :href="href || undefined" v-bind="externalAttrs" class="h-48 flex flex-col">
<div class="integration-card--details p-3 grow min-h-0 overflow-hidden">
<div class="flex justify-between text-sm items-center gap-2">
<span class="truncate">
@{{ scope }}
<svg
v-if="!hasGeneratedPage"
v-if="isExternalLink"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
Expand All @@ -47,15 +55,21 @@ const shortDescription = computed(() => {
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</span>
<span v-if="node.ffCertified" class="certified-pill" title="FlowFuse Certified">
<span v-if="node.tier === 'certified'" class="certified-pill" title="FlowFuse Certified">
<IntegrationsCertifiedIcon />
<span>Certified</span>
</span>
<span v-else-if="node.tier === 'recommended'" class="recommended-pill" title="FlowFuse Recommended">
<span>Recommended</span>
</span>
</div>
<h3 class="text-base font-semibold group-hover:text-indigo-600 cursor-pointer">{{ node.name }}</h3>
<h3 class="text-base font-semibold group-hover:text-indigo-600">{{ node.name }}</h3>
<p class="text-sm my-2 leading-5">{{ shortDescription }}</p>
</div>
<div class="integration-card--meta flex justify-between bg-indigo-50/50 group-hover:bg-indigo-50 p-3 text-sm">
<div v-if="node.tier === 'certified' && collectionLabel" class="integration-card--meta bg-indigo-50/50 group-hover:bg-indigo-50 p-3 text-sm font-medium text-indigo-700">
FlowFuse {{ collectionLabel }}
</div>
<div v-else-if="node.tier !== 'certified'" class="integration-card--meta flex justify-between bg-indigo-50/50 group-hover:bg-indigo-50 p-3 text-sm">
<div class="integration-card--stats">
<span>v{{ node.version }}<span class="ff-helper left-0 after:left-1/4">Version Number</span></span>
<span class="flex items-center gap-1">
Expand All @@ -67,6 +81,6 @@ const shortDescription = computed(() => {
</span>
</div>
</div>
</a>
</component>
</li>
</template>
36 changes: 2 additions & 34 deletions nuxt/components/integrations/CertifiedHero.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,6 @@ defineEmits<{ toggle: [] }>()
<span>Show only Certified</span>
<span v-if="count !== null" class="certified-toggle--count"> ({{ count }})</span>
</button>
<!-- TODO: repoint to a proper FlowFuse-owned Certified Nodes explainer page when one exists. A year-old blog post is not the long-term destination. -->
<a
class="certified-hero--link inline-flex items-center gap-1 uppercase"
href="/blog/2025/07/certified-nodes-v2/"
>
Learn more
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
/>
</svg>
</a>
</div>
</div>
<ul class="certified-pillars md:col-span-7">
Expand All @@ -65,8 +44,8 @@ defineEmits<{ toggle: [] }>()
</svg>
</span>
<div>
<h3 class="certified-pillar--title">Vetted authors</h3>
<p class="certified-pillar--body">Every Certified Node comes from a developer with a track record in their domain — not an anonymous npm publisher.</p>
<h3 class="certified-pillar--title">Accountable ownership</h3>
<p class="certified-pillar--body">Every Certified Node has a known, accountable owner standing behind it — not an anonymous npm publisher.</p>
</div>
</li>
<li class="certified-pillar">
Expand All @@ -80,17 +59,6 @@ defineEmits<{ toggle: [] }>()
<p class="certified-pillar--body">FlowFuse stands behind every Certified Node after launch — patching CVEs on our own timeline. Each node is vetted for reliability, security posture, and current documentation before shipping.</p>
</div>
</li>
<li class="certified-pillar">
<span class="certified-pillar--icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="100%" height="100%">
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
</span>
<div>
<h3 class="certified-pillar--title">Free or commercial, same bar</h3>
<p class="certified-pillar--body">Some Certified Nodes are free and open; others target specific enterprise needs. The certification standard is the same.</p>
</div>
</li>
</ul>
</div>
</div>
Expand Down
28 changes: 5 additions & 23 deletions nuxt/pages/integrations/[...id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ onMounted(() => {
<div class="flex items-center gap-3 mb-4 flex-wrap">
<h1 class="mb-0 text-3xl md:text-4xl font-bold break-words">{{ node._id }}</h1>
<div
v-if="node.ffCertified"
v-if="node.tier === 'recommended'"
class="ff-certified-badge flex items-center gap-2 bg-indigo-100 border-2 border-indigo-600 text-indigo-700 px-4 py-2 rounded-lg font-semibold text-sm whitespace-nowrap flex-shrink-0"
>
<IntegrationsCertifiedIcon class="w-5 h-5" />
<span>FlowFuse Certified</span>
<span>FlowFuse Recommended</span>
</div>
</div>
<p class="text-lg text-gray-700 mb-4 w-full max-w-full break-words">{{ node.description }}</p>
Expand Down Expand Up @@ -101,33 +101,15 @@ onMounted(() => {
<div class="container m-auto max-w-6xl px-6 py-8">
<div class="flex flex-col lg:flex-row gap-8 min-w-0">
<div class="flex-grow min-w-0 overflow-hidden">
<div v-if="node.ffCertified" class="mb-8 p-6 bg-indigo-50 border-l-4 border-indigo-600 rounded-r-lg overflow-hidden">
<div v-if="node.tier === 'recommended'" class="mb-8 p-6 bg-indigo-50 border-l-4 border-indigo-600 rounded-r-lg overflow-hidden">
<h2 class="text-indigo-900 font-bold mb-2 flex items-center gap-2 text-lg">
<IntegrationsCertifiedIcon class="w-8 h-8 fill-indigo-900" />
FlowFuse Certified Node
FlowFuse Recommended Node
</h2>
<p class="text-indigo-800 mb-0">
This node has been certified by FlowFuse, ensuring it meets our standards for quality, security, and support.
<a href="https://flowfuse.com/blog/2025/07/certified-nodes-v2/" class="font-semibold underline hover:text-indigo-900" target="_blank" rel="noopener noreferrer">Learn more about certified nodes</a>.
FlowFuse recommends this node as a well-maintained, dependable choice for production Node-RED projects.
</p>
</div>
<div v-else class="mb-8 p-6 bg-blue-50 border-l-4 border-blue-600 rounded-r-lg overflow-hidden">
<h2 class="text-blue-900 font-bold mb-2 flex items-center gap-2 text-lg">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
Get Your Node Certified
</h2>
<p class="text-blue-800 mb-3">Boost your node's credibility and reach by becoming FlowFuse Certified. Certification demonstrates quality, security, and reliability to the Node-RED community.</p>
<div class="flex flex-col sm:flex-row gap-3">
<a href="https://flowfuse.com/blog/2025/07/certified-nodes-v2/#contact-us-to-discuss-your-node-certification" class="inline-flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-semibold transition-colors duration-200 text-sm hover:no-underline" target="_blank" rel="noopener noreferrer">
START CERTIFICATION PROCESS
</a>
<a href="https://flowfuse.com/blog/2025/07/certified-nodes-v2/" class="inline-flex items-center justify-center gap-2 bg-white hover:bg-gray-50 text-blue-700 border-2 border-blue-600 px-4 py-2 rounded-lg font-semibold transition-colors duration-200 text-sm hover:no-underline" target="_blank" rel="noopener noreferrer">
LEARN MORE
</a>
</div>
</div>

<div v-if="hasExamples" class="border-b border-gray-300 mb-6">
<div class="flex gap-8" role="tablist" aria-label="Integration details">
Expand Down
45 changes: 39 additions & 6 deletions nuxt/pages/integrations/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,36 @@ const PAGE_SIZE = 30
const catalogue = shallowRef<CatalogueNode[] | null>(null)
const tierRank = (n: CatalogueNode) => (n.tier === 'certified' ? 0 : n.tier === 'recommended' ? 1 : 2)
onMounted(async () => {
if (route.query.certified === '1') filterCertified.value = true
if (route.query.recommended === '1') filterRecommended.value = true
const raw = await fetchCatalogue()
const enriched: CatalogueNode[] = raw.map(n => ({ ...n, _idLc: n._id.toLowerCase() }))
enriched.sort((a, b) => {
if (a.ffCertified && !b.ffCertified) return -1
if (!a.ffCertified && b.ffCertified) return 1
const ra = tierRank(a)
const rb = tierRank(b)
if (ra !== rb) return ra - rb
return (b.downloads?.week ?? 0) - (a.downloads?.week ?? 0)
})
catalogue.value = enriched
})
const certifiedCount = computed(
() => (catalogue.value ?? []).filter(n => n.ffCertified).length
() => (catalogue.value ?? []).filter(n => n.tier === 'certified').length
)
const generatedIds = computed(() => {
const list = catalogue.value ?? []
const byDownloads = [...list].sort((a, b) => (b.downloads?.week ?? 0) - (a.downloads?.week ?? 0))
const ids = new Set(byDownloads.slice(0, 50).map(n => n._id))
list.forEach(n => { if (n.ffCertified) ids.add(n._id) })
list.forEach(n => { if (n.tier === 'recommended') ids.add(n._id) })
return ids
})
const filterCertified = ref(false)
const filterRecommended = ref(false)
const selectedCategories = ref<Set<string>>(new Set())
const searchText = ref('')
const searchQuery = ref('')
Expand All @@ -58,6 +63,12 @@ function setCertified (next: boolean) {
syncUrl()
}
function setRecommended (next: boolean) {
filterRecommended.value = next
currentPage.value = 0
syncUrl()
}
function toggleCategory (key: string) {
if (selectedCategories.value.has(key)) {
selectedCategories.value.delete(key)
Expand All @@ -75,13 +86,24 @@ function syncUrl () {
} else {
delete query.certified
}
if (filterRecommended.value) {
query.recommended = '1'
} else {
delete query.recommended
}
router.replace({ query })
}
const filtered = computed(() => {
const search = searchQuery.value.toLowerCase()
const tierFilterActive = filterCertified.value || filterRecommended.value
return (catalogue.value ?? []).filter((node) => {
if (filterCertified.value && !node.ffCertified) return false
if (tierFilterActive) {
const matchesTier =
(filterCertified.value && node.tier === 'certified') ||
(filterRecommended.value && node.tier === 'recommended')
if (!matchesTier) return false
}
if (selectedCategories.value.size > 0) {
for (const key of selectedCategories.value) {
if (!node.categories?.includes(key)) return false
Expand Down Expand Up @@ -125,7 +147,7 @@ watch(filtered, () => {
@toggle="toggleCertified"
/>
<div class="container m-auto text-left md:max-w-6xl pt-8 pb-12 w-full ff-full-bg gap-4 flex">
<div class="catalogue-filters w-52 shrink-0 hidden md:block">
<div class="catalogue-filters w-60 shrink-0 hidden md:block">
<h2 class="catalogue-filters--heading">Filters</h2>
<ul>
<li>
Expand All @@ -140,6 +162,17 @@ watch(filtered, () => {
<IntegrationsCertifiedIcon />
</label>
</li>
<li>
<input
id="catalogue-filter-recommended"
type="checkbox"
:checked="filterRecommended"
@change="setRecommended(($event.target as HTMLInputElement).checked)"
/>
<label class="inline-flex gap-1 items-center" for="catalogue-filter-recommended">
FlowFuse Recommended
</label>
</li>
</ul>
<h2 class="catalogue-filters--heading">Categories</h2>
<ul id="catalogue-filter--categories">
Expand Down
2 changes: 2 additions & 0 deletions nuxt/server/utils/integrations-enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ function sortCertifiedThenDownloads (a: Integration, b: Integration): number {
async function enrichNode (entry: IntegrationCatalogEntry): Promise<{ node: Integration, failed: boolean }> {
const node: Integration = { ...entry }

node.tier = entry.ffCertified ? 'recommended' : undefined

if (!node.categories) node.categories = []

node.categories = node.categories.map(c => c.includes('catalogue') ? c : `catalogue_${c}`)
Expand Down
32 changes: 32 additions & 0 deletions nuxt/types/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ export interface IntegrationDownloads {
week: number
}

export type IntegrationTier = 'recommended' | 'certified'

export type CertifiedCollection = 'hub' | 'edge'

export const COLLECTION_LABELS: Record<CertifiedCollection, string> = {
hub: 'Hub',
edge: 'Edge'
}

export interface IntegrationCatalogEntry {
_id: string
name: string
Expand All @@ -20,6 +29,27 @@ export interface IntegrationCatalogEntry {
downloads: IntegrationDownloads
version: string
updatedAt: string
tier?: IntegrationTier
collection?: CertifiedCollection
docsUrl?: string
}

export interface CertifiedCatalogueModule {
id: string
version: string
description: string
updated_at: string
url: string
types: string[]
keywords: string[]
name?: string
categories?: string[]
}

export interface CertifiedCatalogueResponse {
name: string
updated_at: string
modules: CertifiedCatalogueModule[]
}

export interface IntegrationExample {
Expand Down Expand Up @@ -77,3 +107,5 @@ export const INTEGRATION_CATEGORIES: Record<string, string> = {
}

export const INTEGRATIONS_API = 'https://ff-integrations.flowfuse.cloud/api/nodes'
export const CERTIFIED_HUB_API = 'https://ff-certified-nodes.flowfuse.cloud/ff-it.json'
export const CERTIFIED_EDGE_API = 'https://ff-certified-nodes.flowfuse.cloud/ff-ot.json'
Loading