Compare commits

...

13 Commits

Author SHA1 Message Date
pluja
3afa824c18 Release 202505311149 2025-05-31 11:49:38 +00:00
pluja
9a68112e24 Release 202505311113 2025-05-31 11:13:24 +00:00
pluja
0c40d8eec5 Release 202505311002 2025-05-31 10:02:50 +00:00
pluja
e16c9b64ed Release 202505311001 2025-05-31 10:01:35 +00:00
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
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
pluja
e536ca6519 Release 202505261804 2025-05-26 18:04:45 +00:00
pluja
b361ed3aa8 Release 202505261604 2025-05-26 16:04:25 +00:00
51 changed files with 1192 additions and 376 deletions

View File

@@ -177,6 +177,12 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
LOGS_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: true,
}),
RELEASE_NUMBER: envField.number({ RELEASE_NUMBER: envField.number({
context: 'server', context: 'server',

View File

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

View File

@@ -916,7 +916,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', 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: { moderator: {
name: 'moderator_dev', name: 'moderator_dev',
@@ -928,7 +928,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', 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: { verified: {
name: 'verified_dev', name: 'verified_dev',

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
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, context) => {
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: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
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,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
},
})
if (
!service ||
(service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') ||
!service.listedAt ||
service.listedAt > new Date()
) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
return {
id: service.id,
slug: service.slug,
name: service.name,
description: service.description,
serviceVisibility: service.serviceVisibility,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
'slug',
'label',
'labelShort',
'description',
]),
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
}
},
}),
}

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

