Compare commits

...

6 Commits

Author SHA1 Message Date
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
pluja
e536ca6519 Release 202505261804 2025-05-26 18:04:45 +00:00
pluja
b361ed3aa8 Release 202505261604 2025-05-26 16:04:25 +00:00
33 changed files with 769 additions and 291 deletions

View File

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

View File

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

View File

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

View File

@@ -14,12 +14,8 @@ import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { import { separateServiceUrlsByType } from '../lib/urls'
imageFileSchemaRequired, import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -161,9 +157,8 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' } { message: 'Slug must be unique, try a different one' }
), ),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH), description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)), kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -210,6 +205,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name) const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => { const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = { const serviceSelect = {
id: true, id: true,
@@ -221,9 +222,10 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
imageUrl, imageUrl,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,17 @@ Once submitted, you get a unique tracking page where you can monitor its status
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**. All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
#### Requirements
To list a new service, it must fulfill these requirements:
- Offer a service.
- Publicly available website explaining what the service is about
- Terms of service or FAQ document
For examples:
- Just a Telegram link or a criptocurrency itself is not a valid service.
### Suggestion Review Process ### Suggestion Review Process
#### First Review #### First Review
@@ -190,6 +201,41 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## API
Access basic service data via our public API.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
### `GET /api/v1/service/[id]`
Fetches details for a single service.
- **`[id]`**: Can be a service ID, slug, name, or any registered URL (including .onion/.i2p).
**Example Requests:**
```
/api/v1/service/bisq
/api/v1/service/https://bisq.network
```
**Example Response (200 OK):**
```json
{
"name": "Bisq",
"description": "Decentralized Bitcoin exchange network.",
"kycLevel": 0,
"categories": ["exchange"],
"serviceUrls": ["https://bisq.network/"],
"onionUrls": [],
"i2pUrls": [],
"tosUrls": ["https://bisq.network/terms-of-service/"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
## Support ## Support
If you like this project, you can **support** it through these methods: If you like this project, you can **support** it through these methods:

View File

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

View File

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

View File

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

View File

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

View File

@@ -233,16 +233,30 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<input type="hidden" name="id" value={service.id} /> <input type="hidden" name="id" value={service.id} />
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputText
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
</div>
<InputTextArea <InputTextArea
label="Description" label="Description"
name="description" name="description"
@@ -254,76 +268,44 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description} error={serviceInputErrors.description}
/> />
<InputText <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputTextArea <InputTextArea
label="Service URLs" label="Service URLs"
description="One per line" description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="serviceUrls" name="allServiceUrls"
inputProps={{ inputProps={{
rows: 3, placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
placeholder: 'https://example1.com\nhttps://example2.com', class: 'grow min-h-24',
required: true,
}} }}
value={service.serviceUrls.join('\n')} class="row-span-2 flex flex-col self-stretch"
error={serviceInputErrors.serviceUrls} value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/> />
<InputTextArea <InputTextArea
label="ToS URLs" label="ToS URLs"
description="One per line" description="One per line"
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos', placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true, required: true,
}} }}
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
/> />
<InputTextArea <InputText
label="Onion URLs" label="Referral link path"
description="One per line" name="referral"
name="onionUrls"
inputProps={{ inputProps={{
rows: 3, value: service.referral,
placeholder: 'http://example1.onion\nhttp://example2.onion', placeholder: 'e.g. ?ref=123 or /ref/123',
}} }}
value={service.onionUrls.join('\n')} error={serviceInputErrors.referral}
error={serviceInputErrors.onionUrls} class="self-end"
/> description="Will be appended to the service URL"
<InputTextArea
label="I2P URLs"
description="One per line"
name="i2pUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
}}
value={service.i2pUrls.join('\n')}
/> />
</div> </div>
<InputText
label="Referral link path"
name="referral"
inputProps={{
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={serviceInputErrors.referral}
description="Will be appended to the service URL"
/>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<InputImageFile <InputImageFile
label="Image" label="Image"
@@ -685,9 +667,14 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date(event.startedAt).toISOString().split('T')[0], value: new Date(
new Date(event.startedAt).getTime() -
new Date(event.startedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16),
}} }}
error={eventUpdateInputErrors.startedAt} error={eventUpdateInputErrors.startedAt}
/> />
@@ -696,7 +683,15 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '', type: 'datetime-local',
value: event.endedAt
? new Date(
new Date(event.endedAt).getTime() -
new Date(event.endedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16)
: '',
}} }}
error={eventUpdateInputErrors.endedAt} error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -756,9 +751,11 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date().toISOString().split('T')[0], value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.startedAt} error={eventInputErrors.startedAt}
/> />
@@ -767,7 +764,10 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: new Date().toISOString().split('T')[0], type: 'datetime-local',
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.endedAt} error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."

View File

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

View File

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

View File

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

View File

@@ -712,7 +712,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']} sortSeed={filters['sort-seed']}
filters={filters} filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly} countCommunityOnly={countCommunityOnly}
inlineIcons inlineIcons
/> />

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
lastLoginAt: true,
_count: { _count: {
select: { select: {
comments: true, comments: true,
@@ -469,6 +470,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
</li> </li>
<AdminOnly>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Last login</p>
<p class="text-day-300">
{
formatDateShort(user.lastLoginAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</p>
</div>
</li>
</AdminOnly>
{ {
user.verifiedLink && ( user.verifiedLink && (
<li class="flex items-start"> <li class="flex items-start">