feat(mobile): update rating to support mobile and some more mobile adjustments

This commit is contained in:
Daniel Klein 2024-09-18 20:46:47 +02:00
parent 6a8a305ce7
commit 6830e499cd
15 changed files with 265 additions and 97 deletions

View file

@ -4,7 +4,9 @@ import type { ProjectRating, ProjectShallow } from '~/types'
const props = defineProps<{
project: ProjectShallow
}>()
const { switcher, ecosystems } = storeToRefs(useData())
const { switcher, ecosystems, filter } = storeToRefs(useData())
const isLargeScreen = useMediaQuery('(min-width: 1024px)')
const ratings: { label: string, type: string, rating: ProjectRating }[] = (props.project.ratings || []).map(rating => ({ label: rating.name, type: 'rating', rating: rating }))
const ecosystem: { label: string[], type: string } = { label: ecosystems.value.filter(e => (props.project.ecosystem || []).includes(e.id)).map(e => e.icon!), type: 'ecosystem' }
@ -60,17 +62,24 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
flex
items-center
gap-8px
@click.prevent="navigateTo(project.website, { external: true, open: { target: '_blank' } })"
>
<h1
flex
items-center
text="14px app-white"
font-700
line-clamp-1
hover:underline
underline-offset-3
leading="20px lg:32px"
class="group relative inline-block"
>
{{ project.title1 }}
<div
class=" i-bx-link-external ml-4px text-14px opacity-0 group-hover:opacity-100 transition-opacity"
/>
</h1>
</div>
<p
@ -107,10 +116,11 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
gap-16px
>
<NuxtLink
v-if="projectItem.label[1]"
:to="projectItem.label[1]"
v-if="projectItem.label[0]"
:to="projectItem.label[0]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-ic-baseline-language
@ -118,10 +128,11 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
/>
</NuxtLink>
<NuxtLink
v-if="projectItem.label[2]"
:to="projectItem.label[2]"
v-if="projectItem.label[1]"
:to="projectItem.label[1]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-mdi-github
@ -130,10 +141,11 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
/>
</NuxtLink>
<NuxtLink
v-if="projectItem.label[0]"
:to="projectItem.label[0]"
v-if="projectItem.label[2]"
:to="projectItem.label[2]"
external
target="_blank"
@click.stop
>
<UnoIcon
i-bi-twitter-x
@ -171,13 +183,23 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
w-full
gap-16px
>
<UnoIcon
<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
@ -192,6 +214,12 @@ const projectItems: { label: string | string[], type: string, rating?: ProjectRa
>
{{ 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>
</ClientOnly>
</div>

View file

@ -26,7 +26,7 @@ function onOptionSelected(value: string | number) {
>
<div class="relative font-700 font-24px">
<HeadlessListboxButton
class="relative cursor-pointer py-6px px-14px text-left border-2px bg-app-white text-app-black sm:text-sm sm:leading-6"
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"
>
<span class="block truncate mr-8px font">{{ isOptionSelected?.label }} <span
v-if="titleShowCount"

View file

@ -93,6 +93,8 @@ const cardTitles = ref< { label: string, sortKey: string, togglable?: boolean }[
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'
@ -107,23 +109,9 @@ const cardTitles = ref< { label: string, sortKey: string, togglable?: boolean }[
gap-4px
lg:hidden
>
<p
text="12px lg:14px app-text-grey"
leading="16px lg:24px"
>
Sort by:
</p>
<p
text="12px lg:14px"
leading="16px lg:24px"
font-700
>
Score
</p>
<button
type="button"
i-ic-baseline-arrow-drop-down
text="app-text-grey 20px"
<SortSelectBox
v-model="filter.sortby"
:options="['title', 'openess', 'technology', 'privacy', 'score'].map((o) => ({ label: capitalize(o), value: o }))"
/>
</div>
<div

View file

@ -1,26 +1,17 @@
<script lang="ts" setup>
import type { Project } from '~/types'
import type { Project, ProjectRating } from '~/types'
const props = defineProps<{
project: Project
}>()
// const availableSupport = computed(() => {
// const filteredKeys = ['forum', 'discord', 'twitter', 'lens', 'farcaster', 'telegram']
// if (typeof props.project.links === 'object' && (props.project.links !== null || props.project.links !== undefined))
// return Object.keys(props.project.links).filter(key => filteredKeys.includes(key)).length
const isLargeScreen = useMediaQuery('(min-width: 1024px)')
// return 0
// })
const selectedMobileRating = ref<ProjectRating>()
/**
* From data points
- product readiness
- docs (yes/no)
- github (yes/no)
- team: anon / public
- audit: yes / no
*/
function onSelectMobileRating(rating: ProjectRating) {
selectedMobileRating.value = selectedMobileRating.value?.type === rating.type ? undefined : rating
}
const logo = props.project?.logos?.at(0)?.url
</script>
@ -172,6 +163,7 @@ const logo = props.project?.logos?.at(0)?.url
flex
flex-col
lg:flex-row
gap-y-4px
items-center
>
<p
@ -183,7 +175,10 @@ const logo = props.project?.logos?.at(0)?.url
<ProjectRating
:rating="rating"
:percentage="rating.points"
:disable-popover="!isLargeScreen"
compact
:selected="rating.type === selectedMobileRating?.type && !isLargeScreen"
@selected="onSelectMobileRating(rating)"
/>
</div>
</div>
@ -218,9 +213,18 @@ const logo = props.project?.logos?.at(0)?.url
py="2px lg:8px"
lg:py-4px
>
{{ calculateScore(project) }} %
{{ project.percentage }} %
</div>
</div>
<div col-span-4 flex items-center justify-center w-full v-if="selectedMobileRating && !isLargeScreen">
<ProjectRating
:rating="selectedMobileRating"
:percentage="selectedMobileRating.points"
:disable-popover="!isLargeScreen"
compact
show-only-popover
/>
</div>
</div>
</div>
</div>

View file

@ -104,6 +104,9 @@ function tooltipMouseOut() {
</template>
<div
v-else
flex
items-center
gap-4px
:color="color ?? '#FFF'"
text-14px
:class="`sm:text-${textSize ?? '16px'} font-${bold ? '700' : '400'} opacity-${opacity}`"

View file

@ -54,7 +54,7 @@ defineProps<{
>
<UnoIcon
i-ic-twotone-open-in-new
text="22px app-text-grey"
text="20px app-text-grey"
/>
</NuxtLink>
</ProjectInfoItem>

View file

@ -6,8 +6,13 @@ const props = defineProps<{
rating: ProjectRating
percentage: number
compact?: boolean
disablePopover?: boolean
showOnlyPopover?: boolean
selected?: boolean
}>()
const emits = defineEmits(['selected'])
const colors = [
'#ff0000', // 0-10%
'#ff4500', // 11-20%
@ -26,11 +31,24 @@ const backgroundColorByScore = computed(() => {
const colorIndex = Math.floor(normalizedPercentage / 10)
return colors[colorIndex]
})
const isLargeScreen = useMediaQuery('(min-width: 1024px)')
function onClick() {
if (isLargeScreen.value)
return
if (props.disablePopover)
emits('selected')
else
isPopoverVisible.value = !isPopoverVisible.value
}
const isPopoverVisible = ref(false)
let hideTimeout: ReturnType<typeof setTimeout> | null = null
const showPopover = () => {
if (!isLargeScreen.value)
return
console.log('show')
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
@ -39,6 +57,8 @@ const showPopover = () => {
}
const hidePopover = () => {
if (!isLargeScreen.value)
return
hideTimeout = setTimeout(() => {
isPopoverVisible.value = false
}, 100) // Delay of 200ms before hiding
@ -46,17 +66,24 @@ const hidePopover = () => {
</script>
<template>
<div class="relative">
<div
class="relative"
:class="{ 'w-full': showOnlyPopover }"
>
<!-- Main div that shows rating and triggers the popover on hover -->
<div
v-if="!showOnlyPopover"
flex
items-center
p-12px
gap-4px
hover:bg-app-bg-rating-hover
:class="{ 'bg-app-bg-rating-hover rounded-8px': selected }"
hover:rounded-8px
cursor-pointer
@mouseenter="showPopover"
@mouseleave="hidePopover"
@click.prevent="onClick()"
>
<div
v-for="point of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]"
@ -68,6 +95,7 @@ const hidePopover = () => {
<!-- Popover panel that appears on hover -->
<transition
v-if="!showOnlyPopover"
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
@ -76,10 +104,10 @@ const hidePopover = () => {
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="isPopoverVisible"
v-if="(isPopoverVisible && !disablePopover)"
class="absolute mt-2 p-2 bg-app-bg-rating-hover w-240px shadow-lg rounded"
z-100
style="left: 50%; transform: translateX(-50%);"
left="-250% lg:50%"
flex
flex-col
gap-14px
@ -88,44 +116,13 @@ const hidePopover = () => {
@mouseenter="showPopover"
@mouseleave="hidePopover"
>
<div
v-for="item in rating.items"
:key="item.label"
flex
justify-between
items-center
text-12px
font-700
leading-20px
:class="[item.isValid ? 'text-app-white': 'text-app-text-rating-negative']"
>
<div
flex
items-center
gap-6px
>
<div
:class="[item.isValid ? 'i-ic-sharp-thumb-up' : 'i-ic-sharp-thumb-down']"
text-20px
mt--4px
/>
{{ item.label }}
</div>
<NuxtLink
v-if="item.isValid && item.positive === 'Link'"
:to="item.value"
target="_blank"
external
underline
@click.stop
>
{{ item.positive }}
</NuxtLink>
<div v-else>
{{ item.isValid ? item.positive : item.negative }}
</div>
</div>
<ProjectRatingInfo :items="rating.items" />
</div>
</transition>
<ProjectRatingInfo
v-if="showOnlyPopover"
:items="rating.items"
:compact="false"
/>
</div>
</template>

View file

@ -0,0 +1,55 @@
<script lang="ts" setup>
import type { ProjectRatingItem } from '~/types'
withDefaults(defineProps<{ items: ProjectRatingItem[], compact?: boolean }>(), {
compact: true,
})
</script>
<template>
<div
flex
flex-col
gap-8px
:class="{ 'mt-16px': !compact }"
>
<div
v-for="item in items"
:key="item.label"
flex
justify-between
items-center
text-12px
font-700
leading-20px
w-full
:class="[item.isValid ? 'text-app-white': 'text-app-text-rating-negative']"
>
<div
flex
items-center
gap-6px
>
<div
:class="[item.isValid ? 'i-ic-sharp-thumb-up' : 'i-ic-sharp-thumb-down']"
text-20px
mt--4px
/>
{{ item.label }}
</div>
<NuxtLink
v-if="item.isValid && item.positive === 'Link'"
:to="item.value"
target="_blank"
external
underline
@click.stop
>
{{ item.positive }}
</NuxtLink>
<div v-else>
{{ item.isValid ? item.positive : item.negative }}
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import type { InputOption } from '~/types'
const props = withDefaults(defineProps<SelectProps>(), {
isMarginTop: true,
blackAndWhite: true,
})
const emits = defineEmits(['update:modelValue'])
interface SelectProps {
options: InputOption[]
modelValue: string
isMarginTop?: boolean
blackAndWhite?: boolean
}
const selectedValue = useVModel(props, 'modelValue', emits)
</script>
<template>
<HeadlessListbox
v-model="selectedValue"
as="div"
>
<div
class="relative font-700"
: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"
:class="[blackAndWhite ? ' text-app-white' : 'text-app-black']"
>
<span
block
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>
</HeadlessListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<HeadlessListboxOptions
class="absolute z-100 max-h-60 w-full divide-y-2px border-2px overflow-auto bg-app-black text-app-white focus:outline-none sm:text-sm"
>
<HeadlessListboxOption
v-for="option in props.options"
:key="option.value"
v-slot="{ active, selected }"
as="template"
:value="option.value"
>
<li
class="w-full relative cursor-pointer select-none py-8px p-16px text-xs sm:text-sm"
:class="[active ? 'bg-#ffffff1a' : 'text-white']"
>
<span
class="block truncate"
:class="[selected ? 'font-semibold' : 'font-normal']"
>{{ option.label }}</span>
</li>
</HeadlessListboxOption>
</HeadlessListboxOptions>
</transition>
</div>
</HeadlessListbox>
</template>

View file

@ -63,6 +63,7 @@ export const useData = defineStore('data', () => {
projects.value = data.projects.map(project => ({
...project,
ratings: generateProjectRating(project),
percentage: Math.round((project.ratings?.reduce((a, b) => a + b.points, 0) || 0) / 1.5),
})).filter(p => p.name)
categories.value = data.categories
@ -98,7 +99,7 @@ export const useData = defineStore('data', () => {
id: project.id,
title1: project.name,
description: project.description ?? 'N/A',
percentage: Math.round((project.ratings?.reduce((a, b) => a + b.points, 0) || 0) / 1.5),
percentage: project.percentage,
forum: project.links?.forum,
explorer: project.links?.block_explorer,
twitter: project.links?.twitter,

View file

@ -18,9 +18,10 @@
"devDependencies": {
"@formkit/auto-animate": "^0.8.2",
"@iconify-json/bi": "^1.2.0",
"@iconify-json/bx": "^1.2.0",
"@iconify-json/eos-icons": "^1.1.10",
"@iconify-json/heroicons-outline": "^1.2.0",
"@iconify-json/heroicons-solid": "^1.2.0",
"@iconify-json/eos-icons": "^1.1.10",
"@iconify-json/ic": "^1.2.0",
"@iconify-json/iconoir": "^1.2.0",
"@iconify-json/material-symbols": "^1.2.1",
@ -46,7 +47,8 @@
"vitest": "^2.0.5",
"vue-tsc": "^2.1.4",
"yaml": "^2.5.1",
"yup": "^1.4.0"
"yup": "^1.4.0",
"moment": "^2.30.1"
},
"overrides": {
"vue": "latest"
@ -57,8 +59,5 @@
},
"lint-staged": {
"*": "eslint --fix"
},
"dependencies": {
"moment": "^2.30.1"
}
}

View file

@ -7,10 +7,6 @@ settings:
importers:
.:
dependencies:
moment:
specifier: ^2.30.1
version: 2.30.1
devDependencies:
'@formkit/auto-animate':
specifier: ^0.8.2
@ -18,6 +14,9 @@ importers:
'@iconify-json/bi':
specifier: ^1.2.0
version: 1.2.0
'@iconify-json/bx':
specifier: ^1.2.0
version: 1.2.0
'@iconify-json/eos-icons':
specifier: ^1.1.10
version: 1.2.0
@ -72,6 +71,9 @@ importers:
eslint:
specifier: ^9.9.1
version: 9.9.1(jiti@1.21.6)
moment:
specifier: ^2.30.1
version: 2.30.1
nuxt:
specifier: ^3.13.0
version: 3.13.0(@parcel/watcher@2.4.1)(@types/node@20.8.7)(encoding@0.1.13)(eslint@9.9.1(jiti@1.21.6))(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.3)(rollup@4.21.2)(terser@5.22.0)(typescript@5.5.4)(vite@5.4.2(@types/node@20.8.7)(terser@5.22.0))(vue-tsc@2.1.4(typescript@5.5.4))
@ -1128,6 +1130,9 @@ packages:
'@iconify-json/bi@1.2.0':
resolution: {integrity: sha512-kaBV87cQlyeMkBBiMqsf3b43Nsxdk/rYKvR29dnktht57WUyHCnBAuH+ca/bscX856CzRpVX+sYs7arjrJD0qA==}
'@iconify-json/bx@1.2.0':
resolution: {integrity: sha512-UWTC5BwAYXAPdzpubCH9DDW0Q1SAQNA4CpN4GfsvqKm+SN4I/ppafhxH2EFWSep3liKfkUo0TkzDNh/iKIxyZw==}
'@iconify-json/eos-icons@1.2.0':
resolution: {integrity: sha512-grdfoS20Z4gWAzNPza7ytguNBWeTOkx4Y6aZHs149t2Z6AhW7zG3VWkkq6M+YuL2G8ugHnBw7ZxgazZ6oiMnIQ==}
@ -6629,6 +6634,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/bx@1.2.0':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/eos-icons@1.2.0':
dependencies:
'@iconify/types': 2.0.0

View file

@ -16289,7 +16289,7 @@
{
"field": "links.telegram",
"label": {
"name": "Twitter",
"name": "Telegram",
"positive": "Link",
"negative": "Not available"
},

View file

@ -22,6 +22,7 @@ export interface Project {
token_link?: string
[k: string]: unknown
}[]
percentage: number
description?: string
project_type?: string
product_launch_day?: string

View file

@ -3,3 +3,8 @@ import moment from 'moment'
export function formatDate(date: Date | string) {
return moment(date).format('MM / YYYY')
}
export function capitalize(word: string): string {
if (!word) return word
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
}