Release 2025-05-19
@@ -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 }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
Before Width: | Height: | Size: 379 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -1,11 +0,0 @@
|
||||
---
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
|
||||
type Props = {
|
||||
children: AstroChildren
|
||||
}
|
||||
|
||||
//
|
||||
---
|
||||
|
||||
{!!Astro.locals.user?.admin && <slot />}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)} />
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
document.body.classList.add('js')
|
||||
|
||||
document.addEventListener('astro:before-swap', (event) => {
|
||||
event.newDocument.body.classList.add('js')
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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']
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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[]
|
||||
)
|
||||
@@ -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'>[]
|
||||
)
|
||||
@@ -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[]
|
||||
)
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
||||
@@ -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']
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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>[]
|
||||
)
|
||||
@@ -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.',
|
||||
]
|
||||
@@ -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']
|
||||
@@ -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[]
|
||||
)
|
||||
@@ -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[]
|
||||
)
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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="<Transparent Rectangle>" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 793 B |