fix(performance): dashboard performance - sorting, filtering, pagination

This commit is contained in:
Daniel Klein 2024-09-24 19:51:28 +02:00
parent 073d9d3bed
commit c4e174a48b
7 changed files with 392 additions and 329 deletions

View file

@ -40,18 +40,16 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
h="48px lg:64px"
:class="switcher ? '' : 'lg:max-w-full! lg:w-full '"
>
<ClientOnly>
<NuxtImg
:src="project?.image || '/no-image-1-1.svg'"
class="w-full h-auto"
max-h="md:64px 48px"
max-w="md:64px 48px"
self-center
z-10
object-fit
bg="#121212"
/>
</ClientOnly>
<NuxtImg
:src="project?.image || '/no-image-1-1.svg'"
class="w-full h-auto"
max-h="md:64px 48px"
max-w="md:64px 48px"
self-center
z-10
object-fit
bg="#121212"
/>
<div
flex
flex-col
@ -88,138 +86,135 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
</p>
</div>
</div>
<ClientOnly>
<div
v-for="(projectItem, index) of projectItems"
:key="projectItem.label.toString()"
hidden
lg:flex
items-center
text-14px
leading-24px
:class="{ 'col-span-1 lg:col-span-2': index === 0 }"
<div
v-for="(projectItem, index) of projectItems"
:key="projectItem.label.toString()"
hidden
lg:flex
items-center
text-14px
leading-24px
:class="{ 'col-span-1 lg:col-span-2': index === 0 }"
>
<p
v-if="projectItem.type === 'array'"
text-app-text-grey
>
<p
v-if="projectItem.type === 'array'"
text-app-text-grey
>
{{ (projectItem.label as string[] || []).join(', ') }}
</p>
<div
v-if="projectItem.type === 'links'"
flex
items-center
justify-start
gap-16px
>
<NuxtLink
v-if="projectItem.label[0]"
:to="projectItem.label[0]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-ic-baseline-language
text="24px app-text-grey"
/>
</NuxtLink>
<NuxtLink
v-if="projectItem.label[1]"
:to="projectItem.label[1]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-mdi-github
text="24px app-text-grey"
text-27px
/>
</NuxtLink>
<NuxtLink
v-if="projectItem.label[2]"
:to="projectItem.label[2]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-bi-twitter-x
text="24px app-text-grey"
/>
</NuxtLink>
</div>
<div
v-if="projectItem.type === 'ecosystem'"
flex
items-center
justify-start
gap-2px
>
<NuxtImg
v-for="ecosystem of projectItem.label"
:key="ecosystem"
:src="ecosystem"
w-24px
h-24px
rounded-full
/>
</div>
<ProjectRating
v-if="projectItem.type! === 'rating' && projectItem.rating"
:percentage="projectItem.rating.points"
:rating="projectItem.rating"
:type="projectItem.rating.type"
/>
</div>
{{ (projectItem.label as string[] || []).join(', ') }}
</p>
<div
v-if="projectItem.type === 'links'"
flex
items-center
justify-end
w-full
justify-start
gap-16px
>
<NuxtLink
v-if="project.website"
block
lg:hidden
:to="project.website"
v-if="projectItem.label[0]"
:to="projectItem.label[0]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-iconoir-internet
text="24px"
i-ic-baseline-language
text="24px app-text-grey"
/>
</NuxtLink>
<div
v-if="filter.sortby === 'score' || filter.sortby === 'title' || isLargeScreen"
flex
items-center
justify-center
border="2px app-white"
text="14px md:18px"
leading="24px md:32px"
max-h-="28px md:32px"
max-w="48px md:56px"
w-full
font-700
whitespace-nowrap
<NuxtLink
v-if="projectItem.label[1]"
:to="projectItem.label[1]"
external
target="_blank"
@click.stop
>
{{ project.percentage }} %
</div>
<ProjectRating
v-if="(filter.sortby === 'openess' || filter.sortby === 'technology' || filter.sortby === 'privacy') && project.ratings?.find((r) => r.type === filter.sortby) && !isLargeScreen"
:percentage="project.ratings.find((r) => r.type === filter.sortby)!.points"
:rating="project.ratings.find((r) => r.type === filter.sortby)!"
compact
<UnoIcon
i-mdi-github
text="24px app-text-grey"
text-27px
/>
</NuxtLink>
<NuxtLink
v-if="projectItem.label[2]"
:to="projectItem.label[2]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-bi-twitter-x
text="24px app-text-grey"
/>
</NuxtLink>
</div>
<div
v-if="projectItem.type === 'ecosystem'"
flex
items-center
justify-start
gap-2px
>
<NuxtImg
v-for="ecosystem of projectItem.label"
:key="ecosystem"
:src="ecosystem"
w-24px
h-24px
rounded-full
/>
</div>
</ClientOnly>
</div>
<ProjectRating
v-if="projectItem.type! === 'rating' && projectItem.rating"
:percentage="projectItem.rating.points"
:rating="projectItem.rating"
:type="projectItem.rating.type"
/>
</div>
<div
flex
items-center
justify-end
w-full
gap-16px
>
<NuxtLink
v-if="project.website"
block
lg:hidden
:to="project.website"
external
target="_blank"
@click.stop
>
<UnoIcon
i-iconoir-internet
text="24px"
/>
</NuxtLink>
<div
v-if="filter.sortby === 'score' || filter.sortby === 'title' || isLargeScreen"
flex
items-center
justify-center
border="2px app-white"
text="14px md:18px"
leading="24px md:32px"
max-h-="28px md:32px"
max-w="48px md:56px"
w-full
font-700
whitespace-nowrap
>
{{ project.percentage }} %
</div>
<ProjectRating
v-if="(filter.sortby === 'openess' || filter.sortby === 'technology' || filter.sortby === 'privacy') && project.ratings?.find((r) => r.type === filter.sortby) && !isLargeScreen"
:percentage="project.ratings.find((r) => r.type === filter.sortby)!.points"
:rating="project.ratings.find((r) => r.type === filter.sortby)!"
compact
/>
</div>
</div>
</NuxtLink>
</template>

