Release 2025-05-19

This commit is contained in:
pluja
2025-05-19 10:19:49 +00:00
parent 046c4559e5
commit 2657f936bc
267 changed files with 0 additions and 49432 deletions

View File

@@ -1,221 +0,0 @@
import { ActionError } from 'astro:actions'
import { z } from 'astro:content'
import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { createAccount } from '../lib/accountCreate'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot'
import { startImpersonating } from '../lib/impersonation'
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
import { prisma } from '../lib/prisma'
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
import { login, logout, setUserSessionIdCookie } from '../lib/userCookies'
import {
generateUserSecretToken,
hashUserSecretToken,
parseUserSecretToken,
USER_SECRET_TOKEN_REGEX,
} from '../lib/userSecretToken'
import { imageFileSchema } from '../lib/zodUtils'
export const accountActions = {
login: defineProtectedAction({
accept: 'form',
permissions: 'guest',
input: z.object({
token: z.string().regex(USER_SECRET_TOKEN_REGEX).transform(parseUserSecretToken),
redirect: z.string().optional(),
}),
handler: async (input, context) => {
await logout(context)
const tokenHash = hashUserSecretToken(input.token)
const matchedUser = await prisma.user.findFirst({
where: {
secretTokenHash: tokenHash,
},
})
if (!matchedUser) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'No user exists with this token',
})
}
await login(context, makeUserWithKarmaUnlocks(matchedUser))
return {
user: matchedUser,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
redirect: input.redirect || context.request.headers.get('referer') || '/',
}
},
}),
preGenerateToken: defineProtectedAction({
accept: 'form',
permissions: 'guest',
handler: async () => {
const token = generateUserSecretToken()
await redisPreGeneratedSecretTokens.storePreGeneratedToken(token)
return {
token,
} as const
},
}),
generate: defineProtectedAction({
accept: 'form',
permissions: 'guest',
input: z
.object({
token: z.string().regex(USER_SECRET_TOKEN_REGEX).transform(parseUserSecretToken).optional(),
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
...captchaFormSchemaProperties,
})
.superRefine(captchaFormSchemaSuperRefine),
handler: async (input, context) => {
await handleHoneypotTrap({
input,
honeyPotTrapField: 'message',
userId: context.locals.user?.id,
location: 'account.generate',
})
const isValidToken = input.token
? await redisPreGeneratedSecretTokens.validateAndConsumePreGeneratedToken(input.token)
: true
if (!isValidToken) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Invalid or expired token',
})
}
const { token, user: newUser } = await createAccount(input.token)
await setUserSessionIdCookie(context.cookies, newUser.secretTokenHash)
context.locals.user = makeUserWithKarmaUnlocks(newUser)
return {
token,
user: newUser,
} as const
},
}),
impersonate: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
targetUserId: z.coerce.number().int().positive(),
redirect: z.string().optional(),
}),
handler: async (input, context) => {
const adminUser = context.locals.user
const targetUser = await prisma.user.findUnique({
where: { id: input.targetUserId },
})
if (!targetUser) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Target user not found',
})
}
if (targetUser.admin) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Cannot impersonate admin user',
})
}
await startImpersonating(context, adminUser, makeUserWithKarmaUnlocks(targetUser))
return {
adminUser,
impersonatedUser: targetUser,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
redirect: input.redirect || context.request.headers.get('referer') || '/',
}
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
id: z.coerce.number().int().positive(),
displayName: z.string().max(100, 'Display name must be 100 characters or less').optional().nullable(),
link: z
.string()
.url('Must be a valid URL')
.max(255, 'URL must be 255 characters or less')
.optional()
.nullable(),
pictureFile: imageFileSchema,
}),
handler: async (input, context) => {
if (input.id !== context.locals.user.id) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'You can only update your own profile',
})
}
if (
input.displayName !== undefined &&
input.displayName !== context.locals.user.displayName &&
!context.locals.user.karmaUnlocks.displayName
) {
throw new ActionError({
code: 'FORBIDDEN',
message: makeKarmaUnlockMessage(karmaUnlocksById.displayName),
})
}
if (
input.link !== undefined &&
input.link !== context.locals.user.link &&
!context.locals.user.karmaUnlocks.websiteLink
) {
throw new ActionError({
code: 'FORBIDDEN',
message: makeKarmaUnlockMessage(karmaUnlocksById.websiteLink),
})
}
if (input.pictureFile !== undefined && !context.locals.user.karmaUnlocks.profilePicture) {
throw new ActionError({
code: 'FORBIDDEN',
message: makeKarmaUnlockMessage(karmaUnlocksById.profilePicture),
})
}
const pictureUrl =
input.pictureFile && input.pictureFile.size > 0
? await saveFileLocally(
input.pictureFile,
input.pictureFile.name,
`users/pictures/${String(context.locals.user.id)}`
)
: null
const user = await prisma.user.update({
where: { id: context.locals.user.id },
data: {
displayName: input.displayName ?? null,
link: input.link ?? null,
picture: pictureUrl,
},
})
return { user }
},
}),
}

View File

@@ -1,134 +0,0 @@
import { AttributeCategory, AttributeType } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import type { Prisma } from '@prisma/client'
const attributeInputSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
category: z.nativeEnum(AttributeCategory),
type: z.nativeEnum(AttributeType),
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
slug: z
.string()
.min(1, 'Slug is required')
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens'),
})
const attributeSelect = {
id: true,
slug: true,
title: true,
description: true,
category: true,
type: true,
privacyPoints: true,
trustPoints: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.AttributeSelect
export const adminAttributeActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
category: z.nativeEnum(AttributeCategory),
type: z.nativeEnum(AttributeType),
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
}),
handler: async (input) => {
const slug = slugify(input.title, { lower: true, strict: true })
const attribute = await prisma.attribute.create({
data: {
...input,
slug,
},
select: attributeSelect,
})
return { attribute }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: attributeInputSchema.extend({
id: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const { id, title, slug, ...data } = input
const existingAttribute = await prisma.attribute.findUnique({
where: { id },
select: { title: true, slug: true },
})
if (!existingAttribute) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Attribute not found',
})
}
// Check for slug uniqueness (ignore current attribute)
const slugConflict = await prisma.attribute.findFirst({
where: { slug, NOT: { id } },
select: { id: true },
})
if (slugConflict) {
throw new ActionError({
code: 'CONFLICT',
message: 'Slug already in use',
})
}
const attribute = await prisma.attribute.update({
where: { id },
data: {
title,
slug,
...data,
},
select: attributeSelect,
})
return { attribute }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.coerce.number().int().positive('Attribute ID must be a positive integer.'),
}),
handler: async ({ id }) => {
try {
await prisma.attribute.delete({
where: { id },
})
return { success: true, message: 'Attribute deleted successfully.' }
} catch (error) {
// Prisma throws an error if the record to delete is not found,
// or if there are related records that prevent deletion (foreign key constraints).
// We can customize the error message based on the type of error if needed.
console.error('Error deleting attribute:', error)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete attribute. It might be in use or already deleted.',
})
}
},
}),
}

View File

@@ -1,129 +0,0 @@
import { EventType } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
export const adminEventActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z
.object({
serviceId: z.coerce.number().int().positive(),
title: z.string().min(1),
content: z.string().min(1),
icon: z.string().optional(),
source: z.string().optional(),
type: z.nativeEnum(EventType).default('NORMAL'),
startedAt: z.coerce.date(),
endedAt: z.coerce.date().optional(),
})
.superRefine((data, ctx) => {
if (data.endedAt && data.startedAt > data.endedAt) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endedAt'],
message: 'Ended at must be after started at',
})
}
}),
handler: async (input) => {
const event = await prisma.event.create({
data: {
...input,
visible: true,
},
select: {
id: true,
},
})
return { event }
},
}),
toggle: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
eventId: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const existingEvent = await prisma.event.findUnique({ where: { id: input.eventId } })
if (!existingEvent) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Event not found',
})
}
const event = await prisma.event.update({
where: { id: input.eventId },
data: {
visible: !existingEvent.visible,
},
select: {
id: true,
},
})
return { event }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z
.object({
eventId: z.coerce.number().int().positive(),
title: z.string().min(1),
content: z.string().min(1),
icon: z.string().optional(),
source: z.string().optional(),
type: z.nativeEnum(EventType).default('NORMAL'),
startedAt: z.coerce.date(),
endedAt: z.coerce.date().optional(),
})
.superRefine((data, ctx) => {
if (data.endedAt && data.startedAt > data.endedAt) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endedAt'],
message: 'Ended at must be after started at',
})
}
}),
handler: async (input) => {
const { eventId, ...data } = input
const existingEvent = await prisma.event.findUnique({ where: { id: eventId } })
if (!existingEvent) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Event not found',
})
}
const event = await prisma.event.update({
where: { id: eventId },
data,
select: {
id: true,
},
})
return { event }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
eventId: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const event = await prisma.event.delete({ where: { id: input.eventId } })
return { event }
},
}),
}

View File

@@ -1,15 +0,0 @@
import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event'
import { adminServiceActions } from './service'
import { adminServiceSuggestionActions } from './serviceSuggestion'
import { adminUserActions } from './user'
import { verificationStep } from './verificationStep'
export const adminActions = {
attribute: adminAttributeActions,
event: adminEventActions,
service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions,
user: adminUserActions,
verificationStep,
}

View File

@@ -1,234 +0,0 @@
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import {
imageFileSchema,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(20),
description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z.string().optional().nullable().default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
})
const addSlugIfMissing = <
T extends {
slug?: string | null | undefined
name: string
},
>(
input: T
) => ({
...input,
slug:
input.slug ??
slugify(input.name, {
lower: true,
strict: true,
remove: /[^a-zA-Z0-9\-._]/g,
replacement: '-',
}),
})
const contactMethodSchema = z.object({
id: z.number().optional(),
label: z.string().min(1).max(50),
value: z.string().min(1).max(200),
iconId: z.string().min(1).max(50),
info: z.string().max(200).optional().default(''),
serviceId: z.number(),
})
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
handler: async (input) => {
const existing = await prisma.service.findUnique({
where: {
slug: input.slug,
},
})
if (existing) {
throw new ActionError({
code: 'CONFLICT',
message: 'A service with this slug already exists',
})
}
const { imageFile, ...serviceData } = input
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
const service = await prisma.service.create({
data: {
...serviceData,
categories: {
connect: input.categories.map((id) => ({ id })),
},
attributes: {
create: input.attributes.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
},
imageUrl,
},
select: {
id: true,
slug: true,
},
})
return { service }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.transform(addSlugIfMissing),
handler: async (input) => {
const { id, categories, attributes, imageFile, ...data } = input
const existing = await prisma.service.findUnique({
where: {
slug: input.slug,
NOT: { id },
},
})
if (existing) {
throw new ActionError({
code: 'CONFLICT',
message: 'A service with this slug already exists',
})
}
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
// Get existing attributes and categories to compute differences
const existingService = await prisma.service.findUnique({
where: { id },
include: {
categories: true,
attributes: {
include: {
attribute: true,
},
},
},
})
if (!existingService) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
// Find categories to connect and disconnect
const existingCategoryIds = existingService.categories.map((c) => c.id)
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
// Find attributes to connect and disconnect
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
const service = await prisma.service.update({
where: { id },
data: {
...data,
imageUrl,
categories: {
connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })),
},
attributes: {
// Connect new attributes
create: attributesToAdd.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
// Delete specific attributes that are no longer needed
deleteMany: attributesToRemove.map((attributeId) => ({
attributeId,
})),
},
},
})
return { service }
},
}),
createContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: contactMethodSchema.omit({ id: true }),
handler: async (input) => {
const contactMethod = await prisma.serviceContactMethod.create({
data: input,
})
return { contactMethod }
},
}),
updateContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: contactMethodSchema,
handler: async (input) => {
const { id, ...data } = input
const contactMethod = await prisma.serviceContactMethod.update({
where: { id },
data,
})
return { contactMethod }
},
}),
deleteContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number(),
}),
handler: async (input) => {
await prisma.serviceContactMethod.delete({
where: { id: input.id },
})
return { success: true }
},
}),
}

View File

@@ -1,71 +0,0 @@
import { ServiceSuggestionStatus } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
export const adminServiceSuggestionActions = {
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
suggestionId: z.coerce.number().int().positive(),
status: z.nativeEnum(ServiceSuggestionStatus),
}),
handler: async (input) => {
const suggestion = await prisma.serviceSuggestion.findUnique({
select: {
id: true,
status: true,
serviceId: true,
},
where: { id: input.suggestionId },
})
if (!suggestion) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Suggestion not found',
})
}
await prisma.serviceSuggestion.update({
where: { id: suggestion.id },
data: {
status: input.status,
},
})
},
}),
message: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
suggestionId: z.coerce.number().int().positive(),
content: z.string().min(1).max(1000),
}),
handler: async (input, context) => {
const suggestion = await prisma.serviceSuggestion.findUnique({
select: {
id: true,
userId: true,
},
where: { id: input.suggestionId },
})
if (!suggestion) {
throw new Error('Suggestion not found')
}
await prisma.serviceSuggestionMessage.create({
data: {
content: input.content,
suggestionId: suggestion.id,
userId: context.locals.user.id,
},
})
},
}),
}

View File

@@ -1,288 +0,0 @@
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
const selectUserReturnFields = {
id: true,
name: true,
displayName: true,
link: true,
picture: true,
admin: true,
verified: true,
verifier: true,
verifiedLink: true,
secretTokenHash: true,
totalKarma: true,
createdAt: true,
updatedAt: true,
spammer: true,
} as const satisfies Prisma.UserSelect
export const adminUserActions = {
search: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
name: z.string().min(1, 'User name is required'),
}),
handler: async (input) => {
const user = await prisma.user.findUnique({
where: { name: input.name },
select: selectUserReturnFields,
})
return { user }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
name: z.string().min(1, 'Name is required').max(255, 'Name must be less than 255 characters'),
link: z
.string()
.url('Invalid URL')
.nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
picture: z.string().max(255, 'Picture URL must be less than 255 characters').nullable().default(null),
pictureFile: z.instanceof(File).optional(),
verifier: z.boolean().default(false),
admin: z.boolean().default(false),
spammer: z.boolean().default(false),
verifiedLink: z
.string()
.url('Invalid URL')
.nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
displayName: z
.string()
.max(50, 'Display Name must be less than 50 characters')
.nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
}),
handler: async ({ id, picture, pictureFile, ...valuesToUpdate }) => {
const user = await prisma.user.findUnique({
where: {
id,
},
select: {
id: true,
},
})
if (!user) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'User not found',
})
}
let pictureUrl = picture ?? null
if (pictureFile && pictureFile.size > 0) {
pictureUrl = await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/')
}
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
...valuesToUpdate,
verified: !!valuesToUpdate.verifiedLink,
picture: pictureUrl,
},
select: selectUserReturnFields,
})
return {
updatedUser,
}
},
}),
internalNotes: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userId: z.coerce.number().int().positive(),
content: z.string().min(1).max(1000),
}),
handler: async (input, context) => {
const note = await prisma.internalUserNote.create({
data: {
content: input.content,
userId: input.userId,
addedByUserId: context.locals.user.id,
},
select: {
id: true,
},
})
return { note }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
noteId: z.coerce.number().int().positive(),
}),
handler: async (input) => {
await prisma.internalUserNote.delete({
where: {
id: input.noteId,
},
})
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
noteId: z.coerce.number().int().positive(),
content: z.string().min(1).max(1000),
}),
handler: async (input, context) => {
const note = await prisma.internalUserNote.update({
where: {
id: input.noteId,
},
data: {
content: input.content,
addedByUserId: context.locals.user.id,
},
select: {
id: true,
},
})
return { note }
},
}),
},
serviceAffiliations: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userId: z.coerce.number().int().positive(),
serviceId: z.coerce.number().int().positive(),
role: z.enum(['OWNER', 'ADMIN', 'MODERATOR', 'SUPPORT', 'TEAM_MEMBER']),
}),
handler: async (input) => {
// Check if the user exists
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { id: true },
})
if (!user) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'User not found',
})
}
// Check if the service exists
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
select: { id: true, name: true },
})
if (!service) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Service not found',
})
}
try {
// Check if the service affiliation already exists
const existingAffiliation = await prisma.serviceUser.findUnique({
where: {
userId_serviceId: {
userId: input.userId,
serviceId: input.serviceId,
},
},
})
let serviceAffiliation
if (existingAffiliation) {
// Update existing affiliation
serviceAffiliation = await prisma.serviceUser.update({
where: {
userId_serviceId: {
userId: input.userId,
serviceId: input.serviceId,
},
},
data: {
role: input.role as ServiceUserRole,
},
})
return { serviceAffiliation, serviceName: service.name, updated: true }
} else {
// Create new affiliation
serviceAffiliation = await prisma.serviceUser.create({
data: {
userId: input.userId,
serviceId: input.serviceId,
role: input.role as ServiceUserRole,
},
})
return { serviceAffiliation, serviceName: service.name }
}
} catch (error) {
console.error('Error managing service affiliation:', error)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Error managing service affiliation',
})
}
},
}),
remove: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const serviceAffiliation = await prisma.serviceUser.delete({
where: {
id: input.id,
},
include: {
service: {
select: {
name: true,
},
},
},
})
return { serviceAffiliation }
},
}),
},
}

View File

@@ -1,118 +0,0 @@
import { VerificationStepStatus } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
const verificationStepSchemaBase = z.object({
title: z.string().min(1, 'Title is required'),
description: z
.string()
.min(1, 'Description is required')
.max(200, 'Description must be 200 characters or less'),
status: z.nativeEnum(VerificationStepStatus),
serviceId: z.coerce.number().int().positive(),
evidenceMd: z.string().optional().nullable().default(null),
})
const verificationStepUpdateSchema = z.object({
id: z.coerce.number().int().positive(),
title: z.string().min(1, 'Title is required').optional(),
description: z
.string()
.min(1, 'Description is required')
.max(200, 'Description must be 200 characters or less')
.optional(),
status: z.nativeEnum(VerificationStepStatus).optional(),
evidenceMd: z.string().optional().nullable(),
})
const verificationStepIdSchema = z.object({
id: z.coerce.number().int().positive(),
})
export const verificationStep = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: verificationStepSchemaBase,
handler: async (input) => {
const { serviceId, title, description, status, evidenceMd } = input
const service = await prisma.service.findUnique({
where: { id: serviceId },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
const newVerificationStep = await prisma.verificationStep.create({
data: {
title,
description,
status,
evidenceMd,
service: {
connect: { id: serviceId },
},
},
})
return { verificationStep: newVerificationStep }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: verificationStepUpdateSchema,
handler: async (input) => {
const { id, ...dataToUpdate } = input
const existingStep = await prisma.verificationStep.findUnique({
where: { id },
})
if (!existingStep) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Verification step not found',
})
}
const updatedVerificationStep = await prisma.verificationStep.update({
where: { id },
data: dataToUpdate,
})
return { verificationStep: updatedVerificationStep }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: verificationStepIdSchema,
handler: async ({ id }) => {
const existingStep = await prisma.verificationStep.findUnique({
where: { id },
})
if (!existingStep) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Verification step not found',
})
}
await prisma.verificationStep.delete({ where: { id } })
return { success: true, deletedId: id }
},
}),
}

View File

@@ -1,442 +0,0 @@
import crypto from 'crypto'
import { ActionError } from 'astro:actions'
import { z } from 'astro:schema'
import { formatDistanceStrict } from 'date-fns'
import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { handleHoneypotTrap } from '../lib/honeypot'
import { makeKarmaUnlockMessage } from '../lib/karmaUnlocks'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
import { prisma } from '../lib/prisma'
import { timeTrapSecretKey } from '../lib/timeTrapSecret'
import type { CommentStatus, Prisma } from '@prisma/client'
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 5
const MAX_COMMENTS_PER_WINDOW = 1
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 5
export const commentActions = {
vote: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
commentId: z.coerce.number().int().positive(),
downvote: z.coerce.boolean(),
}),
handler: async (input, context) => {
try {
// Check user karma requirement
if (!context.locals.user.karmaUnlocks.voteComments) {
throw new ActionError({
code: 'FORBIDDEN',
message: makeKarmaUnlockMessage(karmaUnlocksById.voteComments),
})
}
// Handle the vote in a transaction
await prisma.$transaction(async (tx) => {
// Get existing vote if any
const existingVote = await tx.commentVote.findUnique({
where: {
commentId_userId: {
commentId: input.commentId,
userId: context.locals.user.id,
},
},
})
if (existingVote) {
// If vote type is the same, remove the vote
if (existingVote.downvote === input.downvote) {
await tx.commentVote.delete({
where: { id: existingVote.id },
})
} else {
// If vote type is different, update the vote
await tx.commentVote.update({
where: { id: existingVote.id },
data: { downvote: input.downvote },
})
}
} else {
// Create new vote
await tx.commentVote.create({
data: {
downvote: input.downvote,
commentId: input.commentId,
userId: context.locals.user.id,
},
})
}
})
return true
} catch (error) {
if (error instanceof ActionError) throw error
console.error('Error voting on comment:', error)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Error voting on comment',
})
}
},
}),
create: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z
.object({
content: z.string().min(10).max(2000),
serviceId: z.coerce.number().int().positive(),
parentId: z.coerce.number().optional(),
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
rating: z.coerce.number().int().min(1).max(5).optional(),
encTimestamp: z.string().min(1), // time trap field
internalNote: z.string().max(500).optional(),
issueKycRequested: z.coerce.boolean().optional(),
issueFundsBlocked: z.coerce.boolean().optional(),
issueScam: z.coerce.boolean().optional(),
issueDetails: z.string().max(120).optional(),
orderId: z.string().max(100).optional(),
})
.superRefine((data, ctx) => {
if (data.rating && data.parentId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['parentId'],
message: 'Ratings cannot be provided for replies',
})
}
if (!data.parentId) {
if (data.content.length < 30) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 30,
type: 'string',
inclusive: true,
path: ['content'],
message: 'Content must be at least 30 characters',
})
}
}
}),
handler: async (input, context) => {
if (context.locals.user.karmaUnlocks.commentsDisabled) {
throw new ActionError({
code: 'FORBIDDEN',
message: makeKarmaUnlockMessage(karmaUnlocksById.commentsDisabled),
})
}
await handleHoneypotTrap({
input,
honeyPotTrapField: 'message',
userId: context.locals.user.id,
location: 'comment.create',
})
// --- Time Trap Validation Start ---
try {
const algorithm = 'aes-256-cbc'
const decodedValue = Buffer.from(input.encTimestamp, 'base64').toString('utf8')
const [ivHex, encryptedHex] = decodedValue.split(':')
if (!ivHex || !encryptedHex) {
throw new Error('Invalid time trap format.')
}
const iv = Buffer.from(ivHex, 'hex')
const decipher = crypto.createDecipheriv(algorithm, timeTrapSecretKey, iv)
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
const originalTimestamp = parseInt(decrypted, 10)
if (isNaN(originalTimestamp)) {
throw new Error('Invalid timestamp data.')
}
const now = Date.now()
const timeDiff = now - originalTimestamp
const minTimeSeconds = 2 // 2 seconds
const maxTimeMinutes = 60 // 1 hour
if (timeDiff < minTimeSeconds * 1000 || timeDiff > maxTimeMinutes * 60 * 1000) {
console.warn(`Time trap triggered: ${(timeDiff / 1000).toLocaleString()}s`)
throw new Error('Invalid submission timing.')
}
} catch (err) {
console.error('Time trap validation failed:', err instanceof Error ? err.message : 'Unknown error')
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Invalid request',
})
}
// --- Time Trap Validation End ---
// --- Rate Limit Check Start ---
const isVerifiedUser = context.locals.user.admin || context.locals.user.verified
const maxCommentsPerWindow = isVerifiedUser
? MAX_COMMENTS_PER_WINDOW_VERIFIED_USER
: MAX_COMMENTS_PER_WINDOW
const windowStart = new Date(Date.now() - COMMENT_RATE_LIMIT_WINDOW_MINUTES * 60 * 1000)
const recentCommentCount = await prisma.comment.findMany({
where: {
authorId: context.locals.user.id,
createdAt: {
gte: windowStart,
},
},
select: {
id: true,
createdAt: true,
},
})
if (recentCommentCount.length >= maxCommentsPerWindow) {
const oldestCreatedAt = recentCommentCount.reduce<Date | null>((oldestDate, comment) => {
if (!oldestDate) return comment.createdAt
if (comment.createdAt < oldestDate) return comment.createdAt
return oldestDate
}, null)
console.warn(`Rate limit exceeded for user ${context.locals.user.id.toLocaleString()}`)
throw new ActionError({
code: 'TOO_MANY_REQUESTS', // Use specific 429 code
message: `Rate limit exceeded. Please wait ${oldestCreatedAt ? `${formatDistanceStrict(oldestCreatedAt, windowStart)} ` : ''}before commenting again.`,
})
}
// --- Rate Limit Check End ---
// --- Format Internal Note from Issue Reports ---
let formattedInternalNote: string | null = null
// Track if this is an issue report
const isIssueReport =
input.issueKycRequested === true || input.issueFundsBlocked === true || input.issueScam === true
if (isIssueReport) {
const issueTypes = []
if (input.issueKycRequested) issueTypes.push('KYC REQUESTED')
if (input.issueFundsBlocked) issueTypes.push('FUNDS BLOCKED')
if (input.issueScam) issueTypes.push('POTENTIAL SCAM')
const details = input.issueDetails?.trim() ?? ''
formattedInternalNote = `[${issueTypes.join(', ')}]${details ? `: ${details}` : ''}`
} else if (input.internalNote?.trim()) {
formattedInternalNote = input.internalNote.trim()
}
// Determine if admin review is needed (always true for issue reports)
const requiresAdminReview = isIssueReport || !!(formattedInternalNote && !context.locals.user.admin)
try {
await prisma.$transaction(async (tx) => {
// First deactivate any existing ratings if providing a new rating
if (input.rating) {
await tx.comment.updateMany({
where: {
serviceId: input.serviceId,
authorId: context.locals.user.id,
rating: { not: null },
},
data: {
ratingActive: false,
},
})
}
// Check for existing orderId for this service if provided
if (input.orderId?.trim()) {
const existingOrderId = await tx.comment.findFirst({
where: {
serviceId: input.serviceId,
orderId: input.orderId.trim(),
},
select: { id: true },
})
if (existingOrderId) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'This Order ID has already been reported for this service.',
})
}
}
// Prepare data object with proper type safety
const commentData: Prisma.CommentCreateInput = {
content: input.content,
service: { connect: { id: input.serviceId } },
author: { connect: { id: context.locals.user.id } },
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
requiresAdminReview,
orderId: input.orderId?.trim() ?? null,
kycRequested: input.issueKycRequested === true,
fundsBlocked: input.issueFundsBlocked === true,
}
if (input.parentId) {
commentData.parent = { connect: { id: input.parentId } }
}
if (input.rating) {
commentData.rating = input.rating
commentData.ratingActive = true
}
if (formattedInternalNote) {
commentData.internalNote = formattedInternalNote
}
const newComment = await tx.comment.create({
data: commentData,
})
const notiPref = await getOrCreateNotificationPreferences(
context.locals.user.id,
{ enableAutowatchMyComments: true },
tx
)
if (notiPref.enableAutowatchMyComments) {
await tx.notificationPreferences.update({
where: { userId: context.locals.user.id },
data: {
watchedComments: { connect: { id: newComment.id } },
},
})
}
})
return { success: true }
} catch (error) {
if (error instanceof ActionError) throw error
console.error('Error creating comment:', error)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Error creating comment',
})
}
},
}),
moderate: defineProtectedAction({
permissions: ['admin', 'verifier'],
input: z.object({
commentId: z.number(),
userId: z.number(),
action: z.enum([
'status',
'suspicious',
'requires-admin-review',
'community-note',
'internal-note',
'private-context',
'order-id-status',
'kyc-requested',
'funds-blocked',
]),
value: z.union([
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
z.boolean(),
z.string(),
]),
}),
handler: async (input) => {
try {
const comment = await prisma.comment.findUnique({
where: { id: input.commentId },
select: {
id: true,
rating: true,
serviceId: true,
createdAt: true,
authorId: true,
},
})
if (!comment) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Comment not found',
})
}
const updateData: Prisma.CommentUpdateInput = {}
switch (input.action) {
case 'status':
updateData.status = input.value as CommentStatus
break
case 'suspicious': {
const isSpam = !!input.value
updateData.suspicious = isSpam
updateData.ratingActive = false
if (!isSpam && comment.rating) {
const newestRatingOrActiveRating = await prisma.comment.findFirst({
where: {
serviceId: comment.serviceId,
authorId: comment.authorId,
id: { not: input.commentId },
rating: { not: null },
OR: [{ createdAt: { gt: comment.createdAt } }, { ratingActive: true }],
},
})
updateData.ratingActive = !newestRatingOrActiveRating
}
break
}
case 'requires-admin-review':
updateData.requiresAdminReview = !!input.value
break
case 'community-note':
updateData.communityNote = input.value as string
break
case 'internal-note':
updateData.internalNote = input.value as string
break
case 'private-context':
updateData.privateContext = input.value as string
break
case 'order-id-status':
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
break
case 'kyc-requested':
updateData.kycRequested = !!input.value
break
case 'funds-blocked':
updateData.fundsBlocked = !!input.value
break
}
// Update the comment
await prisma.comment.update({
where: { id: input.commentId },
data: updateData,
})
return { success: true }
} catch (error) {
if (error instanceof ActionError) throw error
console.error('Error moderating comment:', error)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Error moderating comment',
})
}
},
}),
}

