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
5 changes: 1 addition & 4 deletions docs/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineAppConfig({
sections: ['adapters', 'frameworks'],
},
{ label: 'Add-ons', sections: ['add-ons'], link: 'section' as const },
{ label: 'Ecosystem', sections: ['ecosystem'], link: 'section' as const },
{ label: 'Reference', sections: ['references'], link: 'section' as const },
{ label: 'Errors', sections: ['errors'], link: 'section' as const },
{ label: 'Blog', sections: ['posts'], link: 'section' as const },
Expand Down Expand Up @@ -143,10 +144,6 @@ export default defineAppConfig({
'/guide/build-your-own-hub-ui',
],
},
{
title: 'Ecosystem',
items: ['/guide/built-with'],
},
],
},
ogImage: {
Expand Down
25 changes: 25 additions & 0 deletions docs/app/components/global/EcosystemCta.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<template>
<div class="not-prose mt-14 flex flex-col gap-4 rounded-lg border border-default bg-muted p-6 md:flex-row md:items-center md:justify-between">
<div>
<p
v-if="$slots.title"
class="text-base font-semibold text-highlighted"
>
<slot name="title" unwrap="p" />
</p>
<p
v-if="$slots.description"
class="mt-1 text-sm text-muted"
>
<slot name="description" unwrap="p" />
</p>
</div>

<div
v-if="$slots.ctas"
class="flex shrink-0 items-center gap-3"
>
<slot name="ctas" />
</div>
</div>
</template>
269 changes: 269 additions & 0 deletions docs/app/components/global/EcosystemList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'

type Category = 'All' | 'Devframes' | 'Hosts' | 'Services'

interface EcosystemPackage {
name: string
description: string
/** GitHub login: powers the avatar. */
author: string
category: Exclude<Category, 'All'>
/** `owner/name`: the row link when `url` is absent. */
repo?: string
/** Docs path or external page; wins over `repo`. */
url?: string
official?: boolean
icon?: string
/** Upstream logo under `public/`; wins over `icon`. */
image?: string
/** Tints the icon and its hover glow. Omitted entries get a neutral tile. */
color?: string
}

const props = defineProps<{ packages: EcosystemPackage[] }>()

const categories: Category[] = ['All', 'Devframes', 'Hosts', 'Services']

const route = useRoute()
const router = useRouter()

const query = ref('')
const officialOnly = ref(false)
const category = ref<Category>('All')

// Content is ISR-cached, so the query string cannot drive the first render.
onMounted(() => {
const q = route.query.q
if (typeof q === 'string')
query.value = q
if (route.query.official === '1')
officialOnly.value = true
const cat = route.query.category
if (typeof cat === 'string') {
const match = categories.find(c => c.toLowerCase() === cat)
if (match)
category.value = match
}
})

const debouncedQuery = refDebounced(query, 200)

watch([debouncedQuery, officialOnly, category], ([q, official, cat]) => {
const next = { ...route.query }
if (q)
next.q = q
else delete next.q
if (official)
next.official = '1'
else delete next.official
if (cat !== 'All')
next.category = cat.toLowerCase()
else delete next.category
router.replace({ query: next })
})

const searchPool = computed(() => {
const q = debouncedQuery.value.trim().toLowerCase()
return props.packages.filter((pkg) => {
if (officialOnly.value && !pkg.official)
return false
if (!q)
return true
return (
pkg.name.toLowerCase().includes(q)
|| pkg.description.toLowerCase().includes(q)
|| pkg.author.toLowerCase().includes(q)
)
})
})

const counts = computed(() => {
const map = new Map<Category, number>([['All', searchPool.value.length]])
for (const cat of categories.slice(1))
map.set(cat, searchPool.value.filter(pkg => pkg.category === cat).length)
return map
})

const visible = computed(() => {
const pool = category.value === 'All'
? searchPool.value
: searchPool.value.filter(pkg => pkg.category === category.value)
// Community entries first: the official ones already have their own docs pages.
return [...pool].sort((a, b) => {
if (!!a.official !== !!b.official)
return a.official ? 1 : -1
return a.name.localeCompare(b.name)
})
})

function href(pkg: EcosystemPackage) {
return pkg.url ?? (pkg.repo ? `https://github.com/${pkg.repo}` : undefined)
}

function isExternal(pkg: EcosystemPackage) {
return !href(pkg)?.startsWith('/')
}

function monogram(name: string) {
const cleaned = name.replace(/^@[^/]+\//, '').replace(/^(?:plugin|service)-/, '')
return (cleaned || name).slice(0, 2).toUpperCase()
}
</script>

<template>
<div class="not-prose">
<div class="flex gap-3">
<UInput
v-model="query"
icon="i-lucide-search"
size="lg"
placeholder="Search packages"
class="flex-1"
:ui="{ base: 'rounded-lg' }"
/>
<button
type="button"
role="switch"
:aria-checked="officialOnly"
class="flex shrink-0 items-center gap-2 rounded-lg border border-default px-3 text-sm font-medium text-toned transition-colors hover:text-highlighted"
@click="officialOnly = !officialOnly"
>
<span
class="flex size-3.5 items-center justify-center rounded-xs border border-default transition-colors"
:class="officialOnly ? 'border-inverted bg-inverted' : 'bg-default'"
>
<UIcon
v-if="officialOnly"
name="i-lucide-check"
class="size-2.5 text-inverted"
/>
</span>
Official only
</button>
</div>

<div class="mt-4 flex flex-wrap gap-2">
<button
v-for="cat in categories"
:key="cat"
type="button"
:aria-pressed="category === cat"
class="flex h-8 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors"
:class="category === cat ? 'bg-inverted text-inverted' : 'border border-default text-toned hover:border-accented'"
@click="category = cat"
>
{{ cat }}
<span
class="font-mono text-xs tabular-nums"
:class="category === cat ? 'text-inverted/60' : 'text-dimmed'"
>
{{ counts.get(cat) }}
</span>
</button>
</div>

<UCard
variant="outline"
class="mt-6"
:ui="{ body: 'p-0 sm:p-0 divide-y divide-default' }"
>
<NuxtLink
v-for="pkg in visible"
:key="pkg.name"
:to="href(pkg)"
:target="isExternal(pkg) ? '_blank' : undefined"
:class="pkg.color ? 'eco-row' : 'hover:bg-muted'"
:style="pkg.color ? { '--eco-color': pkg.color } : undefined"
class="grid grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-x-4 px-4 py-3.5 transition-colors md:grid-cols-[2.25rem_minmax(0,1fr)_6rem_1.25rem]"
>
<div
class="eco-tile flex size-9 shrink-0 items-center justify-center rounded-lg border border-default bg-muted"
:style="pkg.color ? { color: pkg.color } : undefined"
>
<img
v-if="pkg.image"
:src="pkg.image"
alt=""
width="20"
height="20"
class="size-5"
>
<UIcon
v-else-if="pkg.icon"
:name="pkg.icon"
class="size-5"
:class="!pkg.color && 'text-toned'"
/>
<span
v-else
class="font-mono text-xs font-medium"
:class="!pkg.color && 'text-toned'"
>{{ monogram(pkg.name) }}</span>
</div>

<div class="min-w-0">
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span class="text-sm font-medium text-highlighted">{{ pkg.name }}</span>
<span
v-if="pkg.official"
class="rounded-xs bg-primary-50 px-1.5 py-px text-xs font-medium text-primary-700 dark:bg-primary-900/15 dark:text-primary-400"
>
Official
</span>
<span class="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted">
<img
:src="`https://github.com/${pkg.author}.png?size=64`"
:alt="pkg.author"
width="16"
height="16"
loading="lazy"
class="size-4 shrink-0 rounded-full"
>
<span class="truncate">{{ pkg.author }}</span>
</span>
</div>
<p class="mt-0.5 truncate text-sm text-muted">
{{ pkg.description }}
</p>
</div>

<span class="justify-self-end rounded-md border border-default px-2 py-0.5 font-mono text-xs text-muted">
{{ pkg.category }}
</span>

<UIcon
:name="isExternal(pkg) ? 'i-lucide-arrow-up-right' : 'i-lucide-arrow-right'"
class="hidden size-4 text-dimmed md:block"
/>
</NuxtLink>

<p
v-if="!visible.length"
class="py-10 text-center text-sm text-muted"
>
No packages match.
</p>
</UCard>
</div>
</template>

<style scoped>
.eco-tile {
transition:
background-color 0.2s,
border-color 0.2s,
box-shadow 0.2s;
}

.eco-row:hover {
background-color: color-mix(in oklab, var(--eco-color) 6%, transparent);
}

.eco-row:hover .eco-tile {
border-color: color-mix(in oklab, var(--eco-color) 45%, var(--ui-border));
box-shadow:
0 0 0 3px color-mix(in oklab, var(--eco-color) 10%, transparent),
0 6px 16px -8px color-mix(in oklab, var(--eco-color) 35%, transparent);
}
</style>
Loading
Loading