@@ -1,10 +1,4 @@
import { import { Currency } from '@prisma/client'
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns' import { formatDistanceStrict } from 'date-fns'
@@ -12,14 +6,11 @@ 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 { import { separateServiceUrlsByType } from '../lib/urls'
imageFileSchemaRequired, import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -33,11 +24,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: {
@@ -47,8 +39,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>>(
@@ -122,9 +112,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({ const serviceSuggestion = await prisma.serviceSuggestion.create({
data: { data: {
type: ServiceSuggestionType.EDIT_SERVICE, type: 'EDIT_SERVICE',
notes: combinedNotes, notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },
@@ -161,9 +151,8 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' } { message: 'Slug must be unique, try a different one' }
), ),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH), description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)), kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -210,6 +199,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name) 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 { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = { const serviceSelect = {
id: true, id: true,
@@ -221,18 +216,19 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
imageUrl, imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED, verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
listedAt: new Date(), listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED, serviceVisibility: 'UNLISTED',
categories: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),
}, },
@@ -248,8 +244,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({ const serviceSuggestion = await tx.serviceSuggestion.create({
data: { data: {
notes: input.notes, notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE, type: 'CREATE_SERVICE',
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },

View File

@@ -126,11 +126,13 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
VariantProps<typeof badge> & { VariantProps<typeof badge> & {
as: Tag as: Tag
icon?: string icon?: string
endIcon?: string
text: string text: string
inlineIcon?: boolean inlineIcon?: boolean
classNames?: { classNames?: {
icon?: string icon?: string
text?: string text?: string
endIcon?: string
} }
} }
> >
@@ -138,6 +140,7 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
const { const {
as: Tag = 'div', as: Tag = 'div',
icon: iconName, icon: iconName,
endIcon: endIconName,
text: textContent, text: textContent,
inlineIcon, inlineIcon,
classNames, classNames,
@@ -159,4 +162,9 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
) )
} }
<span class={textSlot({ class: classNames?.text })}>{textContent}</span> <span class={textSlot({ class: classNames?.text })}>{textContent}</span>
{
!!endIconName && (
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
)
}
</Tag> </Tag>

View File

@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
as: Tag as: Tag
icon: string icon: string
endIcon?: string
text: string text: string
inlineIcon?: boolean inlineIcon?: boolean
}> }>
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props const { icon, text, class: className, inlineIcon, endIcon, as: Tag = 'div', ...divProps } = Astro.props
--- ---
<Tag <Tag
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
> >
<Icon name={icon} class="size-4" is:inline={inlineIcon} /> <Icon name={icon} class="size-4" is:inline={inlineIcon} />
<span>{text}</span> <span>{text}</span>
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
</Tag> </Tag>

View File

@@ -0,0 +1,41 @@
---
import { uniq } from 'lodash-es'
import { cn } from '../lib/cn'
import BadgeStandard from './BadgeStandard.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<
ComponentProps<typeof BadgeStandard>,
'as' | 'endIcon' | 'href' | 'icon' | 'text' | 'variant'
> & {
name: string
value: string
label: string
icon: string
}
const { name, value, label, icon, ...props } = Astro.props
const selectedValues = Astro.url.searchParams.getAll(name)
const isSelected = selectedValues.includes(value)
const url = new URL(Astro.url)
url.searchParams.delete(name)
const valuesToSet = uniq(isSelected ? selectedValues.filter((v) => v !== value) : [...selectedValues, value])
for (const value of valuesToSet) {
url.searchParams.set(name, value)
}
---
<BadgeStandard
as="a"
href={url.href}
class={cn(isSelected && 'bg-green-950 text-green-500')}
text={label}
icon={icon}
endIcon={isSelected ? 'ri:close-fill' : undefined}
{...props}
/>

View File

@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
/> />
<Tooltip text="Send"> <Tooltip text="Send">
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" /> <Button
type="submit"
icon="ri:send-plane-fill"
size="lg"
color="success"
class="h-16"
label="Send"
iconOnly
/>
</Tooltip> </Tooltip>
</form> </form>
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>} {!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}

View File

@@ -47,12 +47,9 @@ const averageUserRatingFromQuery =
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
if (averageUserRatingFromProps !== undefined) { if (averageUserRatingFromProps !== undefined) {
if ( const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
averageUserRatingFromQuery !== averageUserRatingFromProps || const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
(averageUserRatingFromQuery !== null && if (a !== b) {
averageUserRatingFromProps !== null &&
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
) {
console.error( console.error(
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}` `The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
) )

View File

@@ -27,6 +27,12 @@ const links = [
icon: 'i2p', icon: 'i2p',
external: true, external: true,
}, },
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{ {
href: '/about', href: '/about',
label: 'About', label: 'About',

View File

@@ -160,6 +160,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
<a <a
href={makeUnimpersonateUrl(Astro.url)} href={makeUnimpersonateUrl(Astro.url)}
data-astro-reload data-astro-reload
data-astro-prefetch="tap"
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200" class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
transition:name="header-unimpersonate-link" transition:name="header-unimpersonate-link"
aria-label="Unimpersonate" aria-label="Unimpersonate"

View File

@@ -43,7 +43,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
: Array.from({ length: icons.length }, () => option.iconClassName) : Array.from({ length: icons.length }, () => option.iconClassName)
: [] : []
return ( return (
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1"> <label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1 has-checked:bg-green-800/20 has-checked:hover:bg-green-800/30">
<input <input
transition:persist transition:persist
type="checkbox" type="checkbox"

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

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

View File

@@ -8,7 +8,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
label: string label: string
/** Notice that the first capture group is then used to format the value */ /** Notice that the first capture group is then used to format the value */
matcher: RegExp matcher: RegExp
formatter: (value: string) => string | null formatter: (match: RegExpMatchArray) => string | null
icon: string icon: string
} }
@@ -24,82 +24,96 @@ export const {
label: type ? transformCase(type, 'title') : String(type), label: type ? transformCase(type, 'title') : String(type),
icon: 'ri:shield-fill', icon: 'ri:shield-fill',
matcher: /(.*)/, matcher: /(.*)/,
formatter: (value) => value, formatter: ([, value]) => value ?? String(value),
}), }),
[ [
{ {
type: 'email', type: 'email',
label: 'Email', label: 'Email',
matcher: /mailto:(.+)/, matcher: /mailto:(.+)/,
formatter: (value) => value, formatter: ([, value]) => value ?? 'Email',
icon: 'ri:mail-line', icon: 'ri:mail-line',
}, },
{ {
type: 'telephone', type: 'telephone',
label: 'Telephone', label: 'Telephone',
matcher: /tel:(.+)/, matcher: /tel:(.+)/,
formatter: (value) => { formatter: ([, value]) => {
return parsePhoneNumberWithError(value).formatInternational() return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
}, },
icon: 'ri:phone-line', icon: 'ri:phone-line',
}, },
{ {
type: 'whatsapp', type: 'whatsapp',
label: 'WhatsApp', label: 'WhatsApp',
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.+)/, matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: (value) => { formatter: ([, value]) => {
return parsePhoneNumberWithError(value).formatInternational() return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
}, },
icon: 'ri:whatsapp-line', icon: 'ri:whatsapp-line',
}, },
{ {
type: 'telegram', type: 'telegram',
label: 'Telegram', label: 'Telegram',
matcher: /https?:\/\/(?:www\.)?t\.me\/(.+)/, matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: (value) => `t.me/${value}`, formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
icon: 'ri:telegram-line', icon: 'ri:telegram-line',
}, },
{ {
type: 'linkedin', type: 'linkedin',
label: 'LinkedIn', label: 'LinkedIn',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/, matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: (value) => `in/${value}`, formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
icon: 'ri:linkedin-box-line', icon: 'ri:linkedin-box-line',
}, },
{ {
type: 'x', type: 'x',
label: 'X', label: 'X',
matcher: /https?:\/\/(?:www\.)?x\.com\/(.+)/, matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: (value) => `@${value}`, formatter: ([, value]) => (value ? `@${value}` : 'X'),
icon: 'ri:twitter-x-line', icon: 'ri:twitter-x-line',
}, },
{ {
type: 'instagram', type: 'instagram',
label: 'Instagram', label: 'Instagram',
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.+)/, matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: (value) => `@${value}`, formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
icon: 'ri:instagram-line', icon: 'ri:instagram-line',
}, },
{ {
type: 'matrix', type: 'matrix',
label: 'Matrix', label: 'Matrix',
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/, matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: (value) => value, formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
icon: 'ri:hashtag', icon: 'ri:hashtag',
}, },
{ {
type: 'bitcointalk', type: 'bitcointalk',
label: 'BitcoinTalk', label: 'BitcoinTalk',
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/, matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk', formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line', icon: 'ri:btc-line',
}, },
// Website must go last because it's a catch-all
{ {
type: 'simplex',
label: 'SimpleX Chat',
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
},
{
type: 'nostr',
label: 'Nostr',
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
formatter: () => 'Nostr',
icon: 'nostr',
},
{
// Website must go last because it's a catch-all
type: 'website', type: 'website',
label: 'Website', label: 'Website',
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/, matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: (value) => value, formatter: ([, value]) => value ?? 'Website',
icon: 'ri:global-line', icon: 'ri:global-line',
}, },
] as const satisfies ContactMethodInfo[] ] as const satisfies ContactMethodInfo[]
@@ -107,10 +121,10 @@ export const {
export function formatContactMethod(url: string) { export function formatContactMethod(url: string) {
for (const contactMethod of contactMethods) { for (const contactMethod of contactMethods) {
const captureGroup = url.match(contactMethod.matcher)?.[1] const match = url.match(contactMethod.matcher)
if (!captureGroup) continue if (!match) continue
const formattedValue = contactMethod.formatter(captureGroup) const formattedValue = contactMethod.formatter(match)
if (!formattedValue) continue if (!formattedValue) continue
return { return {

View File

@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
} }
icon: string icon: string
color: ComponentProps<typeof BadgeSmall>['color'] color: ComponentProps<typeof BadgeSmall>['color']
isSolved: boolean
showBanner: boolean
} }
export const { export const {
@@ -36,6 +38,8 @@ export const {
}, },
icon: 'ri:question-fill', icon: 'ri:question-fill',
color: 'gray', color: 'gray',
isSolved: false,
showBanner: false,
}), }),
[ [
{ {
@@ -46,8 +50,10 @@ export const {
classNames: { classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:error-warning-fill', icon: 'ri:alert-fill',
color: 'yellow', color: 'yellow',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'WARNING_SOLVED', id: 'WARNING_SOLVED',
@@ -55,10 +61,12 @@ export const {
label: 'Warning Solved', label: 'Warning Solved',
description: 'A previously reported warning has been solved', description: 'A previously reported warning has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:alert-fill',
color: 'green', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'ALERT', id: 'ALERT',
@@ -68,8 +76,10 @@ export const {
classNames: { classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:alert-fill', icon: 'ri:spam-fill',
color: 'red', color: 'red',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'ALERT_SOLVED', id: 'ALERT_SOLVED',
@@ -77,10 +87,12 @@ export const {
label: 'Alert Solved', label: 'Alert Solved',
description: 'A previously reported alert has been solved', description: 'A previously reported alert has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:spam-fill',
color: 'green', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'INFO', id: 'INFO',
@@ -92,6 +104,8 @@ export const {
}, },
icon: 'ri:information-fill', icon: 'ri:information-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'NORMAL', id: 'NORMAL',
@@ -103,6 +117,8 @@ export const {
}, },
icon: 'ri:notification-fill', icon: 'ri:notification-fill',
color: 'green', color: 'green',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'UPDATE', id: 'UPDATE',
@@ -114,6 +130,8 @@ export const {
}, },
icon: 'ri:pencil-fill', icon: 'ri:pencil-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
] as const satisfies EventTypeInfo<EventType>[] ] as const satisfies EventTypeInfo<EventType>[]
) )

View File

@@ -1 +1 @@
export const SUPPORT_EMAIL = 'support@kycnot.me' export const SUPPORT_EMAIL = 'contact@kycnot.me'

View File

@@ -1,14 +1,18 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { ServiceSuggestionType } from '@prisma/client' import type { ServiceSuggestionType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = { type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
value: T value: T
slug: string slug: string
label: string label: string
icon: string icon: string
order: number
default: boolean default: boolean
color: ComponentProps<typeof BadgeSmall>['color']
} }
export const { export const {
@@ -25,9 +29,11 @@ export const {
(value): ServiceSuggestionTypeInfo<typeof value> => ({ (value): ServiceSuggestionTypeInfo<typeof value> => ({
value, value,
slug: value ? value.toLowerCase() : '', slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value), label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
icon: 'ri:question-line', icon: 'ri:question-line',
order: Infinity,
default: false, default: false,
color: 'zinc',
}), }),
[ [
{ {
@@ -35,14 +41,18 @@ export const {
slug: 'create', slug: 'create',
label: 'Create', label: 'Create',
icon: 'ri:add-line', icon: 'ri:add-line',
order: 1,
default: true, default: true,
color: 'green',
}, },
{ {
value: 'EDIT_SERVICE', value: 'EDIT_SERVICE',
slug: 'edit', slug: 'edit',
label: 'Edit', label: 'Edit',
icon: 'ri:pencil-line', icon: 'ri:pencil-line',
order: 2,
default: false, default: false,
color: 'blue',
}, },
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[] ] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
) )

View File

@@ -65,7 +65,7 @@ export const {
value: 'ARCHIVED', value: 'ARCHIVED',
slug: 'archived', slug: 'archived',
label: 'Archived', label: 'Archived',
description: 'No longer operational', description: 'No longer operational.',
longDescription: longDescription:
'Archived service, no longer exists or ceased operations. Information may be outdated.', 'Archived service, no longer exists or ceased operations. Information may be outdated.',
icon: 'ri:archive-line', icon: 'ri:archive-line',

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

@@ -114,7 +114,13 @@ export class ErrorBanners {
return result return result
} catch (error) { } catch (error) {
this.handler(uiMessage)(error) this.handler(uiMessage)(error)
return fallback as F return fallback as F extends never[]
? T extends [infer _First, ...infer _Rest]
? []
: T extends unknown[]
? T[number][]
: F
: F
} }
} }

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

@@ -0,0 +1,50 @@
import type { Misc } from 'ts-toolbelt'
export async function makeAdminApiCallInfo<T extends Misc.JSON.Object>({
method,
path,
input,
baseUrl,
}: {
method: 'POST' | 'QUERY'
path: `/${string}`
input: T
baseUrl: URL | string
}) {
const fullPath = new URL(`/api/v1${path}`, baseUrl).href
const fetchProsmise = fetch(fullPath, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((res) => {
try {
return res.json() as Promise<Misc.JSON.Value>
} catch (errJson: unknown) {
console.error(errJson)
try {
return res.text()
} catch (errText: unknown) {
console.error(errText)
return ''
}
}
})
let output: Misc.JSON.Value = ''
try {
output = await fetchProsmise
} catch (err: unknown) {
console.error(err)
output = err instanceof Error ? err.message : String(err)
}
return {
method,
path,
fullPath,
input,
output,
}
}

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

@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
} }
return url.origin 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(/\/$/, '') const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput 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', message: 'Invalid URL',
}) })
) )

