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
14 changes: 8 additions & 6 deletions nuxt/components/ChangelogListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@ const formattedDate = computed(() => new Date(props.entry.date).toLocaleDateStri
</script>

<template>
<li class="w-full my-2 py-6 border-b flex flex-col md:flex-row">
<div class="w-full flex flex-col flex-none md:w-72 md:pr-4">
<li class="w-full my-2 py-6 border-b flex flex-col md:flex-row md:gap-10">
<div class="w-full flex flex-col flex-none md:w-72">
<NuxtLink :to="entry.path" class="flex flex-col group hover:no-underline">
<time class="block text-xs text-gray-500">{{ formattedDate }}</time>
<h2 class="mb-0 text-xl font-medium group-hover:underline">{{ entry.title }}</h2>
<div v-if="authorNames" class="italic text-xs mb-3">
<div v-if="authorNames" class="italic text-xs">
<div class="author">{{ authorNames }}</div>
</div>
<time class="block text-xs text-gray-500 mt-1 mb-3">{{ formattedDate }}</time>
</NuxtLink>
<FeatureTierBadges :plans="plans" />
</div>
<div class="flex-grow pt-4">
<!-- pt-4 only while the columns are stacked; side by side the description should
start on the same line as the title, not below it. -->
<div class="flex-grow pt-4 md:pt-0">
<div class="prose">
<p>{{ entry.description }}</p>
<p class="mt-0">{{ entry.description }}</p>
</div>
</div>
</li>
Expand Down
144 changes: 128 additions & 16 deletions nuxt/components/ChangelogListing.vue
Original file line number Diff line number Diff line change
@@ -1,29 +1,141 @@
<script setup lang="ts">
const props = defineProps<{
page: number
}>()

const { entries, totalPages } = useChangelogList(props.page)
const { entries, visibleGroups, hasMore, showMore, revealRelease } = useChangelogList()

useSeoMeta({
title: 'Changelog',
})

// The Atom feed at /changelog/index.xml has always existed but nothing advertised it,
// so feed readers and browsers could not discover it from the page.
useHead({
link: [{
rel: 'alternate',
type: 'application/atom+xml',
title: 'FlowFuse Changelog',
href: '/changelog/index.xml',
}],
})

// Dots are legal in an id but awkward to select, so "2.33" anchors as "release-2-33".
const anchorId = (release: string) => `release-${release.replace(/\./g, '-')}`

// Infinite scroll: a sentinel below the list, which is what IntersectionObserver is
// actually good at.
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null

// A release anchor shared from this page can name a release that is not rendered yet,
// since the list starts at the newest entries only. Reveal it before the browser is
// asked to scroll, otherwise the link lands at the top of the page.
const route = useRoute()

// The hash this already acted on, so repeat triggers are ignored but a later hash (back
// and forward, or a second link) still resolves. A boolean latch would swallow those.
let resolvedHash: string | null = null

async function revealFromHash () {
if (!import.meta.client) return
// Neither source is the truth on its own. A plain in-page anchor click is handled by the
// browser without going through the router, so route.hash lags behind the address bar.
// On a cold load it is the other way round: the router already carries the hash while
// window.location has not been restored yet, which is when this used to give up.
const id = (window.location.hash || route.hash).replace(/^#/, '')
if (!id || id === resolvedHash) return
// Matched against the releases that exist rather than by reversing anchorId, so a
// hash belonging to anything else on the page is left for the browser to handle.
const release = entries.value.find(e => anchorId(e.release) === id)?.release
if (!release || !revealRelease(release)) return
resolvedHash = id
await nextTick()
// `instant` overrides the site-wide `scroll-behavior: smooth`. Arriving at an old
// release is a jump of tens of thousands of pixels, which is not worth animating.
document.getElementById(id)?.scrollIntoView({ behavior: 'instant' })
}

// The hash and the archive can land in either order: the archive is in the payload on the
// prerendered page but still in flight in dev and on a client-side navigation, and the hash
// changes on its own for back and forward. Retry on whichever moves rather than assuming
// both are ready at mount, since the reveal only works once the entries exist.
watch([entries, () => route.hash], () => { revealFromHash() })

onMounted(() => {
observer = new IntersectionObserver((records) => {
if (records.some(r => r.isIntersecting) && hasMore.value) showMore()
}, { rootMargin: '600px 0px' })
if (sentinel.value) observer.observe(sentinel.value)
revealFromHash()
window.addEventListener('hashchange', revealFromHash)
})

onUnmounted(() => {
observer?.disconnect()
if (import.meta.client) window.removeEventListener('hashchange', revealFromHash)
})
</script>

<template>
<div class="ff-blog container m-auto text-left max-w-4xl pt-8 pb-24 w-full px-6">
<div class="w-full sm:flex justify-between">
<h1 class="mb-0">Changelog</h1>
<div class="flex flex-row max-sm:mt-4 justify-between sm:justify-end gap-4 items-end">
<p class="my-0">Getting all the news on new features we ship</p>
<a href="/changelog/index.xml" class="mb-2 hover:text-blue-800 hover:cursor-pointer" title="View the changelog RSS feed">
<UIcon name="i-heroicons-rss" class="w-5 h-5" />
<div class="ff-blog container m-auto text-left max-w-6xl pt-8 pb-24 w-full px-6">
<div class="w-full sm:flex sm:items-end sm:justify-between gap-4">
<div>
<!-- Sized explicitly: .ff-blog leaves h1 at 16px/400, identical to the subtitle
below it, so the heading does not read as one. -->
<h1 class="mb-0 text-2xl font-medium">What's new</h1>
<p class="my-0 text-gray-500">Every feature, improvement and fix we ship, newest first.</p>
</div>
<!-- Search runs on the same Algolia index as the blog, docs and handbook, filtered
to this section, so it reaches entry bodies and tolerates typos. -->
<div class="flex flex-row items-center gap-3 max-sm:mt-4 sm:w-80">
<a
href="/changelog/index.xml"
class="inline-flex items-center gap-1.5 whitespace-nowrap text-sm text-gray-500 hover:text-indigo-600"
title="Subscribe to the changelog feed"
>
<UIcon name="i-heroicons-rss" class="w-4 h-4" />
<span>RSS</span>
</a>
<AlgoliaSearch index-filter="category:changelog" placeholder="Search the changelog" source-id="changelog" />
</div>
</div>
<ul class="flex flex-wrap border-t">
<ChangelogListItem v-for="entry in entries" :key="entry.path" :entry="entry" />
</ul>
<Pagination base-path="/changelog" :page="page" :total-pages="totalPages" />

<div class="mt-8">
<!-- The release label lives in a column beside its own entries rather than in a
separate rail, so it lines up with the entries it belongs to, and sticks while
you read through them. Two columns per release, not one list plus one nav. -->
<section
v-for="group in visibleGroups"
:id="anchorId(group.release)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While going through the deploy preview, I was expecting these to be interactive, actual shareable anchors. Was it deliberate to make them static? Is it related to the infinite scroll choice?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not deliberate. The ids were there but nothing linked to them. The sticky label and the mobile heading are both anchors now.

It was related to the scroll choice though. A link like /changelog/#release-2-20 had nothing to scroll to on arrival, since only the newest entries are rendered. The listing now renders through that release first, then scrolls to it. Tested back to #release-1-12, the oldest one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this on the deploy preview with /changelog/#release-1-12, loading it fresh doesn't scroll anywhere, it stays at the top (newest release).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right, cold load was broken. My test only covered a page that was already loaded, which is why it passed.

At hydration window.location.hash is still empty. Nuxt keeps the hash on the route and restores the address bar only once the initial navigation settles, so revealFromHash gave up before it found the release, and nothing changed afterwards to make it retry.

One line: read whichever of the two is populated.

Verified on a local production build. Cold #release-1-12 and #release-2-20 both render through and land on the release, no hash is unchanged, and label clicks, in-page hash changes and browser back all still work.

Could you re-test on the preview once it redeploys?

:key="group.release"
class="flex scroll-mt-24"
>
<!-- pt-7 so the label lines up with the first entry's title rather than sitting
above it: each entry carries `my-2 py-6` before its title, which this column
would otherwise not share. The spine border sits on the inner element so it
starts level with the label, rather than leaving a stub hanging above it. -->
<div class="hidden lg:block w-36 shrink-0 pt-7">
<div class="h-full border-l border-gray-200">
<!-- top-16 (64px), not further down: the site header (.ff-header) ends at
56px, so anything lower leaves a gap of bare spine above the label.
The label is the release's own anchor, so it can be linked to directly. -->
<a
:href="`#${anchorId(group.release)}`"
class="sticky top-16 block -ml-px border-l-2 border-indigo-600 pl-5 py-1 font-medium leading-tight text-indigo-600 hover:underline"
>Release {{ group.release }}</a>
</div>
</div>

<div class="flex-1 min-w-0 lg:pl-12">
<!-- The label column is hidden below lg, so the release still needs naming. -->
<h2 class="lg:hidden mt-6 mb-0 text-base font-medium">
<a :href="`#${anchorId(group.release)}`" class="text-indigo-600 hover:underline">Release {{ group.release }}</a>
</h2>
<ul class="flex flex-wrap">
<ChangelogListItem v-for="entry in group.entries" :key="entry.path" :entry="entry" />
</ul>
</div>
</section>

<div ref="sentinel" aria-hidden="true" class="h-px" />
<p v-if="hasMore" class="py-6 text-center text-sm text-gray-400">Loading more…</p>
</div>
</div>
</template>
66 changes: 58 additions & 8 deletions nuxt/composables/useChangelogList.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,67 @@
export const CHANGELOG_PAGE_SIZE = 19
// How many entries are in the markup before any scrolling. The whole archive is
// fetched in one query either way (it always was - the old paginated page fetched
// everything and sliced it), so this only governs how much is rendered up front.
export const CHANGELOG_INITIAL_VISIBLE = 20
export const CHANGELOG_VISIBLE_STEP = 20

export function useChangelogList(pageNumber: number) {
export interface ChangelogEntry {
path: string
title: string
description?: string
date: string
release: string
authors?: string[]
}

export interface ChangelogReleaseGroup {
release: string
entries: ChangelogEntry[]
}

export function useChangelogList () {
const { data: allEntries } = useAsyncData(
'changelog-all',
() => queryCollection('changelog').order('date', 'DESC').all()
)

const totalPages = computed(() => Math.max(1, Math.ceil((allEntries.value || []).length / CHANGELOG_PAGE_SIZE)))
const visibleCount = ref(CHANGELOG_INITIAL_VISIBLE)

const entries = computed(() => (allEntries.value || []) as unknown as ChangelogEntry[])

const visible = computed(() => entries.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < entries.value.length)

function groupByRelease (list: ChangelogEntry[]): ChangelogReleaseGroup[] {
const groups: ChangelogReleaseGroup[] = []
for (const entry of list) {
const last = groups.at(-1)
// The list is already date-ordered and releases ship in date order, so
// consecutive runs are exactly the groups.
if (last && last.release === entry.release) last.entries.push(entry)
else groups.push({ release: entry.release, entries: [entry] })
}
return groups
}

/** Groups actually rendered right now. */
const visibleGroups = computed(() => groupByRelease(visible.value))

function showMore () {
visibleCount.value = Math.min(visibleCount.value + CHANGELOG_VISIBLE_STEP, entries.value.length)
}

const entries = computed(() => {
const start = (pageNumber - 1) * CHANGELOG_PAGE_SIZE
return (allEntries.value || []).slice(start, start + CHANGELOG_PAGE_SIZE)
})
/**
* Renders through the end of `release` so that /changelog/#release-2-20 resolves on
* arrival: only the newest entries are in the markup, so an older release's anchor
* does not exist until its entries are rendered. Returns false for an unknown release.
*/
function revealRelease (release: string): boolean {
// Releases ship in date order and the list is date-ordered, so the run is contiguous.
const last = entries.value.findLastIndex(e => e.release === release)
if (last < 0) return false
visibleCount.value = Math.max(visibleCount.value, last + 1)
return true
}

return { entries, totalPages }
return { entries, visibleGroups, hasMore, showMore, revealRelease }
}
5 changes: 5 additions & 0 deletions nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ export default defineContentConfig({
authors: z.array(z.string()).optional(),
issues: z.array(z.string()).optional(),
metaTitle: z.string().optional(),
// The release an entry ships in, e.g. "2.33". Quoted in frontmatter so
// 2.30 does not parse as the number 2.3. Must be declared here or
// @nuxt/content strips it from the entry, which is what silently
// happened to `tags` on every changelog post.
release: z.string().regex(/^\d+\.\d+$/),
})
}),
// Source files stay at src/blog/ (11ty's historical location) rather than
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ Every post requires the following fields at the top of the file:
|---|---|
| `title` | The name of the feature or change. Title case. Keep it short. |
| `description` | One sentence summarising the change. This appears in link previews and search results, so it should make sense without any surrounding context. |
| `date` | The date and time the feature shipped, in `YYYY-MM-DD HH:mm:ss` format (e.g., `2026-03-24 17:00:00`). The timestamp ensures correct sorting when you add multiple features on the same day. |
| `date` | The date and time the feature shipped, in `YYYY-MM-DD HH:mm:ss` format (e.g., `2026-03-24 17:00:00`). Zero-pad the month and day. The timestamp ensures correct sorting when you add multiple features on the same day. |
| `release` | The release this ships in for Self Hosted users, quoted, as `MAJOR.MINOR` (e.g. `"2.33"`). This is the same version you name in the availability note, and the changelog page groups entries by it. Your change is live on FlowFuse Cloud already, so this is the next release that has not shipped yet: take the version from the current [milestone](https://github.com/FlowFuse/flowfuse/milestones). Quote it, or `2.30` is read as the number `2.3`. |
| `authors` | Your handle from `src/_data/team`. Leave it out if there is no single clear author. |
| `tags` | Always include `changelog`. |
| `issues` | A list of related GitHub issue URLs. Link any issues that tracked the work this post announces. |
Expand All @@ -59,6 +60,7 @@ You can start a new changelog post with the following template:
title: Short Feature Title
description: One sentence summarising the change, written for a user, not an engineer.
date: YYYY-MM-DD HH:mm:ss
release: "X.Y"
authors: your-handle
tags:
- changelog
Expand Down
Loading
Loading