View file

@ -2,6 +2,7 @@
import type { InputOption } from '~/types'
const props = defineProps<{
name: string
options: InputOption[]
modelValue: string | number
count?: number
@ -26,6 +27,7 @@ function onOptionSelected(value: string | number) {
>
<div class="relative font-700 font-24px">
<HeadlessListboxButton
:id="`headless-listbox-button-${name}`"
class="relative cursor-pointer py-6px px-14px text-left border-2px bg-app-white text-app-black text-xs sm:text-sm sm:leading-6"
:class="{ 'text-app-danger!': isOptionSelected?.value === 'sunset' }"
>

View file

@ -4,33 +4,8 @@ import type { ProjectShallow } from '~/types'
const props = defineProps<{
projects: { title: string, projects: ProjectShallow[] }[]
}>()
const { switcher, filter } = storeToRefs(useData())
const totalProjectsCount = props.projects.map(g => g.projects.length).reduce((a, b) => a + b, 0)
function onChangeSort(sortKey: string) {
if (filter.value.sortby === sortKey) {
if (filter.value.sortDirection === 'desc' && filter.value.sortby !== 'score') {
filter.value.sortby = 'score'
filter.value.sortDirection = 'desc'
return
}
filter.value.sortDirection = filter.value.sortDirection === 'asc' ? 'desc' : 'asc'
return
}
filter.value.sortby = sortKey
filter.value.sortDirection = sortKey === 'score' ? 'desc' : 'asc'
}
const cardTitles = ref< { label: string, sortKey: string, togglable?: boolean }[]>([
{ label: 'Usecase', sortKey: 'usecase' },
{ label: 'Openess', sortKey: 'openess', togglable: true },
{ label: 'Technology', sortKey: 'technology', togglable: true },
{ label: 'Privacy', sortKey: 'privacy', togglable: true },
{ label: 'Ecosystem', sortKey: 'ecosystem' },
{ label: 'Links', sortKey: 'links' },
{ label: 'W3PN Score', sortKey: 'score', togglable: true },
])
</script>
<template>
@ -39,123 +14,11 @@ const cardTitles = ref< { label: string, sortKey: string, togglable?: boolean }[
flex-col
items-start
>
<template
<ProjectGridGroup
v-for="group in projects"
:key="group.title"
>
<div
flex
items-center
gap-x-12px
w-full
>
<h2
text="app-white 16px lg:24px"
font-700
leading="24px lg:32px"
whitespace-nowrap
>
{{ group.projects.length }} {{ group.title }}
</h2>
<div
h-2px
w-full
bg-app-text-grey
/>
<button
type="button"
i-ic-baseline-arrow-drop-down
text="app-text-grey 24px"
/>
</div>
<div
grid
grid-cols="2 lg:10"
w-full
mb-16px
>
<div
flex
items-center
gap-4px
col-span="1 lg:2"
:class="['title' === filter.sortby ? 'text-app-white' : 'text-app-text-grey', 'cursor-pointer']"
@click="onChangeSort('title')"
>
<p
text="12px lg:14px"
leading="16px lg:24px"
whitespace-nowrap
>
Project name
</p>
<button
hidden
lg:block
type="button"
:class="['title' === filter.sortby ? filter.sortDirection === 'desc' ? 'i-ic-baseline-arrow-drop-up'
: 'i-ic-baseline-arrow-drop-down'
: 'i-ic-baseline-arrow-drop-down']"
text="20px"
/>
</div>
<div
flex
items-center
justify-end
gap-4px
lg:hidden
>
<SortSelectBox
v-model="filter.sortby"
:options="['title', 'openess', 'technology', 'privacy', 'score'].map((o) => ({ label: capitalize(o), value: o }))"
/>
</div>
<div
v-for="(title, index) in cardTitles"
:key="title.label"
lg:flex
items-center
justify-start
last:justify-end
gap-4px
hidden
:class="[title.sortKey === filter.sortby ? 'text-app-white' : 'text-app-text-grey', { 'cursor-pointer': title.togglable, 'col-span-1 lg:col-span-2': index === 0 }]"
@click="onChangeSort(title.sortKey)"
>
<p
text="12px lg:14px "
leading="16px lg:24px"
whitespace-nowrap
>
{{ title.label }}
</p>
<button
v-if="title.togglable"
type="button"
:class="[title.sortKey === filter.sortby ? filter.sortDirection === 'desc' ? 'i-ic-baseline-arrow-drop-up'
: 'i-ic-baseline-arrow-drop-down'
: 'i-ic-baseline-arrow-drop-down']"
text=" 20px"
/>
</div>
</div>
<div
grid
:class="switcher ? 'grid-cols-1 lg:grid-cols-1' : 'xl:grid-cols-3 lg:grid-cols-3 sm:grid-cols-2 grid-cols-1'"
gap-16px
text-white
w-full
mb="8px lg:24px"
>
<Card
v-for="project in group.projects"
:key="project.id"
:project="project"
/>
</div>
</template>
:group="group"
/>
<div v-if="totalProjectsCount === 0">
<h3>No Projects found...</h3>
</div>

View file

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { ProjectShallow } from '~/types'
const props = defineProps<{
group: {
title: string
projects: ProjectShallow[]
}
}>()
const { switcher } = storeToRefs(useData())
const groupCollapsed = ref(false)
const shownProjectsCount = ref(15)
const shownProjects = computed(() => props.group.projects.slice(0, shownProjectsCount.value))
</script>
<template>
<div w-full>
<div
flex
items-center
gap-x-12px
w-full
mb="-8px lg:8px"
>
<h2
text="app-white 16px lg:24px"
font-700
leading="24px lg:32px"
whitespace-nowrap
>
{{ group.projects.length }} {{ group.title }}
</h2>
<div
h-2px
w-full
bg-app-text-grey
/>
<button
type="button"
:class="[groupCollapsed ? 'i-ic-baseline-arrow-drop-up' : 'i-ic-baseline-arrow-drop-down']"
text="app-text-grey 24px"
@click="groupCollapsed = !groupCollapsed"
/>
</div>
<ProjectGridGroupSort v-if="!groupCollapsed" />
<div
v-if="!groupCollapsed"
grid
:class="switcher ? 'grid-cols-1 lg:grid-cols-1' : 'xl:grid-cols-3 lg:grid-cols-3 sm:grid-cols-2 grid-cols-1'"
gap-16px
text-white
w-full
mt="-16px lg:0"
mb-8px
>
<ClientOnly>
<Card
v-for="project in group.projects.slice(0, shownProjectsCount)"
:key="project.id"
:project="project"
/>
<template #fallback>
Loading...
</template>
</ClientOnly>
</div>
<ClientOnly>
<button
v-if="group.projects.length > shownProjects.length && !groupCollapsed"
type="button"
text="12px lg:14px"
leading="24px lg:32px"
font-bold
text-app-text-grey
@click="shownProjectsCount += 15"
>
LOAD MORE
</button>
</ClientOnly>
</div>
</template>

View file

@ -0,0 +1,103 @@
<script lang="ts" setup>
const { filter } = storeToRefs(useData())
function onChangeSort(sortKey: string) {
if (filter.value.sortby === sortKey) {
if (filter.value.sortDirection === 'desc' && filter.value.sortby !== 'score') {
filter.value.sortby = 'score'
filter.value.sortDirection = 'desc'
return
}
filter.value.sortDirection = filter.value.sortDirection === 'asc' ? 'desc' : 'asc'
return
}
filter.value.sortby = sortKey
filter.value.sortDirection = sortKey === 'score' ? 'desc' : 'asc'
}
const cardTitles = ref< { label: string, sortKey: string, togglable?: boolean }[]>([
{ label: 'Usecase', sortKey: 'usecase' },
{ label: 'Openess', sortKey: 'openess', togglable: true },
{ label: 'Technology', sortKey: 'technology', togglable: true },
{ label: 'Privacy', sortKey: 'privacy', togglable: true },
{ label: 'Ecosystem', sortKey: 'ecosystem' },
{ label: 'Links', sortKey: 'links' },
{ label: 'W3PN Score', sortKey: 'score', togglable: true },
])
</script>
<template>
<div
grid
grid-cols="2 lg:10"
w-full
mb-16px
>
<div
flex
items-center
gap-4px
col-span="1 lg:2"
:class="['title' === filter.sortby ? 'text-app-white' : 'text-app-text-grey', 'cursor-pointer']"
@click="onChangeSort('title')"
>
<p
text="12px lg:14px"
leading="16px lg:24px"
whitespace-nowrap
>
Project name
</p>
<button
hidden
lg:block
type="button"
:class="['title' === filter.sortby ? filter.sortDirection === 'desc' ? 'i-ic-baseline-arrow-drop-up'
: 'i-ic-baseline-arrow-drop-down'
: 'i-ic-baseline-arrow-drop-down']"
text-24px
/>
</div>
<div
flex
items-center
justify-end
gap-4px
lg:hidden
>
<SortSelectBox
v-model="filter.sortby"
name="sort"
:options="['title', 'openess', 'technology', 'privacy', 'score'].map((o) => ({ label: capitalize(o), value: o }))"
/>
</div>
<div
v-for="(title, index) in cardTitles"
:key="title.label"
lg:flex
items-center
justify-start
last:justify-end
gap-4px
hidden
:class="[title.sortKey === filter.sortby ? 'text-app-white' : 'text-app-text-grey', { 'cursor-pointer': title.togglable, 'col-span-1 lg:col-span-2': index === 0 }]"
@click="onChangeSort(title.sortKey)"
>
<p
text="12px lg:14px "
leading="16px lg:24px"
whitespace-nowrap
>
{{ title.label }}
</p>
<button
v-if="title.togglable"
type="button"
:class="[title.sortKey === filter.sortby ? filter.sortDirection === 'desc' ? 'i-ic-baseline-arrow-drop-up'
: 'i-ic-baseline-arrow-drop-down'
: 'i-ic-baseline-arrow-drop-down']"
text-24px
/>
</div>
</div>
</template>