View File

@@ -1,28 +0,0 @@
import { accountActions } from './account'
import { adminActions } from './admin'
import { commentActions } from './comment'
import { notificationActions } from './notifications'
import { serviceActions } from './service'
import { serviceSuggestionActions } from './serviceSuggestion'
/**
* @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`
*
* @example
* ```ts
* import { actions } from 'astro:actions'
* import { server } from '~/actions' // WRONG!!!!
*
* const result = Astro.getActionResult(actions.admin.attribute.create)
* ```
*/
export const server = {
account: accountActions,
admin: adminActions,
comment: commentActions,
notification: notificationActions,
service: serviceActions,
serviceSuggestion: serviceSuggestionActions,
}
// Don't create an object named actions, put the actions in the server object instead. Astro will automatically export the server object as actions.

View File

@@ -1,132 +0,0 @@
import { z } from 'astro:content'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { prisma } from '../lib/prisma'
export const notificationActions = {
updateReadStatus: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
notificationId: z.literal('all').or(z.coerce.number().int().positive()),
read: z.coerce.boolean(),
}),
handler: async (input, context) => {
await prisma.notification.updateMany({
where:
input.notificationId === 'all'
? { userId: context.locals.user.id, read: !input.read }
: { userId: context.locals.user.id, id: input.notificationId },
data: {
read: input.read,
},
})
},
}),
preferences: {
update: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
enableAutowatchMyComments: z.coerce.boolean().optional(),
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
}),
handler: async (input, context) => {
await prisma.notificationPreferences.upsert({
where: { userId: context.locals.user.id },
update: {
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
enableAutowatchMyComments: input.enableAutowatchMyComments,
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
},
create: {
userId: context.locals.user.id,
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
enableAutowatchMyComments: input.enableAutowatchMyComments,
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
},
})
},
}),
watchComment: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
commentId: z.coerce.number().int().positive(),
watch: z.coerce.boolean(),
}),
handler: async (input, context) => {
await prisma.notificationPreferences.upsert({
where: { userId: context.locals.user.id },
update: {
watchedComments: input.watch
? { connect: { id: input.commentId } }
: { disconnect: { id: input.commentId } },
},
create: {
userId: context.locals.user.id,
watchedComments: input.watch ? { connect: { id: input.commentId } } : undefined,
},
})
},
}),
watchService: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
serviceId: z.coerce.number().int().positive(),
watchType: z.enum(['all', 'comments', 'events', 'verification']),
value: z.coerce.boolean(),
}),
handler: async (input, context) => {
await prisma.notificationPreferences.upsert({
where: { userId: context.locals.user.id },
update: {
onEventCreatedForServices:
input.watchType === 'events' || input.watchType === 'all'
? input.value
? { connect: { id: input.serviceId } }
: { disconnect: { id: input.serviceId } }
: undefined,
onRootCommentCreatedForServices:
input.watchType === 'comments' || input.watchType === 'all'
? input.value
? { connect: { id: input.serviceId } }
: { disconnect: { id: input.serviceId } }
: undefined,
onVerificationChangeForServices:
input.watchType === 'verification' || input.watchType === 'all'
? input.value
? { connect: { id: input.serviceId } }
: { disconnect: { id: input.serviceId } }
: undefined,
},
create: {
userId: context.locals.user.id,
onEventCreatedForServices:
input.watchType === 'events' || input.watchType === 'all'
? input.value
? { connect: { id: input.serviceId } }
: undefined
: undefined,
onRootCommentCreatedForServices:
input.watchType === 'comments' || input.watchType === 'all'
? input.value
? { connect: { id: input.serviceId } }
: undefined
: undefined,
onVerificationChangeForServices:
input.watchType === 'verification' || input.watchType === 'all'
? input.value
? { connect: { id: input.serviceId } }
: undefined
: undefined,
},
})
},
}),
},
}

View File

@@ -1,104 +0,0 @@
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { prisma } from '../lib/prisma'
export const serviceActions = {
requestVerification: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
serviceId: z.coerce.number().int().positive(),
action: z.enum(['request', 'withdraw']),
}),
handler: async (input, context) => {
const service = await prisma.service.findUnique({
where: {
id: input.serviceId,
},
select: {
verificationStatus: true,
},
})
if (!service) {
throw new ActionError({
message: 'Service not found',
code: 'NOT_FOUND',
})
}
if (
service.verificationStatus === 'VERIFICATION_SUCCESS' ||
service.verificationStatus === 'VERIFICATION_FAILED'
) {
throw new ActionError({
message: 'Service is already verified or marked as scam',
code: 'BAD_REQUEST',
})
}
const existingRequest = await prisma.serviceVerificationRequest.findUnique({
where: {
serviceId_userId: {
serviceId: input.serviceId,
userId: context.locals.user.id,
},
},
select: {
id: true,
},
})
switch (input.action) {
case 'withdraw': {
if (!existingRequest) {
throw new ActionError({
message: 'You have not requested verification for this service',
code: 'BAD_REQUEST',
})
}
await prisma.serviceVerificationRequest.delete({
where: {
id: existingRequest.id,
},
})
break
}
default:
case 'request': {
if (existingRequest) {
throw new ActionError({
message: 'You have already requested verification for this service',
code: 'BAD_REQUEST',
})
}
await prisma.serviceVerificationRequest.create({
data: {
serviceId: input.serviceId,
userId: context.locals.user.id,
},
})
await prisma.notificationPreferences.upsert({
where: { userId: context.locals.user.id },
update: {
onVerificationChangeForServices: {
connect: { id: input.serviceId },
},
},
create: {
userId: context.locals.user.id,
onVerificationChangeForServices: {
connect: { id: input.serviceId },
},
},
})
break
}
}
},
}),
}

View File

@@ -1,359 +0,0 @@
import {
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import {
imageFileSchemaRequired,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client'
const SUGGESTION_MESSAGE_RATE_LIMIT_WINDOW_MINUTES = 1
const MAX_SUGGESTION_MESSAGES_PER_WINDOW = 5
export const SUGGESTION_NOTES_MAX_LENGTH = 1000
export const SUGGESTION_NAME_MAX_LENGTH = 20
export const SUGGESTION_SLUG_MAX_LENGTH = 20
export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => {
const possibleDuplicates = await prisma.service.findMany({
where: {
name: {
contains: input.name,
mode: 'insensitive',
},
},
select: {
id: true,
name: true,
slug: true,
description: true,
},
})
return possibleDuplicates
}
const serializeExtraNotes = <T extends Record<string, unknown>>(
input: T,
skipKeys: (keyof T)[] = []
): string => {
return Object.entries(input)
.filter(([key]) => !skipKeys.includes(key as keyof T))
.map(([key, value]) => {
let serializedValue = ''
if (typeof value === 'string') {
serializedValue = value
} else if (value === undefined || value === null) {
serializedValue = ''
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
serializedValue = value.toString()
} else {
try {
serializedValue = JSON.stringify(value)
} catch (error) {
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
}
}
return `- ${key}: ${serializedValue}`
})
.join('\n')
}
export const serviceSuggestionActions = {
editService: defineProtectedAction({
accept: 'form',
permissions: 'not-spammer',
input: z
.object({
notes: z.string().max(SUGGESTION_NOTES_MAX_LENGTH).optional(),
serviceId: z.coerce.number().int().positive(),
extraNotes: z.string().optional(),
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
...captchaFormSchemaProperties,
})
.superRefine(captchaFormSchemaSuperRefine),
handler: async (input, context) => {
await handleHoneypotTrap({
input,
honeyPotTrapField: 'message',
userId: context.locals.user.id,
location: 'serviceSuggestion.editService',
})
const service = await prisma.service.findUnique({
select: {
id: true,
slug: true,
},
where: { id: input.serviceId },
})
if (!service) {
throw new ActionError({
message: 'Service not found',
code: 'BAD_REQUEST',
})
}
// Combine notes and extraNotes if available
const combinedNotes = input.extraNotes
? `${input.notes ?? ''}\n\nSuggested changes:\n${input.extraNotes}`
: input.notes
const serviceSuggestion = await prisma.serviceSuggestion.create({
data: {
type: ServiceSuggestionType.EDIT_SERVICE,
notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING,
userId: context.locals.user.id,
serviceId: service.id,
},
select: {
id: true,
},
})
return { serviceSuggestion, service }
},
}),
createService: defineProtectedAction({
accept: 'form',
permissions: 'not-spammer',
input: z
.object({
notes: z.string().max(SUGGESTION_NOTES_MAX_LENGTH).optional(),
name: z.string().min(1).max(SUGGESTION_NAME_MAX_LENGTH),
slug: z
.string()
.min(1)
.max(SUGGESTION_SLUG_MAX_LENGTH)
.regex(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
})
.refine(
async (slug) => {
const exists = await prisma.service.findUnique({
select: { id: true },
where: { slug },
})
return !exists
},
{ message: 'Slug must be unique, try a different one' }
),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
imageFile: imageFileSchemaRequired,
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
skipDuplicateCheck: z
.string()
.optional()
.nullable()
.transform((value) => value === 'true'),
...captchaFormSchemaProperties,
})
.superRefine(captchaFormSchemaSuperRefine),
handler: async (input, context) => {
await handleHoneypotTrap({
input,
honeyPotTrapField: 'message',
userId: context.locals.user.id,
location: 'serviceSuggestion.createService',
})
if (!input.skipDuplicateCheck) {
const possibleDuplicates = await findPossibleDuplicates(input)
if (possibleDuplicates.length > 0) {
return {
hasDuplicates: true,
possibleDuplicates,
extraNotes: serializeExtraNotes(input, [
'skipDuplicateCheck',
'message',
'imageFile',
'captcha-value',
'captcha-solution-hash',
]),
serviceSuggestion: undefined,
service: undefined,
} as const
}
}
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = {
id: true,
slug: true,
} satisfies Prisma.ServiceSelect
const service = await tx.service.create({
data: {
name: input.name,
slug: input.slug,
description: input.description,
serviceUrls: input.serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies,
imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
overallScore: 0,
privacyScore: 0,
trustScore: 0,
listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED,
categories: {
connect: input.categories.map((id) => ({ id })),
},
attributes: {
create: input.attributes.map((id) => ({
attributeId: id,
})),
},
},
select: serviceSelect,
})
const serviceSuggestion = await tx.serviceSuggestion.create({
data: {
notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE,
status: ServiceSuggestionStatus.PENDING,
userId: context.locals.user.id,
serviceId: service.id,
},
select: {
id: true,
},
})
return {
hasDuplicates: false,
possibleDuplicates: [],
extraNotes: undefined,
serviceSuggestion,
service,
} as const
})
return {
hasDuplicates: false,
possibleDuplicates: [],
extraNotes: undefined,
serviceSuggestion,
service,
} as const
},
}),
message: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z.object({
suggestionId: z.coerce.number().int().positive(),
content: z.string().min(1).max(SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH),
}),
handler: async (input, context) => {
// --- Rate Limit Check Start --- (Admins are exempt)
if (!context.locals.user.admin) {
const windowStart = new Date(Date.now() - SUGGESTION_MESSAGE_RATE_LIMIT_WINDOW_MINUTES * 60 * 1000)
const recentMessages = await prisma.serviceSuggestionMessage.findMany({
where: {
userId: context.locals.user.id,
createdAt: {
gte: windowStart,
},
},
select: {
id: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' }, // Get the oldest first to calculate wait time
})
if (recentMessages.length >= MAX_SUGGESTION_MESSAGES_PER_WINDOW) {
const oldestMessageInWindow = recentMessages[0]
if (!oldestMessageInWindow) {
console.error(
'Error determining oldest message for rate limit, but length check passed. User:',
context.locals.user.id
)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Could not determine rate limit window. Please try again.',
})
}
const timeToWait = formatDistanceStrict(oldestMessageInWindow.createdAt, windowStart)
console.warn(
`Suggestion message rate limit exceeded for user ${context.locals.user.id.toLocaleString()}`
)
throw new ActionError({
code: 'TOO_MANY_REQUESTS',
message: `Rate limit exceeded. Please wait ${timeToWait} before sending another message.`,
})
}
}
// --- Rate Limit Check End ---
const suggestion = await prisma.serviceSuggestion.findUnique({
select: {
id: true,
userId: true,
},
where: { id: input.suggestionId },
})
if (!suggestion) {
throw new ActionError({
message: 'Suggestion not found',
code: 'BAD_REQUEST',
})
}
if (suggestion.userId !== context.locals.user.id) {
throw new ActionError({
message: 'Not authorized to send messages',
code: 'UNAUTHORIZED',
})
}
await prisma.serviceSuggestionMessage.create({
data: {
content: input.content,
suggestionId: suggestion.id,
userId: context.locals.user.id,
},
})
},
}),
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 76 76">
<path
d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1h-10Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1h-18ZM15 56a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V63.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V57c0-.6-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L26 71.2l-6.7-14.6a1 1 0 0 0-1-.6H15Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 76 52">
<path
d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.5.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.5.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 204 28">
<path
d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 124 52">
<path
d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM1 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4L6.8 32.5A1 1 0 0 0 6 32H1Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1H30Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12H29v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1H50Zm27 0a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V39.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V51c0 .6.5 1 1 1h2c.6 0 1-.4 1-1V33c0-.5-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L88 47.2l-6.7-14.6a1 1 0 0 0-1-.6H77Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4ZM70 48a1 1 0 0 0-1 1v2c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-2Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,11 +0,0 @@
---
import type { AstroChildren } from '../lib/astro'
type Props = {
children: AstroChildren
}
//
---
{!!Astro.locals.user?.admin && <slot />}

View File

@@ -1,162 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import type { Polymorphic } from 'astro/types'
const badge = tv({
slots: {
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
icon: 'size-3 shrink-0',
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
},
variants: {
color: {
red: '',
orange: '',
amber: '',
yellow: '',
lime: '',
green: '',
emerald: '',
teal: '',
cyan: '',
sky: '',
blue: '',
indigo: '',
violet: '',
purple: '',
fuchsia: '',
pink: '',
rose: '',
slate: '',
gray: '',
zinc: '',
neutral: '',
stone: '',
white: '',
black: '',
},
variant: {
solid: '',
faded: '',
},
},
compoundVariants: [
// Red
{ color: 'red', variant: 'solid', class: { base: 'bg-red-500 text-white' } },
{ color: 'red', variant: 'faded', class: { base: 'bg-red-500/30 text-red-300' } },
// Orange
{ color: 'orange', variant: 'solid', class: { base: 'bg-orange-500 text-white' } },
{ color: 'orange', variant: 'faded', class: { base: 'bg-orange-500/30 text-orange-300' } },
// Amber
{ color: 'amber', variant: 'solid', class: { base: 'bg-amber-500 text-black' } },
{ color: 'amber', variant: 'faded', class: { base: 'bg-amber-500/30 text-amber-300' } },
// Yellow
{ color: 'yellow', variant: 'solid', class: { base: 'bg-yellow-500 text-black' } },
{ color: 'yellow', variant: 'faded', class: { base: 'bg-yellow-500/30 text-yellow-300' } },
// Lime
{ color: 'lime', variant: 'solid', class: { base: 'bg-lime-500 text-black' } },
{ color: 'lime', variant: 'faded', class: { base: 'bg-lime-500/30 text-lime-300' } },
// Green
{ color: 'green', variant: 'solid', class: { base: 'bg-green-500 text-black' } },
{ color: 'green', variant: 'faded', class: { base: 'bg-green-500/30 text-green-300' } },
// Emerald
{ color: 'emerald', variant: 'solid', class: { base: 'bg-emerald-500 text-white' } },
{ color: 'emerald', variant: 'faded', class: { base: 'bg-emerald-500/30 text-emerald-300' } },
// Teal
{ color: 'teal', variant: 'solid', class: { base: 'bg-teal-500 text-white' } },
{ color: 'teal', variant: 'faded', class: { base: 'bg-teal-500/30 text-teal-300' } },
// Cyan
{ color: 'cyan', variant: 'solid', class: { base: 'bg-cyan-500 text-white' } },
{ color: 'cyan', variant: 'faded', class: { base: 'bg-cyan-500/30 text-cyan-300' } },
// Sky
{ color: 'sky', variant: 'solid', class: { base: 'bg-sky-500 text-white' } },
{ color: 'sky', variant: 'faded', class: { base: 'bg-sky-500/30 text-sky-300' } },
// Blue
{ color: 'blue', variant: 'solid', class: { base: 'bg-blue-500 text-white' } },
{ color: 'blue', variant: 'faded', class: { base: 'bg-blue-500/30 text-blue-300' } },
// Indigo
{ color: 'indigo', variant: 'solid', class: { base: 'bg-indigo-500 text-white' } },
{ color: 'indigo', variant: 'faded', class: { base: 'bg-indigo-500/30 text-indigo-300' } },
// Violet
{ color: 'violet', variant: 'solid', class: { base: 'bg-violet-500 text-white' } },
{ color: 'violet', variant: 'faded', class: { base: 'bg-violet-500/30 text-violet-300' } },
// Purple
{ color: 'purple', variant: 'solid', class: { base: 'bg-purple-500 text-white' } },
{ color: 'purple', variant: 'faded', class: { base: 'bg-purple-500/30 text-purple-300' } },
// Fuchsia
{ color: 'fuchsia', variant: 'solid', class: { base: 'bg-fuchsia-500 text-white' } },
{ color: 'fuchsia', variant: 'faded', class: { base: 'bg-fuchsia-500/30 text-fuchsia-300' } },
// Pink
{ color: 'pink', variant: 'solid', class: { base: 'bg-pink-500 text-white' } },
{ color: 'pink', variant: 'faded', class: { base: 'bg-pink-500/30 text-pink-300' } },
// Rose
{ color: 'rose', variant: 'solid', class: { base: 'bg-rose-500 text-white' } },
{ color: 'rose', variant: 'faded', class: { base: 'bg-rose-500/30 text-rose-300' } },
// Slate
{ color: 'slate', variant: 'solid', class: { base: 'bg-slate-500 text-white' } },
{ color: 'slate', variant: 'faded', class: { base: 'bg-slate-500/30 text-slate-300' } },
// Gray
{ color: 'gray', variant: 'solid', class: { base: 'bg-gray-500 text-white' } },
{ color: 'gray', variant: 'faded', class: { base: 'bg-gray-500/30 text-gray-300' } },
// Zinc
{ color: 'zinc', variant: 'solid', class: { base: 'bg-zinc-500 text-white' } },
{ color: 'zinc', variant: 'faded', class: { base: 'bg-zinc-500/30 text-zinc-300' } },
// Neutral
{ color: 'neutral', variant: 'solid', class: { base: 'bg-neutral-500 text-white' } },
{ color: 'neutral', variant: 'faded', class: { base: 'bg-neutral-500/30 text-neutral-300' } },
// Stone
{ color: 'stone', variant: 'solid', class: { base: 'bg-stone-500 text-white' } },
{ color: 'stone', variant: 'faded', class: { base: 'bg-stone-500/30 text-stone-300' } },
// White
{ color: 'white', variant: 'solid', class: { base: 'bg-white text-black' } },
{ color: 'white', variant: 'faded', class: { base: 'bg-white-500/30 text-white-300' } },
// Black
{ color: 'black', variant: 'solid', class: { base: 'bg-black text-white' } },
{ color: 'black', variant: 'faded', class: { base: 'bg-black-500/30 text-black-300' } },
],
defaultVariants: {
color: 'gray',
variant: 'solid',
},
})
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
VariantProps<typeof badge> & {
as: Tag
icon?: string
text: string
inlineIcon?: boolean
classNames?: {
icon?: string
text?: string
}
}
>
const {
as: Tag = 'div',
icon: iconName,
text: textContent,
inlineIcon,
classNames,
color,
variant,
class: className,
...props
} = Astro.props
const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
---
<Tag {...props} class={base({ class: className })}>
{
!!iconName && (
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
)
}
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
</Tag>

View File

@@ -1,27 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import type { Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
as: Tag
icon: string
text: string
inlineIcon?: boolean
}>
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
---
<Tag
{...divProps}
class={cn(
'bg-night-900 inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm text-white',
className
)}
>
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
<span>{text}</span>
</Tag>

View File

@@ -1,133 +0,0 @@
---
import LoadingIndicator from 'astro-loading-indicator/component'
import { Schema } from 'astro-seo-schema'
import { ClientRouter } from 'astro:transitions'
import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import HtmxScript from './HtmxScript.astro'
import { makeOgImageUrl } from './OgImage'
import TailwindJsPluggin from './TailwindJsPluggin.astro'
import type { ComponentProps } from 'astro/types'
import type { WithContext, BreadcrumbList, ListItem } from 'schema-dts'
export type BreadcrumArray = [
...{
name: string
url: string
}[],
{
name: string
url?: string
},
]
type Props = {
pageTitle: string
/**
* Whether to enable htmx.
*
* @default false
*/
htmx?: boolean
/**
* Page meta description
*
* @default 'KYCnot.me helps you find services without KYC for better privacy and control over your data.'
*/
description?: string
/**
* Open Graph image.
* - If `string` is provided, it will be used as the image URL.
* - If `{ template: string, ...props }` is provided, it will be used to generate an Open Graph image based on the template.
*/
ogImage?: Parameters<typeof makeOgImageUrl>[0]
schemas?: ComponentProps<typeof Schema>['item'][]
breadcrumbs?: BreadcrumArray | BreadcrumArray[]
}
const {
pageTitle,
htmx = false,
description = 'KYCnot.me helps you find services without KYC for better privacy and control over your data.',
ogImage,
schemas,
breadcrumbs,
} = Astro.props
const breadcrumbLists = breadcrumbs?.every(Array.isArray)
? (breadcrumbs as BreadcrumArray[])
: breadcrumbs?.every(isNotArray)
? [breadcrumbs]
: []
const modeName = DEPLOYMENT_MODE === 'production' ? '' : DEPLOYMENT_MODE === 'staging' ? 'PRE' : 'DEV'
const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
---
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
<!-- Primary Meta Tags -->
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<title>{fullTitle}</title>
<!-- {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} -->
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={fullTitle} />
<meta property="twitter:description" content={description} />
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
<!-- Other -->
<link rel="sitemap" href="/sitemap-index.xml" />
<meta name="theme-color" content="#040505" />
<!-- Components -->
<ClientRouter />
<LoadingIndicator color="green" />
<TailwindJsPluggin />
{htmx && <HtmxScript />}
<!-- JSON-LD Schemas -->
{schemas?.map((item) => <Schema item={item} />)}
<!-- Breadcrumbs -->
{
breadcrumbLists.map((breadcrumbList) => (
<Schema
item={
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbList.map(
(item, index) =>
({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url ? new URL(item.url, Astro.url).href : undefined,
}) satisfies ListItem
),
} satisfies WithContext<BreadcrumbList>
}
/>
))
}

View File

@@ -1,176 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & {
as: Tag
label?: string
icon?: string
endIcon?: string
classNames?: {
label?: string
icon?: string
endIcon?: string
}
dataAstroReload?: boolean
children?: never
disabled?: boolean
}
>
export type ButtonProps<Tag extends 'a' | 'button' | 'label' = 'button'> = Props<Tag>
const button = tv({
slots: {
base: 'inline-flex items-center justify-center gap-2 rounded-lg border transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-hidden',
icon: 'size-4 shrink-0',
label: 'text-left whitespace-nowrap',
endIcon: 'size-4 shrink-0',
},
variants: {
size: {
sm: {
base: 'h-8 px-3 text-sm',
icon: 'size-4',
endIcon: 'size-4',
},
md: {
base: 'h-9 px-4 text-sm',
icon: 'size-4',
endIcon: 'size-4',
label: 'font-medium',
},
lg: {
base: 'h-10 px-5 text-base',
icon: 'size-5',
endIcon: 'size-5',
label: 'font-bold tracking-wider uppercase',
},
},
color: {
black: {
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
},
white: {
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
},
gray: {
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
},
success: {
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
},
error: {
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
},
warning: {
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
},
info: {
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
},
},
shadow: {
true: {
base: 'shadow-lg',
},
},
disabled: {
true: {
base: 'cursor-not-allowed',
},
},
},
compoundVariants: [
{
color: 'black',
shadow: true,
class: 'shadow-black/30',
},
{
color: 'white',
shadow: true,
class: 'shadow-white/30',
},
{
color: 'gray',
shadow: true,
class: 'shadow-day-500/30',
},
{
color: 'success',
shadow: true,
class: 'shadow-green-500/30',
},
{
color: 'error',
shadow: true,
class: 'shadow-red-500/30',
},
{
color: 'warning',
shadow: true,
class: 'shadow-yellow-500/30',
},
{
color: 'info',
shadow: true,
class: 'shadow-blue-500/30',
},
],
defaultVariants: {
size: 'md',
color: 'black',
shadow: false,
disabled: false,
},
})
const {
as: Tag = 'button' as 'a' | 'button' | 'label',
label,
icon,
endIcon,
size,
color,
shadow,
class: className,
classNames,
role,
dataAstroReload,
disabled,
...htmlProps
} = Astro.props
const {
base,
icon: iconSlot,
label: labelSlot,
endIcon: endIconSlot,
} = button({ size, color, shadow, disabled })
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
---
<ActualTag
class={base({ class: className })}
role={role ??
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
aria-disabled={disabled}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps}
>
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} />}
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
{
!!endIcon && (
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })}>
{endIcon}
</Icon>
)
}
</ActualTag>

