Compare commits
11 Commits
release-36
...
release-47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3afa824c18 | ||
|
|
9a68112e24 | ||
|
|
0c40d8eec5 | ||
|
|
e16c9b64ed | ||
|
|
22944fcdb3 | ||
|
|
f7f380c591 | ||
|
|
577c524ca2 | ||
|
|
da12e8de79 | ||
|
|
ea40f17d3c | ||
|
|
7e0d41cc7a | ||
|
|
70a097054b |
@@ -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',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Enable pg_trgm extension for similarity functions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
5
web/src/actions/api/index.ts
Normal file
5
web/src/actions/api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { apiServiceActions } from './service'
|
||||||
|
|
||||||
|
export const apiActions = {
|
||||||
|
service: apiServiceActions,
|
||||||
|
}
|
||||||
129
web/src/actions/api/service.ts
Normal file
129
web/src/actions/api/service.ts
Normal 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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
export const SUPPORT_EMAIL = 'contact@kycnot.me'
|
||||||
|
|||||||
@@ -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
32
web/src/lib/endpoints.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
web/src/lib/findServicesBySimilarity.ts
Normal file
16
web/src/lib/findServicesBySimilarity.ts
Normal 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 }))
|
||||||
|
}
|
||||||
50
web/src/lib/makeAdminApiCallInfo.ts
Normal file
50
web/src/lib/makeAdminApiCallInfo.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -201,6 +201,12 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
|
|||||||
|
|
||||||
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
You can access basic service data via our public API.
|
||||||
|
|
||||||
|
See the [API page](/docs/api) for more details.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you like this project, you can **support** it through these methods:
|
If you like this project, you can **support** it through these methods:
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
13
web/src/pages/api/[...catchAll].ts
Normal file
13
web/src/pages/api/[...catchAll].ts
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
7
web/src/pages/api/v1/service/get.ts
Normal file
7
web/src/pages/api/v1/service/get.ts
Normal 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
174
web/src/pages/docs/api.mdx
Normal 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"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -201,34 +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={{
|
/>
|
||||||
placeholder: 'http://example1.onion\nhttp://example2.onion',
|
</div>
|
||||||
}}
|
|
||||||
error={inputErrors.onionUrls}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputCardGroup
|
<InputCardGroup
|
||||||
name="kycLevel"
|
name="kycLevel"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user