Release 2025-05-25-GgNU
This commit is contained in:
@@ -650,22 +650,22 @@ const generateFakeService = (users: User[]) => {
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||
referral: `?ref=${faker.string.alphanumeric(6)}`,
|
||||
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()),
|
||||
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()),
|
||||
onionUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`
|
||||
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
|
||||
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
|
||||
onionUrls: faker.helpers.multiple(
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
i2pUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`
|
||||
i2pUrls: faker.helpers.multiple(
|
||||
() => `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`,
|
||||
listedAt: faker.date.past(),
|
||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
||||
tosReviewAt: faker.date.past(),
|
||||
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined,
|
||||
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||
userSentimentAt: faker.date.recent(),
|
||||
} as const satisfies Prisma.ServiceCreateInput
|
||||
}
|
||||
|
||||
@@ -106,9 +106,13 @@ export const adminServiceActions = {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.transform(addSlugIfMissing),
|
||||
input: serviceSchemaBase
|
||||
.extend({
|
||||
removeImage: z.boolean().optional(),
|
||||
})
|
||||
.transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const { id, categories, attributes, imageFile, ...data } = input
|
||||
const { id, categories, attributes, imageFile, removeImage, ...data } = input
|
||||
|
||||
const existing = await prisma.service.findUnique({
|
||||
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
|
||||
const existingService = await prisma.service.findUnique({
|
||||
|
||||
@@ -54,9 +54,9 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
disabled={disabled}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 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 && (
|
||||
<label
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
import { cn } from '../lib/cn'
|
||||
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import InputFile from './InputFile.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
||||
square?: boolean
|
||||
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>
|
||||
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
|
||||
'[&: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>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
|
||||
icon?: string
|
||||
inputId?: string
|
||||
hideLabel?: boolean
|
||||
classNames?: {
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -32,13 +35,14 @@ const {
|
||||
class: className,
|
||||
inputId,
|
||||
hideLabel,
|
||||
classNames,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
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 && (
|
||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||
@@ -71,7 +75,12 @@ const hasError = !!error && error.length > 0
|
||||
|
||||
{
|
||||
!!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} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -65,9 +65,9 @@ export const {
|
||||
value: 'ARCHIVED',
|
||||
slug: 'archived',
|
||||
label: 'Archived',
|
||||
description: 'Service no longer exists or ceased operations.',
|
||||
description: 'No ceased operations.',
|
||||
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',
|
||||
iconClass: 'text-day-100',
|
||||
},
|
||||
|
||||
@@ -261,6 +261,7 @@ if (!service) return Astro.rewrite('/404')
|
||||
inputProps={{
|
||||
rows: 3,
|
||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||
required: true,
|
||||
}}
|
||||
value={service.tosUrls.join('\n')}
|
||||
error={serviceInputErrors.tosUrls}
|
||||
@@ -298,16 +299,26 @@ if (!service) return Astro.rewrite('/404')
|
||||
error={serviceInputErrors.referral}
|
||||
/>
|
||||
|
||||
<InputImageFile
|
||||
label="Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
|
||||
error={serviceInputErrors.imageFile}
|
||||
square
|
||||
value={service.imageUrl}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<InputImageFile
|
||||
label="Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
|
||||
error={serviceInputErrors.imageFile}
|
||||
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
|
||||
name="categories"
|
||||
label="Categories"
|
||||
@@ -320,6 +331,7 @@ if (!service) return Astro.rewrite('/404')
|
||||
}))}
|
||||
selectedValues={service.categories.map((c) => c.id.toString())}
|
||||
error={serviceInputErrors.categories}
|
||||
class="min-w-auto"
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
import { ServiceVisibility } from '@prisma/client'
|
||||
import { z } from 'astro:schema'
|
||||
import { groupBy, omit, orderBy, uniq } from 'lodash-es'
|
||||
import seedrandom from 'seedrandom'
|
||||
@@ -223,7 +222,7 @@ const where = {
|
||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||
},
|
||||
serviceVisibility: {
|
||||
in: [ServiceVisibility.PUBLIC, ServiceVisibility.ARCHIVED],
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
overallScore: { gte: filters['min-score'] },
|
||||
acceptedCurrencies: filters.currencies.length
|
||||
@@ -326,7 +325,9 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: 'PUBLIC',
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import {
|
||||
SUGGESTION_DESCRIPTION_MAX_LENGTH,
|
||||
@@ -16,6 +17,8 @@ import InputImageFile from '../../components/InputImageFile.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||
import { currencies } from '../../constants/currencies'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
@@ -55,6 +58,8 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
category: true,
|
||||
type: true,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
@@ -241,27 +246,41 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
error={inputErrors.kycLevel}
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
error={inputErrors.categories}
|
||||
/>
|
||||
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
size="lg"
|
||||
error={inputErrors.categories}
|
||||
class="min-w-auto"
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="attributes"
|
||||
label="Attributes"
|
||||
options={attributes.map((attribute) => ({
|
||||
label: attribute.title,
|
||||
value: attribute.id.toString(),
|
||||
}))}
|
||||
error={inputErrors.attributes}
|
||||
/>
|
||||
<InputCheckboxGroup
|
||||
name="attributes"
|
||||
label="Attributes"
|
||||
options={orderBy(
|
||||
attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
categoryInfo: getAttributeCategoryInfo(attribute.category),
|
||||
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
|
||||
name="acceptedCurrencies"
|
||||
|
||||
Reference in New Issue
Block a user