View File

@@ -1,80 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions'
import { Image } from 'astro:assets'
import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha'
import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types'
import type { z } from 'astro:content'
type Props<
TAccept extends ActionAccept,
TInputSchema extends z.ZodType,
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
> = HTMLAttributes<'div'> & {
action: TAction
}
const { class: className, action: formAction, autofocus, ...htmlProps } = Astro.props
const result = Astro.getActionResult(formAction)
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const captcha = generateCaptcha()
---
{/* eslint-disable astro/jsx-a11y/no-autofocus */}
<div {...htmlProps} class={cn('space-y-3', className)}>
<p class="sr-only" id="captcha-instructions">
This page requires a visual CAPTCHA to ensure you are a human. If you are unable to complete the CAPTCHA,
please email us for assistance. <a href="mailto:contact@kycnot.me">contact@kycnot.me</a>
</p>
<div
class="@container flex flex-wrap items-center justify-center gap-2"
style={{
'--img-width': `${captcha.image.width}px`,
'--img-height': `${captcha.image.height}px`,
'--img-aspect-ratio': `${captcha.image.width} / ${captcha.image.height}`,
}}
>
<label for="captcha-value">
<Image {...captcha.image} alt="CAPTCHA verification" class="w-full max-w-(--img-width) rounded" />
</label>
<Icon name="ri:arrow-right-line" class="size-6 text-zinc-600 @max-[calc(144px*2+8px*2+24px)]:hidden" />
<input
type="text"
id="captcha-value"
name="captcha-value"
required
class={cn(
'aspect-(--img-aspect-ratio) w-full max-w-(--img-width) min-w-0 rounded-md border border-zinc-700 bg-black/20 py-1.5 pl-[0.9em] font-mono text-sm text-zinc-200 uppercase placeholder:text-zinc-600',
'pr-0 tracking-[0.9em] transition-colors focus:border-green-500/50 focus:ring-1 focus:ring-green-500/30 focus:outline-none',
inputErrors['captcha-value'] && 'border-red-500/50 focus:border-red-500/50 focus:ring-red-500/30'
)}
autocomplete="off"
pattern="[A-Za-z0-9]*"
placeholder={'•'.repeat(CAPTCHA_LENGTH)}
maxlength={CAPTCHA_LENGTH}
aria-describedby="captcha-instructions"
autofocus={autofocus}
data-1p-ignore
data-lpignore="true"
data-bwignore
data-form-type="other"
/>
</div>
{
inputErrors['captcha-value'] && (
<p class="mt-1 text-center text-xs text-red-500">{inputErrors['captcha-value'].join(', ')}</p>
)
}
<input type="hidden" name="captcha-solution-hash" value={captcha.solutionHash} />
</div>

View File

@@ -1,86 +0,0 @@
---
import { isInputError } from 'astro:actions'
import { SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH } from '../actions/serviceSuggestion'
import Button from '../components/Button.astro'
import Tooltip from '../components/Tooltip.astro'
import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs'
import ChatMessages, { type ChatMessage } from './ChatMessages.astro'
import type { ActionInputNoFormData, AnyAction } from '../lib/astroActions'
import type { HTMLAttributes } from 'astro/types'
export type Props<TAction extends AnyAction | undefined = AnyAction | undefined> =
HTMLAttributes<'section'> & {
messages: ChatMessage[]
title?: string
userId: number | null
action: TAction
formData?: TAction extends AnyAction
? ActionInputNoFormData<TAction> extends Record<string, unknown>
? Omit<ActionInputNoFormData<TAction>, 'content'>
: ActionInputNoFormData<TAction>
: undefined
}
const { messages, title, userId, action, formData, class: className, ...htmlProps } = Astro.props
const result = action ? Astro.getActionResult(action) : undefined
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
---
<div class={cn(className)} {...htmlProps}>
{!!title && <h3 class="text-day-200 font-title mb-2 text-center text-xl font-bold">{title}</h3>}
<ChatMessages
id="chat-messages"
messages={messages}
userId={userId}
hx-trigger="every 10s"
hx-get={Astro.url.pathname}
hx-target="#chat-messages"
hx-select="#chat-messages>*"
/>
{
!!action && (
<>
<form
method="POST"
action={action}
class="flex items-end gap-2"
hx-post={`${Astro.url.pathname}${action}`}
hx-target="#chat-messages"
hx-select="#chat-messages>*"
hx-push-url="true"
{...{ 'hx-on::after-request': 'if(event.detail.successful) this.reset()' }}
>
{typeof formData === 'object' &&
formData !== null &&
Object.entries(formData).map(([key, value]) => (
<input type="hidden" name={key} value={String(value)} />
))}
<textarea
name="content"
placeholder="Add a message..."
class={cn(
baseInputClassNames.input,
baseInputClassNames.textarea,
'max-h-64',
!!inputErrors.content && baseInputClassNames.error
)}
required
maxlength={SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH}
/>
<Tooltip text="Send">
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
</Tooltip>
</form>
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
</>
)
}
</div>

View File

@@ -1,110 +0,0 @@
---
import { Picture } from 'astro:assets'
import { cn } from '../lib/cn'
import { formatDateShort } from '../lib/timeAgo'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
export type ChatMessage = {
id: number
content: string
createdAt: Date
user: Prisma.UserGetPayload<{
select: {
id: true
name: true
picture: true
}
}>
}
type Props = HTMLAttributes<'div'> & {
messages: ChatMessage[]
userId: number | null
}
const { messages, userId, class: className, ...htmlProps } = Astro.props
---
<div
class={cn(
'mb-1 flex max-h-[60dvh] flex-col-reverse overflow-y-auto mask-t-from-[calc(100%-var(--spacing)*16)] pt-16',
className
)}
{...htmlProps}
>
<p
class="sticky bottom-0 -z-1 flex min-h-7 w-full items-end justify-center text-center text-xs text-balance text-gray-500"
>
<span class="js:hidden">Refresh the page to see new messages</span>
<span class="no-js:hidden" data-refresh-in="10">Refreshing every 10s</span>
</p>
{
messages.length > 0 ? (
messages
.map((message) => ({
...message,
formattedCreatedAt: formatDateShort(message.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
}),
}))
.map((message, index, messages) => {
const isCurrentUser = message.user.id === userId
const prev = messages[index - 1]
const next = messages[index + 1]
const isPrevFromSameUser = !!prev && prev.user.id === message.user.id
const isPrevSameDate = !!prev && prev.formattedCreatedAt === message.formattedCreatedAt
const isNextFromSameUser = !!next && next.user.id === message.user.id
const isNextSameDate = !!next && next.formattedCreatedAt === message.formattedCreatedAt
return (
<div
class={cn(
'flex flex-col',
isCurrentUser ? 'ml-8 items-end' : 'mr-8 items-start',
isNextFromSameUser ? 'mt-1' : 'mt-3'
)}
>
{!isCurrentUser && !isNextFromSameUser && (
<p class="text-day-500 mb-0.5 text-xs">
{!!message.user.picture && (
<Picture
src={message.user.picture}
height={16}
width={16}
class="inline-block rounded-full align-[-0.33em]"
alt=""
formats={['jxl', 'avif', 'webp']}
/>
)}
{message.user.name}
</p>
)}
<p
class={cn(
'rounded-xl p-3 text-sm whitespace-pre-wrap',
isCurrentUser ? 'bg-blue-900 text-white' : 'bg-night-500 text-day-300',
isCurrentUser ? 'rounded-br-xs' : 'rounded-bl-xs',
isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tr-xs',
!isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tl-xs'
)}
id={`message-${message.id.toString()}`}
>
{message.content}
</p>
{(!isPrevFromSameUser || !isPrevSameDate) && (
<p class="text-day-500 mt-0.5 mb-2 text-xs">{message.formattedCreatedAt}</p>
)}
</div>
)
})
) : (
<div class="text-day-500 my-16 text-center text-sm italic">No messages yet</div>
)
}
</div>

View File

@@ -1,504 +0,0 @@
---
import Image from 'astro/components/Image.astro'
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema'
import { actions } from 'astro:actions'
import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { getServiceUserRoleInfo } from '../constants/serviceUserRoles'
import { cn } from '../lib/cn'
import {
makeCommentUrl,
MAX_COMMENT_DEPTH,
type CommentWithRepliesPopulated,
} from '../lib/commentsWithReplies'
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
import { formatDateShort } from '../lib/timeAgo'
import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro'
import CommentReply from './CommentReply.astro'
import TimeFormatted from './TimeFormatted.astro'
import Tooltip from './Tooltip.astro'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
comment: CommentWithRepliesPopulated
depth?: number
showPending?: boolean
highlightedCommentId: number | null
serviceSlug: string
itemReviewedId: string
}
const {
comment,
depth = 0,
showPending = false,
highlightedCommentId = null,
serviceSlug,
itemReviewedId,
class: className,
...htmlProps
} = Astro.props
const user = Astro.locals.user
const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
const authorUnlocks = computeKarmaUnlocks(comment.author.totalKarma)
function checkIsHighlightParent(c: CommentWithRepliesPopulated, highlight: number | null): boolean {
if (!highlight) return false
if (c.id === highlight) return true
if (!c.replies?.length) return false
return c.replies.some((r) => checkIsHighlightParent(r, highlight))
}
const isHighlightParent = checkIsHighlightParent(comment, highlightedCommentId)
const isHighlighted = comment.id === highlightedCommentId
// Get user's current vote if any
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
const isAuthor = user?.id === comment.author.id
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
// Check if user is new (less than 1 week old)
const isNewUser =
new Date().getTime() - new Date(comment.author.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000
const isRatingActive =
comment.rating !== null &&
!comment.parentId &&
comment.ratingActive &&
!comment.suspicious &&
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
const shouldShow =
comment.status === 'APPROVED' ||
comment.status === 'VERIFIED' ||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'PENDING') ||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'HUMAN_PENDING') ||
((isHighlightParent || isHighlighted) && comment.status === 'REJECTED') ||
isAuthorOrPrivileged
if (!shouldShow) return null
const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin: Astro.url.origin })
---
<style>
.collapse-toggle:checked + .comment-header .collapse-symbol::before {
content: '[+]';
}
.collapse-symbol::before {
content: '[-]';
}
</style>
<div
{...htmlProps}
id={`comment-${comment.id.toString()}`}
class={cn([
'group',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]',
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') && 'bg-[#292815]',
comment.status === 'REJECTED' && 'bg-[#2f1f1f]',
isHighlighted && 'bg-[#192633]',
comment.suspicious &&
'opacity-25 transition-opacity not-has-[[data-collapse-toggle]:checked]:opacity-100! focus-within:opacity-100 hover:opacity-100 focus:opacity-100',
className,
])}
>
{
isRatingActive && comment.rating !== null && (
<Schema
item={{
'@context': 'https://schema.org',
'@type': 'Review',
'@id': commentUrl,
author: {
'@type': 'Person',
name: comment.author.displayName ?? comment.author.name,
image: comment.author.picture ?? undefined,
url: new URL(`/u/${comment.author.name}`, Astro.url).href,
},
datePublished: comment.createdAt.toISOString(),
reviewBody: comment.content,
reviewAspect: 'User comment',
commentCount: comment.replies?.length ?? 0,
itemReviewed: { '@id': itemReviewedId },
reviewRating: {
'@type': 'Rating',
ratingValue: comment.rating,
bestRating: 5,
worstRating: 1,
},
}}
/>
)
}
<input
type="checkbox"
id={`collapse-${comment.id.toString()}`}
data-collapse-toggle
class="collapse-toggle peer/collapse hidden"
checked={comment.suspicious}
/>
<div class="comment-header flex items-center gap-2 text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span>
</label>
<span class="flex items-center gap-1">
{
comment.author.picture && (
<Image
src={comment.author.picture}
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`}
class="size-6 rounded-full bg-zinc-700 object-cover"
loading="lazy"
height={24}
width={24}
/>
)
}
<a
href={`/u/${comment.author.name}`}
class={cn([
'font-title text-day-300 font-medium hover:underline focus-visible:underline',
isAuthor && 'font-medium text-green-500',
])}
>
{comment.author.displayName ?? comment.author.name}
</a>
{
(comment.author.verified || comment.author.admin || comment.author.verifier) && (
<Tooltip
text={`${
comment.author.admin || comment.author.verifier
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
>
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
</Tooltip>
)
}
</span>
{/* User badges - more compact but still with text */}
<div class="flex flex-wrap items-center gap-1">
{
comment.author.admin && (
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
)
}
{
comment.author.verifier && !comment.author.admin && (
<BadgeSmall icon="ri:shield-check-fill" color="teal" text="Moderator" variant="faded" inlineIcon />
)
}
{
isNewUser && !comment.author.admin && !comment.author.verifier && (
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
</Tooltip>
)
}
{
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
<BadgeSmall
icon={karmaUnlocksById.highKarmaBadge.icon}
color="lime"
text="High Karma"
variant="faded"
inlineIcon
/>
)
}
{
authorUnlocks.negativeKarmaBadge && !authorUnlocks.untrustedBadge && (
<BadgeSmall
icon={karmaUnlocksById.negativeKarmaBadge.icon}
color="orange"
text="Negative Karma"
variant="faded"
inlineIcon
/>
)
}
{
(authorUnlocks.untrustedBadge || comment.author.spammer) && (
<BadgeSmall
icon={karmaUnlocksById.untrustedBadge.icon}
color="red"
text="Untrusted User"
variant="faded"
inlineIcon
/>
)
}
{
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall
icon={roleInfo.icon}
color={roleInfo.color}
text={`${roleInfo.label} at ${affiliation.service.name}`}
variant="faded"
inlineIcon
/>
)
})
}
</div>
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-1 text-xs text-zinc-400">
<span class="flex items-center gap-1">
<Icon name="ri:arrow-up-line" class="size-3" />
{comment.upvotes}
</span>
<span class="text-zinc-700">•</span>
<a href={commentUrl} class="hover:text-zinc-300">
<TimeFormatted date={comment.createdAt} hourPrecision />
</a>
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{
comment.rating !== null && !comment.parentId && (
<Tooltip
text="Not counting for the total"
position="right"
enabled={!isRatingActive}
class={cn('flex items-center gap-1', isRatingActive ? 'text-yellow-400' : 'text-yellow-400/60')}
>
<Icon name={isRatingActive ? 'ri:star-fill' : 'ri:star-line'} class="size-3" />
{comment.rating.toLocaleString()}/5
</Tooltip>
)
}
{
comment.status === 'VERIFIED' && (
<BadgeSmall icon="ri:check-double-fill" color="green" text="Verified" inlineIcon />
)
}
{
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') &&
(showPending || isHighlightParent || isAuthorOrPrivileged) && (
<BadgeSmall icon="ri:time-fill" color="yellow" text="Unmoderated" inlineIcon />
)
}
{
comment.status === 'REJECTED' && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Rejected" inlineIcon />
)
}
{/* Service usage verification indicators */}
{
comment.orderId && comment.orderIdStatus === 'APPROVED' && (
<BadgeSmall icon="ri:verified-badge-fill" color="green" text="Valid order ID" inlineIcon />
)
}
{
comment.orderId && comment.orderIdStatus === 'REJECTED' && (
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Invalid order ID" inlineIcon />
)
}
{
comment.kycRequested && (
<BadgeSmall icon="ri:user-forbid-fill" color="red" text="KYC issue" inlineIcon />
)
}
{
comment.fundsBlocked && (
<BadgeSmall icon="ri:wallet-3-fill" color="orange" text="Funds blocked" inlineIcon />
)
}
</div>
<div class={cn(['comment-body mt-2 peer-checked/collapse:hidden'])}>
{
isAuthor && comment.status === 'REJECTED' && (
<div class="mb-2 inline-block rounded-xs bg-red-500/30 px-2 py-1 text-xs text-red-300">
This comment has been rejected and is only visible to you
</div>
)
}
<div class="text-sm">
{
!!comment.content && (
<div class="prose prose-invert prose-sm max-w-none overflow-auto">
<Markdown content={comment.content} />
</div>
)
}
</div>
</div>
{
comment.communityNote && (
<div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs">
<span class="font-medium text-zinc-400">Added context:</span>
<span class="text-zinc-300">{comment.communityNote}</span>
</div>
</div>
)
}
{
user && (user.admin || user.verifier) && comment.internalNote && (
<div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
<span class="font-medium text-red-400">Internal note:</span>
<span class="text-red-300">{comment.internalNote}</span>
</div>
</div>
)
}
{
user && (user.admin || user.verifier) && comment.privateContext && (
<div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
<span class="font-medium text-blue-400">Private context:</span>
<span class="text-blue-300">{comment.privateContext}</span>
</div>
</div>
)
}
<div class="mt-2 flex items-center gap-3 text-xs peer-checked/collapse:hidden">
<div class="flex items-center gap-1">
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
<input type="hidden" name="commentId" value={comment.id} />
<input type="hidden" name="downvote" value="false" />
<Tooltip
as="button"
type="submit"
disabled={!user?.totalKarma || user.totalKarma < 20}
class={cn([
'rounded-sm p-1 hover:bg-zinc-800',
userVote?.downvote === false ? 'text-blue-500' : 'text-zinc-500',
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
])}
text={user?.totalKarma && user.totalKarma >= 20 ? 'Upvote' : 'Need 20+ karma to vote'}
position="right"
aria-label="Upvote"
>
<Icon name="ri:arrow-up-line" class="h-3.5 w-3.5" />
</Tooltip>
</form>
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
<input type="hidden" name="commentId" value={comment.id} />
<input type="hidden" name="downvote" value="true" />
<Tooltip
as="button"
text={user?.totalKarma && user.totalKarma >= 20 ? 'Downvote' : 'Need 20+ karma to vote'}
position="right"
disabled={!user?.totalKarma || user.totalKarma < 20}
class={cn([
'rounded-sm p-1 hover:bg-zinc-800',
userVote?.downvote === true ? 'text-red-500' : 'text-zinc-500',
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
])}
aria-label="Downvote"
>
<Icon name="ri:arrow-down-line" class="h-3.5 w-3.5" />
</Tooltip>
</form>
</div>
{
user && userCommentsDisabled ? (
<span class="text-xs text-red-400">You cannot reply due to low karma.</span>
) : (
<label
for={`reply-toggle-${comment.id.toString()}`}
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
>
<Icon name="ri:reply-line" class="h-3.5 w-3.5" />
Reply
</label>
)
}
{
user && (
<form
method="POST"
action={`${actions.notification.preferences.watchComment}&comment=${comment.id.toString()}#comment-${comment.id.toString()}`}
class="inline"
data-astro-reload
>
<input type="hidden" name="commentId" value={comment.id} />
<input type="hidden" name="watch" value={comment.isWatchingReplies ? 'false' : 'true'} />
<button
type="submit"
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
>
<Icon name={comment.isWatchingReplies ? 'ri:eye-off-line' : 'ri:eye-line'} class="size-3" />
{comment.isWatchingReplies ? 'Unwatch' : 'Watch'}
</button>
</form>
)
}
</div>
<CommentModeration class="mt-2 peer-checked/collapse:hidden" comment={comment} />
{
user && userCommentsDisabled ? null : (
<>
<input type="checkbox" id={`reply-toggle-${comment.id.toString()}`} class="peer/reply hidden" />
<CommentReply
serviceId={comment.serviceId}
parentId={comment.id}
commentId={comment.id}
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
/>
</>
)
}
{
comment.replies && comment.replies.length > 0 && depth < MAX_COMMENT_DEPTH && (
<div class="replies mt-3 peer-checked/collapse:hidden">
{comment.replies.map((reply) => (
<Astro.self
comment={reply}
depth={depth + 1}
showPending={showPending}
highlightedCommentId={isHighlightParent ? highlightedCommentId : null}
serviceSlug={serviceSlug}
itemReviewedId={itemReviewedId}
/>
))}
</div>
)
}
</div>

View File

