Release 2025-05-25-GgNU

This commit is contained in:
pluja
2025-05-25 12:28:30 +00:00
parent 8f2b2c34ff
commit 6b86a72d1e
10 changed files with 142 additions and 53 deletions

View File

@@ -650,22 +650,22 @@ const generateFakeService = (users: User[]) => {
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
referral: `?ref=${faker.string.alphanumeric(6)}`, referral: `?ref=${faker.string.alphanumeric(6)}`,
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }), acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()), serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()), tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
onionUrls: Array.from( onionUrls: faker.helpers.multiple(
{ length: faker.number.int({ min: 0, max: 2 }) }, () => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion` { count: { min: 0, max: 2 } }
), ),
i2pUrls: Array.from( i2pUrls: faker.helpers.multiple(
{ length: faker.number.int({ min: 0, max: 2 }) }, () => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p` { count: { min: 0, max: 2 } }
), ),
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`, imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(), listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null, verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples), tosReview: faker.helpers.arrayElement(tosReviewExamples),
tosReviewAt: faker.date.past(), tosReviewAt: faker.date.past(),
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined, userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(), userSentimentAt: faker.date.recent(),
} as const satisfies Prisma.ServiceCreateInput } as const satisfies Prisma.ServiceCreateInput
} }

View File

@@ -106,9 +106,13 @@ export const adminServiceActions = {
update: defineProtectedAction({ update: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: serviceSchemaBase.transform(addSlugIfMissing), input: serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing),
handler: async (input) => { handler: async (input) => {
const { id, categories, attributes, imageFile, ...data } = input const { id, categories, attributes, imageFile, removeImage, ...data } = input
const existing = await prisma.service.findUnique({ const existing = await prisma.service.findUnique({
where: { where: {
@@ -124,7 +128,11 @@ export const adminServiceActions = {
}) })
} }
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined const imageUrl = removeImage
? null
: imageFile
? await saveFileLocally(imageFile, imageFile.name)
: undefined
// Get existing attributes and categories to compute differences // Get existing attributes and categories to compute differences
const existingService = await prisma.service.findUnique({ const existingService = await prisma.service.findUnique({

View File

@@ -54,9 +54,9 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
disabled={disabled} disabled={disabled}
/> />
{icons.map((icon, index) => ( {icons.map((icon, index) => (
<Icon name={icon} class={cn('size-4', iconClassName[index])} /> <Icon name={icon} class={cn('size-4 shrink-0', iconClassName[index])} />
))} ))}
<span class="text-sm leading-none">{option.label}</span> <span class="truncate text-sm leading-none">{option.label}</span>
</label> </label>
) )
}) })

View File

@@ -16,13 +16,20 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
} }
} }
const { accept, disabled, multiple, removeCheckbox, ...wrapperProps } = Astro.props const { accept, disabled, multiple, removeCheckbox, classNames, ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`) const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
--- ---
<InputWrapper inputId={inputId} {...wrapperProps}> <InputWrapper
inputId={inputId}
classNames={{
...classNames,
description: cn(classNames?.description, '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'),
}}
{...wrapperProps}
>
{ {
!!removeCheckbox && ( !!removeCheckbox && (
<label <label

View File

@@ -2,16 +2,24 @@
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils' import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
import Button from './Button.astro'
import InputFile from './InputFile.astro' import InputFile from './InputFile.astro'
import Tooltip from './Tooltip.astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & { type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
square?: boolean square?: boolean
value?: string | null value?: string | null
downloadButton?: boolean
} }
const { class: className, square, value, ...inputFileProps } = Astro.props const { class: className, square, value, downloadButton, ...inputFileProps } = Astro.props
function makeDownloadFilename(value: string) {
const url = new URL(value, Astro.url.origin)
return url.pathname.split('/').pop() ?? 'service-image'
}
--- ---
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image> <div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden' '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
)} )}
/> />
{
downloadButton && value && (
<Tooltip
text="Download"
classNames={{
tooltip: 'min-2xs:[&:is(:has([data-remove-checkbox]:checked)_~_*_*)]:hidden',
}}
>
<Button
as="a"
href={value}
download={makeDownloadFilename(value)}
icon="ri:download-line"
size="sm"
label="Download"
class={cn(
'bg-night-600 border-night-400 text-day-200 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:h-24 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:px-0 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:w-8 shrink-0 rounded-md border'
)}
classNames={{
label: '2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:hidden block ',
}}
/>
</Tooltip>
)
}
</div> </div>
<script> <script>

View File

