417 lines
15 KiB
Plaintext
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>
|