@@ -1,366 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
comment: Prisma.CommentGetPayload<{
select: {
id: true
status: true
suspicious: true
requiresAdminReview: true
kycRequested: true
fundsBlocked: true
communityNote: true
internalNote: true
privateContext: true
orderId: true
orderIdStatus: true
}
}>
}
const { comment, class: className, ...divProps } = Astro.props
const user = Astro.locals.user
// Only render for admin/verifier users
if (!user || !user.admin || !user.verifier) return null
---
<div {...divProps} class={cn('text-xs', className)}>
<input type="checkbox" id={`mod-toggle-${String(comment.id)}`} class="peer hidden" />
<label
for={`mod-toggle-${String(comment.id)}`}
class="text-day-500 hover:text-day-300 flex cursor-pointer items-center gap-1"
>
<Icon name="ri:shield-keyhole-line" class="h-3.5 w-3.5" />
<span class="text-xs">Moderation</span>
<Icon name="ri:arrow-down-s-line" class="h-3.5 w-3.5 transition-transform peer-checked:rotate-180" />
</label>
<div
class="bg-night-600 border-night-500 mt-2 max-h-0 overflow-hidden rounded-md border opacity-0 transition-all duration-200 ease-in-out peer-checked:max-h-[500px] peer-checked:p-2 peer-checked:opacity-100"
>
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
)}
data-action="status"
data-value={comment.status === 'REJECTED' ? 'PENDING' : 'REJECTED'}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.suspicious ? 'Not Spam' : 'Spam'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'VERIFIED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
)}
data-action="status"
data-value={comment.status === 'VERIFIED' ? 'APPROVED' : 'VERIFIED'}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)}
data-action="status"
data-value={comment.status === 'PENDING' ? 'APPROVED' : 'PENDING'}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'PENDING' ? 'Approve' : 'Pending'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.kycRequested
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
)}
data-action="kyc-requested"
data-value={!comment.kycRequested}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.fundsBlocked
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
)}
data-action="funds-blocked"
data-value={!comment.fundsBlocked}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
</button>
</div>
<div class="mt-2 space-y-1.5">
<div class="flex items-center gap-1.5">
<span class="text-day-500 text-[10px]">Community:</span>
<input
type="text"
placeholder="Public note..."
value={comment.communityNote}
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
data-action="community-note"
data-comment-id={comment.id}
data-user-id={user.id}
/>
</div>
<div class="flex items-center gap-1.5">
<span class="text-day-500 text-[10px]">Internal:</span>
<input
type="text"
placeholder="Mod note..."
value={comment.internalNote}
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
data-action="internal-note"
data-comment-id={comment.id}
data-user-id={user.id}
/>
</div>
<div class="flex items-center gap-1.5">
<span class="text-day-500 text-[10px]">Private:</span>
<input
type="text"
placeholder="Context..."
value={comment.privateContext}
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
data-action="private-context"
data-comment-id={comment.id}
data-user-id={user.id}
/>
</div>
{
comment.orderId && (
<div class="border-night-500 mt-3 space-y-1.5 border-t pt-2">
<div class="flex items-center gap-1.5">
<span class="text-day-500 text-[10px]">Order ID:</span>
<div class="bg-night-700 flex-1 rounded-sm px-1.5 py-0.5 text-xs">{comment.orderId}</div>
</div>
<div class="flex items-center gap-1.5">
<span class="text-day-500 text-[10px]">Status:</span>
<div class="flex gap-1">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'APPROVED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
)}
data-action="order-id-status"
data-value="APPROVED"
data-comment-id={comment.id}
data-user-id={user.id}
>
Approve
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
)}
data-action="order-id-status"
data-value="REJECTED"
data-comment-id={comment.id}
data-user-id={user.id}
>
Reject
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)}
data-action="order-id-status"
data-value="PENDING"
data-comment-id={comment.id}
data-user-id={user.id}
>
Pending
</button>
</div>
</div>
</div>
)
}
</div>
</div>
</div>
<script>
import { actions } from 'astro:actions'
document.addEventListener('astro:page-load', () => {
// Handle button clicks
document.querySelectorAll('button[data-action]').forEach((btn) => {
btn.addEventListener('click', async () => {
const action = btn.getAttribute('data-action')
const value = btn.getAttribute('data-value')
const commentId = parseInt(btn.getAttribute('data-comment-id') || '0')
const userId = parseInt(btn.getAttribute('data-user-id') || '0')
if (!value || !commentId || !userId) return
try {
const { error } = await actions.comment.moderate({
commentId,
userId,
action: action as any,
value:
action === 'suspicious' ||
action === 'requires-admin-review' ||
action === 'kyc-requested' ||
action === 'funds-blocked'
? value === 'true'
: value,
})
if (!error) {
// Update button state based on new value
if (action === 'status') {
window.location.reload()
} else if (action === 'suspicious') {
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
btn.classList.toggle('bg-yellow-500/20')
btn.classList.toggle('text-yellow-400')
btn.classList.toggle('border-yellow-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'requires-admin-review') {
btn.textContent = value === 'true' ? 'No Review' : 'Review'
btn.classList.toggle('bg-purple-500/20')
btn.classList.toggle('text-purple-400')
btn.classList.toggle('border-purple-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'order-id-status') {
// Refresh to show updated order ID status
window.location.reload()
} else if (action === 'kyc-requested') {
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'funds-blocked') {
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
}
} else {
console.error('Error moderating comment:', error)
}
} catch (error) {
console.error('Error moderating comment:', error)
}
})
})
// Handle text input changes
document.querySelectorAll('input[data-action]').forEach((input) => {
const action = input.getAttribute('data-action')
const commentId = parseInt(input.getAttribute('data-comment-id') || '0')
const userId = parseInt(input.getAttribute('data-user-id') || '0')
if (!action || !commentId || !userId) return
let timeout: NodeJS.Timeout
input.addEventListener('input', () => {
clearTimeout(timeout)
timeout = setTimeout(async () => {
try {
const { error } = await actions.comment.moderate({
commentId,
userId,
action: action as any,
value: (input as HTMLInputElement).value,
})
if (error) {
console.error('Error updating note:', error)
}
} catch (error) {
console.error('Error updating note:', error)
}
}, 500) // Debounce for 500ms
})
})
})
</script>

View File

@@ -1,172 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { cn } from '../lib/cn'
import { makeLoginUrl } from '../lib/redirectUrls'
import Button from './Button.astro'
import FormTimeTrap from './FormTimeTrap.astro'
import InputHoneypotTrap from './InputHoneypotTrap.astro'
import InputRating from './InputRating.astro'
import InputText from './InputText.astro'
import InputWrapper from './InputWrapper.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
serviceId: number
parentId?: number
commentId?: number
activeRatingComment?: Prisma.CommentGetPayload<{
select: {
id: true
rating: true
}
}> | null
}
const { serviceId, parentId, commentId, activeRatingComment, class: className, ...htmlProps } = Astro.props
const MIN_COMMENT_LENGTH = parentId ? 10 : 30
const user = Astro.locals.user
const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
---
<form
method="POST"
action={actions.comment.create}
enctype="application/x-www-form-urlencoded"
class={cn(className)}
{...htmlProps}
>
<FormTimeTrap />
<input type="hidden" name="serviceId" value={serviceId} />
{parentId && <input type="hidden" name="parentId" value={parentId} />}
<div class="space-y-1.5">
<input
type="checkbox"
id={`use-form-secret-token-${String(commentId ?? 'new')}`}
name="useFormUserSecretToken"
checked={!user}
class="peer/use-form-secret-token hidden"
/>
{
user ? (
userCommentsDisabled ? (
<div class="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-400">
<Icon name="ri:forbid-line" class="mr-1 inline h-4 w-4 align-[-0.2em]" />
You cannot comment due to low karma.
</div>
) : (
<>
<div class="text-day-400 flex items-center gap-2 text-xs peer-checked/use-form-secret-token:hidden">
<Icon name="ri:user-line" class="size-3.5" />
<span>
Commenting as: <span class="font-title font-medium text-green-400">{user.name}</span>
</span>
</div>
<InputHoneypotTrap name="message" />
<div>
<textarea
id={`comment-${String(commentId ?? 'new')}`}
name="content"
required
minlength={MIN_COMMENT_LENGTH}
maxlength={2000}
rows="4"
placeholder="Write your comment..."
class="placeholder:text-day-500 focus:ring-day-500 border-night-500 bg-night-800 focus:border-night-600 max-h-128 min-h-16 w-full resize-y rounded-lg border px-2.5 py-2 text-sm focus:ring-1 focus:outline-hidden"
/>
</div>
{!parentId ? (
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
<div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="Tags" name="tags">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400">
<Icon name="ri:user-forbid-fill" class="size-3" />
KYC Issue
</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
<span class="flex items-center gap-1 text-xs text-orange-400">
<Icon name="ri:wallet-3-fill" class="size-3" />
Funds Blocked
</span>
</label>
</InputWrapper>
<InputText
label="Order ID"
name="orderId"
inputProps={{
maxlength: 100,
placeholder: 'Order ID / URL / Proof',
class: 'bg-night-800',
}}
descriptionLabel="Only visible to admins, to verify your comment"
class="grow"
/>
</div>
<div class="mt-4 flex items-start justify-end gap-2">
{!!activeRatingComment?.rating && (
<div
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
data-show-if-rating
>
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
<a
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
class="inline-flex items-center gap-1 underline"
target="_blank"
rel="noopener noreferrer"
>
Your previous rating
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
</a>
of
{[
activeRatingComment.rating.toLocaleString(),
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
]}
won't count for the total.
</div>
)}
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
</div>
</div>
) : (
<div class="flex items-center justify-end gap-2">
<Button type="submit" label="Reply" icon="ri:reply-line" />
</div>
)}
</>
)
) : (
<a
href={makeLoginUrl(Astro.url, { message: 'Login to comment' })}
data-astro-reload
class="font-title mb-4 inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-1.5 text-xs text-blue-400 shadow-xs transition-colors duration-200 hover:bg-blue-500/20 focus:ring-1 focus:ring-blue-500 focus:outline-hidden"
>
<Icon name="ri:login-box-line" class="size-3.5" />
Login to comment
</a>
)
}
</div>
</form>

View File

@@ -1,263 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { Schema } from 'astro-seo-schema'
import { z } from 'zod'
import CommentItem from '../components/CommentItem.astro'
import CommentReply from '../components/CommentReply.astro'
import { getCommentStatusInfo } from '../constants/commentStatus'
import { cn } from '../lib/cn'
import {
commentSortSchema,
makeCommentsNestedQuery,
MAX_COMMENT_DEPTH,
type CommentSortOption,
type CommentWithReplies,
type CommentWithRepliesPopulated,
} from '../lib/commentsWithReplies'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { makeOgImageUrl } from './OgImage'
import type { Prisma } from '@prisma/client'
import type { Comment, DiscussionForumPosting, WithContext } from 'schema-dts'
type Props = {
itemReviewedId: string
service: Prisma.ServiceGetPayload<{
select: {
id: true
slug: true
listedAt: true
name: true
description: true
createdAt: true
}
}>
}
const { service, itemReviewedId } = Astro.props
const { data: params } = zodParseQueryParamsStoringErrors(
{
showPending: z.coerce.boolean().default(false),
comment: z.coerce.number().int().positive().nullable().default(null),
sort: commentSortSchema,
},
Astro
)
const toggleUrl = new URL(Astro.request.url)
toggleUrl.hash = '#comments'
if (params.showPending) {
toggleUrl.searchParams.delete('showPending')
} else {
toggleUrl.searchParams.set('showPending', 'true')
}
const getSortUrl = (sortOption: CommentSortOption) => {
const url = new URL(Astro.request.url)
url.searchParams.set('sort', sortOption)
return url.toString() + '#comments'
}
const user = Astro.locals.user
const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.locals.banners.tryMany([
[
'Failed to fetch comments',
async () =>
await prisma.comment.findMany(
makeCommentsNestedQuery({
depth: MAX_COMMENT_DEPTH,
user,
showPending: params.showPending,
serviceId: service.id,
sort: params.sort,
})
),
[],
],
[
'Failed to count unmoderated comments',
async () =>
prisma.comment.count({
where: {
serviceId: service.id,
status: { in: ['PENDING', 'HUMAN_PENDING'] },
},
}),
0,
],
[
"Failed to fetch user's service rating",
async () =>
user
? await prisma.comment.findFirst({
where: { serviceId: service.id, authorId: user.id, ratingActive: true },
orderBy: { createdAt: 'desc' },
select: {
id: true,
rating: true,
},
})
: null,
null,
],
])
const notiPref = user
? await getOrCreateNotificationPreferences(user.id, {
watchedComments: { select: { id: true } },
})
: null
const populateComment = (comment: CommentWithReplies): CommentWithRepliesPopulated => ({
...comment,
isWatchingReplies: notiPref?.watchedComments.some((c) => c.id === comment.id) ?? false,
replies: comment.replies?.map(populateComment),
})
const comments = dbComments.map(populateComment)
function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
const statusInfo = getCommentStatusInfo(comment.status)
return {
'@type': 'Comment',
text: comment.content,
datePublished: comment.createdAt.toISOString(),
dateCreated: comment.createdAt.toISOString(),
creativeWorkStatus: statusInfo.creativeWorkStatus,
author: {
'@type': 'Person',
name: comment.author.displayName ?? comment.author.name,
url: new URL(`/u/${comment.author.name}`, Astro.url).href,
image: comment.author.picture ?? undefined,
},
interactionStatistic: [
{
'@type': 'InteractionCounter',
interactionType: { '@type': 'LikeAction' },
userInteractionCount: comment.upvotes,
},
{
'@type': 'InteractionCounter',
interactionType: { '@type': 'ReplyAction' },
userInteractionCount: comment.replies?.length ?? 0,
},
],
commentCount: comment.replies?.length ?? 0,
comment: comment.replies?.map(makeReplySchema),
} satisfies Comment
}
---
<section class="mt-8" id="comments">
<Schema
item={{
'@context': 'https://schema.org',
'@type': 'DiscussionForumPosting',
url: new URL(`/service/${service.slug}#comments`, Astro.url).href,
mainEntityOfPage: new URL(`/service/${service.slug}#comments`, Astro.url).href,
datePublished: service.listedAt?.toISOString(),
dateCreated: service.createdAt.toISOString(),
headline: `${service.name} comments on KYCnot.me`,
text: service.description,
author: KYCNOTME_SCHEMA_MINI,
image: makeOgImageUrl({ template: 'generic', title: `${service.name} comments` }, Astro.url),
commentCount: comments.length,
comment: comments.map(makeReplySchema),
} as WithContext<DiscussionForumPosting>}
/>
<CommentReply serviceId={service.id} activeRatingComment={activeRatingComment} class="xs:mb-4 mb-2" />
<div class="mb-6 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center">
<div class="flex items-center space-x-1">
<a
href={getSortUrl('newest')}
class={cn([
'rounded-md px-2 py-1 text-sm',
params.sort === 'newest'
? 'bg-blue-500/20 text-blue-400'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
])}
>
<Icon name="ri:time-line" class="mr-1 inline h-3.5 w-3.5" />
Newest
</a>
<a
href={getSortUrl('upvotes')}
class={cn([
'rounded-md px-2 py-1 text-sm',
params.sort === 'upvotes'
? 'bg-blue-500/20 text-blue-400'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
])}
>
<Icon name="ri:arrow-up-line" class="mr-1 inline h-3.5 w-3.5" />
Most Upvotes
</a>
{
user && (user.admin || user.verifier) && (
<a
href={getSortUrl('status')}
class={cn([
'rounded-md px-2 py-1 text-sm',
params.sort === 'status'
? 'bg-blue-500/20 text-blue-400'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
])}
>
<Icon name="ri:filter-line" class="mr-1 inline h-3.5 w-3.5" />
Status
</a>
)
}
</div>
</div>
<div class="flex items-center">
{
pendingCommentsCount > 0 && (
<div class="flex items-center">
<a
href={toggleUrl.toString()}
class={cn([
'flex items-center gap-2 text-sm',
params.showPending ? 'text-yellow-500' : 'text-zinc-400 hover:text-zinc-300',
])}
>
<div class="relative flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-zinc-700 p-1 transition-colors duration-200 ease-in-out focus:outline-hidden">
<span
class={cn([
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-zinc-400 shadow-sm transition-transform duration-200 ease-in-out',
params.showPending && 'translate-x-4 bg-yellow-500',
])}
/>
</div>
<span>Show unmoderated ({pendingCommentsCount})</span>
</a>
</div>
)
}
</div>
</div>
<div class="space-y-4">
{
comments.map((comment) => (
<CommentItem
comment={comment}
highlightedCommentId={params.comment}
showPending={params.showPending}
serviceSlug={service.slug}
itemReviewedId={itemReviewedId}
/>
))
}
</div>
</section>

View File

@@ -1,128 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { Schema } from 'astro-seo-schema'
import { clamp, round, sum, sumBy } from 'lodash-es'
import { cn } from '../lib/cn'
import { prisma } from '../lib/prisma'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
serviceId: number
itemReviewedId: string
averageUserRating?: number | null
}
const {
serviceId,
itemReviewedId,
averageUserRating: averageUserRatingFromProps,
class: className,
...htmlProps
} = Astro.props
const ratingsFromDb = await prisma.comment.groupBy({
by: ['rating'],
where: {
serviceId,
ratingActive: true,
status: {
in: ['APPROVED', 'VERIFIED'],
},
parentId: null,
suspicious: false,
},
_count: true,
})
const ratings = ([5, 4, 3, 2, 1] as const).map((rating) => ({
rating,
count: ratingsFromDb.find((stat) => stat.rating === rating)?._count ?? 0,
}))
const totalComments = sumBy(ratings, 'count')
const averageUserRatingFromQuery =
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
if (averageUserRatingFromProps !== undefined) {
if (
averageUserRatingFromQuery !== averageUserRatingFromProps ||
(averageUserRatingFromQuery !== null &&
averageUserRatingFromProps !== null &&
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
) {
console.error(
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
)
}
}
const averageUserRating =
averageUserRatingFromProps === undefined ? averageUserRatingFromQuery : averageUserRatingFromProps
---
<div {...htmlProps} class={cn('flex flex-wrap items-center justify-center gap-4', className)}>
{
averageUserRating !== null && (
<Schema
item={{
'@context': 'https://schema.org',
'@type': 'AggregateRating',
itemReviewed: { '@id': itemReviewedId },
ratingValue: round(averageUserRating, 1),
bestRating: 5,
worstRating: 1,
ratingCount: totalComments,
}}
/>
)
}
<div class="flex flex-col items-center">
<div class="mb-1 text-5xl">
{averageUserRating !== null ? round(averageUserRating, 1).toLocaleString() : '-'}
</div>
<div class="flex items-center space-x-1">
{
([1, 2, 3, 4, 5] as const).map((rating) => (
<div
class="relative size-5"
style={`--percent: ${clamp((averageUserRating ?? 0) - (rating - 1), 0, 1) * 100}%`}
>
<Icon name="ri:star-line" class="absolute inset-0 size-full text-zinc-500" />
<Icon
name="ri:star-fill"
class="absolute inset-0 size-full text-yellow-400 [clip-path:inset(0_calc(100%_-_var(--percent))_0_0)]"
/>
</div>
))
}
</div>
<div class="mt-1 text-sm text-zinc-400">
{totalComments.toLocaleString()} ratings
</div>
</div>
<div class="grid min-w-32 flex-1 grid-cols-[auto_1fr_auto] items-center gap-1">
{
ratings.map(({ rating, count }) => {
const percent = totalComments > 0 ? (count / totalComments) * 100 : null
return (
<>
<div class="text-center text-xs text-zinc-400" aria-label={`${rating} stars`}>
{rating.toLocaleString()}
</div>
<div class="h-2 flex-1 overflow-hidden rounded-full bg-zinc-700">
<div class="h-full w-(--percent) bg-yellow-400" style={`--percent: ${percent ?? 0}%`} />
</div>
<div class="text-right text-xs text-zinc-400">
{[<span>{round(percent ?? 0).toLocaleString()}</span>, <span class="text-zinc-500">%</span>]}
</div>
</>
)
})
}
</div>
</div>

View File

@@ -1,45 +0,0 @@
---
import { cn } from '../lib/cn'
import Button, { type ButtonProps } from './Button.astro'
import type { Optional } from 'ts-toolbelt/out/Object/Optional'
type Props = Optional<ButtonProps<'button'>, 'icon' | 'label'> & {
copyText: string
}
const { copyText, class: className, icon, label, ...buttonProps } = Astro.props
---
<Button
data-copy-text={copyText}
data-copy-button
{...buttonProps}
label={label ?? 'Copy'}
icon={icon ?? 'ri:clipboard-line'}
class={cn(['no-js:hidden', className])}
/>
<script>
document.addEventListener('astro:page-load', () => {
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-copy-button]')
buttons.forEach((button) => {
button.addEventListener('click', () => {
const text = button.dataset.copyText
if (text === undefined) {
throw new Error('Copy button must have a data-copy-text attribute')
}
navigator.clipboard.writeText(text)
const span = button.querySelector<HTMLSpanElement>('span')
if (span) {
span.textContent = 'Copied'
setTimeout(() => {
span.textContent = 'Copy'
}, 2000)
}
})
})
})
</script>

View File

@@ -1,58 +0,0 @@
---
import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { AstroChildren } from '../lib/astro'
import type { HTMLAttributes } from 'astro/types'
export type Props = HTMLAttributes<'div'> & {
children: AstroChildren
label: string
icon?: string
classNames?: {
label?: string
icon?: string
arrow?: string
button?: string
}
}
const buttonId = Astro.locals.makeId('dropdown-button')
const menuId = Astro.locals.makeId('dropdown-menu')
const { label, icon, class: className, classNames, ...htmlProps } = Astro.props
---
<div class={cn('group/dropdown relative', className)} {...htmlProps}>
<Button
class={cn(
'group-hover/dropdown:bg-night-900 group-focus-within/dropdown:bg-night-900 group-focus-within/dropdown:text-day-200 group-hover/dropdown:text-day-200',
classNames?.button
)}
icon={icon}
label={label}
endIcon="ri:arrow-down-s-line"
classNames={{
label: classNames?.label,
icon: classNames?.icon,
endIcon: cn(
'transition-transform group-focus-within/dropdown:rotate-180 group-hover/dropdown:rotate-180',
classNames?.arrow
),
}}
aria-haspopup="true"
aria-controls={menuId}
id={buttonId}
/>
<div
class="border-night-500 bg-night-900 absolute right-0 z-50 mt-1 hidden w-48 items-stretch rounded-md border py-1 shadow-lg group-focus-within/dropdown:block group-hover/dropdown:block before:absolute before:-inset-x-px before:bottom-[calc(100%-1*var(--spacing))] before:box-content before:h-2 before:pb-px"
role="menu"
aria-orientation="vertical"
aria-labelledby={buttonId}
id={menuId}
>
<slot />
</div>
</div>

View File

@@ -1,29 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import type { ActionInputNoFormData, AnyAction } from '../lib/astroActions'
import type { HTMLAttributes } from 'astro/types'
export type Props<TAction extends AnyAction = AnyAction> = Omit<HTMLAttributes<'form'>, 'action'> & {
label: string
icon?: string
action: TAction
data: ActionInputNoFormData<TAction>
}
const { label, icon, action, data, class: className, ...htmlProps } = Astro.props
---
<form action={action} class={cn('contents', className)} {...htmlProps}>
{Object.entries(data).map(([key, value]) => <input type="hidden" name={key} value={String(value)} />)}
<button
class="text-day-300 hover:bg-night-800 flex w-full items-center px-4 py-2 text-left text-sm hover:text-white"
type="submit"
>
{icon && <Icon name={icon} class="mr-2 size-4" />}
<span class="flex-1">{label}</span>
<slot name="end" />
</button>
</form>

View File

@@ -1,27 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types'
export type Props = HTMLAttributes<'a'> & {
label: string
icon?: string
href: string
}
const { label, icon, href, class: className, ...htmlProps } = Astro.props
---
<a
href={href}
class={cn(
'text-day-300 hover:bg-night-800 flex items-center px-4 py-2 text-sm hover:text-white',
className
)}
{...htmlProps}
>
{icon && <Icon name={icon} class="mr-2 size-4" />}
<span class="flex-1">{label}</span>
</a>

View File

@@ -1,51 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { SOURCE_CODE_URL } from 'astro:env/server'
import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'footer'>
const links = [
{
href: SOURCE_CODE_URL,
label: 'Source Code',
icon: 'ri:git-repository-line',
external: true,
},
{
href: '/about',
label: 'About',
icon: 'ri:information-line',
external: false,
},
] as const satisfies {
href: string
label: string
icon: string
external: boolean
}[]
const { class: className, ...htmlProps } = Astro.props
---
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
{
links.map(
({ href, label, icon, external }) =>
href && (
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
>
<Icon name={icon} class="h-4 w-4" />
{label}
</a>
)
)
}
</footer>

View File

@@ -1,64 +0,0 @@
---
// Time Trap Component //
// This component is used to prevent bots from submitting the form.
// It encrypts the current timestamp and stores it in a hidden input field.
// The server then decrypts the timestamp and checks if it's valid and
// if the time difference is within the allowed range.
// If the timestamp is invalid, the form is not submitted.
import crypto from 'crypto'
import { timeTrapSecretKey } from '../lib/timeTrapSecret'
const algorithm = 'aes-256-cbc'
const iv = crypto.randomBytes(16) // Generate a random IV for each encryption
const timestamp = Date.now().toString()
const cipher = crypto.createCipheriv(algorithm, timeTrapSecretKey, iv)
let encrypted = cipher.update(timestamp, 'utf8', 'hex')
encrypted += cipher.final('hex')
// Combine IV and encrypted timestamp, then encode as base64 for the input value
const encryptedValue = Buffer.from(`${iv.toString('hex')}:${encrypted}`).toString('base64')
// --- Time Trap Validation Start ---
// try {
// const algorithm = 'aes-256-cbc'
// const decodedValue = Buffer.from(input.encTimestamp, 'base64').toString('utf8')
// const [ivHex, encryptedHex] = decodedValue.split(':')
// if (!ivHex || !encryptedHex) {
// throw new Error('Invalid time trap format.')
// }
// const iv = Buffer.from(ivHex, 'hex')
// const decipher = crypto.createDecipheriv(algorithm, timeTrapSecretKey, iv)
// let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
// decrypted += decipher.final('utf8')
// const originalTimestamp = parseInt(decrypted, 10)
// if (isNaN(originalTimestamp)) {
// throw new Error('Invalid timestamp data.')
// }
// const now = Date.now()
// const timeDiff = now - originalTimestamp
// const minTimeSeconds = 2 // 2 seconds
// const maxTimeMinutes = 60 // 1 hour
// if (timeDiff < minTimeSeconds * 1000 || timeDiff > maxTimeMinutes * 60 * 1000) {
// console.warn(`Time trap triggered: ${timeDiff / 1000}s`)
// throw new Error('Invalid submission timing.')
// }
// } catch (err: any) {
// console.error('Time trap validation failed:', err.message)
// throw new ActionError({
// code: 'BAD_REQUEST',
// message: 'Invalid request',
// })
// }
// --- Time Trap Validation End ---
---
<input type="hidden" name="encTimestamp" value={encryptedValue} data-time-trap class="hidden" />

View File

@@ -1,82 +0,0 @@
---
import { cn } from '../lib/cn'
import { timeAgo } from '../lib/timeAgo'
import type { Polymorphic } from 'astro/types'
type Props<Tag extends 'div' | 'li' | 'p' | 'span' = 'span'> = Polymorphic<{
as: Tag
start: Date
end?: Date | null
classNames?: {
fadedWords?: string
}
now?: Date
}>
const { start, end = null, classNames = {}, now = new Date(), as: Tag = 'span', ...htmlProps } = Astro.props
const actualEndedAt = end ?? now
const startedAtFormatted = timeAgo.format(start, 'twitter-minute-now')
const isUpcoming = now < start
const isOngoing = now >= start && (!end || now <= end)
const endedAtFormatted = timeAgo.format(actualEndedAt, 'twitter-minute-now')
const isOneTimeEvent = start === actualEndedAt || startedAtFormatted === endedAtFormatted
---
<Tag {...htmlProps}>
{
!end ? (
isUpcoming ? (
<>
Upcoming
<span class={cn('text-current/50', classNames.fadedWords)}>on</span>
<time class="whitespace-nowrap">{startedAtFormatted}</time>
</>
) : (
<>
Ongoing
<span class={cn('text-current/50', classNames.fadedWords)}>since</span>
<time class="whitespace-nowrap">{startedAtFormatted}</time>
</>
)
) : isOneTimeEvent ? (
isUpcoming ? (
<>
Upcoming
<span class={cn('text-current/50', classNames.fadedWords)}>on</span>
<time class="whitespace-nowrap">{startedAtFormatted}</time>
</>
) : (
<time class="whitespace-nowrap">{startedAtFormatted}</time>
)
) : (
<>
{isUpcoming ? (
<>
Upcoming
<span class={cn('text-current/50', classNames.fadedWords)}>from</span>
<time class="whitespace-nowrap">{startedAtFormatted}</time>
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
<time class="whitespace-nowrap">{endedAtFormatted}</time>
</>
) : isOngoing ? (
<>
Ongoing
<span class={cn('text-current/50', classNames.fadedWords)}>since</span>
<time class="whitespace-nowrap">{startedAtFormatted}</time>
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
<time class="whitespace-nowrap">{endedAtFormatted}</time>
</>
) : (
<>
<span class={cn('text-current/50', classNames.fadedWords)}>From</span>
<time class="whitespace-nowrap">{startedAtFormatted}</time>
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
<time class="whitespace-nowrap">{endedAtFormatted}</time>
</>
)}
</>
)
}
</Tag>

View File

@@ -1,187 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { sample } from 'lodash-es'
import { splashTexts } from '../constants/splashTexts'
import { cn } from '../lib/cn'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
import AdminOnly from './AdminOnly.astro'
import HeaderNotificationIndicator from './HeaderNotificationIndicator.astro'
import HeaderSplashTextScript from './HeaderSplashTextScript.astro'
import Logo from './Logo.astro'
import Tooltip from './Tooltip.astro'
const user = Astro.locals.user
const actualUser = Astro.locals.actualUser
type Props = {
classNames?: {
nav?: string
}
showSplashText?: boolean
}
const { classNames, showSplashText = false } = Astro.props
const splashText = showSplashText ? sample(splashTexts) : null
---
<header
class={cn(
'bg-night-900/80 sticky inset-x-0 top-0 z-50 h-16 border-b border-zinc-800 backdrop-blur-sm [&_~_*_[id]]:scroll-mt-18',
{
'border-red-900 bg-red-500/60': !!actualUser,
}
)}
>
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
<a href="/" class="relative inline-flex h-full items-center pr-4 pl-4 @[2rem]:pr-0">
<Logo
class={cn(
'h-6 text-green-500 transition-colors hover:text-green-400',
{
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
},
'hidden @[192px]:block'
)}
transition:name="header-logo"
/>
<Logo
variant="small"
class={cn(
'font-title h-8 text-green-500 transition-colors hover:text-green-400',
{
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
},
'hidden @[94px]:block @[192px]:hidden'
)}
transition:name="header-logo"
/>
<Logo
variant="mini"
class={cn(
'font-title h-8 text-green-500 transition-colors hover:text-green-400',
{
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
},
'hidden @[63px]:block @[94px]:hidden'
)}
transition:name="header-logo"
/>
{
DEPLOYMENT_MODE !== 'production' && (
<span
class={cn(
'absolute bottom-1 left-9.5 -translate-x-1/2 @[192px]:left-12.5',
'text-2xs pointer-events-none hidden rounded-full bg-zinc-800 px-1.25 py-0.75 leading-none font-semibold tracking-wide text-white @[63px]:block',
{
'border border-red-800 bg-red-950 text-red-400': DEPLOYMENT_MODE === 'development',
'border border-cyan-800 bg-cyan-950 text-cyan-400': DEPLOYMENT_MODE === 'staging',
}
)}
transition:name="header-deployment-mode"
>
{DEPLOYMENT_MODE === 'development' ? 'DEV' : 'PRE'}
</span>
)
}
</a>
</div>
{
!!splashText && (
<div
class="js:cursor-pointer @container flex min-w-0 flex-1 items-center justify-center"
data-splash-text-container
aria-hidden="true"
>
<span
class="font-title line-clamp-2 hidden shrink text-center text-xs text-balance text-lime-500 @[6rem]:inline @4xl:ml-0"
data-splash-text
>
{splashText}
</span>
<HeaderSplashTextScript />
</div>
)
}
<div class="flex items-center">
<AdminOnly>
<Tooltip
as="a"
href="/admin"
class="text-red-500 transition-colors hover:text-red-400"
transition:name="header-admin-link"
text="Admin Dashboard"
position="left"
>
<Icon name="ri:home-gear-line" class="size-10" />
</Tooltip>
</AdminOnly>
{
user ? (
<>
{actualUser && (
<span class="text-sm text-white/40 hover:text-white" transition:name="header-actual-user-name">
({actualUser.name})
</span>
)}
<HeaderNotificationIndicator
class="xs:px-3 2xs:px-2 h-full px-1"
transition:name="header-notification-indicator"
/>
<a
href="/account"
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-zinc-400 transition-colors last:-mr-1 hover:text-zinc-300"
transition:name="header-user-link"
>
{user.name}
</a>
{actualUser ? (
<a
href={makeUnimpersonateUrl(Astro.url)}
data-astro-reload
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"
aria-label="Unimpersonate"
>
<Icon name="ri:user-shared-2-line" class="size-4" />
</a>
) : (
DEPLOYMENT_MODE !== 'production' && (
<a
href="/account/logout"
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"
transition:name="header-logout-link"
aria-label="Logout"
>
<Icon name="ri:logout-box-r-line" class="size-4" />
</a>
)
)}
</>
) : (
<a
href={makeLoginUrl(Astro.url)}
data-astro-reload
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-green-500 transition-colors last:-mr-1 hover:text-green-400"
transition:name="header-login-link"
>
Login
</a>
)
}
</div>
</nav>
</header>

