Files
kycnotme/web/src/pages/attributes.astro
2025-06-10 17:42:42 +00:00

417 lines
15 KiB
Plaintext

---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { z } from 'astro:content'
import { orderBy } from 'lodash-es'
import BadgeStandard from '../components/BadgeStandard.astro'
import MyPicture from '../components/MyPicture.astro'
import SortArrowIcon from '../components/SortArrowIcon.astro'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { nonDbAttributes, sortAttributes } from '../lib/attributes'
import { cn } from '../lib/cn'
import { formatNumber } from '../lib/numbers'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['name', 'category', 'type', 'privacy', 'trust']),
'sort-order': z.enum(['asc', 'desc']).default('asc'),
},
Astro
)
const attributes = await Astro.locals.banners.try(
'Error fetching attributes',
async () =>
prisma.attribute.findMany({
select: {
id: true,
slug: true,
title: true,
description: true,
category: true,
type: true,
privacyPoints: true,
trustPoints: true,
services: {
select: {
service: {
select: {
id: true,
slug: true,
name: true,
imageUrl: true,
overallScore: true,
verificationStatus: true,
},
},
},
},
},
}),
[]
)
const sortBy = filters['sort-by']
const mergedAttributes = [
...nonDbAttributes.map((attribute) => ({ ...attribute, services: [], id: attribute.slug })),
...attributes,
]
const sortedAttributes = sortBy
? orderBy(
sortAttributes(mergedAttributes),
sortBy === 'type'
? (attribute) => getAttributeTypeInfo(attribute.type).order
: sortBy === 'category'
? (attribute) => getAttributeCategoryInfo(attribute.category).order
: sortBy === 'name'
? 'title'
: sortBy === 'privacy'
? 'privacyPoints'
: 'trustPoints',
filters['sort-order']
)
: sortAttributes(mergedAttributes)
const attributesWithInfo = sortedAttributes.map((attribute) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
services: orderBy(
attribute.services.map(({ service }) => ({
...service,
verificationStatusInfo: getVerificationStatusInfo(service.verificationStatus),
overallScoreInfo: makeOverallScoreInfo(service.overallScore),
})),
[
(service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : -1),
'overallScore',
() => Math.random(),
],
['asc', 'desc', 'asc']
),
}))
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const sortOrder = filters['sort-by'] === slug ? (filters['sort-order'] === 'asc' ? 'desc' : 'asc') : 'asc'
return `/attributes?sort-by=${slug}&sort-order=${sortOrder}`
}
---
<BaseLayout
pageTitle="Attributes"
description="Browse all available service attributes used to evaluate privacy and trust scores."
ogImage={{
template: 'generic',
title: 'All attributes',
description: 'Browse all available service attributes',
icon: 'ri:list-radio',
}}
>
<h1 class="font-title mb-2 text-center text-3xl font-bold text-white">Service attributes</h1>
<p class="text-center text-balance text-zinc-300">
Characteristics or features of services that affect their scores.
</p>
<p class="mb-8 text-center">
<a
href="/about#service-attributes"
class="mt-2 inline-flex items-center text-sm text-zinc-400 hover:text-zinc-200"
>
<Icon name="ri:information-line" class="mr-1 size-4" />
Learn more about attributes
</a>
</p>
<!-- Mobile view -->
<div class="grid grid-cols-1 gap-4 md:hidden">
{
attributesWithInfo.map((attribute) => (
<div class="space-y-2 rounded-lg border border-zinc-600 bg-zinc-800 p-4">
<div class="flex flex-col items-center space-y-2">
<h3 class={cn('text-center text-lg font-bold', attribute.typeInfo.classNames.text)}>
{attribute.title}
</h3>
<div class="flex space-x-2">
<BadgeStandard
text={attribute.categoryInfo.label}
icon={attribute.categoryInfo.icon}
class={attribute.categoryInfo.classNames.icon}
/>
<BadgeStandard
text={attribute.typeInfo.label}
icon={attribute.typeInfo.icon}
class={attribute.typeInfo.classNames.icon}
/>
</div>
</div>
<div
class={cn(
'prose prose-sm prose-invert mx-auto max-w-lg text-sm',
attribute.typeInfo.classNames.text
)}
>
<Markdown content={attribute.description} />
</div>
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col items-center text-zinc-200">
<span
class={cn('text-base', attribute.typeInfo.classNames.textLight, {
'text-red-400': attribute.privacyPoints < 0,
'text-green-400': attribute.privacyPoints > 0,
'opacity-50': attribute.privacyPoints === 0,
})}
>
{formatNumber(attribute.privacyPoints, { showSign: true })}
</span>
<span
class={cn('text-2xs font-bold text-white uppercase', attribute.typeInfo.classNames.textLight)}
>
Privacy
</span>
</div>
<div class="flex flex-col items-center text-zinc-200">
<span
class={cn('text-base', attribute.typeInfo.classNames.textLight, {
'text-red-400': attribute.trustPoints < 0,
'text-green-400': attribute.trustPoints > 0,
'opacity-50': attribute.trustPoints === 0,
})}
>
{formatNumber(attribute.trustPoints, { showSign: true })}
</span>
<span
class={cn('text-2xs font-bold text-white uppercase', attribute.typeInfo.classNames.textLight)}
>
Trust
</span>
</div>
</div>
{attribute.services.length > 0 && (
<details class="pt-2">
<summary class="flex cursor-pointer items-center text-sm text-zinc-300">
Show services
<Icon name="ri:arrow-down-s-line" class="ml-1 size-4" />
</summary>
<ul class="mt-2 grid max-h-64 grid-cols-1 overflow-y-auto rounded bg-zinc-700">
{attribute.services.map((service) => (
<li>
<a
href={`/service/${service.slug}`}
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800"
>
{service.imageUrl ? (
<MyPicture
src={service.imageUrl}
alt={service.name}
width={24}
height={24}
class="size-6 shrink-0 rounded-xs object-contain"
/>
) : (
<div class="flex size-6 shrink-0 items-center justify-center rounded-xs bg-zinc-800 text-zinc-500">
<Icon name="ri:image-line" class="size-4" />
</div>
)}
<div
class={cn(
'mt-1 flex-1 truncate text-xs text-zinc-300',
service.verificationStatus === 'VERIFICATION_FAILED' && 'text-red-200'
)}
>
{service.name}
</div>
{service.verificationStatus !== 'APPROVED' && (
<Icon
name={service.verificationStatusInfo.icon}
class={cn(
'inline-block size-4 shrink-0',
service.verificationStatusInfo.classNames.icon
)}
/>
)}
<span
class={cn(
'inline-flex h-5 w-5 items-center justify-center rounded-xs text-xs font-bold',
service.overallScoreInfo.classNameBg
)}
>
{service.overallScoreInfo.formattedScore}
</span>
</a>
</li>
))}
</ul>
</details>
)}
</div>
))
}
</div>
<!-- Desktop view -->
<div
class="-m-2 hidden grid-cols-[minmax(auto,calc(var(--spacing)*64))_auto_auto_1fr_auto_auto_auto] gap-x-4 md:grid"
>
<div class="col-span-full grid grid-cols-subgrid p-2">
<a href={makeSortUrl('name')}>
Name <SortArrowIcon active={filters['sort-by'] === 'name'} sortOrder={filters['sort-order']} />
</a>
<a href={makeSortUrl('category')}>
Category <SortArrowIcon
active={filters['sort-by'] === 'category'}
sortOrder={filters['sort-order']}
/>
</a>
<a href={makeSortUrl('type')}>
Type <SortArrowIcon active={filters['sort-by'] === 'type'} sortOrder={filters['sort-order']} />
</a>
<div>Description</div>
<a href={makeSortUrl('privacy')}>
Privacy <SortArrowIcon active={filters['sort-by'] === 'privacy'} sortOrder={filters['sort-order']} />
</a>
<a href={makeSortUrl('trust')}>
Trust <SortArrowIcon active={filters['sort-by'] === 'trust'} sortOrder={filters['sort-order']} />
</a>
<div></div>
</div>
{
attributesWithInfo.map((attribute) => (
<div class="group col-span-full grid grid-cols-subgrid hover:bg-zinc-800">
<input type="checkbox" id={`show-services-${attribute.id}`} class="peer/show-services hidden" />
<label
for={`show-services-${attribute.id}`}
class={cn(
'col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180',
attribute.services.length === 0 && 'cursor-default'
)}
aria-label={`Show services for ${attribute.title}`}
>
<h3 class={cn('text-lg font-bold', attribute.typeInfo.classNames.text)}>{attribute.title}</h3>
<BadgeStandard
text={attribute.categoryInfo.label}
icon={attribute.categoryInfo.icon}
class={attribute.categoryInfo.classNames.icon}
/>
<BadgeStandard
text={attribute.typeInfo.label}
icon={attribute.typeInfo.icon}
class={attribute.typeInfo.classNames.icon}
/>
<div
class={cn(
'prose prose-sm prose-invert text-sm text-pretty',
attribute.typeInfo.classNames.text
)}
>
<Markdown content={attribute.description} />
</div>
<div
class={cn('text-center text-base', attribute.typeInfo.classNames.text, {
'text-red-400': attribute.privacyPoints < 0,
'text-green-400': attribute.privacyPoints > 0,
'text-zinc-400': attribute.privacyPoints === 0,
})}
>
{formatNumber(attribute.privacyPoints, { showSign: true })}
</div>
<div
class={cn('text-center text-base', attribute.typeInfo.classNames.text, {
'text-red-400': attribute.trustPoints < 0,
'text-green-400': attribute.trustPoints > 0,
'text-zinc-400': attribute.trustPoints === 0,
})}
>
{formatNumber(attribute.trustPoints, { showSign: true })}
</div>
<div class="flex items-center justify-center">
{attribute.services.length > 0 && (
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon />
)}
</div>
</label>
{attribute.services.length > 0 && (
<div class="col-span-full hidden rounded bg-zinc-700/80 peer-checked/show-services:block">
<ul class="grid max-h-64 grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*64),1fr))] gap-x-4 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] px-3 py-3">
{attribute.services.map((service) => (
<li>
<a
href={`/service/${service.slug}`}
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800"
>
{service.imageUrl ? (
<MyPicture
src={service.imageUrl}
alt={service.name}
width={24}
height={24}
class="size-6 shrink-0 rounded-xs object-contain"
/>
) : (
<div class="flex size-6 shrink-0 items-center justify-center rounded-xs bg-zinc-800 text-zinc-500">
<Icon name="ri:image-line" class="size-4" />
</div>
)}
<div
class={cn(
'mt-1 flex-1 truncate text-xs text-zinc-300',
service.verificationStatus === 'VERIFICATION_FAILED' && 'text-red-200'
)}
>
{service.name}
</div>
{service.verificationStatus !== 'APPROVED' && (
<Icon
name={service.verificationStatusInfo.icon}
class={cn(
'inline-block size-4 shrink-0',
service.verificationStatusInfo.classNames.icon
)}
/>
)}
<span
class={cn(
'inline-flex h-5 w-5 items-center justify-center rounded-xs text-xs font-bold',
service.overallScoreInfo.classNameBg
)}
>
{service.overallScoreInfo.formattedScore}
</span>
</a>
</li>
))}
</ul>
</div>
)}
</div>
))
}
</div>
</BaseLayout>