Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
22944fcdb3 Release 202505310921 2025-05-31 09:21:32 +00:00
pluja
f7f380c591 Release 202505302056 2025-05-30 20:56:04 +00:00
pluja
577c524ca2 Release 202505302029 2025-05-30 20:29:01 +00:00
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
19 changed files with 427 additions and 58 deletions

View File

@@ -0,0 +1,2 @@
-- Enable pg_trgm extension for similarity functions
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View File

@@ -0,0 +1,5 @@
import { apiServiceActions } from './service'
export const apiActions = {
service: apiServiceActions,
}

View File

@@ -0,0 +1,113 @@
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { pick } from 'lodash-es'
import { getKycLevelInfo } from '../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
import type { Prisma } from '@prisma/client'
export const apiServiceActions = {
get: defineProtectedAction({
accept: 'json',
permissions: 'guest',
input: z.object({
id: z.coerce.number().int().positive().optional(),
slug: z
.string()
.min(1)
.max(2048)
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'At least one of the following parameters is required: id, slug, url',
})
}
const urlVariants = input.url
? [input.url]
.flatMap((url) =>
[
url,
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
].filter((url) => url !== undefined)
)
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined
const service = await prisma.service.findFirst({
where: {
OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
...(urlVariants
? ([
{ serviceUrls: { hasSome: urlVariants } },
{ onionUrls: { hasSome: urlVariants } },
{ i2pUrls: { hasSome: urlVariants } },
] satisfies Prisma.ServiceWhereInput[])
: []),
],
},
select: {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
},
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
return {
id: service.id,
slug: service.slug,
name: service.name,
description: service.description,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
'slug',
'label',
'labelShort',
'description',
]),
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
categories: service.categories,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
}
},
}),
}

View File

@@ -1,5 +1,6 @@
import { accountActions } from './account' import { accountActions } from './account'
import { adminActions } from './admin' import { adminActions } from './admin'
import { apiActions } from './api'
import { commentActions } from './comment' import { commentActions } from './comment'
import { notificationActions } from './notifications' import { notificationActions } from './notifications'
import { serviceActions } from './service' import { serviceActions } from './service'
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
export const server = { export const server = {
account: accountActions, account: accountActions,
admin: adminActions, admin: adminActions,
api: apiActions,
comment: commentActions, comment: commentActions,
notification: notificationActions, notification: notificationActions,
service: serviceActions, service: serviceActions,

View File

@@ -12,6 +12,7 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation' import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { separateServiceUrlsByType } from '../lib/urls' import { separateServiceUrlsByType } from '../lib/urls'
@@ -29,11 +30,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000 export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => { const findPossibleDuplicates = async (input: { name: string }) => {
const possibleDuplicates = await prisma.service.findMany({ const matches = await findServicesBySimilarity(input.name, 0.3)
return await prisma.service.findMany({
where: { where: {
name: { id: {
contains: input.name, in: matches.map(({ id }) => id),
mode: 'insensitive',
}, },
}, },
select: { select: {
@@ -43,8 +45,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true, description: true,
}, },
}) })
return possibleDuplicates
} }
const serializeExtraNotes = <T extends Record<string, unknown>>( const serializeExtraNotes = <T extends Record<string, unknown>>(

View File

@@ -49,6 +49,7 @@ const {
class={cn( class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved // Check the scam filter when there is a text quey and the user has checked verified and approved
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked', 'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
className className
)} )}
> >
@@ -80,16 +81,20 @@ const {
)) ))
} }
</select> </select>
<p class="text-day-500 mt-1.5 text-center text-sm"> <p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" /> <Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
Ties randomly sorted Ties randomly sorted
</p> </p>
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
Sorted by match first
</p>
</fieldset> </fieldset>
<!-- Text Search --> <!-- Text Search -->
<fieldset class="mb-6"> <fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500"> <legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label> <label for="q">Name</label>
</legend> </legend>
<input <input
type="text" type="text"

View File