View File

@@ -1,45 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import { prisma } from '../lib/prisma'
import type { HTMLAttributes } from 'astro/types'
type Props = Omit<HTMLAttributes<'a'>, 'href'> & {
count?: number | null
}
const { count: propsCount, class: className, ...htmlProps } = Astro.props
const user = Astro.locals.user
const count =
propsCount ??
(await Astro.locals.banners.try(
'Error getting unread notification count',
async () => (user ? await prisma.notification.count({ where: { userId: user.id, read: false } }) : 0),
0
))
---
{
user && (
<a
href="/notifications"
class={cn(
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
className
)}
aria-label={`Go to notifications${count > 0 ? ` (${count} unread)` : ''}`}
{...htmlProps}
>
<Icon name="material-symbols:notifications-outline" class="size-5" />
{count > 0 && (
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
{count > 99 ? '★' : count.toLocaleString()}
</span>
)}
</a>
)
}

View File

@@ -1,41 +0,0 @@
---
---
<script>
////////////////////////////////////////////////////////
// Optional script to change the splash text on click //
////////////////////////////////////////////////////////
import { splashTexts } from '../constants/splashTexts'
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLDivElement>('[data-splash-text-container]').forEach((container) => {
const updateSplashText = () => {
const splashTextElem = container.querySelector<HTMLSpanElement>('[data-splash-text]')
if (!splashTextElem) return
const splashTextsFiltered = splashTexts.filter((text) => text !== splashTextElem.textContent)
const newSplashText = splashTextsFiltered[Math.floor(Math.random() * splashTextsFiltered.length)]
if (!newSplashText) return
splashTextElem.textContent = newSplashText
}
container.addEventListener('click', updateSplashText)
const autoUpdateInterval = setInterval(updateSplashText, 60_000)
document.addEventListener('astro:before-swap', () => {
clearInterval(autoUpdateInterval)
})
container.addEventListener(
'mousedown',
(event) => {
if (event.detail > 1) event.preventDefault()
},
false
)
})
})
</script>

View File

@@ -1,15 +0,0 @@
---
---
<script>
import * as htmx from 'htmx.org'
htmx.config.globalViewTransitions = false
document.addEventListener('astro:after-swap', () => {
htmx.process(document.body)
})
window.htmx = htmx
</script>

View File

@@ -1,119 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { cn } from '../lib/cn'
import InputWrapper from './InputWrapper.astro'
import type { MarkdownString } from '../lib/markdown'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
options: {
label: string
value: string
icon?: string
iconClass?: string
description?: MarkdownString
}[]
disabled?: boolean
selectedValue?: string
cardSize?: 'lg' | 'md' | 'sm'
iconSize?: 'md' | 'sm'
multiple?: boolean
}
const {
options,
disabled,
selectedValue,
cardSize = 'sm',
iconSize = 'sm',
class: className,
multiple,
...wrapperProps
} = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
<InputWrapper inputId={inputId} class={cn('@container', className)} {...wrapperProps}>
<div
class={cn(
'grid grid-cols-[repeat(auto-fill,minmax(var(--card-min-size),1fr))] gap-2 rounded-lg',
!multiple &&
'has-focus-visible:ring-offset-night-900 has-focus-visible:ring-day-200 has-focus-visible:bg-night-900 has-focus-visible:ring-2 has-focus-visible:ring-offset-3',
{
'[--card-min-size:12rem] @max-[12rem]:grid-cols-1': cardSize === 'sm',
'[--card-min-size:16rem] @max-[16rem]:grid-cols-1': cardSize === 'md',
'[--card-min-size:32rem] @max-[32rem]:grid-cols-1': cardSize === 'lg',
},
hasError && 'border border-red-700 p-2'
)}
>
{
options.map((option) => (
<label
class={cn(
'group border-night-400 bg-night-600 hover:bg-night-500 relative cursor-pointer items-start gap-3 rounded-lg border p-3 transition-all',
'has-checked:border-green-700 has-checked:bg-green-700/20 has-checked:ring-1 has-checked:ring-green-700',
multiple &&
'has-focus-visible:border-day-300 has-focus-visible:ring-2 has-focus-visible:ring-green-700 has-focus-visible:ring-offset-1',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<input
transition:persist
type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name}
value={option.value}
checked={selectedValue === option.value}
class="peer sr-only"
disabled={disabled}
/>
<div class="flex items-center gap-1.5">
{option.icon && (
<Icon
name={option.icon}
class={cn(
'text-day-200 group-peer-checked:text-day-300 size-8',
{
'size-4': iconSize === 'sm',
'size-8': iconSize === 'md',
},
option.iconClass
)}
/>
)}
<p class="text-day-200 group-peer-checked:text-day-300 flex-1 text-sm leading-none font-medium text-pretty">
{option.label}
</p>
<div class="self-stretch">
<div
class={cn(
'border-day-600 flex size-5 items-center justify-center border-2',
'group-has-checked:border-green-600 group-has-checked:bg-green-600',
multiple ? 'rounded-md' : 'rounded-full',
!!option.description && '-m-1'
)}
>
<Icon
name="ri:check-line"
class="text-day-100 size-3 opacity-0 group-has-checked:opacity-100"
/>
</div>
</div>
</div>
{option.description && (
<div class="prose prose-sm prose-invert text-day-400 mt-1 text-xs text-pretty">
<Markdown content={option.description} />
</div>
)}
</label>
))
}
</div>
</InputWrapper>

View File

@@ -1,49 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs'
import InputWrapper from './InputWrapper.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
options: {
label: string
value: string
icon?: string
}[]
disabled?: boolean
selectedValues?: string[]
}
const { options, disabled, selectedValues = [], ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
<InputWrapper inputId={inputId} {...wrapperProps}>
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
<div class="h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5">
{
options.map((option) => (
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
<input
transition:persist
type="checkbox"
name={wrapperProps.name}
value={option.value}
checked={selectedValues.includes(option.value)}
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
disabled={disabled}
/>
{option.icon && <Icon name={option.icon} class="size-4" />}
<span class="text-sm leading-none">{option.label}</span>
</label>
))
}
</div>
</div>
</InputWrapper>

View File

@@ -1,38 +0,0 @@
---
import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs'
import InputWrapper from './InputWrapper.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
accept?: string
disabled?: boolean
multiple?: boolean
}
const { accept, disabled, multiple, ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
<InputWrapper inputId={inputId} {...wrapperProps}>
<input
transition:persist
type="file"
id={inputId}
class={cn(
baseInputClassNames.input,
baseInputClassNames.file,
hasError && baseInputClassNames.error,
disabled && baseInputClassNames.disabled
)}
required={wrapperProps.required}
disabled={disabled}
name={wrapperProps.name}
accept={accept}
multiple={multiple}
/>
</InputWrapper>

View File

@@ -1,20 +0,0 @@
---
type Props = {
name: string
}
//
---
<input
type="text"
name={Astro.props.name || 'message'}
aria-hidden="true"
style="display:none !important"
autocomplete="off"
tabindex="-1"
data-1p-ignore
data-lpignore="true"
data-bwignore
data-form-type="other"
/>

View File

@@ -1,54 +0,0 @@
---
import { cn } from '../lib/cn'
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
import InputFile from './InputFile.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
square?: boolean
}
const { class: className, square, ...inputFileProps } = Astro.props
---
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
<InputFile accept={ACCEPTED_IMAGE_TYPES.join(',')} class="min-w-0 flex-1 basis-2xs" {...inputFileProps} />
<img
src="#"
alt="Preview"
class={cn(
'block w-26.5 rounded object-cover',
'no-js:hidden [&[src="#"]]:hidden',
square && 'aspect-square'
)}
/>
</div>
<script>
////////////////////////////////////////////////////////////
// Optional script for image preview. //
// Shows a preview of the selected image before upload. //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll('[data-preview-image]').forEach((wrapper) => {
const input = wrapper.querySelector<HTMLInputElement>('input[type="file"]')
if (!input) return
const previewImageElements = wrapper.querySelectorAll<HTMLImageElement>('img')
if (!previewImageElements.length) return
input.addEventListener('change', () => {
const file = input.files?.[0]
if (!file) return
const fileUrl = URL.createObjectURL(file)
previewImageElements.forEach((previewImage) => {
previewImage.src = fileUrl
})
})
})
})
</script>

View File

@@ -1,149 +0,0 @@
---
import { cn } from '../lib/cn'
import { includeDevUsers, USER_SECRET_TOKEN_REGEX_STRING } from '../lib/userSecretToken'
import InputText from './InputText.astro'
type Props = {
name: string
autofocus?: boolean
}
const { name, autofocus } = Astro.props
---
<input
type="hidden"
name="username"
id="username"
value=""
autocomplete="username"
data-keep-in-sync-with="#token"
autocorrect="off"
spellcheck="false"
autocapitalize="off"
/>
<InputText
label="Login Key"
name={name}
inputIcon="ri:key-2-line"
inputIconClass="size-6"
inputProps={{
type: 'password',
id: 'token',
placeholder: 'ABCD-EFGH-IJKL-MNOP-1234',
required: true,
autofocus,
pattern: USER_SECRET_TOKEN_REGEX_STRING,
title: 'LLLL-LLLL-LLLL-LLLL-DDDD (L: letter, D: digit, dashes are optional)',
minlength: includeDevUsers ? undefined : 24,
maxlength: 24,
autocomplete: 'current-password',
autocorrect: 'off',
spellcheck: 'false',
autocapitalize: 'off',
class: cn('2xs:text-lg h-10 font-mono text-sm uppercase'),
'data-input-type-text-hack': true,
'data-enable-token-autoformat': true,
'data-bwautofill': true,
}}
/>
<script>
////////////////////////////////////////////////////////////
// Optional script for keeping username in sync. //
// This way the password manager detects the credentials. //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-keep-in-sync-with]')
inputs.forEach((input) => {
const inputId = input.getAttribute('data-keep-in-sync-with')
if (!inputId) throw new Error('Username input ID not found')
const tokenInput = document.querySelector<HTMLInputElement>(inputId)
if (!tokenInput) throw new Error('Token input not found')
tokenInput.addEventListener('input', () => {
input.value = tokenInput.value
input.dispatchEvent(new Event('input', { bubbles: true }))
})
})
})
</script>
<script>
/////////////////////////////////////////////////////
// Optional script for token input autoformatting //
////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
const tokenInputs = document.querySelectorAll<HTMLInputElement>('input[data-enable-token-autoformat]')
tokenInputs.forEach((tokenInput) => {
tokenInput.addEventListener('keydown', (e) => {
const cursor = tokenInput.selectionStart
if (tokenInput.selectionEnd !== cursor) return
if (e.key === 'Delete') {
if (cursor !== null && tokenInput.value[cursor] === '-') {
tokenInput.selectionStart = cursor + 1
}
} else if (e.key === 'Backspace') {
if (cursor !== null && cursor > 0 && tokenInput.value[cursor - 1] === '-') {
tokenInput.selectionEnd = cursor - 1
}
}
})
tokenInput.addEventListener('input', () => {
const value = tokenInput.value
const cursor = tokenInput.selectionStart || 0
// Count dashes before cursor to adjust position
const dashesBeforeCursor = (value.substring(0, cursor).match(/-/g) || []).length
// Remove all non-alphanumeric characters
let cleaned = value.replace(/[^A-Za-z0-9]/g, '').toUpperCase()
cleaned = cleaned.substring(0, 20) // Limit to 20 chars (24 with dashes)
// Format with dashes
let formatted = ''
for (let i = 0; i < cleaned.length; i++) {
if (i > 0 && i % 4 === 0) {
formatted += '-'
}
formatted += cleaned[i]
}
// Only update if value changed
if (formatted === value) return
// Calculate new cursor position
let newCursor = cursor
const dashesBeforeNew = (formatted.substring(0, cursor).match(/-/g) || []).length
newCursor += dashesBeforeNew - dashesBeforeCursor
// Update input
tokenInput.value = formatted
tokenInput.setSelectionRange(newCursor, newCursor)
})
})
})
</script>
<script>
//////////////////////////////////////////////////////////////////////
// Optional script for making the password visible. //
// Otherwise the password manager will not detect it as a passowrd. //
//////////////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-input-type-text-hack]')
inputs.forEach((input) => {
input.addEventListener('input', () => {
input.type = 'text'
})
})
})
</script>

View File

@@ -1,64 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import InputWrapper from './InputWrapper.astro'
import type { ComponentProps } from 'astro/types'
const ratings = [1, 2, 3, 4, 5] as const
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
value?: number | null
required?: boolean
id?: string
}
const { value, required, id, ...wrapperProps } = Astro.props
const actualValue = value !== undefined && value !== null ? Math.round(value) : null
const inputId = id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
---
<InputWrapper inputId={inputId} required={required} {...wrapperProps}>
<div
class="group/fieldset has-focus-visible:ring-day-200 has-focus-visible:ring-offset-night-700 relative flex items-center has-focus-visible:rounded-full has-focus-visible:ring-2 has-focus-visible:ring-offset-2 [&>*:has(~_*:hover)]:[&>[data-star]]:opacity-100!"
>
<label
aria-label="Clear"
class="has-focus-visible:before:bg-day-200 hover:before:bg-day-200 relative order-last block size-6 p-0.5 text-zinc-500 not-has-checked:cursor-pointer before:absolute before:inset-0.5 before:-z-1 before:rounded-full hover:text-black has-checked:before:hidden has-focus-visible:text-black has-focus-visible:before:block!"
>
<input
type="radio"
name={wrapperProps.name}
value=""
checked={actualValue === null}
class="peer sr-only"
/>
<Icon
name="ri:close-line"
class="size-full group-hover/fieldset:block group-has-focus-visible/fieldset:block peer-checked:hidden! peer-focus-visible:block! pointer-fine:hidden"
/>
</label>
{
ratings.toSorted().map((rating) => (
<label class="relative cursor-pointer [&:has(~_*:hover),&:hover]:[&>[data-star]]:opacity-100!">
<input
type="radio"
name={wrapperProps.name}
value={rating}
checked={actualValue === rating}
class="peer sr-only"
/>
<Icon name="ri:star-line" class="size-6 p-0.5 text-zinc-500" />
<Icon
name="ri:star-fill"
class="absolute top-0 left-0 size-6 p-0.5 text-yellow-400 not-peer-checked:opacity-0 group-hover/fieldset:opacity-0"
data-star
/>
</label>
))
}
</div>
</InputWrapper>

View File

@@ -1,26 +0,0 @@
---
import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
hideCancel?: boolean
icon?: string
label?: string
}
const {
hideCancel = false,
icon = 'ri:send-plane-2-line',
label = 'Submit',
class: className,
...htmlProps
} = Astro.props
---
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
</div>

View File

@@ -1,65 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { omit } from 'lodash-es'
import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs'
import InputWrapper from './InputWrapper.astro'
import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
inputProps?: Omit<HTMLAttributes<'input'>, 'name'>
inputIcon?: string
inputIconClass?: string
}
const { inputProps, inputIcon, inputIconClass, ...wrapperProps } = Astro.props
const inputId = inputProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
<InputWrapper inputId={inputId} required={inputProps?.required} {...wrapperProps}>
{
inputIcon ? (
<div class="relative">
<input
transition:persist
{...omit(inputProps, ['class', 'id', 'name'])}
id={inputId}
class={cn(
baseInputClassNames.input,
!!inputIcon && 'pl-10',
inputProps?.class,
hasError && baseInputClassNames.error,
!!inputProps?.disabled && baseInputClassNames.disabled
)}
name={wrapperProps.name}
/>
<Icon
name={inputIcon}
class={cn(
'text-day-300 pointer-events-none absolute top-1/2 left-5.5 size-5 -translate-1/2',
inputIconClass
)}
/>
</div>
) : (
<input
transition:persist
{...omit(inputProps, ['class', 'id', 'name'])}
id={inputId}
class={cn(
baseInputClassNames.input,
!!inputIcon && 'pl-10',
inputProps?.class,
hasError && baseInputClassNames.error,
!!inputProps?.disabled && baseInputClassNames.disabled
)}
name={wrapperProps.name}
/>
)
}
</InputWrapper>

View File

@@ -1,44 +0,0 @@
---
import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs'
import InputWrapper from './InputWrapper.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
value?: string
placeholder?: string
disabled?: boolean
autofocus?: boolean
rows?: number
maxlength?: number
}
const { value, placeholder, maxlength, disabled, autofocus, rows = 3, ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
{/* eslint-disable astro/jsx-a11y/no-autofocus */}
<InputWrapper inputId={inputId} {...wrapperProps}>
<textarea
transition:persist
id={inputId}
class={cn(
baseInputClassNames.input,
baseInputClassNames.textarea,
hasError && baseInputClassNames.error,
disabled && baseInputClassNames.disabled
)}
placeholder={placeholder}
required={wrapperProps.required}
disabled={disabled}
name={wrapperProps.name}
autofocus={autofocus}
maxlength={maxlength}
rows={rows}>{value}</textarea
>
</InputWrapper>

View File

@@ -1,74 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { cn } from '../lib/cn'
import type { AstroChildren } from '../lib/astro'
import type { MarkdownString } from '../lib/markdown'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
children: AstroChildren
label: string
name: string
description?: MarkdownString
descriptionLabel?: string
required?: HTMLAttributes<'input'>['required']
error?: string[] | string
icon?: string
inputId?: string
}
const {
label,
name,
description,
descriptionLabel,
required,
error,
icon,
class: className,
inputId,
...htmlProps
} = Astro.props
const hasError = !!error && error.length > 0
---
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
<label for={inputId}>{label}</label>{required && '*'}
</legend>
{
!!descriptionLabel && (
<span class="text-day-400 flex-1 basis-24 text-xs text-pretty">{descriptionLabel}</span>
)
}
</div>
<slot />
{
hasError &&
(typeof error === 'string' ? (
<p class="text-sm text-red-500">{error}</p>
) : (
<ul class="text-sm text-red-500">
{error.map((e) => (
<li>{e}</li>
))}
</ul>
))
}
{
!!description && (
<div class="prose prose-sm prose-invert text-day-400 text-xs text-pretty">
<Markdown content={description} />
</div>
)
}
</fieldset>

View File

@@ -1,30 +0,0 @@
---
import { orderBy } from 'lodash-es'
import { karmaUnlocks } from '../constants/karmaUnlocks'
const karmaUnlocksSorted = orderBy(karmaUnlocks, [
({ karma }) => (karma >= 0 ? 1 : 2),
({ karma }) => Math.abs(karma),
'id',
])
---
<table>
<thead>
<tr>
<th>Karma</th>
<th>Unlock</th>
</tr>
</thead>
<tbody>
{
karmaUnlocksSorted.map((unlock) => (
<tr>
<td>{unlock.karma.toLocaleString()}</td>
<td>{unlock.name}</td>
</tr>
))
}
</tbody>
</table>

View File

@@ -1,65 +0,0 @@
---
import type { HTMLAttributes } from 'astro/types'
type Props = Omit<HTMLAttributes<'svg'>, 'viewBox' | 'xmlns'> & {
variant?: 'mini-full' | 'mini' | 'normal' | 'small'
}
const { variant = 'normal', ...htmlProps } = Astro.props
---
{
variant === 'normal' && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 204 28"
fill="currentColor"
aria-label="KYCnot.me"
{...htmlProps}
>
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>
)
}
{
variant === 'small' && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 124 52"
fill="currentColor"
aria-label="KYCnot.me"
{...htmlProps}
>
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM1 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4L6.8 32.5A1 1 0 0 0 6 32H1Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1H30Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12H29v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1H50Zm27 0a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V39.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V51c0 .6.5 1 1 1h2c.6 0 1-.4 1-1V33c0-.5-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L88 47.2l-6.7-14.6a1 1 0 0 0-1-.6H77Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4ZM70 48a1 1 0 0 0-1 1v2c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-2Z" />
</svg>
)
}
{
variant === 'mini' && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 76 52"
fill="currentColor"
aria-label="KYCnot.me"
{...htmlProps}
>
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.5.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.5.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1z" />
</svg>
)
}
{
variant === 'mini-full' && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 76 76"
fill="currentColor"
aria-label="KYCnot.me"
{...htmlProps}
>
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1h-10Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1h-18ZM15 56a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V63.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V57c0-.6-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L26 71.2l-6.7-14.6a1 1 0 0 0-1-.6H15Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4Z" />
</svg>
)
}

File diff suppressed because one or more lines are too long

View File

@@ -1,134 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import { createPageUrl } from '../lib/urls'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'nav'> & {
currentPage: number
totalPages: number
currentUrl?: URL | string
sortSeed?: string
}
const {
currentPage,
totalPages,
currentUrl = Astro.url,
sortSeed,
class: className,
...navProps
} = Astro.props
const prevPage = currentPage > 1 ? currentPage - 1 : null
const nextPage = currentPage < totalPages ? currentPage + 1 : null
const getVisiblePages = () => {
const pages: (number | '...')[] = []
if (totalPages <= 9) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
// Always show first page
pages.push(1)
if (currentPage > 4) {
pages.push('...')
}
// Calculate range around current page
let rangeStart = Math.max(2, currentPage - 2)
let rangeEnd = Math.min(totalPages - 1, currentPage + 2)
// Adjust range if at the start or end
if (currentPage <= 4) {
rangeEnd = 6
}
if (currentPage >= totalPages - 3) {
rangeStart = totalPages - 5
}
// Add range numbers
for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push(i)
}
if (currentPage < totalPages - 3) {
pages.push('...')
}
// Always show last page
pages.push(totalPages)
return pages
}
const PrevTag = prevPage ? 'a' : 'span'
const NextTag = nextPage ? 'a' : 'span'
---
<nav
aria-label="Pagination"
{...navProps}
class={cn('flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-lg sm:flex-nowrap', className)}
>
<PrevTag
href={PrevTag === 'a' && prevPage
? createPageUrl(prevPage, currentUrl, { 'sort-seed': sortSeed })
: undefined}
class={cn(
'flex w-[5.5ch] items-center justify-end text-green-500 hover:text-green-400',
!prevPage && 'pointer-events-none opacity-50'
)}
aria-label="Previous page"
>
<Icon name="ri:arrow-left-s-line" class="size-6 shrink-0" />
<span class="text-green-500">Prev</span>
</PrevTag>
<div class="order-first flex w-full items-center justify-center gap-4 sm:order-none sm:w-auto">
{
getVisiblePages().map((page) => {
if (page === '...') {
return <span class="text-gray-400">...</span>
}
const isCurrentPage = page === currentPage
return isCurrentPage ? (
<button
class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500 text-white"
aria-current="page"
aria-label={`Page ${page.toLocaleString()}`}
disabled
>
{page}
</button>
) : (
<a
href={createPageUrl(page, currentUrl, { 'sort-seed': sortSeed })}
class="text-white hover:text-gray-300"
aria-label={`Page ${page.toLocaleString()}`}
>
{page}
</a>
)
})
}
</div>
<NextTag
href={NextTag === 'a' && nextPage
? createPageUrl(nextPage, currentUrl, { 'sort-seed': sortSeed })
: undefined}
class={cn(
'flex w-[5.5ch] items-center justify-start text-green-500 hover:text-green-400',
!nextPage && 'pointer-events-none opacity-50'
)}
aria-label="Next page"
>
<span class="text-green-500">Next</span>
<Icon name="ri:arrow-right-s-line" class="size-6 shrink-0" />
</NextTag>
</nav>

View File

@@ -1,41 +0,0 @@
---
import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
name: string
options: {
value: HTMLAttributes<'input'>['value']
label: string
}[]
selectedValue?: string | null
}
const { name, options, selectedValue, class: className, ...rest } = Astro.props
---
<div
class={cn(
'bg-night-500 divide-night-700 flex divide-x-2 overflow-hidden rounded-md text-[0.6875rem]',
className
)}
{...rest}
>
{
options.map((option) => (
<label>
<input
type="radio"
name={name}
value={option.value}
checked={selectedValue === option.value}
class="peer hidden"
/>
<span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500">
{option.label}
</span>
</label>
))
}
</div>

View File

