Skip to content

Commit eecf966

Browse files
authored
docs: add an ecosystem page for devtools, hosts, services and kits built on Devframe (#427)
1 parent 72d917d commit eecf966

23 files changed

Lines changed: 796 additions & 69 deletions

‎docs/app/app.config.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default defineAppConfig({
2121
sections: ['adapters', 'frameworks'],
2222
},
2323
{ label: 'Add-ons', sections: ['add-ons'], link: 'section' as const },
24+
{ label: 'Ecosystem', sections: ['ecosystem'], link: 'section' as const },
2425
{ label: 'Reference', sections: ['references'], link: 'section' as const },
2526
{ label: 'Errors', sections: ['errors'], link: 'section' as const },
2627
{ label: 'Blog', sections: ['posts'], link: 'section' as const },
@@ -143,10 +144,6 @@ export default defineAppConfig({
143144
'/guide/build-your-own-hub-ui',
144145
],
145146
},
146-
{
147-
title: 'Ecosystem',
148-
items: ['/guide/built-with'],
149-
},
150147
],
151148
},
152149
ogImage: {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<template>
2+
<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">
3+
<div>
4+
<p
5+
v-if="$slots.title"
6+
class="text-base font-semibold text-highlighted"
7+
>
8+
<slot name="title" unwrap="p" />
9+
</p>
10+
<p
11+
v-if="$slots.description"
12+
class="mt-1 text-sm text-muted"
13+
>
14+
<slot name="description" unwrap="p" />
15+
</p>
16+
</div>
17+
18+
<div
19+
v-if="$slots.ctas"
20+
class="flex shrink-0 items-center gap-3"
21+
>
22+
<slot name="ctas" />
23+
</div>
24+
</div>
25+
</template>
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
<script setup lang="ts">
2+
import { refDebounced } from '@vueuse/core'
3+
4+
type Category = 'All' | 'Devframes' | 'Hosts' | 'Services'
5+
6+
interface EcosystemPackage {
7+
name: string
8+
description: string
9+
/** GitHub login: powers the avatar. */
10+
author: string
11+
category: Exclude<Category, 'All'>
12+
/** `owner/name`: the row link when `url` is absent. */
13+
repo?: string
14+
/** Docs path or external page; wins over `repo`. */
15+
url?: string
16+
official?: boolean
17+
icon?: string
18+
/** Upstream logo under `public/`; wins over `icon`. */
19+
image?: string
20+
/** Tints the icon and its hover glow. Omitted entries get a neutral tile. */
21+
color?: string
22+
}
23+
24+
const props = defineProps<{ packages: EcosystemPackage[] }>()
25+
26+
const categories: Category[] = ['All', 'Devframes', 'Hosts', 'Services']
27+
28+
const route = useRoute()
29+
const router = useRouter()
30+
31+
const query = ref('')
32+
const officialOnly = ref(false)
33+
const category = ref<Category>('All')
34+
35+
// Content is ISR-cached, so the query string cannot drive the first render.
36+
onMounted(() => {
37+
const q = route.query.q
38+
if (typeof q === 'string')
39+
query.value = q
40+
if (route.query.official === '1')
41+
officialOnly.value = true
42+
const cat = route.query.category
43+
if (typeof cat === 'string') {
44+
const match = categories.find(c => c.toLowerCase() === cat)
45+
if (match)
46+
category.value = match
47+
}
48+
})
49+
50+
const debouncedQuery = refDebounced(query, 200)
51+
52+
watch([debouncedQuery, officialOnly, category], ([q, official, cat]) => {
53+
const next = { ...route.query }
54+
if (q)
55+
next.q = q
56+
else delete next.q
57+
if (official)
58+
next.official = '1'
59+
else delete next.official
60+
if (cat !== 'All')
61+
next.category = cat.toLowerCase()
62+
else delete next.category
63+
router.replace({ query: next })
64+
})
65+
66+
const searchPool = computed(() => {
67+
const q = debouncedQuery.value.trim().toLowerCase()
68+
return props.packages.filter((pkg) => {
69+
if (officialOnly.value && !pkg.official)
70+
return false
71+
if (!q)
72+
return true
73+
return (
74+
pkg.name.toLowerCase().includes(q)
75+
|| pkg.description.toLowerCase().includes(q)
76+
|| pkg.author.toLowerCase().includes(q)
77+
)
78+
})
79+
})
80+
81+
const counts = computed(() => {
82+
const map = new Map<Category, number>([['All', searchPool.value.length]])
83+
for (const cat of categories.slice(1))
84+
map.set(cat, searchPool.value.filter(pkg => pkg.category === cat).length)
85+
return map
86+
})
87+
88+
const visible = computed(() => {
89+
const pool = category.value === 'All'
90+
? searchPool.value
91+
: searchPool.value.filter(pkg => pkg.category === category.value)
92+
// Community entries first: the official ones already have their own docs pages.
93+
return [...pool].sort((a, b) => {
94+
if (!!a.official !== !!b.official)
95+
return a.official ? 1 : -1
96+
return a.name.localeCompare(b.name)
97+
})
98+
})
99+
100+
function href(pkg: EcosystemPackage) {
101+
return pkg.url ?? (pkg.repo ? `https://github.com/${pkg.repo}` : undefined)
102+
}
103+
104+
function isExternal(pkg: EcosystemPackage) {
105+
return !href(pkg)?.startsWith('/')
106+
}
107+
108+
function monogram(name: string) {
109+
const cleaned = name.replace(/^@[^/]+\//, '').replace(/^(?:plugin|service)-/, '')
110+
return (cleaned || name).slice(0, 2).toUpperCase()
111+
}
112+
</script>
113+
114+
<template>
115+
<div class="not-prose">
116+
<div class="flex gap-3">
117+
<UInput
118+
v-model="query"
119+
icon="i-lucide-search"
120+
size="lg"
121+
placeholder="Search packages"
122+
class="flex-1"
123+
:ui="{ base: 'rounded-lg' }"
124+
/>
125+
<button
126+
type="button"
127+
role="switch"
128+
:aria-checked="officialOnly"
129+
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"
130+
@click="officialOnly = !officialOnly"
131+
>
132+
<span
133+
class="flex size-3.5 items-center justify-center rounded-xs border border-default transition-colors"
134+
:class="officialOnly ? 'border-inverted bg-inverted' : 'bg-default'"
135+
>
136+
<UIcon
137+
v-if="officialOnly"
138+
name="i-lucide-check"
139+
class="size-2.5 text-inverted"
140+
/>
141+
</span>
142+
Official only
143+
</button>
144+
</div>
145+
146+
<div class="mt-4 flex flex-wrap gap-2">
147+
<button
148+
v-for="cat in categories"
149+
:key="cat"
150+
type="button"
151+
:aria-pressed="category === cat"
152+
class="flex h-8 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors"
153+
:class="category === cat ? 'bg-inverted text-inverted' : 'border border-default text-toned hover:border-accented'"
154+
@click="category = cat"
155+
>
156+
{{ cat }}
157+
<span
158+
class="font-mono text-xs tabular-nums"
159+
:class="category === cat ? 'text-inverted/60' : 'text-dimmed'"
160+
>
161+
{{ counts.get(cat) }}
162+
</span>
163+
</button>
164+
</div>
165+
166+
<UCard
167+
variant="outline"
168+
class="mt-6"
169+
:ui="{ body: 'p-0 sm:p-0 divide-y divide-default' }"
170+
>
171+
<NuxtLink
172+
v-for="pkg in visible"
173+
:key="pkg.name"
174+
:to="href(pkg)"
175+
:target="isExternal(pkg) ? '_blank' : undefined"
176+
:class="pkg.color ? 'eco-row' : 'hover:bg-muted'"
177+
:style="pkg.color ? { '--eco-color': pkg.color } : undefined"
178+
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]"
179+
>
180+
<div
181+
class="eco-tile flex size-9 shrink-0 items-center justify-center rounded-lg border border-default bg-muted"
182+
:style="pkg.color ? { color: pkg.color } : undefined"
183+
>
184+
<img
185+
v-if="pkg.image"
186+
:src="pkg.image"
187+
alt=""
188+
width="20"
189+
height="20"
190+
class="size-5"
191+
>
192+
<UIcon
193+
v-else-if="pkg.icon"
194+
:name="pkg.icon"
195+
class="size-5"
196+
:class="!pkg.color && 'text-toned'"
197+
/>
198+
<span
199+
v-else
200+
class="font-mono text-xs font-medium"
201+
:class="!pkg.color && 'text-toned'"
202+
>{{ monogram(pkg.name) }}</span>
203+
</div>
204+
205+
<div class="min-w-0">
206+
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5">
207+
<span class="text-sm font-medium text-highlighted">{{ pkg.name }}</span>
208+
<span
209+
v-if="pkg.official"
210+
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"
211+
>
212+
Official
213+
</span>
214+
<span class="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted">
215+
<img
216+
:src="`https://github.com/${pkg.author}.png?size=64`"
217+
:alt="pkg.author"
218+
width="16"
219+
height="16"
220+
loading="lazy"
221+
class="size-4 shrink-0 rounded-full"
222+
>
223+
<span class="truncate">{{ pkg.author }}</span>
224+
</span>
225+
</div>
226+
<p class="mt-0.5 truncate text-sm text-muted">
227+
{{ pkg.description }}
228+
</p>
229+
</div>
230+
231+
<span class="justify-self-end rounded-md border border-default px-2 py-0.5 font-mono text-xs text-muted">
232+
{{ pkg.category }}
233+
</span>
234+
235+
<UIcon
236+
:name="isExternal(pkg) ? 'i-lucide-arrow-up-right' : 'i-lucide-arrow-right'"
237+
class="hidden size-4 text-dimmed md:block"
238+
/>
239+
</NuxtLink>
240+
241+
<p
242+
v-if="!visible.length"
243+
class="py-10 text-center text-sm text-muted"
244+
>
245+
No packages match.
246+
</p>
247+
</UCard>
248+
</div>
249+
</template>
250+
251+
<style scoped>
252+
.eco-tile {
253+
transition:
254+
background-color 0.2s,
255+
border-color 0.2s,
256+
box-shadow 0.2s;
257+
}
258+
259+
.eco-row:hover {
260+
background-color: color-mix(in oklab, var(--eco-color) 6%, transparent);
261+
}
262+
263+
.eco-row:hover .eco-tile {
264+
border-color: color-mix(in oklab, var(--eco-color) 45%, var(--ui-border));
265+
box-shadow:
266+
0 0 0 3px color-mix(in oklab, var(--eco-color) 10%, transparent),
267+
0 6px 16px -8px color-mix(in oklab, var(--eco-color) 35%, transparent);
268+
}
269+
</style>

0 commit comments

Comments
 (0)