Skip to content

Commit 53bf8fe

Browse files
committed
docs: add an ecosystem page that lists devtools, hosts, services and kits built on Devframe
Replaces the Built with Devframe guide page with a filterable /ecosystem page and a header tab. The old URL redirects to the new page.
1 parent 72d917d commit 53bf8fe

11 files changed

Lines changed: 634 additions & 69 deletions

File tree

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

0 commit comments

Comments
 (0)