@@ -1,175 +0,0 @@
---
import { Schema } from 'astro-seo-schema'
import { cn } from '../lib/cn'
import { interpolate } from '../lib/numbers'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types'
import type { Review, WithContext } from 'schema-dts'
export type Props = HTMLAttributes<'div'> & {
score: number
label: string
total?: number
itemReviewedId?: string
}
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
function makeScoreInfo(score: number, total: number) {
const formattedScore = Math.round(score).toLocaleString()
const angle = interpolate(progress, -100, 100)
const n = score / total
if (n > 1) return { text: 'Excellent', step: 5, formattedScore, angle: 100 }
if (n >= 0.9 && n <= 1) return { text: 'Excellent', step: 5, formattedScore, angle }
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', step: 5, formattedScore, angle }
if (n >= 0.6 && n < 0.8) return { text: 'Good', step: 4, formattedScore, angle }
if (n >= 0.45 && n < 0.6) return { text: 'Average', step: 3, formattedScore, angle }
if (n >= 0.4 && n < 0.45) return { text: 'Average', step: 3, formattedScore, angle: angle + 5 }
if (n >= 0.2 && n < 0.4) return { text: 'Bad', step: 2, formattedScore, angle: angle + 5 }
if (n >= 0.1 && n < 0.2) return { text: 'Very Bad', step: 1, formattedScore, angle }
if (n >= 0 && n < 0.1) return { text: 'Terrible', step: 1, formattedScore, angle }
if (n < 0) return { text: 'Terrible', step: 1, formattedScore, angle: -100 }
return { text: '', step: undefined, formattedScore, angle: undefined }
}
const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
---
{
!!itemReviewedId && (
<Schema
item={
{
'@context': 'https://schema.org',
'@type': 'Review',
reviewAspect: label,
name: `${text} ${transformCase(label, 'lower')}`,
itemReviewed: { '@id': itemReviewedId },
reviewRating: {
'@type': 'Rating',
ratingValue: score,
worstRating: 0,
bestRating: total,
},
author: KYCNOTME_SCHEMA_MINI,
} satisfies WithContext<Review>
}
/>
)
}
<div
{...htmlProps}
class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className
)}
role="group"
>
<div
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
'text-score-saturate-1 text-shadow-glow': step === 1,
'text-score-saturate-2 text-shadow-glow': step === 2,
'text-score-saturate-3 text-shadow-glow': step === 3,
'text-score-saturate-4 text-shadow-glow': step === 4,
'text-score-saturate-5 text-shadow-glow': step === 5,
'mr-[0.05em] ml-[-0.025em] text-[1.75rem] leading-[calc(2/1.75)] tracking-[-0.075em]':
formattedScore.length > 2,
})}
>
<span>{formattedScore}</span>
</div>
<div class="2xs:mb-0.5 text-base leading-none font-bold tracking-wide uppercase">
{label}
</div>
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
<svg class="absolute inset-0 -z-1 overflow-visible" viewBox="0 0 96 96" aria-hidden="true">
<!-- Background segments -->
<g opacity="0.2">
<path
d="M19.84 29.962C20.8221 30.3687 21.3066 31.4709 21.0222 32.4951C20.3586 34.8848 20.0039 37.4031 20.0039 40.0042C20.0039 42.6054 20.3586 45.1237 21.0223 47.5135C21.3067 48.5376 20.8221 49.6398 19.8401 50.0466L7.05214 55.3435C5.88072 55.8288 4.55702 55.1152 4.38993 53.8583C4.13532 51.9431 4.00391 49.989 4.00391 48.0042C4.00391 40.5588 5.85316 33.5454 9.11742 27.3981C9.58659 26.5145 10.656 26.1579 11.5803 26.5407L19.84 29.962Z"
class="fill-score-saturate-1"></path>
<path
d="M33.3538 8.55389C32.9417 7.55882 31.8133 7.06445 30.8219 7.48539C23.7527 10.4869 17.6304 15.2843 13.0275 21.3051C12.2575 22.3122 12.689 23.7526 13.8603 24.2378L20.9906 27.1912C21.9721 27.5978 23.0937 27.1616 23.6168 26.237C26.1243 21.8048 29.805 18.1241 34.2373 15.6167C35.1619 15.0936 35.5981 13.972 35.1915 12.9904L33.3538 8.55389Z"
class="fill-score-saturate-2"></path>
<path
d="M40.4948 13.0224C39.4706 13.3068 38.3684 12.8222 37.9616 11.8402L36.316 7.86723C35.8618 6.77068 36.4564 5.51825 37.6099 5.23888C40.9424 4.43183 44.423 4.00415 48.0035 4.00415C51.5842 4.00415 55.0651 4.43188 58.3977 5.23902C59.5512 5.5184 60.1458 6.77082 59.6916 7.86736L58.046 11.8403C57.6392 12.8224 56.537 13.307 55.5128 13.0225C53.123 12.3588 50.6047 12.0042 48.0035 12.0042C45.4025 12.0042 42.8844 12.3588 40.4948 13.0224Z"
class="fill-score-saturate-3"></path>
<path
d="M75.017 27.1913C74.0355 27.5979 72.9139 27.1617 72.3908 26.2371C69.8834 21.805 66.2028 18.1244 61.7708 15.617C60.8461 15.0938 60.41 13.9723 60.8166 12.9907L62.6542 8.55414C63.0664 7.55905 64.1948 7.06469 65.1862 7.48564C72.2552 10.4871 78.3773 15.2845 82.9801 21.3051C83.75 22.3123 83.3186 23.7527 82.1473 24.2378L75.017 27.1913Z"
class="fill-score-saturate-4"></path>
<path
d="M76.1682 50.0463C75.1862 49.6395 74.7016 48.5373 74.986 47.5131C75.6496 45.1235 76.0043 42.6053 76.0043 40.0042C76.0043 37.4031 75.6496 34.8849 74.986 32.4952C74.7016 31.471 75.1861 30.3688 76.1682 29.962L84.4279 26.5407C85.3521 26.1579 86.4216 26.5145 86.8908 27.3981C90.155 33.5454 92.0043 40.5588 92.0043 48.0042C92.0043 49.9889 91.8729 51.9429 91.6183 53.8579C91.4512 55.1148 90.1275 55.8284 88.9561 55.3432L76.1682 50.0463Z"
fill="#7CFF00"></path>
</g>
<!-- Active segments -->
<g>
{
step === 1 && (
<path
d="M19.84 29.962C20.8221 30.3687 21.3066 31.4709 21.0222 32.4951C20.3586 34.8848 20.0039 37.4031 20.0039 40.0042C20.0039 42.6054 20.3586 45.1237 21.0223 47.5135C21.3067 48.5376 20.8221 49.6398 19.8401 50.0466L7.05214 55.3435C5.88072 55.8288 4.55702 55.1152 4.38993 53.8583C4.13532 51.9431 4.00391 49.989 4.00391 48.0042C4.00391 40.5588 5.85316 33.5454 9.11742 27.3981C9.58659 26.5145 10.656 26.1579 11.5803 26.5407L19.84 29.962Z"
class="text-score-saturate-1 drop-shadow-glow fill-current"
/>
)
}
{
step === 2 && (
<path
d="M33.3538 8.55389C32.9417 7.55882 31.8133 7.06445 30.8219 7.48539C23.7527 10.4869 17.6304 15.2843 13.0275 21.3051C12.2575 22.3122 12.689 23.7526 13.8603 24.2378L20.9906 27.1912C21.9721 27.5978 23.0937 27.1616 23.6168 26.237C26.1243 21.8048 29.805 18.1241 34.2373 15.6167C35.1619 15.0936 35.5981 13.972 35.1915 12.9904L33.3538 8.55389Z"
class="text-score-saturate-2 drop-shadow-glow fill-current"
/>
)
}
{
step === 3 && (
<path
d="M40.4948 13.0224C39.4706 13.3068 38.3684 12.8222 37.9616 11.8402L36.316 7.86723C35.8618 6.77068 36.4564 5.51825 37.6099 5.23888C40.9424 4.43183 44.423 4.00415 48.0035 4.00415C51.5842 4.00415 55.0651 4.43188 58.3977 5.23902C59.5512 5.5184 60.1458 6.77082 59.6916 7.86736L58.046 11.8403C57.6392 12.8224 56.537 13.307 55.5128 13.0225C53.123 12.3588 50.6047 12.0042 48.0035 12.0042C45.4025 12.0042 42.8844 12.3588 40.4948 13.0224Z"
class="text-score-saturate-3 drop-shadow-glow fill-current"
/>
)
}
{
step === 4 && (
<path
d="M75.017 27.1913C74.0355 27.5979 72.9139 27.1617 72.3908 26.2371C69.8834 21.805 66.2028 18.1244 61.7708 15.617C60.8461 15.0938 60.41 13.9723 60.8166 12.9907L62.6542 8.55414C63.0664 7.55905 64.1948 7.06469 65.1862 7.48564C72.2552 10.4871 78.3773 15.2845 82.9801 21.3051C83.75 22.3123 83.3186 23.7527 82.1473 24.2378L75.017 27.1913Z"
class="text-score-saturate-4 drop-shadow-glow fill-current"
/>
)
}
{
step === 5 && (
<path
d="M76.1682 50.0463C75.1862 49.6395 74.7016 48.5373 74.986 47.5131C75.6496 45.1235 76.0043 42.6053 76.0043 40.0042C76.0043 37.4031 75.6496 34.8849 74.986 32.4952C74.7016 31.471 75.1861 30.3688 76.1682 29.962L84.4279 26.5407C85.3521 26.1579 86.4216 26.5145 86.8908 27.3981C90.155 33.5454 92.0043 40.5588 92.0043 48.0042C92.0043 49.9889 91.8729 51.9429 91.6183 53.8579C91.4512 55.1148 90.1275 55.8284 88.9561 55.3432L76.1682 50.0463Z"
class="text-score-saturate-5 drop-shadow-glow fill-current"
/>
)
}
</g>
<!-- Arrow -->
<path
d="M47.134 9.4282C47.3126 9.7376 47.6427 9.9282 48 9.9282C48.3573 9.9282 48.6874 9.7376 48.866 9.4282L52.866 2.5C53.0447 2.1906 53.0447 1.8094 52.866 1.5C52.6874 1.1906 52.3573 1 52 1L44 1C43.6427 1 43.3126 1.1906 43.134 1.5C42.9553 1.8094 42.9553 2.1906 43.134 2.5L47.134 9.4282Z"
fill="white"
stroke-width="2"
stroke-linejoin="round"
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
class="stroke-night-700"></path>
<!-- Info icon -->
<!-- <path
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
fill="white"
fill-opacity="0.67"></path> -->
</svg>
</div>

View File

@@ -1,106 +0,0 @@
---
import { Schema } from 'astro-seo-schema'
import { cn } from '../lib/cn'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types'
export type Props = HTMLAttributes<'div'> & {
score: number
label: string
total?: number
itemReviewedId?: string
}
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
export function makeOverallScoreInfo(score: number, total = 10) {
const classNamesByColor = {
red: 'bg-score-1 text-black',
orange: 'bg-score-2 text-black',
yellow: 'bg-score-3 text-black',
blue: 'bg-score-4 text-black',
green: 'bg-score-5 text-black',
} as const satisfies Record<string, string>
const formattedScore = Math.round(score).toLocaleString()
const n = score / total
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
if (n >= 0.5 && n < 0.6) {
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
}
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
return { text: '', classNameBg: undefined, formattedScore }
}
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
---
<div
{...htmlProps}
class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className
)}
role="group"
>
{
!!itemReviewedId && (
<Schema
item={{
'@context': 'https://schema.org',
'@type': 'Review',
reviewAspect: label,
name: `${text} ${transformCase(label, 'lower')}`,
itemReviewed: { '@id': itemReviewedId },
reviewRating: {
'@type': 'Rating',
ratingValue: score,
worstRating: 0,
bestRating: total,
},
author: KYCNOTME_SCHEMA_MINI,
}}
/>
)
}
<!-- <svg
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
viewBox="0 0 12 12"
fill="currentColor"
>
<path
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
></path>
</svg> -->
<div
class={cn(
'2xs:mt-2 2xs:size-12 mt-0.5 mb-1 flex size-10 shrink-0 items-center justify-center rounded-md leading-none font-bold tracking-tight text-black',
classNameBg,
{
'text-[1.75rem] leading-[calc(2/1.75)] tracking-tighter': formattedScore.length > 2,
}
)}
>
<span class="2xs:text-[2rem] text-[1.5rem] leading-none font-bold tracking-tight text-black">
{formattedScore}
</span>
</div>
<div class="2xs:mb-0.5 text-base leading-none font-bold tracking-wide uppercase">
{label}
</div>
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
</div>

View File

@@ -1,155 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import defaultImage from '../assets/fallback-service-image.jpg'
import { currencies } from '../constants/currencies'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn'
import { transformCase } from '../lib/strings'
import { makeOverallScoreInfo } from './ScoreSquare.astro'
import Tooltip from './Tooltip.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'a'> & {
inlineIcons?: boolean
withoutLink?: boolean
service: Prisma.ServiceGetPayload<{
select: {
name: true
slug: true
description: true
overallScore: true
kycLevel: true
imageUrl: true
verificationStatus: true
acceptedCurrencies: true
categories: {
select: {
name: true
icon: true
}
}
}
}>
}
const {
inlineIcons = false,
service: {
name = 'Unnamed Service',
slug,
description,
overallScore,
kycLevel,
imageUrl,
categories,
verificationStatus,
acceptedCurrencies,
},
class: className,
withoutLink = false,
...aProps
} = Astro.props
const statusIcon = {
...verificationStatusesByValue,
APPROVED: undefined,
}[verificationStatus]
const Element = withoutLink ? 'div' : 'a'
const overallScoreInfo = makeOverallScoreInfo(overallScore)
---
<Element
href={Element === 'a' ? `/service/${slug}` : undefined}
{...aProps}
class={cn(
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
className
)}
>
<!-- Header with Icon and Title -->
<div class="flex items-center gap-(--gap)">
<Image
src={// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
imageUrl || (defaultImage as unknown as string)}
alt={name || 'Service logo'}
class="size-12 shrink-0 rounded-sm object-contain text-white"
width={48}
height={48}
/>
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
{name}{
statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-my-2 shrink-0">
<Icon
is:inline={inlineIcons}
name={statusIcon.icon}
class={cn('inline-block size-6 shrink-0 rounded-lg p-1', statusIcon.classNames.icon)}
/>
</Tooltip>
)
}
</h3>
<div class="max-h-2 flex-1"></div>
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
{
categories.map((category) => (
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
<span>{category.name}</span>
</span>
))
}
</div>
</div>
</div>
<div class="flex-1">
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
{description}
</p>
</div>
<div class="flex items-center justify-start">
<Tooltip
class={cn(
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
overallScoreInfo.classNameBg
)}
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`}
>
{overallScoreInfo.formattedScore}
</Tooltip>
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
KYC &nbsp;{kycLevel.toLocaleString()}
</span>
<div class="-m-1 ml-auto flex">
{
currencies.map((currency) => {
const isAccepted = acceptedCurrencies.includes(currency.id)
return (
<Tooltip text={currency.name}>
<Icon
is:inline={inlineIcons}
name={currency.icon}
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
/>
</Tooltip>
)
})
}
</div>
</div>
</Element>

View File

@@ -1,36 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'a'> & {
text: string
searchParamName: string
searchParamValue?: string
icon?: string
iconClass?: string
}
const { text, searchParamName, searchParamValue, icon, iconClass, class: className, ...aProps } = Astro.props
const makeUrlWithoutFilter = (filter: string, value?: string) => {
const url = new URL(Astro.url)
url.searchParams.delete(filter, value)
return url.toString()
}
---
<a
href={makeUrlWithoutFilter(searchParamName, searchParamValue)}
{...aProps}
class={cn(
'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white',
className
)}
>
{icon && <Icon name={icon} class={cn('size-4', iconClass)} />}
{text}
<Icon name="ri:close-large-line" class="text-day-400 size-4" />
</a>

View File

@@ -1,132 +0,0 @@
---
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import { networksBySlug } from '../constants/networks'
import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types'
type Props = Omit<HTMLAttributes<'a'>, 'href' | 'rel' | 'target'> & {
url: string
referral: string | null
enableMinWidth?: boolean
}
const { url: baseUrl, referral, class: className, enableMinWidth = false, ...htmlProps } = Astro.props
function makeLink(url: string, referral: string | null) {
const hostname = new URL(url).hostname
const urlWithReferral = url + (referral ?? '')
const onionMatch = /^(?:https?:\/\/)?(.{0,10}).*?(.{0,10})(\.onion)$/.exec(hostname)
if (onionMatch) {
return {
type: 'onion' as const,
url: urlWithReferral,
textBits: onionMatch.length
? [
{
style: 'normal' as const,
text: onionMatch[1] ?? '',
},
{
style: 'irrelevant' as const,
text: '...',
},
{
style: 'normal' as const,
text: onionMatch[2] ?? '',
},
{
style: 'irrelevant' as const,
text: onionMatch[3] ?? '',
},
]
: [
{
style: 'normal' as const,
text: hostname,
},
],
icon: networksBySlug.onion.icon,
}
}
const i2pMatch = /^(?:https?:\/\/)?(.{0,10}).*?(.{0,8})((?:\.b32)?\.i2p)$/.exec(hostname)
if (i2pMatch) {
return {
type: 'i2p' as const,
url: urlWithReferral,
textBits: i2pMatch.length
? [
{
style: 'normal' as const,
text: i2pMatch[1] ?? '',
},
{
style: 'irrelevant' as const,
text: '...',
},
{
style: 'normal' as const,
text: i2pMatch[2] ?? '',
},
{
style: 'irrelevant' as const,
text: i2pMatch[3] ?? '',
},
]
: [
{
style: 'normal' as const,
text: hostname,
},
],
icon: networksBySlug.i2p.icon,
}
}
return {
type: 'clearnet' as const,
url: urlWithReferral,
textBits: [
{
style: 'normal' as const,
text: hostname.replace(/^www\./, ''),
},
],
icon: networksBySlug.clearnet.icon,
}
}
const link = makeLink(baseUrl, referral)
if (!z.string().url().safeParse(link.url).success) {
console.error(`Invalid service URL with referral: ${link.url}`)
}
---
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class={cn(
'2xs:text-sm 2xs:h-8 2xs:gap-2 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black',
className
)}
{...htmlProps}
>
<Icon name={link.icon} class="2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4" />
<span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}>
{
link.textBits.map((textBit) => (
<span class={cn(textBit.style === 'irrelevant' && 'text-zinc-500')}>{textBit.text}</span>
))
}
</span>
<Icon
name="ri:arrow-right-line"
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
/>
</a>

View File

@@ -1,497 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { kycLevels } from '../constants/kycLevels'
import { cn } from '../lib/cn'
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
import Button from './Button.astro'
import PillsRadioGroup from './PillsRadioGroup.astro'
import { makeOverallScoreInfo } from './ScoreSquare.astro'
import Tooltip from './Tooltip.astro'
import type { HTMLAttributes } from 'astro/types'
export type Props = HTMLAttributes<'form'> & {
filters: ServicesFiltersObject
hasDefaultFilters: boolean
options: ServicesFiltersOptions
searchResultsId: string
showFiltersId: string
}
const {
filters,
hasDefaultFilters,
options,
searchResultsId,
showFiltersId,
class: className,
...formProps
} = Astro.props
---
<form
method="GET"
hx-get={Astro.url.pathname}
hx-trigger={`input delay:500ms from:input[type='text'], keyup[key=='Enter'], change from:input:not([data-show-more-input], #${showFiltersId}), change from:select`}
hx-target={`#${searchResultsId}`}
hx-select={`#${searchResultsId}`}
hx-push-url="true"
hx-indicator="#search-indicator"
data-services-filters-form
data-default-verification-filter={options.verification
.filter((verification) => verification.default)
.map((verification) => verification.slug)}
{...formProps}
class={cn('', className)}
>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-title text-xl text-green-500">FILTERS</h2>
<a
href={Astro.url.pathname}
class={cn('text-sm text-green-500 hover:text-green-400', hasDefaultFilters && 'hidden')}
id="clear-filters-button">Clear all</a
>
</div>
<!-- Sort Selector -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="sort">Sort By</label>
</legend>
<select
name="sort"
id="sort"
class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
>
{
options.sort.map((option) => (
<option value={option.value} selected={filters.sort === option.value}>
{option.label}
</option>
))
}
</select>
<p class="text-day-500 mt-1.5 text-center text-sm">
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
Ties randomly sorted
</p>
</fieldset>
<!-- Text Search -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label>
</legend>
<input
type="text"
name="q"
id="q"
value={filters?.q}
placeholder="Search..."
class="placeholder-day-500 border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
/>
</fieldset>
<!-- Type Filter -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">Type</legend>
<input type="checkbox" id="show-more-categories" class="peer hidden" hx-preserve data-show-more-input />
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden">
{
options.categories?.map((category) => (
<li data-show-always={category.showAlways ? '' : undefined}>
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white">
<input
type="checkbox"
class="peer text-green-500"
name="categories"
value={category.slug}
checked={category.checked}
/>
<span class="peer-checked:font-bold">
{category.name}
<span class="text-day-500 font-normal">{category._count.services}</span>
</span>
</label>
</li>
))
}
</ul>
{
options.categories.filter((category) => category.showAlways).length < options.categories.length && (
<>
<label
for="show-more-categories"
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden"
>
+ Show more
</label>
<label
for="show-more-categories"
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block"
>
- Show less
</label>
</>
)
}
</fieldset>
<!-- Verification Filter -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">Verification</legend>
<div>
{
options.verification.map((verification) => (
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input
type="checkbox"
class="peer text-green-500"
name="verification"
value={verification.slug}
checked={filters.verification.includes(verification.value)}
/>
<Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} />
<span class="peer-checked:font-bold">{verification.labelShort}</span>
</label>
))
}
</div>
</fieldset>
<!-- Accepted currencies Filter -->
<fieldset class="mb-6">
<div class="mb-3 flex items-center justify-between">
<legend class="font-title leading-none text-green-500">Currencies</legend>
<PillsRadioGroup
name="currency-mode"
options={options.modeOptions}
selectedValue={filters['currency-mode']}
class="-my-2"
/>
</div>
<div>
{
options.currencies.map((currency) => (
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input
type="checkbox"
class="peer text-green-500"
name="currencies"
value={currency.slug}
checked={filters.currencies?.some((id) => id === currency.id)}
/>
<Icon name={currency.icon} class="size-4" />
<span class="peer-checked:font-bold">{currency.name}</span>
</label>
))
}
</div>
</fieldset>
<!-- Network Filter -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">Networks</legend>
<div>
{
options.network.map((network) => (
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input
type="checkbox"
class="peer text-green-500"
name="networks"
value={network.slug}
checked={filters.networks?.some((slug) => slug === network.slug)}
/>
<Icon name={network.icon} class="size-4" />
<span class="peer-checked:font-bold">{network.name}</span>
</label>
))
}
</div>
</fieldset>
<!-- KYC Level Filter -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="max-kyc">KYC Level (max)</label>
</legend>
<div class="flex items-center gap-2">
<input
type="range"
min="0"
max="4"
name="max-kyc"
id="max-kyc"
value={filters['max-kyc'] ?? 4}
class="w-full accent-green-500"
/>
</div>
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
{
kycLevels.map((level) => (
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
{level.value}
<Icon name={level.icon} class="ms-1 size-3 shrink-0" />
</span>
))
}
</div>
</fieldset>
<!-- User Score Filter -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="user-rating">User Rating (min)</label>
</legend>
<div class="flex items-center gap-2">
<input
type="range"
min={0}
max={4}
name="user-rating"
id="user-rating"
value={filters['user-rating']}
class="w-full accent-green-500"
/>
</div>
<div class="text-day-400 mt-1 flex justify-between px-2 text-xs">
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">-</span>
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
1<Icon name="ri:star-line" class="size-3 shrink-0" />
</span>
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
2<Icon name="ri:star-line" class="size-3 shrink-0" />
</span>
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
3<Icon name="ri:star-line" class="size-3 shrink-0" />
</span>
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
4<Icon name="ri:star-line" class="size-3 shrink-0" />
</span>
</div>
</fieldset>
<!-- Attributes Filter -->
<fieldset class="mb-6 min-w-0 space-y-2">
<div class="mb-3 flex items-center justify-between">
<legend class="font-title leading-none text-green-500">Attributes</legend>
<PillsRadioGroup
name="attribute-mode"
options={options.modeOptions}
selectedValue={filters['attribute-mode']}
class="-my-2"
/>
</div>
{
options.attributesByCategory.map(({ category, attributes }) => (
<fieldset class="min-w-0">
<legend class="font-title mb-0.5 text-xs tracking-wide text-white">{category}</legend>
<input
type="checkbox"
id={`show-more-attributes-${category}`}
class="peer hidden"
hx-preserve
data-show-more-input
/>
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden">
{attributes.map((attribute) => {
const inputName = `attr-${attribute.id}` as const
const yesId = `attr-${attribute.id}=yes` as const
const noId = `attr-${attribute.id}=no` as const
const emptyId = `attr-${attribute.id}=empty` as const
const isPositive = attribute.type === 'GOOD' || attribute.type === 'INFO'
return (
<li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer">
<fieldset class="flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
<legend class="sr-only">
{attribute.title} ({attribute._count?.services})
</legend>
<input
type="radio"
class="peer/empty hidden"
id={emptyId}
name={inputName}
value=""
checked={!attribute.value}
aria-label="Ignore"
/>
<input
type="radio"
name={inputName}
value="yes"
id={yesId}
class="peer/yes hidden"
checked={attribute.value === 'yes'}
aria-label="Include"
/>
<input
type="radio"
name={inputName}
value="no"
id={noId}
class="peer/no hidden"
checked={attribute.value === 'no'}
aria-label="Exclude"
/>
<label
for={yesId}
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-zinc-950 peer-checked/yes:hidden"
aria-hidden="true"
>
<Icon name="ri:check-line" class="size-3" />
</label>
<label
for={emptyId}
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-green-600 peer-checked/yes:flex"
aria-hidden="true"
>
<Icon name="ri:check-line" class="size-3" />
</label>
<span class="block h-4 w-px border-y-2 border-zinc-950 bg-zinc-800" aria-hidden="true" />
<label
for={noId}
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-zinc-950 peer-checked/no:hidden"
aria-hidden="true"
>
<Icon name="ri:close-line" class="size-3" />
</label>
<label
for={emptyId}
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-red-600 peer-checked/no:flex"
aria-hidden="true"
>
<Icon name="ri:close-line" class="size-3" />
</label>
<label
for={isPositive ? yesId : noId}
class="ml-2 flex min-w-0 cursor-pointer items-center font-normal peer-checked/no:hidden peer-checked/yes:hidden"
aria-hidden="true"
>
<Icon
name={attribute.icon}
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.iconClass)}
aria-hidden="true"
/>
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{attribute.title}
</span>
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
</label>
<label
for={emptyId}
class="ml-2 hidden min-w-0 cursor-pointer items-center font-bold peer-checked/no:flex peer-checked/yes:flex"
aria-hidden="true"
>
<Icon
name={attribute.icon}
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.iconClass)}
aria-hidden="true"
/>
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{attribute.title}
</span>
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
</label>
</fieldset>
</li>
)
})}
</ul>
{attributes.filter((attribute) => attribute.showAlways).length < attributes.length && (
<>
<label
for={`show-more-attributes-${category}`}
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden"
>
+ Show more
</label>
<label
for={`show-more-attributes-${category}`}
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block"
>
- Show less
</label>
</>
)}
</fieldset>
))
}
</fieldset>
<!-- Score Filter -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="min-score">Score (min)</label>
</legend>
<div class="flex items-center gap-2">
<input
type="range"
min="0"
max="10"
name="min-score"
id="min-score"
value={filters['min-score']}
class="w-full accent-green-500"
/>
</div>
<div class="-mx-1.5 mt-2 flex justify-between px-1">
{
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((score) => {
const info = makeOverallScoreInfo(score)
return (
<Tooltip
text={info.text}
position="bottom"
class={cn(
'flex h-4 w-full max-w-4 min-w-0 cursor-default items-center justify-center rounded-xs text-xs font-bold tracking-tighter',
info.classNameBg
)}
>
{score.toLocaleString()}
</Tooltip>
)
})
}
</div>
</fieldset>
<input type="hidden" name="sort-seed" value={filters['sort-seed']} />
<div
class="sm:js:hidden bg-night-700 sticky inset-x-0 bottom-0 mt-4 block rounded-t-md pb-4 shadow-[0_0_16px_16px_var(--color-night-700)]"
>
<Button type="submit" label="Apply" size="lg" class="w-full" color="success" shadow />
</div>
</form>
<script>
document.addEventListener('astro:page-load', () => {
const forms = document.querySelectorAll<HTMLFormElement>('form[data-services-filters-form]')
forms.forEach((form) => {
form.addEventListener('input', () => {
form.querySelectorAll<HTMLAnchorElement>('a#clear-filters-button').forEach((button) => {
button.classList.remove('hidden')
})
const verificationInputs = form.querySelectorAll<HTMLInputElement>('input[name="verification"]')
const noVerificationChecked = Array.from(verificationInputs).every((input) => !input.checked)
if (noVerificationChecked) {
verificationInputs.forEach((input) => {
if (form.dataset.defaultVerificationFilter?.includes(input.value)) {
input.checked = true
}
})
}
})
})
})
</script>

