Compare commits

...

3 Commits

Author SHA1 Message Date
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
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
15 changed files with 371 additions and 191 deletions

View File

@@ -916,7 +916,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
moderator: {
name: 'moderator_dev',
@@ -928,7 +928,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
verified: {
name: 'verified_dev',

View File

@@ -6,12 +6,8 @@ import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import {
imageFileSchema,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../../lib/zodUtils'
import { separateServiceUrlsByType } from '../../lib/urls'
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
@@ -19,11 +15,10 @@ const serviceSchemaBase = z.object({
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(20),
name: z.string().min(1).max(40),
description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired,
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -85,13 +80,20 @@ export const adminServiceActions = {
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({
data: {
name: input.name,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
@@ -187,14 +189,21 @@ export const adminServiceActions = {
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,

View File

@@ -12,14 +12,11 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import {
imageFileSchemaRequired,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import { separateServiceUrlsByType } from '../lib/urls'
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
import type { Prisma } from '@prisma/client'
@@ -33,11 +30,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
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: {
name: {
contains: input.name,
mode: 'insensitive',
id: {
in: matches.map(({ id }) => id),
},
},
select: {
@@ -47,8 +45,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true,
},
})
return possibleDuplicates
}
const serializeExtraNotes = <T extends Record<string, unknown>>(
@@ -161,9 +157,8 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' }
),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired,
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -210,6 +205,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = {
id: true,
@@ -221,9 +222,10 @@ export const serviceSuggestionActions = {
name: input.name,
slug: input.slug,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies,
imageUrl,

View File

@@ -49,6 +49,7 @@ const {
class={cn(
// 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]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
className
)}
>
@@ -80,16 +81,20 @@ const {
))
}
</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]" />
Ties randomly sorted
</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>
<!-- Text Search -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label>
<label for="q">Name</label>
</legend>
<input
type="text"

View File

@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number
sortSeed?: string
filters: ServicesFiltersObject
includeScams: boolean
countCommunityOnly: number | null
inlineIcons?: boolean
}
@@ -35,15 +34,12 @@ const {
sortSeed,
class: className,
filters,
includeScams,
countCommunityOnly,
inlineIcons,
...divProps
} = Astro.props
const hasScams =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
/>
{
countCommunityOnly && (
!!countCommunityOnly && (
<>
<Button
as="a"
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
inlineIcon={inlineIcons}
/>
)}
{countCommunityOnly && (
{!!countCommunityOnly && (
<Button
as="a"
href={urlIfIncludingCommunity}

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 = {
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
findMany: (...args: any[]) => Promise<any>
}
? PrismaClient[Model] & {
findManyAndCount: FindManyAndCountType
}
: PrismaClient[Model]
}
// type ModelsWithCustomMethods = {
// [Model in keyof PrismaClient]: PrismaClient[Model] extends {
// findMany: (...args: any[]) => Promise<any>
// }
// ? PrismaClient[Model] & {
// findManyAndCount: FindManyAndCountType
// }
// : PrismaClient[Model]
// }
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
// type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
function prismaClientSingleton(): ExtendedPrismaClient {
const prisma = new PrismaClient().$extends(findManyAndCount)
return prisma as unknown as ExtendedPrismaClient
function prismaClientSingleton() {
return new PrismaClient().$extends(findManyAndCount)
}
declare global {

View File

@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
}
return url.origin
}
export function separateServiceUrlsByType(allServiceUrls: string[]) {
const result: {
web: string[]
onion: string[]
i2p: string[]
} = {
web: [],
onion: [],
i2p: [],
}
for (const url of allServiceUrls) {
const parsedUrl = new URL(url)
if (parsedUrl.origin.endsWith('.onion')) {
result.onion.push(url)
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
result.i2p.push(url)
} else {
result.web.push(url)
}
}
return result
}

View File

@@ -19,7 +19,7 @@ export const zodUrlOptionalProtocol = z.preprocess(
const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
},
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
message: 'Invalid URL',
})
)

View File

@@ -201,6 +201,41 @@ 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.
## API
Access basic service data via our public API.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
### `GET /api/v1/service/[id]`
Fetches details for a single service.
- **`[id]`**: Can be a service ID, slug, name, or any registered URL (including .onion/.i2p).
**Example Requests:**
```
/api/v1/service/bisq
/api/v1/service/https://bisq.network
```
**Example Response (200 OK):**
```json
{
"name": "Bisq",
"description": "Decentralized Bitcoin exchange network.",
"kycLevel": 0,
"categories": ["exchange"],
"serviceUrls": ["https://bisq.network/"],
"onionUrls": [],
"i2pUrls": [],
"tosUrls": ["https://bisq.network/terms-of-service/"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
## Support
If you like this project, you can **support** it through these methods:

View File

@@ -233,16 +233,30 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={service.id} />
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputText
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
</div>
<InputTextArea
label="Description"
name="description"
@@ -254,76 +268,44 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description}
/>
<InputText
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line"
name="serviceUrls"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com\nhttps://example2.com',
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'grow min-h-24',
required: true,
}}
value={service.serviceUrls.join('\n')}
error={serviceInputErrors.serviceUrls}
class="row-span-2 flex flex-col self-stretch"
value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true,
}}
value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls}
/>
<InputTextArea
label="Onion URLs"
description="One per line"
name="onionUrls"
<InputText
label="Referral link path"
name="referral"
inputProps={{
rows: 3,
placeholder: 'http://example1.onion\nhttp://example2.onion',
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
value={service.onionUrls.join('\n')}
error={serviceInputErrors.onionUrls}
/>
<InputTextArea
label="I2P URLs"
description="One per line"
name="i2pUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
}}
value={service.i2pUrls.join('\n')}
error={serviceInputErrors.referral}
class="self-end"
description="Will be appended to the service URL"
/>
</div>
<InputText
label="Referral link path"
name="referral"
inputProps={{
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={serviceInputErrors.referral}
description="Will be appended to the service URL"
/>
<div class="flex items-center justify-between gap-2">
<InputImageFile
label="Image"

View File

@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div>
<div>
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label>
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="serviceUrls"
id="serviceUrls"
name="allServiceUrls"
id="allServiceUrls"
rows={3}
placeholder="https://example1.com https://example2.com"
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text=""
/>
{
inputErrors.serviceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
)
}
</div>
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
}
</div>
<div>
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="onionUrls"
id="onionUrls"
rows={3}
placeholder="http://example.onion"
set:text=""
/>
{
inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2">

View File

@@ -0,0 +1,121 @@
import { prisma } from '../../../../lib/prisma'
import type { Prisma } from '@prisma/client'
import type { APIRoute } from 'astro'
const MAX_ID_LENGTH = 2048
export const GET: APIRoute = async ({ params }) => {
const { id } = params
if (!id) {
return new Response(JSON.stringify({ error: 'ID parameter is missing' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
}
if (id.length > MAX_ID_LENGTH) {
return new Response(JSON.stringify({ error: 'ID parameter is too long' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
}
const orConditions: Prisma.ServiceWhereInput[] = [
{ slug: id },
{ name: id },
{ serviceUrls: { has: id } },
{ onionUrls: { has: id } },
{ i2pUrls: { has: id } },
]
// Try direct ID lookup first
const numericId = parseInt(id, 10)
if (!isNaN(numericId)) {
orConditions.push({ id: numericId })
}
if (id.startsWith('http://') || id.startsWith('https://')) {
let alternativeId: string
if (id.endsWith('/')) {
alternativeId = id.slice(0, -1) // Remove trailing slash
} else {
alternativeId = id + '/' // Add trailing slash
}
orConditions.push({ serviceUrls: { has: alternativeId } })
orConditions.push({ onionUrls: { has: alternativeId } })
orConditions.push({ i2pUrls: { has: alternativeId } })
} else {
// For non-HTTP/S IDs, check as is (could be a direct onion/i2p address without protocol)
orConditions.push({ serviceUrls: { has: id } })
orConditions.push({ onionUrls: { has: id } })
orConditions.push({ i2pUrls: { has: id } })
}
try {
const service = await prisma.service.findFirst({
where: {
OR: orConditions,
},
select: {
name: true,
slug: true,
description: true,
kycLevel: true,
categories: {
select: {
name: true,
slug: true,
icon: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
},
})
if (!service) {
return new Response(JSON.stringify({ error: 'Service not found' }), {
status: 404,
headers: {
'Content-Type': 'application/json',
},
})
}
const responseData = {
name: service.name,
description: service.description,
kycLevel: service.kycLevel,
categories: service.categories.map((category) => category.slug),
serviceUrls: service.serviceUrls,
onionUrls: service.onionUrls,
i2pUrls: service.i2pUrls,
tosUrls: service.tosUrls,
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
}
return new Response(JSON.stringify(responseData), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
} catch (error) {
console.error('Error fetching service:', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
})
}
}

View File

@@ -26,6 +26,7 @@ import {
} from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
@@ -213,7 +214,10 @@ const groupedAttributes = groupBy(
'value'
)
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
const where = {
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
listedAt: {
lte: new Date(),
},
@@ -243,16 +247,6 @@ const where = {
} 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
? [
{
@@ -338,33 +332,45 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
[
'Unable to load services.',
async () => {
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
where,
select: {
id: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(typeof sortOptions)[number]['orderBy']['key'],
true
>),
},
})
const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
{
where,
select: {
id: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(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 selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
const sortedServices = orderBy(
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)
const unsortedServicesWithInfo = await prisma.service.findMany({
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
where: {
id: {
in: sortedServices.map((service) => service.id),
},
},
select: {
id: true,
name: true,
slug: 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(
unsortedServicesWithInfo,
[
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key,
// Now we can shuffle indeternimistically, because the pagination was already applied
() => Math.random(),
],
[selectedSort.orderBy.direction, 'asc']
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
)
return [sortedServicesWithInfo, totalServices] as const
@@ -712,7 +724,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']}
filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly}
inlineIcons
/>

View File

@@ -201,34 +201,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.description}
/>
<InputTextArea
label="Service URLs"
name="serviceUrls"
inputProps={{
required: true,
placeholder: 'https://example1.com\nhttps://example2.org',
}}
error={inputErrors.serviceUrls}
/>
<InputTextArea
label="Terms of Service URLs"
name="tosUrls"
inputProps={{
required: true,
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms',
}}
error={inputErrors.tosUrls}
/>
<InputTextArea
label="Onion URLs"
name="onionUrls"
inputProps={{
placeholder: 'http://example1.onion\nhttp://example2.onion',
}}
error={inputErrors.onionUrls}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'min-h-24',
required: true,
}}
class="row-span-2 flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
required: true,
}}
error={inputErrors.tosUrls}
/>
</div>
<InputCardGroup
name="kycLevel"