View file

@ -8,6 +8,7 @@ const props = withDefaults(defineProps<SelectProps>(), {
const emits = defineEmits(['update:modelValue'])
interface SelectProps {
name: string
options: InputOption[]
modelValue: string
isMarginTop?: boolean
@ -27,23 +28,24 @@ const selectedValue = useVModel(props, 'modelValue', emits)
:class="[isMarginTop ? 'mt-2' : 'mt-0', blackAndWhite ? 'bg-app-black' : 'bg-app-white']"
>
<HeadlessListboxButton
class="relative w-full cursor-pointer py-8px p-16px text-left text-xs sm:text-sm sm:leading-6 flex items-center gap-4px"
:id="`headless-listbox-button-${name}`"
class="cursor-pointer py-8px text-left text-xs sm:text-sm sm:leading-6 flex items-center justify-end gap-4px"
:class="[blackAndWhite ? ' text-app-white' : 'text-app-black']"
>
<span
block
<div
text="12px lg:14px app-text-grey"
leading="16px lg:24px"
>
Sort by:
</span>
<span class="block truncate mr-8px">{{ props.options.find(option => option.value === selectedValue)?.label }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<UnoIcon
i-ic-baseline-arrow-drop-down
:class="[blackAndWhite ? ' text-app-white' : 'text-app-black']"
/>
</span>
</div>
<div class="truncate">
{{ props.options.find(option => option.value === selectedValue)?.label }}
</div>
<UnoIcon
i-ic-baseline-arrow-drop-down
:class="[blackAndWhite ? ' text-app-white' : 'text-app-black']"
text="18px lg:20px"
/>
</HeadlessListboxButton>
<transition

View file

@ -124,23 +124,48 @@ export const useData = defineStore('data', () => {
const shallowProjects = computed(() => projects.value.map(project => projectToShallow(project)))
const getProjectsByFilters = <T extends ProjectShallow>(options?: { shallow: boolean }): T[] => {
const filteredProjects = projects.value
.filter(project =>
selectedCategoryId.value !== 'all' ? project.categories.includes(selectedCategoryId.value) : true,
)
.filter(project =>
selectedUsecaseId.value !== 'all' ? selectedUsecaseId.value === 'sunset' ? project.sunset : project.usecases?.map(u => u.toLowerCase()).includes(selectedUsecaseId.value.toLowerCase()) : true,
)
.filter(project =>
selectedEcosystemId.value !== 'all' ? project.ecosystem?.map(e => e.toLowerCase()).includes(selectedEcosystemId.value.toLowerCase()) : true,
)
.filter(project =>
selectedAssetsUsedId.value !== 'all' ? project.assets_used?.map(a => a.toLowerCase()).includes(selectedAssetsUsedId.value.toLowerCase()) : true,
)
.filter(project =>
selectedFeaturesId.value !== 'all' ? project.technology?.features?.map(f => f.toLowerCase()).includes(selectedFeaturesId.value.toLowerCase()) : true,
)
return (filteredProjects.map(project => options?.shallow ? projectToShallow(project) : project) as T[])
const selectedCategory = selectedCategoryId.value !== 'all' ? selectedCategoryId.value : undefined
const selectedUsecase = selectedUsecaseId.value !== 'all' ? selectedUsecaseId.value.toLowerCase() : undefined
const selectedEcosystem = selectedEcosystemId.value !== 'all' ? selectedEcosystemId.value.toLowerCase() : undefined
const selectedAssetsUsed = selectedAssetsUsedId.value !== 'all' ? selectedAssetsUsedId.value.toLowerCase() : undefined
const selectedFeature = selectedFeaturesId.value !== 'all' ? selectedFeaturesId.value.toLowerCase() : undefined
return (projects.value.filter((project) => {
if (selectedCategory && !project.categories.includes(selectedCategory))
return false
if (selectedUsecase) {
if (selectedUsecase === 'sunset') {
if (!project.sunset)
return false
}
else {
const usecases = project.usecases?.map(u => u.toLowerCase()) || []
if (!usecases.includes(selectedUsecase))
return false
}
}
if (selectedEcosystem) {
const ecosystems = project.ecosystem?.map(e => e.toLowerCase()) || []
if (!ecosystems.includes(selectedEcosystem))
return false
}
if (selectedAssetsUsed) {
const assetsUsed = project.assets_used?.map(a => a.toLowerCase()) || []
if (!assetsUsed.includes(selectedAssetsUsed))
return false
}
if (selectedFeature) {
const features = project.technology?.features?.map(f => f.toLowerCase()) || []
if (!features.includes(selectedFeature))
return false
}
return true
}).map(project => (options?.shallow ? projectToShallow(project) : project)) as T[])
}
const getProjectById = <T extends Project | ProjectShallow>(id: string, options?: { shallow: boolean }): T => {
@ -149,51 +174,39 @@ export const useData = defineStore('data', () => {
}
const filteredProjects = computed(() => {
if (!projects.value)
return []
if (!projects.value) return []
const query = filter.query.toLowerCase()
const sortDirection = filter.sortDirection === 'asc' ? 1 : -1
const sortBy = filter.sortby
const filteredShallowProjects = getProjectsByFilters({ shallow: true })
.filter((project) => {
return (
project
&& project.title1
&& project.title1.toLowerCase().includes(query)
)
}).filter((project) => {
if (filter.sortby === 'anonymity')
return project.anonymity === true
else
return true
}).sort((a, b) => {
if (filter.sortby === 'score')
if (filter.sortDirection === 'asc')
return a.percentage - b.percentage
else
return b.percentage - a.percentage
if (filter.sortby === 'title')
if (filter.sortDirection === 'asc')
return a.title1.toLowerCase().localeCompare(b.title1.toLowerCase())
else
return b.title1.toLowerCase().localeCompare(a.title1.toLowerCase())
if (filter.sortby === 'openess' || filter.sortby === 'technology' || filter.sortby === 'privacy') {
const scoreA = a.ratings?.find(r => r.type === filter.sortby)?.points || 0
const scoreB = b.ratings?.find(r => r.type === filter.sortby)?.points || 0
if (filter.sortDirection === 'asc')
return scoreB - scoreA
else
return scoreA - scoreB
}
else
return 0
return project?.title1?.toLowerCase().includes(query)
})
.sort((a, b) => {
if (sortBy === 'score') {
return sortDirection * (a.percentage - b.percentage)
}
if (sortBy === 'title') {
return sortDirection * a.title1.toLowerCase().localeCompare(b.title1.toLowerCase())
}
if (sortBy === 'openess' || sortBy === 'technology' || sortBy === 'privacy') {
const scoreA = a.ratings?.find(r => r.type === sortBy)?.points || 0
const scoreB = b.ratings?.find(r => r.type === sortBy)?.points || 0
return sortDirection * (scoreB - scoreA)
}
return 0
})
return filteredShallowProjects
})
const groupedProjectsPerCategory = computed(() => {
const groupedProjects = categories.value.map((category) => {
// Find all projects that include this category
const projectsInCategory = filteredProjects.value.filter(project =>
project.categories.includes(category.id),
)