@@ -71,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
/> />
{ {
countCommunityOnly && ( !!countCommunityOnly && (
<> <>
<Button <Button
as="a" as="a"
@@ -192,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
inlineIcon={inlineIcons} inlineIcon={inlineIcons}
/> />
)} )}
{countCommunityOnly && ( {!!countCommunityOnly && (
<Button <Button
as="a" as="a"
href={urlIfIncludingCommunity} href={urlIfIncludingCommunity}

32
web/src/lib/endpoints.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type ActionClient } from 'astro:actions'
import type { APIRoute } from 'astro'
import type { z } from 'astro/zod'
export function makeEndpointFromAction<Action extends ActionClient<unknown, 'json', z.ZodType> & string>(
action: Action
): APIRoute {
return async (context) => {
try {
const input = await context.request.json()
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const result = await context.callAction(action, input)
if (result.error) {
console.error('Error on endpoint', result.error)
return new Response(JSON.stringify({ error: result.error.message }), {
status: result.error.status,
})
}
return new Response(JSON.stringify(result.data), {
status: 200,
})
} catch (error) {
console.error('Error on endpoint', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
})
}
}
}

View File

@@ -0,0 +1,16 @@
import { z } from 'astro/zod'
import { prisma } from './prisma'
export async function findServicesBySimilarity(value: string, similarityThreshold = 0.01) {
const data = await prisma.$queryRaw`
SELECT id, similarity(name, ${value}) AS similarity_score
FROM "Service"
WHERE similarity(name, ${value}) >= ${similarityThreshold}
ORDER BY similarity(name, ${value}) desc`
const schema = z.array(z.object({ id: z.number(), similarity_score: z.number() }))
const parsedData = schema.parse(data)
return parsedData.map(({ id, similarity_score }) => ({ id, similarityScore: similarity_score }))
}

View File

@@ -25,24 +25,23 @@ const findManyAndCount = {
}, },
} }
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount // NOTE: This used to be necessary to cast the prismaClientSingleton return type, but it seems not anymore. I left it, just in case we need it again
// type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
type ModelsWithCustomMethods = { // type ModelsWithCustomMethods = {
[Model in keyof PrismaClient]: PrismaClient[Model] extends { // [Model in keyof PrismaClient]: PrismaClient[Model] extends {
findMany: (...args: any[]) => Promise<any> // findMany: (...args: any[]) => Promise<any>
} // }
? PrismaClient[Model] & { // ? PrismaClient[Model] & {
findManyAndCount: FindManyAndCountType // findManyAndCount: FindManyAndCountType
} // }
: PrismaClient[Model] // : PrismaClient[Model]
} // }
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient // type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
function prismaClientSingleton(): ExtendedPrismaClient { function prismaClientSingleton() {
const prisma = new PrismaClient().$extends(findManyAndCount) return new PrismaClient().$extends(findManyAndCount)
return prisma as unknown as ExtendedPrismaClient
} }
declare global { declare global {

View File

@@ -201,6 +201,12 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## API
You can access basic service data via our public API.
See the [API page](/docs/api) for more details.
## Support ## Support
If you like this project, you can **support** it through these methods: If you like this project, you can **support** it through these methods:

View File

@@ -53,7 +53,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.displayName, disabled: !user.karmaUnlocks.displayName,
}} }}
description={!user.karmaUnlocks.displayName description={!user.karmaUnlocks.displayName
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/docs/karma)`
: undefined} : undefined}
/> />
@@ -68,7 +68,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.websiteLink, disabled: !user.karmaUnlocks.websiteLink,
}} }}
description={!user.karmaUnlocks.websiteLink description={!user.karmaUnlocks.websiteLink
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/docs/karma)`
: undefined} : undefined}
/> />
@@ -80,7 +80,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
square square
disabled={!user.karmaUnlocks.profilePicture} disabled={!user.karmaUnlocks.profilePicture}
description={!user.karmaUnlocks.profilePicture description={!user.karmaUnlocks.profilePicture
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/docs/karma)`
: undefined} : undefined}
removeCheckbox={user.picture removeCheckbox={user.picture
? { ? {

View File

@@ -529,8 +529,9 @@ if (!user) return Astro.rewrite('/404')
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300"> <p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline" Earn karma to unlock features and privileges. <a
>Learn about karma</a href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
> >
</p> </p>
</div> </div>

View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
export const ALL: APIRoute = (context) => {
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
return new Response(
JSON.stringify({
error: 'Endpoint not found',
}),
{
status: 404,
}
)
}

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const QUERY: APIRoute = makeEndpointFromAction(actions.api.service.get)

155
web/src/pages/docs/api.mdx Normal file
View File

@@ -0,0 +1,155 @@
---
layout: ../../layouts/MarkdownLayout.astro
title: API
author: KYCnot.me
pubDate: 2025-05-31
description: 'Access basic service data via our public API.'
icon: 'ri:plug-line'
---
import { SOURCE_CODE_URL } from 'astro:env/server'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
Access basic service data via our public API.
All endpoints should be prefixed with `/api/v1/`.
The endpoints <a href={SOURCE_CODE_URL}>source code</a> is available on the `/web/src/actions/api/index.ts` file.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
## `QUERY` `/service/get`
Fetches details for a single service by various lookup criteria.
### Request Parameters
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `id` | number | No* | Service ID |
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
\* At least one of the marked parameters is required.
### Response Format
```typescript
type ServiceResponse = {
id: number
slug: string
name: string
description: string
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
verificationStatusInfo: {
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
slug: string
label: string
labelShort: string
description: string
}
kycLevel: 0 | 1 | 2 | 3 | 4
kycLevelInfo: {
value: 0 | 1 | 2 | 3 | 4
name: string
description: string
}
categories: {
name: string
slug: string
}[]
serviceUrls: string[]
tosUrls: string[]
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
}
```
### KYC Levels
<ul>
{kycLevels.map((level) => (
<li key={level.id}>
<strong>{level.id}</strong>: {level.name} - {level.description}
</li>
))}
</ul>
### Verification Status
<ul>
{verificationStatuses.map((status) => (
<li key={status.value}>
<strong>{status.value}</strong>: {status.description}
</li>
))}
</ul>
### Example Request
```zsh
curl -X QUERY https://kycnot.me/api/v1/service/get \
-H "Content-Type: application/json" \
-d '{"slug": "my-example-service"}'
```
### Example Response
```json
{
"name": "My Example Service",
"description": "This is a description of my example service",
"verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": {
"value": "VERIFICATION_SUCCESS",
"slug": "verified",
"label": "Verified",
"labelShort": "Verified",
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
},
"kycLevel": 0,
"kycLevelInfo": {
"value": 0,
"name": "Guaranteed no KYC",
"description": "Terms explicitly state KYC will never be requested."
},
"categories": [
{
"name": "Exchange",
"slug": "exchange"
}
],
"serviceUrls": [
"https://example.com",
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
],
"tosUrls": ["https://example.com/terms-of-service"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
### Error Responses
**404 Not Found**: Service not found
```json
{
"error": "Service not found"
}
```
**400 Bad Request**: Invalid input parameters
```json
{
"error": "Validation error message"
}
```
**500 Internal Server Error**: Server error
```json
{
"error": "Internal server error"
}
```

View File

@@ -1,5 +1,5 @@
--- ---
layout: ../layouts/MarkdownLayout.astro layout: ../../layouts/MarkdownLayout.astro
title: How does karma work? title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works" description: "KYCnot.me has a user karma system, here's how it works"
icon: 'ri:hearts-line' icon: 'ri:hearts-line'
@@ -7,7 +7,7 @@ author: KYCnot.me
pubDate: 2025-05-15 pubDate: 2025-05-15
--- ---
import KarmaUnlocksTable from '../components/KarmaUnlocksTable.astro' import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services. [KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.

View File

@@ -26,6 +26,7 @@ import {
} from '../constants/verificationStatus' } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays' import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { parseIntWithFallback } from '../lib/numbers' import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects' import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
@@ -213,7 +214,10 @@ const groupedAttributes = groupBy(
'value' 'value'
) )
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
const where = { const where = {
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
listedAt: { listedAt: {
lte: new Date(), lte: new Date(),
}, },
@@ -243,16 +247,6 @@ const where = {
} satisfies Prisma.ServiceWhereInput, } satisfies Prisma.ServiceWhereInput,
] ]
: []), : []),
...(filters.q
? [
{
OR: [
{ name: { contains: filters.q, mode: 'insensitive' as const } },
{ description: { contains: filters.q, mode: 'insensitive' as const } },
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.networks.length ...(filters.networks.length
? [ ? [
{ {
@@ -338,33 +332,45 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
[ [
'Unable to load services.', 'Unable to load services.',
async () => { async () => {
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({ const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
where, {
select: { where,
id: true, select: {
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record< id: true,
(typeof sortOptions)[number]['orderBy']['key'], ...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
true (typeof sortOptions)[number]['orderBy']['key'],
>), true
}, >),
}) },
}
)
const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({
...service,
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
}))
const rng = seedrandom(filters['sort-seed']) const rng = seedrandom(filters['sort-seed'])
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
const sortedServices = orderBy( const sortedServices = orderBy(
unsortedServices, unsortedServices,
[selectedSort.orderBy.key, () => rng()], [
[selectedSort.orderBy.direction, 'asc'] ...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key,
() => rng(),
],
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE) ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
const unsortedServicesWithInfo = await prisma.service.findMany({ const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
where: { where: {
id: { id: {
in: sortedServices.map((service) => service.id), in: sortedServices.map((service) => service.id),
}, },
}, },
select: { select: {
id: true,
name: true, name: true,
slug: true, slug: true,
description: true, description: true,
@@ -398,14 +404,20 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
}, },
}) })
const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({
...service,
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
}))
const sortedServicesWithInfo = orderBy( const sortedServicesWithInfo = orderBy(
unsortedServicesWithInfo, unsortedServicesWithInfo,
[ [
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key, selectedSort.orderBy.key,
// Now we can shuffle indeternimistically, because the pagination was already applied // Now we can shuffle indeternimistically, because the pagination was already applied
() => Math.random(), () => Math.random(),
], ],
[selectedSort.orderBy.direction, 'asc'] [...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
) )
return [sortedServicesWithInfo, totalServices] as const return [sortedServicesWithInfo, totalServices] as const

View File

@@ -647,8 +647,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300"> <p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline" Earn karma to unlock features and privileges. <a
>Learn about karma</a href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
> >
</p> </p>
</div> </div>