View File

@@ -67,6 +67,17 @@ Once submitted, you get a unique tracking page where you can monitor its status
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**. All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
#### Requirements
To list a new service, it must fulfill these requirements:
- Offer a service.
- Publicly available website explaining what the service is about
- Terms of service or FAQ document
For examples:
- Just a Telegram link or a criptocurrency itself is not a valid service.
### Suggestion Review Process ### Suggestion Review Process
#### First Review #### First Review
@@ -190,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

@@ -50,6 +50,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
<a <a
href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })} href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })}
data-astro-reload data-astro-reload
data-astro-prefetch="tap"
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
> >
<Icon <Icon
@@ -62,6 +63,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
Astro.locals.actualUser && ( Astro.locals.actualUser && (
<a <a
href={makeUnimpersonateUrl(Astro.url, { redirect })} href={makeUnimpersonateUrl(Astro.url, { redirect })}
data-astro-prefetch="tap"
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
> >
<Icon <Icon

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

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { DATABASE_UI_URL } from 'astro:env/server' import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
@@ -81,6 +81,18 @@ const adminLinks: AdminLink[] = [
base: 'text-gray-300', base: 'text-gray-300',
}, },
}, },
...(LOGS_UI_URL
? [
{
icon: 'ri:menu-search-line',
title: 'Logs',
href: LOGS_UI_URL,
classNames: {
base: 'text-cyan-300',
},
},
]
: []),
] ]
--- ---
@@ -93,25 +105,27 @@ const adminLinks: AdminLink[] = [
<nav> <nav>
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4"> <ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
{ {
adminLinks.map((link) => ( adminLinks
<li .filter((link) => link.href)
class={cn( .map((link) => (
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105', <li
link.classNames.base class={cn(
)} 'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
> link.classNames.base
<a )}
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
> >
<Icon <a
name={link.icon} href={link.href}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100" class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
/> >
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span> <Icon
</a> name={link.icon}
</li> class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
)) />
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
} }
</ol> </ol>
</nav> </nav>

View File

@@ -1,8 +1,8 @@
--- ---
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server' import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
import TimeFormatted from '../../components/TimeFormatted.astro'
import MiniLayout from '../../layouts/MiniLayout.astro' import MiniLayout from '../../layouts/MiniLayout.astro'
import { timeAgo } from '../../lib/timeAgo'
const releaseDate = const releaseDate =
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
@@ -37,7 +37,7 @@ const releaseDate =
{ {
!!releaseDate && ( !!releaseDate && (
<p class="text-day-500 mt-2"> <p class="text-day-500 mt-2">
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />) (<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
</p> </p>
) )
} }

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro' import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro' import ServiceCard from '../../../components/ServiceCard.astro'
@@ -10,6 +11,7 @@ import {
getServiceSuggestionStatusInfo, getServiceSuggestionStatusInfo,
serviceSuggestionStatuses, serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus' } from '../../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn' import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers' import { parseIntWithFallback } from '../../../lib/numbers'
@@ -57,6 +59,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
acceptedCurrencies: true, acceptedCurrencies: true,
serviceVisibility: true,
categories: { categories: {
select: { select: {
name: true, name: true,
@@ -92,6 +95,7 @@ if (!serviceSuggestion) {
} }
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status) const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
--- ---
<BaseLayout <BaseLayout
@@ -110,7 +114,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
label="Back" label="Back"
/> />
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1> <h1 class="font-title text-day-200 text-xl">Service suggestion</h1>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div> </div>
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
@@ -118,12 +124,13 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" /> <ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
</div> </div>
<div <div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
class="rounded-lg border border-green-500/30 bg-black/40 p-4 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs" <h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
>
<h2 class="font-title mb-3 text-lg text-green-500">Suggestion Details</h2>
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm"> <div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<span class="font-title text-gray-400">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<span class="font-title text-gray-400">Status:</span> <span class="font-title text-gray-400">Status:</span>
<span <span
class={cn( class={cn(
@@ -142,7 +149,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span> <span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
<span class="font-title text-gray-400">Service page:</span> <span class="font-title text-gray-400">Service page:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="text-green-400 hover:text-green-500"> <a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
View Service <Icon View Service <Icon
name="ri:external-link-line" name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]" class="ml-0.5 inline-block size-3 align-[-0.05em]"
@@ -164,11 +171,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
</div> </div>
</div> </div>
<div <div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
class="rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="font-title text-lg text-green-500">Messages</h2> <h2 class="font-title text-day-200 text-lg">Messages</h2>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2"> <form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} /> <input type="hidden" name="suggestionId" value={serviceSuggestion.id} />

View File

@@ -4,6 +4,7 @@ import { actions } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro' import TimeFormatted from '../../../components/TimeFormatted.astro'
@@ -12,59 +13,67 @@ import UserBadge from '../../../components/UserBadge.astro'
import { import {
getServiceSuggestionStatusInfo, getServiceSuggestionStatusInfo,
serviceSuggestionStatuses, serviceSuggestionStatuses,
serviceSuggestionStatusesZodEnumBySlug,
serviceSuggestionStatusSlugToId,
} from '../../../constants/serviceSuggestionStatus' } from '../../../constants/serviceSuggestionStatus'
import {
getServiceSuggestionTypeInfo,
serviceSuggestionTypes,
serviceSuggestionTypeSlugToId,
serviceSuggestionTypesZodEnumBySlug,
} from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls' import { makeLoginUrl } from '../../../lib/redirectUrls'
import type { Prisma, ServiceSuggestionStatus } from '@prisma/client' import type { Prisma } from '@prisma/client'
const user = Astro.locals.user const user = Astro.locals.user
if (!user?.admin) { if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' })) return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
} }
const search = Astro.url.searchParams.get('search') ?? ''
const statusEnumValues = serviceSuggestionStatuses.map((s) => s.value) as [string, ...string[]]
const statusParam = Astro.url.searchParams.get('status')
const statusFilter = z
.enum(statusEnumValues)
.nullable()
.parse(statusParam === '' ? null : statusParam) as ServiceSuggestionStatus | null
const { data: filters } = zodParseQueryParamsStoringErrors( const { data: filters } = zodParseQueryParamsStoringErrors(
{ {
'sort-by': z.enum(['service', 'status', 'user', 'createdAt', 'messageCount']).default('createdAt'), search: z.string().optional(),
status: serviceSuggestionStatusesZodEnumBySlug
.transform((slug) => serviceSuggestionStatusSlugToId(slug))
.optional(),
type: serviceSuggestionTypesZodEnumBySlug
.transform((slug) => serviceSuggestionTypeSlugToId(slug))
.optional(),
'sort-by': z
.enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount'])
.default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
}, },
Astro Astro
) )
const sortBy = filters['sort-by']
const sortOrder = filters['sort-order']
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' } let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
if (sortBy === 'createdAt') { if (filters['sort-by'] === 'createdAt') {
prismaOrderBy = { createdAt: sortOrder } prismaOrderBy = { createdAt: filters['sort-order'] }
} }
let suggestions = await prisma.serviceSuggestion.findMany({ let suggestions = await prisma.serviceSuggestion.findMany({
where: { where: {
...(search ...(filters.search
? { ? {
OR: [ OR: [
{ service: { name: { contains: search, mode: 'insensitive' } } }, { service: { name: { contains: filters.search, mode: 'insensitive' } } },
{ user: { name: { contains: search, mode: 'insensitive' } } }, { user: { name: { contains: filters.search, mode: 'insensitive' } } },
{ notes: { contains: search, mode: 'insensitive' } }, { notes: { contains: filters.search, mode: 'insensitive' } },
], ],
} }
: {}), : {}),
status: statusFilter ?? undefined, status: filters.status,
type: filters.type,
}, },
orderBy: prismaOrderBy, orderBy: prismaOrderBy,
select: { select: {
id: true, id: true,
type: true,
status: true, status: true,
notes: true, notes: true,
createdAt: true, createdAt: true,
@@ -119,18 +128,33 @@ let suggestions = await prisma.serviceSuggestion.findMany({
let suggestionsWithDetails = suggestions.map((s) => ({ let suggestionsWithDetails = suggestions.map((s) => ({
...s, ...s,
statusInfo: getServiceSuggestionStatusInfo(s.status), statusInfo: getServiceSuggestionStatusInfo(s.status),
typeInfo: getServiceSuggestionTypeInfo(s.type),
messageCount: s._count.messages, messageCount: s._count.messages,
lastMessage: s.messages[0], lastMessage: s.messages[0],
})) }))
if (sortBy === 'service') { if (filters['sort-by'] === 'service') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder]) suggestionsWithDetails = orderBy(
} else if (sortBy === 'status') { suggestionsWithDetails,
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder]) [(s) => s.service.name.toLowerCase()],
} else if (sortBy === 'user') { [filters['sort-order']]
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder]) )
} else if (sortBy === 'messageCount') { } else if (filters['sort-by'] === 'status') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder]) suggestionsWithDetails = orderBy(
suggestionsWithDetails,
[(s) => s.statusInfo.label],
[filters['sort-order']]
)
} else if (filters['sort-by'] === 'type') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.typeInfo.label], [filters['sort-order']])
} else if (filters['sort-by'] === 'user') {
suggestionsWithDetails = orderBy(
suggestionsWithDetails,
[(s) => s.user.name.toLowerCase()],
[filters['sort-order']]
)
} else if (filters['sort-by'] === 'messageCount') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [filters['sort-order']])
} }
const suggestionCount = suggestionsWithDetails.length const suggestionCount = suggestionsWithDetails.length
@@ -162,7 +186,7 @@ const makeSortUrl = (slug: string) => {
type="text" type="text"
name="search" name="search"
id="search" id="search"
value={search} value={filters.search}
placeholder="Search by service, user, notes..." placeholder="Search by service, user, notes..."
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none" class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
@@ -177,13 +201,30 @@ const makeSortUrl = (slug: string) => {
<option value="">All Statuses</option> <option value="">All Statuses</option>
{ {
serviceSuggestionStatuses.map((status) => ( serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={statusFilter === status.value}> <option value={status.slug} selected={filters.status === status.value}>
{status.label} {status.label}
</option> </option>
)) ))
} }
</select> </select>
</div> </div>
<div>
<label for="type-filter" class="block text-xs font-medium text-zinc-400">Type</label>
<select
name="type"
id="type-filter"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Types</option>
{
serviceSuggestionTypes.map((type) => (
<option value={type.slug} selected={filters.type === type.value}>
{type.label}
</option>
))
}
</select>
</div>
<div class="flex items-end"> <div class="flex items-end">
<Button <Button
as="button" as="button"
@@ -212,7 +253,7 @@ const makeSortUrl = (slug: string) => {
<thead class="bg-zinc-900/30"> <thead class="bg-zinc-900/30">
<tr> <tr>
<th <th
class="w-[25%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[20%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
<a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200"> <a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
Service <SortArrowIcon Service <SortArrowIcon
@@ -222,7 +263,7 @@ const makeSortUrl = (slug: string) => {
</a> </a>
</th> </th>
<th <th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[12%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
<a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200"> <a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200">
User <SortArrowIcon User <SortArrowIcon
@@ -232,7 +273,17 @@ const makeSortUrl = (slug: string) => {
</a> </a>
</th> </th>
<th <th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[10%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('type')} class="flex items-center hover:text-zinc-200">
Type <SortArrowIcon
active={filters['sort-by'] === 'type'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[13%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
<a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200"> <a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200">
Status <SortArrowIcon Status <SortArrowIcon
@@ -295,6 +346,13 @@ const makeSortUrl = (slug: string) => {
<td class="px-4 py-3"> <td class="px-4 py-3">
<UserBadge user={suggestion.user} size="md" /> <UserBadge user={suggestion.user} size="md" />
</td> </td>
<td class="px-4 py-3">
<BadgeSmall
color={suggestion.typeInfo.color}
text={suggestion.typeInfo.label}
icon={suggestion.typeInfo.icon}
/>
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<form method="POST" action={actions.admin.serviceSuggestions.update}> <form method="POST" action={actions.admin.serviceSuggestions.update}>
<input type="hidden" name="suggestionId" value={suggestion.id} /> <input type="hidden" name="suggestionId" value={suggestion.id} />

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { Code } from 'astro:components'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../../components/BadgeSmall.astro' import BadgeSmall from '../../../../components/BadgeSmall.astro'
@@ -33,6 +34,7 @@ import {
verificationStepStatuses, verificationStepStatuses,
} from '../../../../constants/verificationStepStatus' } from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro' import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize' import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma' import { prisma } from '../../../../lib/prisma'
@@ -181,6 +183,20 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
]) ])
if (!service) return Astro.rewrite('/404') if (!service) return Astro.rewrite('/404')
const apiCalls = await Astro.locals.banners.try(
'Error fetching api calls',
() =>
Promise.all([
makeAdminApiCallInfo({
method: 'QUERY',
path: '/service/get',
input: { slug: service.slug },
baseUrl: Astro.url,
}),
]),
[]
)
--- ---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}> <BaseLayout pageTitle={`Edit Service: ${service.name}`}>
@@ -233,16 +249,30 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<input type="hidden" name="id" value={service.id} /> <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 <InputTextArea
label="Description" label="Description"
name="description" name="description"
@@ -254,76 +284,44 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description} error={serviceInputErrors.description}
/> />
<InputText <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
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">
<InputTextArea <InputTextArea
label="Service URLs" label="Service URLs"
description="One per line" description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="serviceUrls" name="allServiceUrls"
inputProps={{ inputProps={{
rows: 3, placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
placeholder: 'https://example1.com\nhttps://example2.com', class: 'grow min-h-24',
required: true,
}} }}
value={service.serviceUrls.join('\n')} class="row-span-2 flex flex-col self-stretch"
error={serviceInputErrors.serviceUrls} value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/> />
<InputTextArea <InputTextArea
label="ToS URLs" label="ToS URLs"
description="One per line" description="One per line"
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos', placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true, required: true,
}} }}
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
/> />
<InputTextArea <InputText
label="Onion URLs" label="Referral link path"
description="One per line" name="referral"
name="onionUrls"
inputProps={{ inputProps={{
rows: 3, value: service.referral,
placeholder: 'http://example1.onion\nhttp://example2.onion', placeholder: 'e.g. ?ref=123 or /ref/123',
}} }}
value={service.onionUrls.join('\n')} error={serviceInputErrors.referral}
error={serviceInputErrors.onionUrls} class="self-end"
/> description="Will be appended to the service URL"
<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')}
/> />
</div> </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"> <div class="flex items-center justify-between gap-2">
<InputImageFile <InputImageFile
label="Image" label="Image"
@@ -685,9 +683,14 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date(event.startedAt).toISOString().split('T')[0], value: new Date(
new Date(event.startedAt).getTime() -
new Date(event.startedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16),
}} }}
error={eventUpdateInputErrors.startedAt} error={eventUpdateInputErrors.startedAt}
/> />
@@ -696,7 +699,15 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '', type: 'datetime-local',
value: event.endedAt
? new Date(
new Date(event.endedAt).getTime() -
new Date(event.endedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16)
: '',
}} }}
error={eventUpdateInputErrors.endedAt} error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -756,9 +767,11 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date().toISOString().split('T')[0], value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.startedAt} error={eventInputErrors.startedAt}
/> />
@@ -767,7 +780,10 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: new Date().toISOString().split('T')[0], type: 'datetime-local',
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.endedAt} error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -1100,5 +1116,19 @@ if (!service) return Astro.rewrite('/404')
</form> </form>
</FormSubSection> </FormSubSection>
</FormSection> </FormSection>
<FormSection title="API">
{
apiCalls.map((call) => (
<FormSubSection title={`${call.method} ${call.path}`}>
<p class="text-day-400 text-sm">Input:</p>
<Code code={JSON.stringify(call.input, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
<p class="text-day-400 text-sm">Output:</p>
<Code code={JSON.stringify(call.output, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
</FormSubSection>
))
}
</FormSection>
</div> </div>
</BaseLayout> </BaseLayout>

View File

@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div> </div>
<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 <textarea
transition:persist 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" 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" name="allServiceUrls"
id="serviceUrls" id="allServiceUrls"
rows={3} rows={3}
placeholder="https://example1.com https://example2.com" placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text="" set:text=""
/> />
{ {
inputErrors.serviceUrls && ( inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
) )
} }
</div> </div>
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
} }
</div> </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> <div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label> <label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2"> <div class="space-y-2">

View File

@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors( const { data: filters } = zodParseQueryParamsStoringErrors(
{ {
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'), 'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(), search: z.string().optional(),
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(), role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
@@ -29,7 +29,10 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
// Set up Prisma orderBy with correct typing // Set up Prisma orderBy with correct typing
const prismaOrderBy = const prismaOrderBy =
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma' filters['sort-by'] === 'name' ||
filters['sort-by'] === 'createdAt' ||
filters['sort-by'] === 'lastLoginAt' ||
filters['sort-by'] === 'karma'
? { ? {
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]: [filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc', filters['sort-order'] === 'asc' ? 'asc' : 'desc',
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
lastLoginAt: true,
internalNotes: { internalNotes: {
select: { select: {
id: true, id: true,
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
<a <div class="flex flex-wrap items-center justify-center gap-1">
href={makeSortUrl('createdAt')} <a
class="flex items-center justify-center hover:text-zinc-200" href={makeSortUrl('lastLoginAt')}
> class="flex items-center justify-center hover:text-zinc-200"
Joined <SortArrowIcon >
active={filters['sort-by'] === 'createdAt'} Login <SortArrowIcon
sortOrder={filters['sort-order']} active={filters['sort-by'] === 'lastLoginAt'}
/> sortOrder={filters['sort-order']}
</a> />
</a>
<span class="text-zinc-600">/</span>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</div>
</th> </th>
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
@@ -305,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
{user.totalKarma} {user.totalKarma}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm text-zinc-400"> <td class="px-4 py-3 text-center text-sm">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} /> <div class="flex flex-wrap items-center justify-center gap-1 text-center">
<TimeFormatted
class="text-zinc-300"
date={user.lastLoginAt}
hourPrecision
hoursShort
prefix={false}
/>
<span class="text-zinc-600">/</span>
<TimeFormatted
class="text-zinc-400"
date={user.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</div>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex justify-center gap-3"> <div class="flex justify-center gap-3">

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)

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

@@ -0,0 +1,174 @@
---
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'
import { serviceVisibilities } from '../../constants/serviceVisibility'
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
serviceVisibility: 'PUBLIC' | 'ARCHIVED' | 'UNLISTED'
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
}
verifiedAt: Date | null
kycLevel: 0 | 1 | 2 | 3 | 4
kycLevelInfo: {
value: 0 | 1 | 2 | 3 | 4
name: string
description: string
}
categories: {
name: string
slug: string
}[]
listedAt: Date
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>
#### Service Visibility
<ul>
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
<li key={visibility.value}>
<strong>{visibility.value}</strong>: {visibility.longDescription}
</li>
))}
</ul>
### Examples
#### Request
```zsh
curl -X QUERY https://kycnot.me/api/v1/service/get \
-H "Content-Type: application/json" \
-d '{"slug": "my-example-service"}'
```
#### Response
```json
{
"name": "My Example Service",
"description": "This is a description of my example service",
"serviceVisibility": "PUBLIC",
"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."
},
"verifiedAt": "2025-01-20T07:12:29.393Z",
"kycLevel": 0,
"kycLevelInfo": {
"value": 0,
"name": "Guaranteed no KYC",
"description": "Terms explicitly state KYC will never be requested."
},
"categories": [
{
"name": "Exchange",
"slug": "exchange"
}
],
"listedAt": "2025-05-31T19:09:18.043Z",
"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,17 +214,16 @@ const groupedAttributes = groupBy(
'value' 'value'
) )
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
const where = { const where = {
listedAt: { id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
lte: new Date(), listedAt: { lte: new Date() },
},
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined, categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: { verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification, in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
}, },
serviceVisibility: { serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
in: ['PUBLIC', 'ARCHIVED'],
},
overallScore: { gte: filters['min-score'] }, overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and' ? filters['currency-mode'] === 'and'
@@ -243,16 +243,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
? [ ? [
{ {
@@ -325,9 +315,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
select: { select: {
services: { services: {
where: { where: {
serviceVisibility: { serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
in: ['PUBLIC', 'ARCHIVED'], listedAt: { lte: new Date() },
},
}, },
}, },
}, },
@@ -338,33 +327,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 +399,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
@@ -712,7 +719,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']} sortSeed={filters['sort-seed']}
filters={filters} filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly} countCommunityOnly={countCommunityOnly}
inlineIcons inlineIcons
/> />

View File

@@ -3,9 +3,12 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import AdminOnly from '../../components/AdminOnly.astro' import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro'
import Button from '../../components/Button.astro'
import Chat from '../../components/Chat.astro' import Chat from '../../components/Chat.astro'
import ServiceCard from '../../components/ServiceCard.astro' import ServiceCard from '../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus' import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
import { parseIntWithFallback } from '../../lib/numbers' import { parseIntWithFallback } from '../../lib/numbers'
@@ -28,6 +31,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
prisma.serviceSuggestion.findUnique({ prisma.serviceSuggestion.findUnique({
select: { select: {
id: true, id: true,
type: true,
status: true, status: true,
notes: true, notes: true,
createdAt: true, createdAt: true,
@@ -42,6 +46,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
acceptedCurrencies: true, acceptedCurrencies: true,
serviceVisibility: true,
categories: { categories: {
select: { select: {
name: true, name: true,
@@ -59,6 +64,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
select: { select: {
id: true, id: true,
name: true, name: true,
displayName: true,
picture: true, picture: true,
}, },
}, },
@@ -81,6 +87,7 @@ if (!serviceSuggestion) {
} }
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status) const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
--- ---
<BaseLayout <BaseLayout
@@ -104,17 +111,22 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
}, },
]} ]}
> >
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1> <div class="mt-12 mb-6 flex flex-col items-center justify-center gap-3">
<div class="flex items-center gap-2">
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<AdminOnly>
<Button
as="a"
href={`/admin/service-suggestions/${serviceSuggestionIdRaw}`}
size="sm"
icon="ri:lock-line"
label="View in admin"
/>
</AdminOnly>
</div>
<AdminOnly> <h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
<a </div>
href={`/admin/service-suggestions/${serviceSuggestionIdRaw}`}
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:lock-line" class="size-4" />
View in admin
</a>
</AdminOnly>
<ServiceCard service={serviceSuggestion.service} class="mb-6" /> <ServiceCard service={serviceSuggestion.service} class="mb-6" />

View File

@@ -3,6 +3,8 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import BadgeSmall from '../../components/BadgeSmall.astro'
import BadgeStandardFilter from '../../components/BadgeStandardFilter.astro'
import Button from '../../components/Button.astro' import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro' import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
@@ -10,10 +12,16 @@ import Tooltip from '../../components/Tooltip.astro'
import { import {
getServiceSuggestionStatusInfo, getServiceSuggestionStatusInfo,
serviceSuggestionStatuses, serviceSuggestionStatuses,
serviceSuggestionStatusesZodEnumBySlug,
serviceSuggestionStatusSlugToId,
} from '../../constants/serviceSuggestionStatus' } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType' import {
getServiceSuggestionTypeInfo,
serviceSuggestionTypes,
serviceSuggestionTypeSlugToId,
serviceSuggestionTypesZodEnumBySlug,
} from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { zodEnumFromConstant } from '../../lib/arrays'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
@@ -26,8 +34,13 @@ if (!user) {
const { data: filters } = zodParseQueryParamsStoringErrors( const { data: filters } = zodParseQueryParamsStoringErrors(
{ {
serviceId: z.array(z.number().int().positive()).default([]), serviceId: z.array(z.number().int().positive()),
status: z.array(zodEnumFromConstant(serviceSuggestionStatuses, 'value')).default([]), status: z.array(
serviceSuggestionStatusesZodEnumBySlug.transform((slug) => serviceSuggestionStatusSlugToId(slug))
),
type: z.array(
serviceSuggestionTypesZodEnumBySlug.transform((slug) => serviceSuggestionTypeSlugToId(slug))
),
}, },
Astro Astro
) )
@@ -52,6 +65,7 @@ const serviceSuggestions = await Astro.locals.banners.try('Error fetching servic
where: { where: {
id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined, id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined,
status: filters.status.length > 0 ? { in: filters.status } : undefined, status: filters.status.length > 0 ? { in: filters.status } : undefined,
type: filters.type.length > 0 ? { in: filters.type } : undefined,
userId: user.id, userId: user.id,
}, },
orderBy: { orderBy: {
@@ -104,6 +118,23 @@ const success = !!createResult && !createResult.error
) )
} }
<div class="mb-6">
<div class="text-day-200 mb-2 font-medium">Filter by:</div>
<div class="flex flex-wrap gap-2">
{
serviceSuggestionTypes.map((type) => (
<BadgeStandardFilter name="type" value={type.slug} label={type.label} icon={type.icon} />
))
}
{
serviceSuggestionStatuses.map((status) => (
<BadgeStandardFilter name="status" value={status.slug} label={status.label} icon={status.icon} />
))
}
</div>
</div>
{ {
serviceSuggestions.length === 0 ? ( serviceSuggestions.length === 0 ? (
<p class="text-day-400">No suggestions yet.</p> <p class="text-day-400">No suggestions yet.</p>
@@ -137,15 +168,7 @@ const success = !!createResult && !createResult.error
<span class="shrink truncate">{suggestion.service.name}</span> <span class="shrink truncate">{suggestion.service.name}</span>
</a> </a>
<Tooltip <BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
as="span"
class="inline-flex items-center gap-1"
text={typeInfo.label}
classNames={{ tooltip: 'md:hidden!' }}
>
<Icon name={typeInfo.icon} class="size-4" />
<span class="hidden md:inline">{typeInfo.label}</span>
</Tooltip>
<Tooltip <Tooltip
as="span" as="span"

View File

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

View File

@@ -1,5 +1,5 @@
--- ---
import { VerificationStepStatus } from '@prisma/client' import { VerificationStepStatus, EventType } from '@prisma/client'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
@@ -380,6 +380,10 @@ const ogImageTemplateData = {
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility) const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
)
--- ---
<BaseLayout <BaseLayout
@@ -480,6 +484,32 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
}), }),
]} ]}
> >
{
activeAlertOrWarningEvents.length > 0 && (
<a
href="#events"
class={cn(
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
)}
>
<Icon
name={
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'ri:alert-fill'
: 'ri:alarm-warning-fill'
}
class="me-1.5 inline-block size-4 align-[-0.15em]"
/>
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'There is an active alert for this service. Click to see details.'
: 'There is an active warning for this service. Click to see details.'}
</a>
)
}
{ {
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && ( (serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}> <div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
@@ -1182,6 +1212,7 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
<div class="mt-3 max-w-md pe-8"> <div class="mt-3 max-w-md pe-8">
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white"> <h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
{typeInfo.isSolved && <BadgeSmall text="Solved" icon="ri:check-line" color="green" />}
{event.title} {event.title}
</h3> </h3>

View File

@@ -48,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
lastLoginAt: true,
_count: { _count: {
select: { select: {
comments: true, comments: true,
@@ -93,7 +94,16 @@ const user = await Astro.locals.banners.try('user', async () => {
}, },
}, },
}, },
where: { service: { serviceVisibility: 'PUBLIC' } }, where: {
service: {
listedAt: {
lte: new Date(),
},
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
},
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 5, take: 5,
}, },
@@ -469,6 +479,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
</li> </li>
<AdminOnly>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Last login</p>
<p class="text-day-300">
{
formatDateShort(user.lastLoginAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</p>
</div>
</li>
</AdminOnly>
{ {
user.verifiedLink && ( user.verifiedLink && (
<li class="flex items-start"> <li class="flex items-start">
@@ -628,8 +656,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>