View File

@@ -1,141 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
import { pluralize } from '../lib/pluralize'
import { createPageUrl } from '../lib/urls'
import Button from './Button.astro'
import ServiceCard from './ServiceCard.astro'
import type { ServicesFiltersObject } from '../pages/index.astro'
import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
hasDefaultFilters?: boolean
services: ComponentProps<typeof ServiceCard>['service'][] | undefined
currentPage?: number
total: number
pageSize: number
sortSeed?: string
filters: ServicesFiltersObject
hadToIncludeCommunityContributed: boolean
}
const {
services,
hasDefaultFilters = false,
currentPage = 1,
total,
pageSize,
sortSeed,
class: className,
filters,
hadToIncludeCommunityContributed,
...divProps
} = Astro.props
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
const hasCommunityContributed =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('COMMUNITY_CONTRIBUTED') || hadToIncludeCommunityContributed
const totalPages = Math.ceil(total / pageSize) || 1
---
<div {...divProps} class={cn('flex-1', className)}>
<div class="mb-6 flex items-center justify-between">
<span class="text-day-500 text-sm">
{total.toLocaleString()}
{pluralize('result', total)}
<span
id="search-indicator"
class="htmx-request:opacity-100 text-white opacity-0 transition-opacity duration-500"
>
<Icon name="ri:loader-4-line" class="inline-block size-4 animate-spin" />
Loading...
</span>
</span>
<Button as="a" href="/service-suggestion/new" label="Add service" icon="ri:add-line" />
</div>
{
hasScams && hasCommunityContributed && (
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500">
<Icon name="ri:alert-fill" class="-mr-1 inline-block size-4 text-red-500" />
<Icon name="ri:question-line" class="mr-2 inline-block size-4 text-yellow-500" />
Showing SCAM and unverified community-contributed services.
{hadToIncludeCommunityContributed && 'Because there were no other results.'}
</div>
)
}
{
hasScams && !hasCommunityContributed && (
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500">
<Icon name="ri:alert-fill" class="mr-2 inline-block size-4 text-red-500" />
Showing SCAM services!
</div>
)
}
{
!hasScams && hasCommunityContributed && (
<div class="font-title mb-6 rounded-lg border border-yellow-500/30 bg-yellow-950 p-4 text-sm text-yellow-500">
<Icon name="ri:question-line" class="mr-2 inline-block size-4" />
{hadToIncludeCommunityContributed
? 'Showing unverified community-contributed services, because there were no other results. Some might be scams.'
: 'Showing unverified community-contributed services, some might be scams.'}
</div>
)
}
{
!services || services.length === 0 ? (
<div class="sticky top-20 flex flex-col items-center justify-center rounded-lg border border-green-500/30 bg-black/40 p-12 text-center">
<Icon name="ri:emotion-sad-line" class="mb-4 size-16 text-green-500/50" />
<h3 class="font-title mb-3 text-xl text-green-500">No services found</h3>
<p class="text-day-400">Try adjusting your filters to find more services</p>
<a
href={Astro.url.pathname}
class={cn(
'bg-night-800 font-title mt-4 rounded-md px-4 py-2 text-sm tracking-wider text-white uppercase',
hasDefaultFilters && 'hidden'
)}
>
Clear filters
</a>
</div>
) : (
<>
<div class="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
{services.map((service, i) => (
<ServiceCard
inlineIcons
service={service}
data-hx-search-results-card
{...(i === services.length - 1 && currentPage < totalPages
? {
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
'hx-trigger': 'revealed',
'hx-swap': 'afterend',
'hx-select': '[data-hx-search-results-card]',
'hx-indicator': '#infinite-scroll-indicator',
}
: {})}
/>
))}
</div>
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
<Icon name="ri:loader-4-line" class="size-8 animate-spin text-green-500" />
Loading more services...
</div>
</div>
</>
)
}
</div>

View File

@@ -1,25 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { cn } from '../lib/cn'
type Props = {
active: boolean
sortOrder: 'asc' | 'desc' | null | undefined
class?: string
}
const { active, sortOrder, class: className }: Props = Astro.props
---
{
active && sortOrder ? (
sortOrder === 'asc' ? (
<Icon name="ri:arrow-down-s-line" class={cn('inline-block size-4', className)} />
) : (
<Icon name="ri:arrow-up-s-line" class={cn('inline-block size-4', className)} />
)
) : (
<Icon name="ri:expand-up-down-line" class={cn('inline-block size-4 text-current/50', className)} />
)
}

View File

@@ -1,11 +0,0 @@
---
---
<script>
document.body.classList.add('js')
document.addEventListener('astro:before-swap', (event) => {
event.newDocument.body.classList.add('js')
})
</script>

View File

@@ -1,16 +0,0 @@
---
import { omit } from 'lodash-es'
import { formatDateShort, type FormatDateShortOptions } from '../lib/timeAgo'
import type { HTMLAttributes } from 'astro/types'
type Props = FormatDateShortOptions &
Omit<HTMLAttributes<'time'>, keyof FormatDateShortOptions | 'datetime'> & {
date: Date
}
const { date, ...props } = Astro.props
---
<time datetime={date.toISOString()} {...omit(props, 'prefix')}>{formatDateShort(date, props)}</time>

View File

@@ -1,93 +0,0 @@
---
import { cn } from '../lib/cn'
import type { AstroChildren, AstroComponent, PolymorphicComponent } from '../lib/astro'
import type { HTMLTag } from 'astro/types'
type Props<Component extends AstroComponent | HTMLTag = 'span'> = PolymorphicComponent<Component> & {
children: AstroChildren
text: string
classNames?: {
tooltip?: string
}
color?: 'black' | 'white' | 'zinc-700'
position?: 'bottom' | 'left' | 'right' | 'top'
enabled?: boolean
}
const {
as: Component = 'span',
text,
classNames,
class: className,
color = 'zinc-700',
position = 'top',
enabled = true,
...htmlProps
} = Astro.props
---
<Component {...htmlProps} class={cn('group/tooltip relative overflow-visible', className)}>
<slot />
{
enabled && (
<span
tabindex="-1"
class={cn(
'pointer-events-none hidden select-none group-hover/tooltip:flex',
'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0',
'z-1000 w-max max-w-sm rounded-lg px-3 py-2 font-sans text-sm font-normal tracking-normal text-pretty whitespace-pre-wrap',
// Position classes
{
'absolute -top-2 left-1/2 origin-bottom -translate-x-1/2 translate-y-[calc(-100%+0.5rem)] text-center group-hover/tooltip:-translate-y-full starting:group-hover/tooltip:translate-y-[calc(-100%+0.25rem)]':
position === 'top',
'absolute -bottom-2 left-1/2 origin-top -translate-x-1/2 translate-y-[calc(100%-0.5rem)] text-center group-hover/tooltip:translate-y-full starting:group-hover/tooltip:translate-y-[calc(100%-0.25rem)]':
position === 'bottom',
'absolute top-1/2 -left-2 origin-right translate-x-[calc(-100%+0.5rem)] -translate-y-1/2 text-left group-hover/tooltip:-translate-x-full starting:group-hover/tooltip:translate-x-[calc(-100%+0.25rem)]':
position === 'left',
'absolute top-1/2 -right-2 origin-left translate-x-[calc(100%-0.5rem)] -translate-y-1/2 text-left group-hover/tooltip:translate-x-full starting:group-hover/tooltip:translate-x-[calc(100%-0.25rem)]':
position === 'right',
},
// Arrow position classes
{
'after:absolute after:top-[100%] after:left-1/2 after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:content-[""]':
position === 'top',
'after:absolute after:bottom-[100%] after:left-1/2 after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-t-transparent after:content-[""]':
position === 'bottom',
'after:absolute after:top-1/2 after:left-[100%] after:-translate-y-1/2 after:border-8 after:border-y-transparent after:border-r-transparent after:content-[""]':
position === 'left',
'after:absolute after:top-1/2 after:right-[100%] after:-translate-y-1/2 after:border-8 after:border-y-transparent after:border-l-transparent after:content-[""]':
position === 'right',
},
// Background and text color classes
{
'bg-zinc-700 text-white': color === 'zinc-700',
'bg-white text-black': color === 'white',
'bg-black text-white': color === 'black',
},
// Arrow color classes
{
'after:border-t-zinc-700': position === 'top' && color === 'zinc-700',
'after:border-t-white': position === 'top' && color === 'white',
'after:border-t-black': position === 'top' && color === 'black',
'after:border-b-zinc-700': position === 'bottom' && color === 'zinc-700',
'after:border-b-white': position === 'bottom' && color === 'white',
'after:border-b-black': position === 'bottom' && color === 'black',
'after:border-l-zinc-700': position === 'left' && color === 'zinc-700',
'after:border-l-white': position === 'left' && color === 'white',
'after:border-l-black': position === 'left' && color === 'black',
'after:border-r-zinc-700': position === 'right' && color === 'zinc-700',
'after:border-r-white': position === 'right' && color === 'white',
'after:border-r-black': position === 'right' && color === 'black',
},
classNames?.tooltip
)}
>
{text}
</span>
)
}
</Component>

View File

@@ -1,69 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { differenceInDays, isPast } from 'date-fns'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn'
import TimeFormatted from './TimeFormatted.astro'
import type { Prisma } from '@prisma/client'
const RECENTLY_ADDED_DAYS = 7
type Props = {
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
verificationProofMd: true
verificationSummary: true
listedAt: true
createdAt: true
}
}>
}
const { service } = Astro.props
const listedDate = service.listedAt ?? service.createdAt
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
---
{
service.verificationStatus === 'VERIFICATION_FAILED' ? (
<div class="mb-4 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<p class="flex items-center gap-2">
<Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/>
<span class="font-bold">This service is a SCAM!</span>
{!!service.verificationProofMd && (
<a href="#verification" class="cursor-pointer text-red-100 underline">
Proof
</a>
)}
</p>
{!!service.verificationSummary && (
<div class="mt-2 whitespace-pre-wrap">{service.verificationSummary}</div>
)}
</div>
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
<span>Community-contributed. Information not reviewed.</span>
</div>
) : wasRecentlyAdded ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution.
</div>
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
<Icon name="ri:information-line" class="size-5 text-blue-400" />
<span>Basic checks passed, but not fully verified.</span>
</div>
) : null
}

View File

@@ -1,68 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { AccountStatusChange } from '@prisma/client'
type AccountStatusChangeInfo<T extends string | null | undefined = string> = {
value: T
label: string
notificationTitle: string
}
export const {
dataArray: accountStatusChanges,
dataObject: accountStatusChangesById,
getFn: getAccountStatusChangeInfo,
zodEnumById: accountStatusChangesZodEnumById,
} = makeHelpersForOptions(
'value',
(value): AccountStatusChangeInfo<typeof value> => ({
value,
label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
notificationTitle: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
}),
[
{
value: 'ADMIN_TRUE',
label: 'Admin role granted',
notificationTitle: 'Admin role granted',
},
{
value: 'ADMIN_FALSE',
label: 'Admin role revoked',
notificationTitle: 'Admin role revoked',
},
{
value: 'VERIFIED_TRUE',
label: 'Account verified',
notificationTitle: 'Your account is now verified',
},
{
value: 'VERIFIED_FALSE',
label: 'Account unverified',
notificationTitle: 'Your account is no longer verified',
},
{
value: 'VERIFIER_TRUE',
label: 'Verifier role granted',
notificationTitle: 'Verifier role granted',
},
{
value: 'VERIFIER_FALSE',
label: 'Verifier role revoked',
notificationTitle: 'Verifier role revoked',
},
{
value: 'SPAMMER_TRUE',
label: 'Banned',
notificationTitle: 'Your account has been banned',
},
{
value: 'SPAMMER_FALSE',
label: 'Unbanned',
notificationTitle: 'Your account is no longer banned',
},
] as const satisfies AccountStatusChangeInfo<AccountStatusChange>[]
)
export type AccountStatusChangeType = (typeof accountStatusChanges)[number]['value']

View File

@@ -1,60 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { AttributeCategory } from '@prisma/client'
type AttributeCategoryInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
icon: string
classNames: {
icon: string
}
order: number
}
export const {
dataArray: attributeCategories,
dataObject: attributeCategoriesById,
getFn: getAttributeCategoryInfo,
getFnSlug: getAttributeCategoryInfoBySlug,
zodEnumBySlug: attributeCategoriesZodEnumBySlug,
zodEnumById: attributeCategoriesZodEnumById,
keyToSlug: attributeCategoryIdToSlug,
slugToKey: attributeCategorySlugToId,
} = makeHelpersForOptions(
'value',
(value): AttributeCategoryInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:shield-fill',
classNames: {
icon: 'text-current/60',
},
order: Infinity,
}),
[
{
value: 'PRIVACY',
slug: 'privacy',
label: 'Privacy',
icon: 'ri:shield-user-fill',
classNames: {
icon: 'text-blue-500',
},
order: 1,
},
{
value: 'TRUST',
slug: 'trust',
label: 'Trust',
icon: 'ri:shield-check-fill',
classNames: {
icon: 'text-green-500',
},
order: 2,
},
] as const satisfies AttributeCategoryInfo<AttributeCategory>[]
)

View File

@@ -1,110 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { AttributeType } from '@prisma/client'
type AttributeTypeInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
icon: string
order: number
classNames: {
container: string
subcontainer: string
text: string
textLight: string
icon: string
button: string
}
}
export const {
dataArray: attributeTypes,
dataObject: attributeTypesById,
getFn: getAttributeTypeInfo,
getFnSlug: getAttributeTypeInfoBySlug,
zodEnumBySlug: attributeTypesZodEnumBySlug,
zodEnumById: attributeTypesZodEnumById,
keyToSlug: attributeTypeIdToSlug,
slugToKey: attributeTypeSlugToId,
} = makeHelpersForOptions(
'value',
(value): AttributeTypeInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line',
order: Infinity,
classNames: {
container: 'bg-current/30',
subcontainer: 'bg-current/5 border-current/30',
text: 'text-current/60',
textLight: 'text-current/40',
icon: 'text-current/60',
button: 'bg-current/80 text-current/100 hover:bg-current/50',
},
}),
[
{
value: 'BAD',
slug: 'bad',
label: 'Bad',
icon: 'ri:close-line',
order: 1,
classNames: {
container: 'bg-red-600/30',
subcontainer: 'bg-red-600/5 border-red-600/30',
text: 'text-red-200',
textLight: 'text-red-100',
icon: 'text-red-400',
button: 'bg-red-200 text-red-900 hover:bg-red-50',
},
},
{
value: 'WARNING',
slug: 'warning',
label: 'Warning',
icon: 'ri:alert-line',
order: 2,
classNames: {
container: 'bg-yellow-600/30',
subcontainer: 'bg-yellow-600/5 border-yellow-600/30',
text: 'text-yellow-200',
textLight: 'text-amber-100',
icon: 'text-yellow-400',
button: 'bg-amber-100 text-amber-900 hover:bg-amber-50',
},
},
{
value: 'GOOD',
slug: 'good',
label: 'Good',
icon: 'ri:check-line',
order: 3,
classNames: {
container: 'bg-green-600/30',
subcontainer: 'bg-green-600/5 border-green-600/30',
text: 'text-green-200',
textLight: 'text-green-100',
icon: 'text-green-400',
button: 'bg-green-200 text-green-900 hover:bg-green-50',
},
},
{
value: 'INFO',
slug: 'info',
label: 'Info',
icon: 'ri:information-line',
order: 4,
classNames: {
container: 'bg-blue-600/30',
subcontainer: 'bg-blue-600/5 border-blue-600/30',
text: 'text-blue-200',
textLight: 'text-blue-100',
icon: 'text-blue-400',
button: 'bg-blue-200 text-blue-900 hover:bg-blue-50',
},
},
] as const satisfies AttributeTypeInfo<AttributeType>[]
)

View File

@@ -1,92 +0,0 @@
export const SEARCH_PARAM_CHARACTERS_NO_ESCAPE = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'-',
'_',
'.',
'~',
] as const
export const LOWERCASE_VOWEL_CHARACTERS = ['a', 'e', 'i', 'o', 'u'] as const
export const LOWERCASE_CONSONANT_CHARACTERS = [
'b',
'c',
'd',
'f',
'g',
'h',
'j',
'k',
'l',
'm',
'n',
'p',
'r',
's',
't',
'v',
'w',
'y',
'z',
] as const
export const DIGIT_CHARACTERS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] as const

View File

@@ -1,57 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { CommentStatus } from '@prisma/client'
type CommentStatusInfo<T extends string | null | undefined = string> = {
id: T
icon: string
label: string
creativeWorkStatus: string | undefined
}
export const {
dataArray: commentStatus,
dataObject: commentStatusById,
getFn: getCommentStatusInfo,
} = makeHelpersForOptions(
'id',
(id): CommentStatusInfo<typeof id> => ({
id,
icon: 'ri:question-line',
label: id ? transformCase(id, 'title') : String(id),
creativeWorkStatus: undefined,
}),
[
{
id: 'PENDING',
icon: 'ri:question-line',
label: 'Pending',
creativeWorkStatus: 'Deleted',
},
{
id: 'HUMAN_PENDING',
icon: 'ri:question-line',
label: 'Pending 2',
creativeWorkStatus: 'Deleted',
},
{
id: 'VERIFIED',
icon: 'ri:check-line',
label: 'Verified',
creativeWorkStatus: 'Verified',
},
{
id: 'REJECTED',
icon: 'ri:close-line',
label: 'Rejected',
creativeWorkStatus: 'Deleted',
},
{
id: 'APPROVED',
icon: 'ri:check-line',
label: 'Approved',
creativeWorkStatus: 'Active',
},
] as const satisfies CommentStatusInfo<CommentStatus>[]
)

View File

@@ -1,68 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { CommentStatusChange } from '@prisma/client'
type CommentStatusChangeInfo<T extends string | null | undefined = string> = {
value: T
label: string
notificationTitle: string
}
export const {
dataArray: commentStatusChanges,
dataObject: commentStatusChangesById,
getFn: getCommentStatusChangeInfo,
zodEnumById: commentStatusChangesZodEnumById,
} = makeHelpersForOptions(
'value',
(value): CommentStatusChangeInfo<typeof value> => ({
value,
label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
notificationTitle: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
}),
[
{
value: 'MARKED_AS_SPAM',
label: 'Marked as spam',
notificationTitle: 'was marked as spam',
},
{
value: 'UNMARKED_AS_SPAM',
label: 'Unmarked as spam',
notificationTitle: 'is no longer marked as spam',
},
{
value: 'MARKED_FOR_ADMIN_REVIEW',
label: 'Marked for admin review',
notificationTitle: 'was marked for admin review',
},
{
value: 'UNMARKED_FOR_ADMIN_REVIEW',
label: 'Unmarked for admin review',
notificationTitle: 'is no longer marked for admin review',
},
{
value: 'STATUS_CHANGED_TO_APPROVED',
label: 'Approved',
notificationTitle: 'was approved',
},
{
value: 'STATUS_CHANGED_TO_VERIFIED',
label: 'Verified',
notificationTitle: 'was verified',
},
{
value: 'STATUS_CHANGED_TO_REJECTED',
label: 'Rejected',
notificationTitle: 'was rejected',
},
{
value: 'STATUS_CHANGED_TO_PENDING',
label: 'Pending',
notificationTitle: 'is now pending',
},
] as const satisfies CommentStatusChangeInfo<CommentStatusChange>[]
)
export type CommentStatusChangeType = (typeof commentStatusChanges)[number]['value']

View File

@@ -1,143 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { Prisma } from '@prisma/client'
type CommentStatusFilterInfo<T extends string | null | undefined = string> = {
value: T
label: string
whereClause: Prisma.CommentWhereInput
styles: {
filter: string
badge: string
}
}
export const {
dataArray: commentStatusFilters,
dataObject: commentStatusFiltersById,
getFn: getCommentStatusFilterInfo,
zodEnumById: commentStatusFiltersZodEnum,
} = makeHelpersForOptions(
'value',
(value): CommentStatusFilterInfo<typeof value> => ({
value,
label: value ? transformCase(value, 'title') : String(value),
whereClause: {},
styles: {
filter: 'border-zinc-700 transition-colors hover:border-green-500/50',
badge: '',
},
}),
[
{
label: 'All',
value: 'all',
whereClause: {},
styles: {
filter: 'border-green-500 bg-green-500/20 text-green-400',
badge: '',
},
},
{
label: 'Pending',
value: 'pending',
whereClause: {
OR: [{ status: 'PENDING' }, { status: 'HUMAN_PENDING' }],
},
styles: {
filter: 'border-blue-500 bg-blue-500/20 text-blue-400',
badge: 'rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500',
},
},
{
label: 'Rejected',
value: 'rejected',
whereClause: {
status: 'REJECTED',
},
styles: {
filter: 'border-red-500 bg-red-500/20 text-red-400',
badge: 'rounded-sm bg-red-500/20 px-2 py-0.5 text-[12px] font-medium text-red-500',
},
},
{
label: 'Suspicious',
value: 'suspicious',
whereClause: {
suspicious: true,
},
styles: {
filter: 'border-red-500 bg-red-500/20 text-red-400',
badge: 'rounded-sm bg-red-500/20 px-2 py-0.5 text-[12px] font-medium text-red-500',
},
},
{
label: 'Verified',
value: 'verified',
whereClause: {
status: 'VERIFIED',
},
styles: {
filter: 'border-blue-500 bg-blue-500/20 text-blue-400',
badge: 'rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500',
},
},
{
label: 'Approved',
value: 'approved',
whereClause: {
status: 'APPROVED',
},
styles: {
filter: 'border-green-500 bg-green-500/20 text-green-400',
badge: 'rounded-sm bg-green-500/20 px-2 py-0.5 text-[12px] font-medium text-green-500',
},
},
{
label: 'Needs Review',
value: 'needs-review',
whereClause: {
requiresAdminReview: true,
},
styles: {
filter: 'border-yellow-500 bg-yellow-500/20 text-yellow-400',
badge: 'rounded-sm bg-yellow-500/20 px-2 py-0.5 text-[12px] font-medium text-yellow-500',
},
},
] as const satisfies CommentStatusFilterInfo[]
)
export type CommentStatusFilter = (typeof commentStatusFilters)[number]['value']
export function getCommentStatusFilterValue(
comment: Prisma.CommentGetPayload<{
select: {
status: true
suspicious: true
requiresAdminReview: true
}
}>
): CommentStatusFilter {
if (comment.requiresAdminReview) return 'needs-review'
if (comment.suspicious) return 'suspicious'
switch (comment.status) {
case 'PENDING':
case 'HUMAN_PENDING': {
return 'pending'
}
case 'VERIFIED': {
return 'verified'
}
case 'REJECTED': {
return 'rejected'
}
case 'APPROVED': {
return 'approved'
}
default: {
return 'all'
}
}
}

View File

@@ -1,61 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { Currency } from '@prisma/client'
type CurrencyInfo<T extends string | null | undefined = string> = {
id: T
icon: string
name: string
slug: string
}
export const {
dataArray: currencies,
dataObject: currenciesById,
getFn: getCurrencyInfo,
getFnSlug: getCurrencyInfoBySlug,
zodEnumBySlug: currenciesZodEnumBySlug,
keyToSlug: currencyIdToSlug,
slugToKey: currencySlugToId,
} = makeHelpersForOptions(
'id',
(id): CurrencyInfo<typeof id> => ({
id,
icon: 'ri:question-line',
name: id ? transformCase(id, 'title') : String(id),
slug: id ? id.toLowerCase() : '',
}),
[
{
id: 'MONERO',
icon: 'monero',
name: 'Monero',
slug: 'xmr',
},
{
id: 'BITCOIN',
icon: 'bitcoin',
name: 'Bitcoin',
slug: 'btc',
},
{
id: 'LIGHTNING',
icon: 'ri:flashlight-line',
name: 'Lightning',
slug: 'btc-ln',
},
{
id: 'FIAT',
icon: 'credit-card',
name: 'Fiat',
slug: 'fiat',
},
{
id: 'CASH',
icon: 'coins',
name: 'Cash',
slug: 'cash',
},
] as const satisfies CurrencyInfo<Currency>[]
)

View File

@@ -1,108 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { EventType } from '@prisma/client'
type EventTypeInfo<T extends string | null | undefined = string> = {
id: T
slug: string
label: string
description: string
classNames: {
dot: string
}
icon: string
}
export const {
dataArray: eventTypes,
dataObject: eventTypesById,
getFn: getEventTypeInfo,
getFnSlug: getEventTypeInfoBySlug,
zodEnumBySlug: eventTypesZodEnumBySlug,
zodEnumById: eventTypesZodEnumById,
} = makeHelpersForOptions(
'id',
(id): EventTypeInfo<typeof id> => ({
id,
slug: id ? id.toLowerCase() : '',
label: id ? transformCase(id, 'title') : String(id),
description: '',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
},
icon: 'ri:question-fill',
}),
[
{
id: 'WARNING',
slug: 'warning',
label: 'Warning',
description: 'Potential issues that users should be aware of',
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
},
icon: 'ri:error-warning-fill',
},
{
id: 'WARNING_SOLVED',
slug: 'warning-solved',
label: 'Warning Solved',
description: 'A previously reported warning has been solved',
classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
},
icon: 'ri:check-fill',
},
{
id: 'ALERT',
slug: 'alert',
label: 'Alert',
description: 'Critical issues affecting service functionality',
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
},
icon: 'ri:alert-fill',
},
{
id: 'ALERT_SOLVED',
slug: 'alert-solved',
label: 'Alert Solved',
description: 'A previously reported alert has been solved',
classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
},
icon: 'ri:check-fill',
},
{
id: 'INFO',
slug: 'info',
label: 'Information',
description: 'General information about the service',
classNames: {
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
},
icon: 'ri:information-fill',
},
{
id: 'NORMAL',
slug: 'normal',
label: 'Normal',
description: 'Regular service update or announcement',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
},
icon: 'ri:notification-fill',
},
{
id: 'UPDATE',
slug: 'update',
label: 'Update',
description: 'Service details were updated on kycnot.me',
classNames: {
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
},
icon: 'ri:pencil-fill',
},
] as const satisfies EventTypeInfo<EventType>[]
)

View File

