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
147 changes: 127 additions & 20 deletions nuxt/components/content/FfCommand.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,79 @@ const props = withDefaults(defineProps<{
// the inline button leaves too little room for the address to stay on one
// line, and where the button needs to match the other CTAs beside it.
stacked?: boolean
}>(), { event: undefined, position: undefined, stacked: false })
// Offers a field for a self-hosted platform address, which replaces the host in
// the command shown and copied. For addresses that are only the Cloud one by
// default: the docs say "substitute your own platform address" in prose, and a
// self-hosted reader otherwise copies an address that is not theirs.
hostSwap?: boolean
}>(), { event: undefined, position: undefined, stacked: false, hostSwap: false })

const capture = useCapture()
const copied = ref(false)
let resetTimer: ReturnType<typeof setTimeout> | undefined

// Shared by every FfCommand on the page rather than held per instance. /ai renders
// one per agent tab, so per-instance state would mean typing the address again on
// each tab, which is the friction this is here to remove.
//
// It holds what the reader typed, untouched, and the host is derived from it.
// Normalising the field itself on each keystroke does not work: the first slash of
// "https://" reads as the start of a path the moment it lands, so it is stripped
// again and the scheme can never complete.
const typed = useState('ff-command-host', () => '')
const editing = useState('ff-command-host-editing', () => false)

const STORAGE_KEY = 'ff-command-host'

// Takes what people actually paste: a bare domain, a full URL, a trailing path.
function normaliseHost (value: string) {
return value.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[/?#].*$/, '')
}

const host = computed(() => normaliseHost(typed.value))

const shownCommand = computed(() => {
if (!props.hostSwap || !host.value) return props.command
return props.command.replace(/^(https?:\/\/)[^/\s]+/i, `$1${host.value}`)
})

function remember () {
try {
if (host.value) localStorage.setItem(STORAGE_KEY, host.value)
else localStorage.removeItem(STORAGE_KEY)
} catch {
// Storage can be unavailable (private mode, blocked cookies). The field
// still works for this visit; it just will not be remembered.
}
}

function onHostInput (event: Event) {
typed.value = (event.target as HTMLInputElement).value
remember()
}

function useCloud () {
typed.value = ''
remember()
editing.value = false
}

// Read after hydration, not during setup: the prerendered HTML carries the default
// address, so reading storage any earlier would make the server and client markup
// disagree.
onMounted(() => {
if (!props.hostSwap || typed.value) return
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
typed.value = stored
editing.value = true
}
} catch {
// As above.
}
})

async function writeToClipboard (text: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
Expand All @@ -42,11 +109,18 @@ async function writeToClipboard (text: string) {

async function copy () {
try {
await writeToClipboard(props.command)
await writeToClipboard(shownCommand.value)
} catch {
return
}
if (props.event) capture(props.event, props.position ? { position: props.position } : undefined)
if (props.event) {
const payload: Record<string, unknown> = {}
if (props.position) payload.position = props.position
// Whether the reader copied their own address or ours, which is the one
// thing this control can tell us that the copy count cannot.
if (props.hostSwap) payload.self_hosted = !!host.value
capture(props.event, Object.keys(payload).length ? payload : undefined)
}
copied.value = true
clearTimeout(resetTimer)
resetTimer = setTimeout(() => { copied.value = false }, 2000)
Expand All @@ -56,22 +130,55 @@ onUnmounted(() => clearTimeout(resetTimer))
</script>

<template>
<div class="ff-command" :class="{ 'ff-command--stacked': stacked }">
<code class="ff-command__text">{{ command }}</code>
<!-- Stacked uses the site's own button classes rather than the chip styling,
so it is the same object as the CTAs it sits beside. -->
<button
v-if="stacked"
type="button"
class="ff-btn ff-btn--primary flex w-full uppercase"
@click="copy"
>{{ copied ? 'Copied' : 'Copy' }}</button>
<button
v-else
type="button"
class="ff-command__copy"
:class="{ 'ff-command__copy--done': copied }"
@click="copy"
>{{ copied ? 'Copied' : 'Copy' }}</button>
<!-- The wrapper exists so the address field can sit above the block: the block is
the thing you copy, and the field changes what that is. Above rather than below
so the copy button stays the last element, level with the CTAs in the columns
beside it on /ai. -->
<div>
<div v-if="hostSwap" class="ff-command__host">
<button
v-if="!editing"
type="button"
class="ff-command__host-toggle"
@click="editing = true"
>Self-hosted? Use your own address</button>
<div v-else class="ff-command__host-field">
<input
:value="typed"
type="text"
inputmode="url"
autocomplete="off"
spellcheck="false"
class="ff-command__host-input"
placeholder="flowfuse.example.com"
aria-label="Your FlowFuse address"
@input="onHostInput"
>
<button
type="button"
class="ff-command__host-reset"
@click="useCloud"
>Use FlowFuse Cloud</button>
</div>
</div>

<div class="ff-command" :class="{ 'ff-command--stacked': stacked }">
<code class="ff-command__text">{{ shownCommand }}</code>
<!-- Stacked uses the site's own button classes rather than the chip styling,
so it is the same object as the CTAs it sits beside. -->
<button
v-if="stacked"
type="button"
class="ff-btn ff-btn--primary flex w-full uppercase"
@click="copy"
>{{ copied ? 'Copied' : 'Copy' }}</button>
<button
v-else
type="button"
class="ff-command__copy"
:class="{ 'ff-command__copy--done': copied }"
@click="copy"
>{{ copied ? 'Copied' : 'Copy' }}</button>
</div>
</div>
</template>
5 changes: 4 additions & 1 deletion nuxt/pages/ai/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ useSchemaOrg([
...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })),
])

// The Cloud address. Self-hosted platforms answer on their own domain, so the block
// takes one (host-swap) rather than telling the reader in prose to edit what they
// have just copied.
const ENDPOINT = 'https://app.flowfuse.com/mcp'

const STEP1 = {
Expand Down Expand Up @@ -326,7 +329,7 @@ onUnmounted(() => {
<CtaSignUp variant="primary" position="ai-tab-expert" class="w-full" />
</div>
<div v-else class="mt-auto pt-5">
<FfCommand :command="ENDPOINT" event="cta-copy-mcp-endpoint" :position="client.id" stacked />
<FfCommand :command="ENDPOINT" event="cta-copy-mcp-endpoint" :position="client.id" stacked host-swap />
</div>
</div>

Expand Down
43 changes: 43 additions & 0 deletions src/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,49 @@ dl.message-properties > dd {
@apply rounded-lg p-4;
}

/* Self-hosted address control. Under the block, not inside it: the block is what you
copy, and this changes what that is. Closed by default as a single line of link
text, because most readers are on Cloud and the address is already right for them. */
.ff-command__host {
margin-bottom: 0.5rem;
}

.ff-command__host-toggle,
.ff-command__host-reset {
font-size: 0.75rem;
line-height: 1.5;
font-weight: 500;
text-decoration: underline;
color: var(--color-indigo-600);
}

.ff-command__host-toggle:hover,
.ff-command__host-reset:hover {
color: var(--color-indigo-800);
}

.ff-command__host-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}

.ff-command__host-input {
flex: 1 1 12rem;
min-width: 0;
font-size: 0.875rem;
color: var(--color-gray-900);
border: 1px solid var(--color-gray-300);
@apply rounded px-3 py-2;
}

.ff-command__host-input:focus {
outline: none;
border-color: var(--color-indigo-500);
box-shadow: 0 0 0 1px var(--color-indigo-500);
}

/*
Event banner
*/
Expand Down