docs/ecosystem/Packages.vue
<script setup lang="ts">
import PackageCard from './PackageCard.vue'
import { PackageOutput } from './types'
import { ref, computed, onMounted } from 'vue'
import { uniqBy } from './helpers'
import { useQuery } from './useQuery'
const packageSource = 'https://ecosystem.featherscloud.workers.dev/'
const makeDate = (obj: Record<string, any>, key: string) => {
if (obj[key]) {
obj[key] = new Date(obj[key])
}
}
const fetchedPackages = ref<PackageOutput[]>([])
async function getPackageStats(): Promise<PackageOutput[]> {
const packages = await fetch(packageSource).then((response) => response.json())
packages.forEach((pkg: any) => {
makeDate(pkg, 'lastPublish')
pkg.id = pkg.name || `${pkg.repository?.name}/${pkg.repository?.directory}`
const hasNPM = !isNaN(pkg.lastPublish.getTime())
pkg.hasNPM = hasNPM
})
const uniq = uniqBy(packages, (pkg) => pkg.id)
return uniq
}
const categories: CategoryOption[] = [
{ label: 'Authentication', value: ['authentication'] },
{ label: 'Authorization', value: ['authorization'] },
{ label: 'Caching', value: ['caching'] },
{ label: 'Client', value: ['client'] },
{ label: 'Database', value: ['database'] },
{ label: 'APIs', value: ['api', 'apis'] },
{ label: 'Documentation', value: ['documentation', 'docs'] },
{ label: 'Email & SMS', value: ['email', 'mail', 'mailer', 'nodemailer', 'sms'] },
{ label: 'Frontend', value: ['frontend', 'front-end'] },
{ label: 'Google', value: ['google'] },
{ label: 'Hooks', value: ['hook', 'hooks'] },
{ label: 'Images', value: ['image', 'images'] },
{ label: 'Payments', value: ['payment', 'payments'] },
{ label: 'Scaling', value: ['scale', 'scaling'] },
{ label: 'Search', value: ['search'] },
{ label: 'Social Media', value: ['social media', 'social-media', 'socialmedia'] },
{ label: 'Testing', value: ['test', 'testing'] },
{ label: 'Logging', value: ['log', 'logs', 'logging'] },
{ label: 'Transports', value: ['transport', 'transports'] },
{ label: 'Utilities', value: ['utility', 'utilities'] },
{ label: 'Validation', value: ['validation', 'validator', 'validators'] }
]
const keyToSortBy = ref<'stars' | 'downloads' | 'lastPublish'>('lastPublish')
const showCore = ref(true)
function filterCore(pkg: PackageOutput) {
return pkg.ownerName === 'feathersjs'
}
const coreCount = computed(() => {
return fetchedPackages.value.filter(filterCore).length
})
const packagesAreOldIfOlderThan = 1000 * 60 * 60 * 24 * 365 * 5
const showOld = ref(false)
function filterOld(pkg: PackageOutput) {
return pkg.lastPublish.getTime() < Date.now() - packagesAreOldIfOlderThan
}
const oldCount = computed(() => {
return fetchedPackages.value.filter(filterOld).length
})
const countByCategory = computed(() => {
const counts: Record<string, number> = {}
categories.forEach((category) => {
counts[category.label] = fetchedPackages.value.filter((pkg) => {
return category.value.some((value) => {
return pkg.keywords?.some((keyword) => keyword.toLowerCase().includes(value))
})
}).length
})
return counts
})
const categoriesToFilter = ref<string[]>([])
const categoriesToShow = computed(() => {
const cats = categories.filter((category) => {
return categoriesToFilter.value.includes(category.label)
})
return cats
})
type Category = string[]
type CategoryOption = {
label: string
value: Category
}
const search = ref('')
const filteredPackages = computed(() => {
let pkgs = [...fetchedPackages.value]
if (!showCore.value) {
pkgs = pkgs.filter((pkg) => !filterCore(pkg))
}
if (!showOld.value) {
pkgs = pkgs.filter((pkg) => !filterOld(pkg))
}
if (search.value) {
const _search = search.value.toLowerCase()
pkgs = pkgs.filter(
(pkg) =>
pkg.id.includes(_search) ||
pkg.description?.includes(_search) ||
pkg.keywords?.some((keyword) => keyword.includes(_search))
)
}
if (categoriesToShow.value.length) {
pkgs = pkgs.filter((pkg) => {
return pkg.keywords?.some((keyword) => {
return categoriesToShow.value.some((category) => {
return category.value.some((value) => {
return keyword.toLowerCase().includes(value)
})
})
})
})
}
return pkgs
})
const packagesToShow = computed(() => {
const key = keyToSortBy.value
const result = filteredPackages.value.sort((a, b) => {
if (key === 'lastPublish' && (!a.hasNPM || !b.hasNPM)) {
return a.hasNPM ? -1 : 1
}
const valA = a[key] || 0
const valB = b[key] || 0
if (valA > valB) {
return -1
} else if (valA < valB) {
return 1
} else {
return 0
}
})
return result
})
onMounted(async () => {
fetchedPackages.value = await getPackageStats()
// sync values with URL
useQuery(keyToSortBy, 'sort', 'string')
useQuery(showCore, 'core', 'boolean')
useQuery(showOld, 'old', 'boolean')
useQuery(search, 's', 'string')
useQuery(categoriesToFilter, 'cat', 'string[]')
})
</script>
<template>
<div>
<el-input v-model="search" placeholder="Search package" clearable class="mb-1" />
<div class="flex justify-between mb-2">
<div>
<el-checkbox v-model="showCore" size="small" title="packages under '@feathersjs/'"
>core ({{ coreCount }})</el-checkbox
>
<el-checkbox v-model="showOld" size="small" title="older than 3 years"
>outdated ({{ oldCount }})</el-checkbox
>
</div>
<el-select
v-model="categoriesToFilter"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="Filter by category"
style="width: 240px"
value-key="label"
clearable
>
<el-option
v-for="option in categories"
:key="option.label"
:label="option.label"
:value="option.label"
:title="option.value.join(', ')"
>
{{ option.label }} ({{ countByCategory[option.label] }})
</el-option>
</el-select>
</div>
<div class="flex justify-end mb-3">
<el-radio-group v-model="keyToSortBy">
<el-radio label="downloads" size="small" title="Monthly npm downloads">Downloads</el-radio>
<el-radio label="stars" size="small" title="Github stars">Stars</el-radio>
<el-radio label="lastPublish" size="small" title="Recently published on npm">Newest</el-radio>
</el-radio-group>
</div>
<div class="font-bold mb-5">{{ packagesToShow.length }}/{{ fetchedPackages.length }} packages:</div>
<TransitionGroup name="list" tag="div">
<package-card
v-for="pkg in packagesToShow"
:key="pkg.id"
:stats="pkg"
:is-old="pkg.lastPublish.getTime() < Date.now() - packagesAreOldIfOlderThan"
/>
</TransitionGroup>
</div>
</template>
<style scoped>
.list-move, /* apply transition to moving elements */
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* ensure leaving items are taken out of layout flow so that moving
animations can be calculated correctly. */
.list-leave-active {
position: absolute;
}
</style>