@@ -1,89 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
export type KarmaUnlockInfo<T extends string | null | undefined = string> = {
id: T
name: string
verb: string
description: string
karma: number
icon: string
}
export const { dataArray: karmaUnlocks, dataObject: karmaUnlocksById } = makeHelpersForOptions(
'id',
(id): KarmaUnlockInfo<typeof id> => ({
id,
name: id ? transformCase(id, 'title') : String(id),
description: id ? transformCase(id, 'sentence') : String(id),
karma: 0,
icon: 'ri:question-line',
verb: id ? transformCase(id, 'title') : String(id),
}),
[
{
id: 'voteComments',
name: 'Vote on comments',
verb: 'vote on comments',
description: 'You can vote on comments',
karma: 20,
icon: 'ri:thumb-up-line',
},
{
id: 'websiteLink',
name: 'Website link',
verb: 'add a website link',
description: 'You can add a website link to your profile',
karma: 175,
icon: 'ri:link',
},
{
id: 'displayName',
name: 'Display name',
verb: 'have a display name',
description: 'You can change your display name',
karma: 150,
icon: 'ri:user-smile-line',
},
{
id: 'profilePicture',
name: 'Profile picture',
verb: 'have a profile picture',
description: 'You can change your profile picture',
karma: 200,
icon: 'ri:image-line',
},
{
id: 'highKarmaBadge',
name: 'High Karma badge',
verb: 'become a high karma user',
description: 'You are a high karma user',
karma: 500,
icon: 'ri:shield-star-line',
},
{
id: 'negativeKarmaBadge',
name: 'Negative Karma badge',
verb: 'be a suspicious user',
description: 'You are a suspicious user',
karma: -10,
icon: 'ri:error-warning-line',
},
{
id: 'untrustedBadge',
name: 'Untrusted badge',
verb: 'be an untrusted user',
description: 'You are an untrusted user',
karma: -30,
icon: 'ri:spam-2-line',
},
{
id: 'commentsDisabled',
name: 'Comments disabled',
verb: 'cannot comment',
description: 'You cannot comment',
karma: -50,
icon: 'ri:forbid-line',
},
] as const satisfies KarmaUnlockInfo[]
)

View File

@@ -1,64 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { parseIntWithFallback } from '../lib/numbers'
import { transformCase } from '../lib/strings'
type KycLevelInfo<T extends string | null | undefined = string> = {
id: T
value: number
icon: string
name: string
description: string
}
export const {
dataArray: kycLevels,
dataObject: kycLevelsById,
getFn: getKycLevelInfo,
} = makeHelpersForOptions(
'id',
(id): KycLevelInfo<typeof id> => ({
id,
value: parseIntWithFallback(id, 4),
icon: 'diamond-question',
name: `KYC ${id ? transformCase(id, 'title') : String(id)}`,
description: '',
}),
[
{
id: '0',
value: 0,
icon: 'anonymous-mask',
name: 'Guaranteed no KYC',
description: 'Terms explicitly state KYC will never be requested.',
},
{
id: '1',
value: 1,
icon: 'diamond-question',
name: 'No KYC mention',
description: 'No mention of current or future KYC requirements.',
},
{
id: '2',
value: 2,
icon: 'handcuffs',
name: 'KYC on authorities request',
description:
'No routine KYC, but may cooperate with authorities, block funds or implement future KYC requirements.',
},
{
id: '3',
value: 3,
icon: 'gun',
name: 'Shotgun KYC',
description: 'May request KYC and block funds based on automated triggers.',
},
{
id: '4',
value: 4,
icon: 'fingerprint-detailed',
name: 'Mandatory KYC',
description: 'Required for key features and can be required arbitrarily at any time.',
},
] as const satisfies KycLevelInfo<'0' | '1' | '2' | '3' | '4'>[]
)

View File

@@ -1,38 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
type NetworkInfo<T extends string | null | undefined = string> = {
slug: T
icon: string
name: string
}
export const {
dataArray: networks,
dataObject: networksBySlug,
getFn: getNetworkInfo,
} = makeHelpersForOptions(
'slug',
(slug): NetworkInfo<typeof slug> => ({
slug,
icon: 'ri:global-line',
name: slug ? transformCase(slug, 'title') : String(slug),
}),
[
{
slug: 'clearnet',
icon: 'ri:global-line',
name: 'Clearnet',
},
{
slug: 'onion',
icon: 'onion',
name: 'Onion',
},
{
slug: 'i2p',
icon: 'i2p',
name: 'I2P',
},
] as const satisfies NetworkInfo[]
)

View File

@@ -1,70 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import type { NotificationType } from '@prisma/client'
type NotificationTypeInfo<T extends string | null | undefined = string> = {
id: T
label: string
icon: string
}
export const {
dataArray: notificationTypes,
dataObject: notificationTypeLabels,
getFn: getNotificationTypeInfo,
} = makeHelpersForOptions(
'id',
(id): NotificationTypeInfo<typeof id> => ({
id,
label: 'Notification',
icon: 'ri:notification-line',
}),
[
{
id: 'COMMENT_STATUS_CHANGE',
label: 'Comment status changed',
icon: 'ri:chat-check-line',
},
{
id: 'REPLY_COMMENT_CREATED',
label: 'New reply',
icon: 'ri:chat-4-line',
},
{
id: 'ROOT_COMMENT_CREATED',
label: 'New comment/rating',
icon: 'ri:chat-4-line',
},
{
id: 'SUGGESTION_MESSAGE',
label: 'New message in suggestion',
icon: 'ri:mail-line',
},
{
id: 'SUGGESTION_STATUS_CHANGE',
label: 'Suggestion status changed',
icon: 'ri:lightbulb-line',
},
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// {
// id: 'KARMA_UNLOCK',
// label: 'Karma unlock',
// icon: 'ri:award-line',
// },
{
id: 'ACCOUNT_STATUS_CHANGE',
label: 'Change in account status',
icon: 'ri:user-settings-line',
},
{
id: 'EVENT_CREATED',
label: 'New event',
icon: 'ri:calendar-event-line',
},
{
id: 'SERVICE_VERIFICATION_STATUS_CHANGE',
label: 'Service verification changed',
icon: 'ri:verified-badge-line',
},
] as const satisfies NotificationTypeInfo<NotificationType>[]
)

View File

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

View File

@@ -1,48 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { ServiceVerificationStatusChange } from '@prisma/client'
type ServiceVerificationStatusChangeInfo<T extends string | null | undefined = string> = {
value: T
label: string
notificationTitle: string
}
export const {
dataArray: serviceVerificationStatusChanges,
dataObject: serviceVerificationStatusChangesById,
getFn: getServiceVerificationStatusChangeInfo,
zodEnumById: serviceVerificationStatusChangesZodEnumById,
} = makeHelpersForOptions(
'value',
(value): ServiceVerificationStatusChangeInfo<typeof value> => ({
value,
label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
notificationTitle: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
}),
[
{
value: 'STATUS_CHANGED_TO_COMMUNITY_CONTRIBUTED',
label: 'status changed to community contributed',
notificationTitle: 'status changed to community contributed',
},
{
value: 'STATUS_CHANGED_TO_APPROVED',
label: 'status changed to approved',
notificationTitle: 'status changed to approved',
},
{
value: 'STATUS_CHANGED_TO_VERIFICATION_SUCCESS',
label: 'status changed to verification success',
notificationTitle: 'status changed to verification success',
},
{
value: 'STATUS_CHANGED_TO_VERIFICATION_FAILED',
label: 'status changed to verification failed',
notificationTitle: 'status changed to verification failed',
},
] as const satisfies ServiceVerificationStatusChangeInfo<ServiceVerificationStatusChange>[]
)
export type ServiceVerificationStatusChangeType = (typeof serviceVerificationStatusChanges)[number]['value']

View File

@@ -1,68 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { ServiceSuggestionStatus } from '@prisma/client'
type ServiceSuggestionStatusInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
icon: string
iconClass: string
default: boolean
}
export const {
dataArray: serviceSuggestionStatuses,
dataObject: serviceSuggestionStatusesById,
getFn: getServiceSuggestionStatusInfo,
getFnSlug: getServiceSuggestionStatusInfoBySlug,
zodEnumBySlug: serviceSuggestionStatusesZodEnumBySlug,
zodEnumById: serviceSuggestionStatusesZodEnumById,
keyToSlug: serviceSuggestionStatusIdToSlug,
slugToKey: serviceSuggestionStatusSlugToId,
} = makeHelpersForOptions(
'value',
(value): ServiceSuggestionStatusInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line',
iconClass: 'text-current/60',
default: false,
}),
[
{
value: 'PENDING',
slug: 'pending',
label: 'Pending',
icon: 'ri:time-line',
iconClass: 'text-yellow-400',
default: true,
},
{
value: 'APPROVED',
slug: 'approved',
label: 'Approved',
icon: 'ri:check-line',
iconClass: 'text-green-400',
default: false,
},
{
value: 'REJECTED',
slug: 'rejected',
label: 'Rejected',
icon: 'ri:close-line',
iconClass: 'text-red-400',
default: false,
},
{
value: 'WITHDRAWN',
slug: 'withdrawn',
label: 'Withdrawn',
icon: 'ri:arrow-left-line',
iconClass: 'text-gray-400',
default: false,
},
] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[]
)

View File

@@ -1,48 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { ServiceSuggestionType } from '@prisma/client'
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
icon: string
default: boolean
}
export const {
dataArray: serviceSuggestionTypes,
dataObject: serviceSuggestionTypesById,
getFn: getServiceSuggestionTypeInfo,
getFnSlug: getServiceSuggestionTypeInfoBySlug,
zodEnumBySlug: serviceSuggestionTypesZodEnumBySlug,
zodEnumById: serviceSuggestionTypesZodEnumById,
keyToSlug: serviceSuggestionTypeIdToSlug,
slugToKey: serviceSuggestionTypeSlugToId,
} = makeHelpersForOptions(
'value',
(value): ServiceSuggestionTypeInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line',
default: false,
}),
[
{
value: 'CREATE_SERVICE',
slug: 'create',
label: 'Create',
icon: 'ri:add-line',
default: true,
},
{
value: 'EDIT_SERVICE',
slug: 'edit',
label: 'Edit',
icon: 'ri:pencil-line',
default: false,
},
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
)

View File

@@ -1,73 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { ServiceUserRole } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
icon: string
order: number
color: NonNullable<ComponentProps<typeof BadgeSmall>['color']>
}
export const {
dataArray: serviceUserRoles,
dataObject: serviceUserRolesById,
getFn: getServiceUserRoleInfo,
} = makeHelpersForOptions(
'value',
(value): ServiceUserRoleInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title').replace('_', ' ') : String(value),
icon: 'ri:user-3-line',
order: Infinity,
color: 'gray',
}),
[
{
value: 'OWNER',
slug: 'owner',
label: 'Owner',
icon: 'ri:vip-crown-2-fill',
order: 1,
color: 'lime',
},
{
value: 'ADMIN',
slug: 'admin',
label: 'Admin',
icon: 'ri:shield-star-fill',
order: 2,
color: 'green',
},
{
value: 'MODERATOR',
slug: 'moderator',
label: 'Moderator',
icon: 'ri:glasses-2-line',
order: 3,
color: 'teal',
},
{
value: 'SUPPORT',
slug: 'support',
label: 'Support',
icon: 'ri:customer-service-2-fill',
order: 4,
color: 'blue',
},
{
value: 'TEAM_MEMBER',
slug: 'team_member',
label: 'Team Member',
icon: 'ri:team-fill',
order: 5,
color: 'cyan',
},
] as const satisfies ServiceUserRoleInfo<ServiceUserRole>[]
)

View File

@@ -1,60 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { ServiceVisibility } from '@prisma/client'
type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
description: string
icon: string
iconClass: string
}
export const {
dataArray: serviceVisibilities,
dataObject: serviceVisibilitiesById,
getFn: getServiceVisibilityInfo,
getFnSlug: getServiceVisibilityInfoBySlug,
zodEnumBySlug: serviceVisibilitiesZodEnumBySlug,
zodEnumById: serviceVisibilitiesZodEnumById,
keyToSlug: serviceVisibilityIdToSlug,
slugToKey: serviceVisibilitySlugToId,
} = makeHelpersForOptions(
'value',
(value): ServiceVisibilityInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
description: '',
icon: 'ri:eye-line',
iconClass: 'text-current/60',
}),
[
{
value: 'PUBLIC',
slug: 'public',
label: 'Public',
description: 'Listed in search and browse.',
icon: 'ri:global-line',
iconClass: 'text-green-500',
},
{
value: 'UNLISTED',
slug: 'unlisted',
label: 'Unlisted',
description: 'Only accessible via direct link.',
icon: 'ri:link',
iconClass: 'text-yellow-500',
},
{
value: 'HIDDEN',
slug: 'hidden',
label: 'Hidden',
description: 'Only visible to moderators.',
icon: 'ri:lock-line',
iconClass: 'text-red-500',
},
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
)

View File

@@ -1,17 +0,0 @@
export const splashTexts: string[] = [
'Privacy is not a crime.',
'True financial independence.',
'Privacy is a human right.',
'Cypherpunk zone ahead.',
'KYC? Not me!',
'Freedom through privacy.',
'Resist surveillance.',
'Anonymity is power.',
'Defend your privacy.',
'Unbank yourself.',
'Banking without borders.',
'Escape the panopticon.',
'Ditch the gatekeepers.',
'Own your identity.',
'Financial privacy matters.',
]

View File

@@ -1,48 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { ServiceSuggestionStatusChange } from '@prisma/client'
type ServiceSuggestionStatusChangeInfo<T extends string | null | undefined = string> = {
value: T
label: string
notificationTitle: string
}
export const {
dataArray: serviceSuggestionStatusChanges,
dataObject: serviceSuggestionStatusChangesById,
getFn: getServiceSuggestionStatusChangeInfo,
zodEnumById: serviceSuggestionStatusChangesZodEnumById,
} = makeHelpersForOptions(
'value',
(value): ServiceSuggestionStatusChangeInfo<typeof value> => ({
value,
label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
notificationTitle: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
}),
[
{
value: 'STATUS_CHANGED_TO_PENDING',
label: 'status changed to pending',
notificationTitle: 'status changed to pending',
},
{
value: 'STATUS_CHANGED_TO_APPROVED',
label: 'status changed to approved',
notificationTitle: 'status changed to approved',
},
{
value: 'STATUS_CHANGED_TO_REJECTED',
label: 'status changed to rejected',
notificationTitle: 'status changed to rejected',
},
{
value: 'STATUS_CHANGED_TO_WITHDRAWN',
label: 'status changed to withdrawn',
notificationTitle: 'status changed to withdrawn',
},
] as const satisfies ServiceSuggestionStatusChangeInfo<ServiceSuggestionStatusChange>[]
)
export type ServiceSuggestionStatusChangeType = (typeof serviceSuggestionStatusChanges)[number]['value']

View File

@@ -1,63 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
type TosHighlightRatingInfo<T extends string | null | undefined = string> = {
id: T
icon: string
name: string
classNames: {
icon: string
borderColor: string
}
order: number
}
export const {
dataArray: tosHighlightRatings,
dataObject: tosHighlightRatingsById,
getFn: getTosHighlightRatingInfo,
} = makeHelpersForOptions(
'id',
(id): TosHighlightRatingInfo<typeof id> => ({
id,
icon: 'ri:question-line',
name: id ? transformCase(id, 'title') : String(id),
classNames: {
icon: 'text-yellow-400',
borderColor: 'border-yellow-500/40',
},
order: Infinity,
}),
[
{
id: 'negative',
icon: 'ri:thumb-down-line',
name: 'Negative',
classNames: {
icon: 'text-red-400',
borderColor: 'border-red-500/40',
},
order: 1,
},
{
id: 'positive',
icon: 'ri:thumb-up-line',
name: 'Positive',
classNames: {
icon: 'text-green-400',
borderColor: 'border-green-500/40',
},
order: 2,
},
{
id: 'neutral',
icon: 'ri:information-line',
name: 'Neutral',
classNames: {
icon: 'text-blue-400',
borderColor: 'border-blue-500/40',
},
order: 3,
},
] as const satisfies TosHighlightRatingInfo[]
)

View File

@@ -1,63 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
type UserSentimentInfo<T extends string | null | undefined = string> = {
id: T
icon: string
name: string
classNames: {
icon: string
borderColor: string
background: string
}
}
export const {
dataArray: userSentiments,
dataObject: userSentimentsById,
getFn: getUserSentimentInfo,
} = makeHelpersForOptions(
'id',
(id): UserSentimentInfo<typeof id> => ({
id,
icon: 'ri:emotion-normal-line',
name: id ? transformCase(id, 'title') : String(id),
classNames: {
icon: 'text-yellow-400',
borderColor: 'border-yellow-500/40',
background: 'bg-yellow-950/20',
},
}),
[
{
id: 'positive',
icon: 'ri:emotion-happy-line',
name: 'Positive',
classNames: {
icon: 'text-green-400',
borderColor: 'border-green-500/40',
background: 'bg-green-950/20',
},
},
{
id: 'neutral',
icon: 'ri:emotion-normal-line',
name: 'Neutral',
classNames: {
icon: 'text-blue-400',
borderColor: 'border-blue-500/40',
background: 'bg-blue-950/20',
},
},
{
id: 'negative',
icon: 'ri:emotion-unhappy-line',
name: 'Negative',
classNames: {
icon: 'text-red-400',
borderColor: 'border-red-500/40',
background: 'bg-red-950/20',
},
},
] as const satisfies UserSentimentInfo[]
)

View File

@@ -1,146 +0,0 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { MarkdownString } from '../lib/markdown'
import type { VerificationStatus } from '@prisma/client'
type VerificationStatusInfo<T extends string | null | undefined = string> = {
value: T
slug: string
labelShort: string
label: string
icon: string
default: boolean
description: string
privacyPoints: number
trustPoints: number
classNames: {
icon: string
badgeBig: string
button: string
description: string
containerBg: string
}
order: number
verbPast: string
}
export const READ_MORE_SENTENCE_LINK =
'Read more about the [suggestion review process](/about#suggestion-review-process).' satisfies MarkdownString
export const {
dataArray: verificationStatuses,
dataObject: verificationStatusesByValue,
getFn: getVerificationStatusInfo,
getFnSlug: getVerificationStatusInfoBySlug,
zodEnumBySlug: verificationStatusesZodEnumBySlug,
zodEnumById: verificationStatusesZodEnumById,
keyToSlug: verificationStatusIdToSlug,
slugToKey: verificationStatusSlugToId,
} = makeHelpersForOptions(
'value',
(value): VerificationStatusInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
labelShort: value ? transformCase(value, 'title') : String(value),
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:loader-line',
default: false,
description: '',
privacyPoints: 0,
trustPoints: 0,
classNames: {
icon: 'text-current',
badgeBig: 'bg-night-400 text-day-100',
button: 'bg-night-400 hover:bg-night-300',
description: 'text-day-200',
containerBg: 'bg-night-600',
},
order: Infinity,
verbPast: value ? transformCase(value, 'title') : String(value),
}),
[
{
value: 'VERIFICATION_SUCCESS',
slug: 'verified',
labelShort: 'Verified',
label: 'Verified',
icon: 'ri:verified-badge-fill',
default: true,
description:
'Thoroughly tested and verified by the team. But things might change, this is not a guarantee.',
privacyPoints: 0,
trustPoints: 5,
classNames: {
icon: 'text-[#40e6c2]',
badgeBig: 'bg-green-800/50 text-green-100',
button: 'bg-green-700 hover:bg-green-600',
description: 'text-green-200',
containerBg: 'bg-green-900/30',
},
order: 1,
verbPast: 'verified',
},
{
value: 'APPROVED',
slug: 'approved',
labelShort: 'Approved',
label: 'Approved',
icon: 'ri:check-line',
default: true,
description:
'Everything checks out at first glance, but not verified nor thoroughly tested by the team.',
privacyPoints: 0,
trustPoints: 5,
classNames: {
icon: 'text-white',
badgeBig: 'bg-night-400 text-day-100',
button: 'bg-night-400 hover:bg-night-300',
description: 'text-day-200',
containerBg: 'bg-night-600',
},
order: 2,
verbPast: 'approved',
},
{
value: 'COMMUNITY_CONTRIBUTED',
slug: 'community',
labelShort: 'Community',
label: 'Community Contributed',
icon: 'ri:question-line',
default: false,
description: 'Suggested by the community, but not reviewed by the team yet.',
privacyPoints: 0,
trustPoints: 0,
classNames: {
icon: 'text-yellow-400',
badgeBig: 'bg-amber-800/50 text-amber-100',
button: 'bg-amber-700 hover:bg-amber-600',
description: 'text-amber-200',
containerBg: 'bg-amber-900/30',
},
order: 3,
verbPast: 'contributed by the community',
},
{
value: 'VERIFICATION_FAILED',
slug: 'scam',
labelShort: 'Scam',
label: 'Scam',
icon: 'ri:alert-fill',
default: false,
description: 'Confirmed as a SCAM or not what it claims to be.',
privacyPoints: 0,
trustPoints: -30,
classNames: {
icon: 'text-red-500',
badgeBig: 'bg-red-800/50 text-red-100',
button: 'bg-red-700 hover:bg-red-600',
description: 'text-red-200',
containerBg: 'bg-red-900/30',
},
order: 4,
verbPast: 'marked as a SCAM',
},
] as const satisfies VerificationStatusInfo<VerificationStatus>[]
)

44
web/src/env.d.ts vendored
View File

@@ -1,44 +0,0 @@
import type { ErrorBanners } from './lib/errorBanners'
import type { KarmaUnlocks } from './lib/karmaUnlocks'
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import type { Prisma } from '@prisma/client'
import type * as htmx from 'htmx.org'
declare global {
namespace App {
interface Locals {
user: (Prisma.UserGetPayload<true> & { karmaUnlocks: KarmaUnlocks }) | null
actualUser: (Prisma.UserGetPayload<true> & { karmaUnlocks: KarmaUnlocks }) | null
banners: ErrorBanners
makeId: <T extends string>(prefix: T) => `${T}-${number}-${string}`
}
}
interface Window {
htmx?: typeof htmx
}
namespace PrismaJson {
type TosReview = {
kycLevel: 0 | 1 | 2 | 3 | 4
/** Less than 200 characters */
summary: MarkdownString
contentHash: string
complexity: 'high' | 'low' | 'medium'
highlights: {
/** Very short */
title: string
/** Short */
content: MarkdownString
rating: 'negative' | 'neutral' | 'positive'
}[]
}
type UserSentiment = {
summary: MarkdownString
sentiment: 'negative' | 'neutral' | 'positive'
whatUsersLike: string[]
whatUsersDislike: string[]
}
}
}

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 80 81">
<path fill="currentColor"
d="M40 .5C22 .5 7.5 5.3 7.5 11.2v30.2c0 9 9.8 34 32.5 39.1 22.7-5.2 32.5-30 32.5-39.1V11.2C72.5 5.2 57.9.5 40 .5Zm-14.4 16c6.2 0 9.3 5.5 10 6.1 1 1 1 2.7 0 3.8-1 1-2.8 1-3.8 0-.8-.7-3.2-6.3-8-6.3-2.6 0-5.6 1-9 3.5 3.3-5.5 7.2-7.1 10.8-7.1ZM31 29.8a10.4 10.4 0 0 1-7.3 2.7c-3 0-5.5-1-7.2-2.7 1.7-1.6 4.3-2.6 7.3-2.6s5.5 1 7.2 2.6Zm12.6 43.6L40 76.9l-3.6-3.5v-8.9h7.2v8.9Zm12.6-16H43.6L40 53.8l-3.6 3.6H23.8l-9-14.2L25.5 52h7.2l5.4-5.4h3.6l5.4 5.4h7.2l10.9-9-9 14.3ZM49 29.8c1.7-1.6 4.3-2.6 7.3-2.6s5.5 1 7.2 2.6a10.4 10.4 0 0 1-7.3 2.7c-3 0-5.5-1-7.2-2.7Zm7.3-9.7c-4.9 0-7.3 5.6-8 6.3-1.1 1-2.8 1-3.9 0-1-1-1-2.7 0-3.8.7-.6 3.8-6.1 10-6.1 3.6 0 7.5 1.6 10.9 7.1a16.8 16.8 0 0 0-9-3.5Z" />
</svg>

Before

Width:  |  Height:  |  Size: 799 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor"
d="M9 21v-2H6v-2h2V7H6V5h3V3h2v2h2V3h2v2.125q1.3.35 2.15 1.413T18 9q0 .725-.25 1.388t-.7 1.187q.875.525 1.413 1.425T19 15q0 1.65-1.175 2.825T15 19v2h-2v-2h-2v2zm1-10h4q.825 0 1.413-.587T16 9t-.587-1.412T14 7h-4zm0 6h5q.825 0 1.413-.587T17 15t-.587-1.412T15 13h-5z" />
</svg>

Before

Width:  |  Height:  |  Size: 367 B

View File

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 14c0 1.657 2.686 3 6 3s6 -1.343 6 -3s-2.686 -3 -6 -3s-6 1.343 -6 3z" />
<path d="M9 14v4c0 1.656 2.686 3 6 3s6 -1.344 6 -3v-4" />
<path
d="M3 6c0 1.072 1.144 2.062 3 2.598s4.144 .536 6 0c1.856 -.536 3 -1.526 3 -2.598c0 -1.072 -1.144 -2.062 -3 -2.598s-4.144 -.536 -6 0c-1.856 .536 -3 1.526 -3 2.598z" />
<path d="M3 6V16C3 16.888 4.5 18 6 18" />
<path d="M3 11C3 11.888 4.5 13.5 6 13.5" />
</svg>

Before

Width:  |  Height:  |  Size: 533 B

View File

@@ -1,6 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" />
<rect x="3" y="9" width="18" height="1" fill="currentColor" />
<path d="M9 14H7V15H9V14Z" />
</svg>

Before

Width:  |  Height:  |  Size: 317 B

View File

@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<defs>
<style>
.cls-1 {
fill: none
}
</style>
</defs>
<path
d="m29.4 14.6-12-12a2 2 0 0 0-2.8 0l-12 12a2 2 0 0 0 0 2.8l12 12a2 2 0 0 0 2.8 0l12-12a2 2 0 0 0 0-2.8ZM16 24a1.5 1.5 0 1 1 1.5-1.5A1.5 1.5 0 0 1 16 24Zm1.1-6.8v2H15V15H17a1.9 1.9 0 0 0 0-3.8h-2a1.9 1.9 0 0 0-1.9 2v.4H11v-.5A4.1 4.1 0 0 1 15 9h2a4.1 4.1 0 0 1 .1 8.2Z" />
<path id="inner-path"
d="M16 21a1.5 1.5 0 1 1-1.5 1.5A1.5 1.5 0 0 1 16 21Zm1.1-3.8A4.1 4.1 0 0 0 17 9h-2a4.1 4.1 0 0 0-4.1 4.1v.5H13v-.5a1.9 1.9 0 0 1 1.9-1.8h2a1.9 1.9 0 0 1 0 3.7h-2.1v4.1H17Z"
class="cls-1" />
<path id="_Transparent_Rectangle_" d="M0 0h32v32H0z" class="cls-1" data-name="&lt;Transparent Rectangle&gt;" />
</svg>

Before

Width:  |  Height:  |  Size: 793 B

Some files were not shown because too many files have changed in this diff Show More