@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
icon?: string icon?: string
inputId?: string inputId?: string
hideLabel?: boolean hideLabel?: boolean
classNames?: {
description?: string
}
} }
const { const {
@@ -32,13 +35,14 @@ const {
class: className, class: className,
inputId, inputId,
hideLabel, hideLabel,
classNames,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
const hasError = !!error && error.length > 0 const hasError = !!error && error.length > 0
--- ---
<fieldset class={cn('space-y-1', className)} {...htmlProps}> <fieldset class={cn('min-w-0 space-y-1', className)} {...htmlProps}>
{ {
!hideLabel && ( !hideLabel && (
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}> <div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
@@ -71,7 +75,12 @@ const hasError = !!error && error.length > 0
{ {
!!description && ( !!description && (
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty"> <div
class={cn(
'prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty',
classNames?.description
)}
>
<Markdown content={description} /> <Markdown content={description} />
</div> </div>
) )

View File

@@ -65,9 +65,9 @@ export const {
value: 'ARCHIVED', value: 'ARCHIVED',
slug: 'archived', slug: 'archived',
label: 'Archived', label: 'Archived',
description: 'Service no longer exists or ceased operations.', description: 'No ceased operations.',
longDescription: longDescription:
'This service has been archived and no longer exists or ceased operations. Information may be outdated.', 'Archived service, no longer exists or ceased operations. Information may be outdated.',
icon: 'ri:archive-line', icon: 'ri:archive-line',
iconClass: 'text-day-100', iconClass: 'text-day-100',
}, },

View File

@@ -261,6 +261,7 @@ if (!service) return Astro.rewrite('/404')
inputProps={{ inputProps={{
rows: 3, rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos', placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true,
}} }}
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
@@ -298,16 +299,26 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.referral} error={serviceInputErrors.referral}
/> />
<InputImageFile <div class="flex items-center justify-between gap-2">
label="Image" <InputImageFile
name="imageFile" label="Image"
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image." name="imageFile"
error={serviceInputErrors.imageFile} description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
square error={serviceInputErrors.imageFile}
value={service.imageUrl} square
/> value={service.imageUrl}
downloadButton
removeCheckbox={service.imageUrl
? {
name: 'removeImage',
label: 'Remove image',
}
: undefined}
class="grow"
/>
</div>
<div class="grid grid-cols-1 items-stretch gap-x-4 gap-y-6 sm:grid-cols-[1fr_2fr]"> <div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<InputCheckboxGroup <InputCheckboxGroup
name="categories" name="categories"
label="Categories" label="Categories"
@@ -320,6 +331,7 @@ if (!service) return Astro.rewrite('/404')
}))} }))}
selectedValues={service.categories.map((c) => c.id.toString())} selectedValues={service.categories.map((c) => c.id.toString())}
error={serviceInputErrors.categories} error={serviceInputErrors.categories}
class="min-w-auto"
/> />
<InputCheckboxGroup <InputCheckboxGroup

View File

@@ -1,5 +1,4 @@
--- ---
import { ServiceVisibility } from '@prisma/client'
import { z } from 'astro:schema' import { z } from 'astro:schema'
import { groupBy, omit, orderBy, uniq } from 'lodash-es' import { groupBy, omit, orderBy, uniq } from 'lodash-es'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
@@ -223,7 +222,7 @@ const where = {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification, in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
}, },
serviceVisibility: { serviceVisibility: {
in: [ServiceVisibility.PUBLIC, ServiceVisibility.ARCHIVED], in: ['PUBLIC', 'ARCHIVED'],
}, },
overallScore: { gte: filters['min-score'] }, overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length acceptedCurrencies: filters.currencies.length
@@ -326,7 +325,9 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
select: { select: {
services: { services: {
where: { where: {
serviceVisibility: 'PUBLIC', serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
}, },
}, },
}, },

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es'
import { import {
SUGGESTION_DESCRIPTION_MAX_LENGTH, SUGGESTION_DESCRIPTION_MAX_LENGTH,
@@ -16,6 +17,8 @@ import InputImageFile from '../../components/InputImageFile.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro' import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro' import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro' import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { currencies } from '../../constants/currencies' import { currencies } from '../../constants/currencies'
import { kycLevels } from '../../constants/kycLevels' import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
@@ -55,6 +58,8 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
select: { select: {
id: true, id: true,
title: true, title: true,
category: true,
type: true,
}, },
}), }),
[], [],
@@ -241,27 +246,41 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.kycLevel} error={inputErrors.kycLevel}
/> />
<InputCheckboxGroup <div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
name="categories" <InputCheckboxGroup
label="Categories" name="categories"
required label="Categories"
options={categories.map((category) => ({ required
label: category.name, options={categories.map((category) => ({
value: category.id.toString(), label: category.name,
icon: category.icon, value: category.id.toString(),
}))} icon: category.icon,
error={inputErrors.categories} }))}
/> size="lg"
error={inputErrors.categories}
class="min-w-auto"
/>
<InputCheckboxGroup <InputCheckboxGroup
name="attributes" name="attributes"
label="Attributes" label="Attributes"
options={attributes.map((attribute) => ({ options={orderBy(
label: attribute.title, attributes.map((attribute) => ({
value: attribute.id.toString(), ...attribute,
}))} categoryInfo: getAttributeCategoryInfo(attribute.category),
error={inputErrors.attributes} typeInfo: getAttributeTypeInfo(attribute.type),
/> })),
['categoryInfo.order', 'typeInfo.order']
).map((attribute) => ({
label: attribute.title,
value: attribute.id.toString(),
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))}
error={inputErrors.attributes}
size="lg"
/>
</div>
<InputCardGroup <InputCardGroup
name="acceptedCurrencies" name="acceptedCurrencies"