Skip to content

Commit 6b62eac

Browse files
authored
Merge pull request #5641 from FlowFuse/opcua-landing
Add OPC UA landing page and interlink existing OPC UA content
2 parents e5fe67f + 05dc5c5 commit 6b62eac

23 files changed

Lines changed: 807 additions & 107 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<script setup lang="ts" generic="T extends { id: string; label: string }">
2+
// Sticky left nav + stacked, always-visible content sections, with the nav
3+
// link highlighted for whichever section the reader has scrolled to. Shared by
4+
// /product ("Find the product for how you work") and /integrations/opcua
5+
// ("What is OPC UA").
6+
//
7+
// Deliberately not @nuxt/ui's UContentToc/useScrollspy: that tracks active
8+
// headings via IntersectionObserver on the heading elements themselves, which
9+
// works for dense docs TOCs but breaks here - when the gap between sections
10+
// (padding + divider) is wider than the heading, scrolling through that gap
11+
// leaves no heading intersecting and the indicator sticks on the previous
12+
// section. Walking sections in DOM order and taking whichever one's top has
13+
// crossed the viewport's vertical midpoint (overwriting on each match, so the
14+
// deepest/furthest-scrolled section wins) doesn't have that gap.
15+
//
16+
// Generic over T (not just { id, label }) so a scoped slot can read whatever
17+
// extra fields a caller's item carries (e.g. /product's heading/image/slug)
18+
// without a cast.
19+
const props = withDefaults(defineProps<{
20+
items: T[]
21+
ariaLabel?: string
22+
gapClass?: string
23+
dividerClass?: string
24+
// Viewport width at which the nav switches from stacked-above-content to
25+
// beside it. 'lg' is /product's original width (its item content is
26+
// wider - text + image per step); opcua's items are text-only and have
27+
// room to go side-by-side sooner, at 'md'. Literal class strings below
28+
// (not a template-interpolated breakpoint) since Tailwind's build-time
29+
// scanner only picks up classes it can see written out in full.
30+
breakpoint?: 'md' | 'lg'
31+
}>(), {
32+
ariaLabel: undefined,
33+
gapClass: 'gap-16',
34+
dividerClass: 'pt-16 border-t border-gray-200',
35+
breakpoint: 'lg',
36+
})
37+
38+
const rowClass = computed(() => props.breakpoint === 'md' ? 'flex flex-col md:flex-row gap-10' : 'flex flex-col lg:flex-row gap-10')
39+
const navClass = computed(() => props.breakpoint === 'md' ? 'hidden md:block md:w-44 shrink-0' : 'hidden lg:block lg:w-44 shrink-0')
40+
41+
const active = ref(props.items[0]?.id ?? '')
42+
const stepRefs = ref<Record<string, HTMLElement | null>>({})
43+
44+
function updateActive () {
45+
const steps = Object.entries(stepRefs.value).filter((entry): entry is [string, HTMLElement] => !!entry[1])
46+
if (!steps.length) return
47+
const mid = window.innerHeight / 2
48+
let current = steps[0][0]
49+
for (const [id, el] of steps) {
50+
if (el.getBoundingClientRect().top <= mid) current = id
51+
}
52+
active.value = current
53+
}
54+
55+
let ticking = false
56+
function onScroll () {
57+
if (!ticking) { ticking = true; requestAnimationFrame(() => { ticking = false; updateActive() }) }
58+
}
59+
60+
onMounted(() => {
61+
window.addEventListener('scroll', onScroll, { passive: true })
62+
window.addEventListener('resize', onScroll, { passive: true })
63+
updateActive()
64+
})
65+
66+
onUnmounted(() => {
67+
window.removeEventListener('scroll', onScroll)
68+
window.removeEventListener('resize', onScroll)
69+
})
70+
</script>
71+
72+
<template>
73+
<div :class="rowClass">
74+
<nav :class="navClass" :aria-label="ariaLabel">
75+
<ul class="sticky top-24 flex flex-col border-l border-gray-200">
76+
<li v-for="item in items" :key="item.id">
77+
<a
78+
:href="`#${item.id}`"
79+
class="block py-[0.6rem] pl-5 -ml-px border-l-2 font-medium transition-colors duration-200"
80+
:class="active === item.id ? 'text-indigo-600 border-indigo-600' : 'border-transparent text-gray-500 hover:text-indigo-600'"
81+
>{{ item.label }}</a>
82+
</li>
83+
</ul>
84+
</nav>
85+
86+
<div class="flex-1 min-w-0 flex flex-col" :class="gapClass">
87+
<div
88+
v-for="(item, index) in items"
89+
:id="item.id"
90+
:key="item.id"
91+
:ref="(el) => { stepRefs[item.id] = el as HTMLElement | null }"
92+
class="scroll-mt-24"
93+
:class="index > 0 ? dividerClass : ''"
94+
>
95+
<slot :name="item.id" :item="item" :index="index" />
96+
</div>
97+
</div>
98+
</div>
99+
</template>

nuxt/components/cta/CtaButton.vue

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,16 @@ const VARIANT_MAP: Record<string, { color: string, variant: string, hover: strin
9090
ghost: { color: 'primary', variant: 'ghost', hover: '' },
9191
}
9292
93-
// UButton's own "ghost" variant compoundVariants add a faint hover background
94-
// (hover:bg-{color}/10) - that's Nuxt UI's default ghost affordance, but a
95-
// ghost CTA here should have no background at any point, hover included, so
96-
// every entry explicitly cancels it with hover:bg-transparent.
93+
// UButton's own "ghost" variant compoundVariants add a faint hover AND active
94+
// background (hover:bg-{color}/10, active:bg-{color}/10) - that's Nuxt UI's
95+
// default ghost affordance, but a ghost CTA here should have no background at
96+
// any point, hover and click both, so every entry cancels both explicitly.
97+
// Missing active:bg-transparent here used to leave a visible fill flash on
98+
// click/tap even though hover looked fine.
9799
const GHOST_COLOR_CLASSES: Record<string, string> = {
98-
primary: 'text-primary hover:text-primary/75 hover:bg-transparent',
99-
highlight: 'text-highlight hover:text-highlight/75 hover:bg-transparent',
100-
white: 'text-white hover:text-gray-200 hover:bg-transparent',
100+
primary: 'text-primary hover:text-primary/75 hover:bg-transparent active:bg-transparent',
101+
highlight: 'text-highlight hover:text-highlight/75 hover:bg-transparent active:bg-transparent',
102+
white: 'text-white hover:text-gray-200 hover:bg-transparent active:bg-transparent',
101103
}
102104
103105
const uiVariant = computed(() => VARIANT_MAP[props.variant])

nuxt/lib/custom-cta-destinations.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ export const CUSTOM_CTA_DESTINATIONS = {
6161
agentSetupClientOpen: {
6262
event: 'cta-ai-open-client',
6363
},
64+
// Event name preserved from the hand-written capture() call this replaces
65+
// on the OPC UA integration page (see the note above on migrated events).
66+
opcuaCertifiedNodeDocs: {
67+
href: '/node-red/flowfuse/edge/opcua/',
68+
event: 'cta-certified-opcua-node',
69+
},
6470
} as const
6571

6672
// Self-check, run once when this module loads (so at build/dev-start time,

nuxt/nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ export default defineNuxtConfig({
374374
'/terms',
375375
'/privacy-policy',
376376
'/integrations',
377+
'/integrations/opcua',
377378
'/pricing',
378379
'/product',
379380
// /ai is only linked from 11ty-generated HTML (nav, homepage), which the

0 commit comments

Comments
 (0)