Compare commits

..

2 Commits

Author SHA1 Message Date
pluja
6b86a72d1e Release 2025-05-25-GgNU 2025-05-25 12:28:30 +00:00
pluja
8f2b2c34ff Release 2025-05-25-irZj 2025-05-25 11:21:35 +00:00
21 changed files with 333 additions and 119 deletions

View File

@@ -5,6 +5,7 @@ alwaysApply: false
--- ---
- We use Prisma as ORM. - We use Prisma as ORM.
- Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database. - Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database.
- After making changes to the [schema.prisma](mdc:web/prisma/schema.prisma) database or [faker.ts](mdc:web/scripts/faker.ts), you can run `npm run db-reset` (from `/web/` folder) [package.json](mdc:web/package.json).
- Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this: - Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this:
```ts ```ts
type Props = { type Props = {

0
.env.example Normal file
View File

View File

@@ -12,7 +12,7 @@
"db-push": "prisma migrate dev", "db-push": "prisma migrate dev",
"db-triggers": "just import-triggers", "db-triggers": "just import-triggers",
"db-update": "prisma migrate dev && just import-triggers", "db-update": "prisma migrate dev && just import-triggers",
"db-reset": "prisma migrate reset && prisma migrate dev && just import-triggers && tsx scripts/faker.ts", "db-reset": "prisma migrate reset -f && prisma migrate dev && just import-triggers && tsx scripts/faker.ts",
"db-fill": "tsx scripts/faker.ts", "db-fill": "tsx scripts/faker.ts",
"db-fill-clean": "tsx scripts/faker.ts --cleanup", "db-fill-clean": "tsx scripts/faker.ts --cleanup",
"format": "prettier --write .", "format": "prettier --write .",

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';

View File

@@ -87,6 +87,7 @@ enum ServiceVisibility {
PUBLIC PUBLIC
UNLISTED UNLISTED
HIDDEN HIDDEN
ARCHIVED
} }
enum Currency { enum Currency {

View File

@@ -2,19 +2,20 @@ import crypto from 'crypto'
import { faker } from '@faker-js/faker' import { faker } from '@faker-js/faker'
import { import {
AnnouncementType,
AttributeCategory, AttributeCategory,
AttributeType, AttributeType,
CommentStatus, CommentStatus,
Currency, Currency,
EventType,
PrismaClient, PrismaClient,
ServiceSuggestionStatus, ServiceSuggestionStatus,
ServiceSuggestionType, ServiceSuggestionType,
ServiceUserRole,
VerificationStatus, VerificationStatus,
type Prisma, type Prisma,
EventType,
type User, type User,
ServiceUserRole, type ServiceVisibility,
AnnouncementType,
} from '@prisma/client' } from '@prisma/client'
import { uniqBy } from 'lodash-es' import { uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
@@ -611,7 +612,12 @@ const generateFakeEvent = (serviceId: number) => {
} }
const generateFakeService = (users: User[]) => { const generateFakeService = (users: User[]) => {
const status = faker.helpers.arrayElement(Object.values(VerificationStatus)) const status = faker.helpers.weightedArrayElement<VerificationStatus>([
{ weight: 20, value: 'VERIFICATION_SUCCESS' },
{ weight: 30, value: 'APPROVED' },
{ weight: 40, value: 'COMMUNITY_CONTRIBUTED' },
{ weight: 10, value: 'VERIFICATION_FAILED' },
])
const name = faker.helpers.arrayElement(serviceNames) const name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
@@ -623,6 +629,12 @@ const generateFakeService = (users: User[]) => {
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
]),
verificationStatus: status, verificationStatus: status,
verificationSummary: verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -638,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

@@ -150,7 +150,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
checked={comment.suspicious} checked={comment.suspicious}
/> />
<div class="comment-header flex items-center gap-2 text-sm"> <div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300"> <label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span> <span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span> <span class="sr-only">Toggle comment visibility</span>

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

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { currencies } from '../constants/currencies' import { currencies } from '../constants/currencies'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
kycLevel: true kycLevel: true
imageUrl: true imageUrl: true
verificationStatus: true verificationStatus: true
serviceVisibility: true
acceptedCurrencies: true acceptedCurrencies: true
categories: { categories: {
select: { select: {
@@ -43,11 +45,11 @@ const {
slug, slug,
description, description,
overallScore, overallScore,
kycLevel, kycLevel,
imageUrl, imageUrl,
categories, categories,
verificationStatus, verificationStatus,
serviceVisibility,
acceptedCurrencies, acceptedCurrencies,
}, },
class: className, class: className,
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
href={Element === 'a' ? `/service/${slug}` : undefined} href={Element === 'a' ? `/service/${slug}` : undefined}
{...aProps} {...aProps}
class={cn( class={cn(
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]', 'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
className className
)} )}
> >
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
src={imageUrl} src={imageUrl}
fallback="service" fallback="service"
alt={name || 'Service logo'} alt={name || 'Service logo'}
class="size-12 shrink-0 rounded-sm object-contain text-white" class={cn(
'size-12 shrink-0 rounded-sm object-contain text-white',
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
)}
width={48} width={48}
height={48} height={48}
/> />
@@ -110,6 +118,23 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
]} ]}
</Tooltip> </Tooltip>
) )
}{
serviceVisibility === 'ARCHIVED' && (
<Tooltip
text={serviceVisibilitiesById.ARCHIVED.label}
position="right"
class="-my-2 shrink-0 whitespace-nowrap"
>
<Icon
is:inline={inlineIcons}
name={serviceVisibilitiesById.ARCHIVED.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
serviceVisibilitiesById.ARCHIVED.iconClass
)}
/>
</Tooltip>
)
} }
</h3> </h3>
<div class="max-h-2 flex-1"></div> <div class="max-h-2 flex-1"></div>

View File

@@ -8,6 +8,7 @@ type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
slug: string slug: string
label: string label: string
description: string description: string
longDescription: string
icon: string icon: string
iconClass: string iconClass: string
} }
@@ -28,6 +29,7 @@ export const {
slug: value ? value.toLowerCase() : '', slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value), label: value ? transformCase(value, 'title') : String(value),
description: '', description: '',
longDescription: '',
icon: 'ri:eye-line', icon: 'ri:eye-line',
iconClass: 'text-current/60', iconClass: 'text-current/60',
}), }),
@@ -37,6 +39,7 @@ export const {
slug: 'public', slug: 'public',
label: 'Public', label: 'Public',
description: 'Listed in search and browse.', description: 'Listed in search and browse.',
longDescription: 'Listed in search and browse.',
icon: 'ri:global-line', icon: 'ri:global-line',
iconClass: 'text-green-500', iconClass: 'text-green-500',
}, },
@@ -45,6 +48,7 @@ export const {
slug: 'unlisted', slug: 'unlisted',
label: 'Unlisted', label: 'Unlisted',
description: 'Only accessible via direct link.', description: 'Only accessible via direct link.',
longDescription: "Unlisted service, only accessible via direct link and won't appear in searches.",
icon: 'ri:link', icon: 'ri:link',
iconClass: 'text-yellow-500', iconClass: 'text-yellow-500',
}, },
@@ -53,8 +57,19 @@ export const {
slug: 'hidden', slug: 'hidden',
label: 'Hidden', label: 'Hidden',
description: 'Only visible to moderators.', description: 'Only visible to moderators.',
longDescription: 'Hidden service, only visible to moderators.',
icon: 'ri:lock-line', icon: 'ri:lock-line',
iconClass: 'text-red-500', iconClass: 'text-red-500',
}, },
{
value: 'ARCHIVED',
slug: 'archived',
label: 'Archived',
description: 'No ceased operations.',
longDescription:
'Archived service, no longer exists or ceased operations. Information may be outdated.',
icon: 'ri:archive-line',
iconClass: 'text-day-100',
},
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[] ] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
) )

View File

@@ -2,6 +2,7 @@ import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus' import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo' import { formatDateShort } from './timeAgo'
@@ -36,6 +37,7 @@ export function makeNonDbAttributes(
service: Prisma.ServiceGetPayload<{ service: Prisma.ServiceGetPayload<{
select: { select: {
verificationStatus: true verificationStatus: true
serviceVisibility: true
isRecentlyListed: true isRecentlyListed: true
listedAt: true listedAt: true
createdAt: true createdAt: true
@@ -134,6 +136,16 @@ export function makeNonDbAttributes(
}, },
], ],
}, },
{
title: serviceVisibilitiesById.ARCHIVED.label,
show: service.serviceVisibility === 'ARCHIVED',
type: 'WARNING',
category: 'TRUST',
description: serviceVisibilitiesById.ARCHIVED.longDescription,
privacyPoints: 0,
trustPoints: 0,
links: [],
},
{ {
title: 'Recently listed', title: 'Recently listed',
show: service.isRecentlyListed, show: service.isRecentlyListed,

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,6 +299,7 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.referral} error={serviceInputErrors.referral}
/> />
<div class="flex items-center justify-between gap-2">
<InputImageFile <InputImageFile
label="Image" label="Image"
name="imageFile" name="imageFile"
@@ -305,9 +307,18 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.imageFile} error={serviceInputErrors.imageFile}
square square
value={service.imageUrl} 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
@@ -478,28 +490,43 @@ if (!service) return Astro.rewrite('/404')
</div> </div>
</div> </div>
<div class="flex shrink-0 gap-1.5"> <div class="flex shrink-0 gap-1.5">
<Tooltip text={event.visible ? 'Hide' : 'Show'}>
<form method="POST" action={actions.admin.event.toggle} class="contents"> <form method="POST" action={actions.admin.event.toggle} class="contents">
<input type="hidden" name="eventId" value={event.id} /> <input type="hidden" name="eventId" value={event.id} />
<Tooltip text={event.visible ? 'Hide Event' : 'Show Event'}>
<Button <Button
type="submit" type="submit"
variant="faded" variant="faded"
size="sm" size="sm"
icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'} icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'}
iconOnly
label={event.visible ? 'Hide' : 'Show'}
/> />
</Tooltip>
</form> </form>
</Tooltip>
<Tooltip text="Edit">
<Button <Button
type="button" type="button"
variant="faded" variant="faded"
size="sm" size="sm"
icon="ri:pencil-line" icon="ri:pencil-line"
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`} onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/> />
</Tooltip>
<Tooltip text="Delete">
<form method="POST" action={actions.admin.event.delete} class="contents"> <form method="POST" action={actions.admin.event.delete} class="contents">
<input type="hidden" name="eventId" value={event.id} /> <input type="hidden" name="eventId" value={event.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" /> <Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form> </form>
</Tooltip>
</div> </div>
</div> </div>
{/* Edit Event Form - Hidden by default */} {/* Edit Event Form - Hidden by default */}
@@ -673,17 +700,30 @@ if (!service) return Astro.rewrite('/404')
</p> </p>
</div> </div>
<div class="flex shrink-0 gap-1.5"> <div class="flex shrink-0 gap-1.5">
<Tooltip text="Edit">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="faded" variant="faded"
icon="ri:pencil-line" icon="ri:pencil-line"
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`} onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/> />
</Tooltip>
<Tooltip text="Delete">
<form method="POST" action={actions.admin.verificationStep.delete} class="inline"> <form method="POST" action={actions.admin.verificationStep.delete} class="inline">
<input type="hidden" name="id" value={step.id} /> <input type="hidden" name="id" value={step.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" /> <Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form> </form>
</Tooltip>
</div> </div>
</div> </div>
@@ -844,21 +884,34 @@ if (!service) return Astro.rewrite('/404')
<p class="text-day-400 text-sm text-pretty">{method.value}</p> <p class="text-day-400 text-sm text-pretty">{method.value}</p>
</div> </div>
<div class="flex shrink-0 gap-1.5"> <div class="flex shrink-0 gap-1.5">
<Tooltip text="Edit">
<Button <Button
type="button" type="button"
variant="faded" variant="faded"
size="sm" size="sm"
icon="ri:pencil-line" icon="ri:pencil-line"
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`} onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/> />
</Tooltip>
<Tooltip text="Delete">
<form <form
method="POST" method="POST"
action={actions.admin.service.deleteContactMethod} action={actions.admin.service.deleteContactMethod}
class="contents" class="contents"
> >
<input type="hidden" name="id" value={method.id} /> <input type="hidden" name="id" value={method.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" /> <Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form> </form>
</Tooltip>
</div> </div>
</div> </div>

View File

@@ -8,7 +8,8 @@ import MyPicture from '../../../components/MyPicture.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import Tooltip from '../../../components/Tooltip.astro' import Tooltip from '../../../components/Tooltip.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels' import { getKycLevelInfo } from '../../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../../constants/verificationStatus' import { serviceVisibilities } from '../../../constants/serviceVisibility'
import { getVerificationStatusInfo, verificationStatuses } from '../../../constants/verificationStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn' import { cn } from '../../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
@@ -209,11 +210,11 @@ const truncate = (text: string, length: number) => {
id="visibility" id="visibility"
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" 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 Visibilities</option> <option value="">All</option>
{ {
Object.values(ServiceVisibility).map((status) => ( serviceVisibilities.map((visibility) => (
<option value={status} selected={filters.visibility === status}> <option value={visibility.value} selected={filters.visibility === visibility.value}>
{status} {visibility.label}
</option> </option>
)) ))
} }
@@ -227,11 +228,11 @@ const truncate = (text: string, length: number) => {
id="verificationStatus" id="verificationStatus"
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" 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 Statuses</option> <option value="">All</option>
{ {
Object.values(VerificationStatus).map((status) => ( verificationStatuses.map((status) => (
<option value={status} selected={filters.verificationStatus === status}> <option value={status.value} selected={filters.verificationStatus === status.value}>
{status} {status.label}
</option> </option>
)) ))
} }

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'
@@ -222,7 +221,9 @@ const where = {
verificationStatus: { verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification, in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
}, },
serviceVisibility: ServiceVisibility.PUBLIC, serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
overallScore: { gte: filters['min-score'] }, overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and' ? filters['currency-mode'] === 'and'
@@ -324,7 +325,9 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
select: { select: {
services: { services: {
where: { where: {
serviceVisibility: 'PUBLIC', serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
}, },
}, },
}, },
@@ -372,6 +375,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
acceptedCurrencies: true, acceptedCurrencies: true,
serviceVisibility: true,
attributes: { attributes: {
select: { select: {
attribute: { attribute: {

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,6 +246,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.kycLevel} error={inputErrors.kycLevel}
/> />
<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"
@@ -250,18 +256,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
value: category.id.toString(), value: category.id.toString(),
icon: category.icon, icon: category.icon,
}))} }))}
size="lg"
error={inputErrors.categories} error={inputErrors.categories}
class="min-w-auto"
/> />
<InputCheckboxGroup <InputCheckboxGroup
name="attributes" name="attributes"
label="Attributes" label="Attributes"
options={attributes.map((attribute) => ({ options={orderBy(
attributes.map((attribute) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
})),
['categoryInfo.order', 'typeInfo.order']
).map((attribute) => ({
label: attribute.title, label: attribute.title,
value: attribute.id.toString(), value: attribute.id.toString(),
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))} }))}
error={inputErrors.attributes} error={inputErrors.attributes}
size="lg"
/> />
</div>
<InputCardGroup <InputCardGroup
name="acceptedCurrencies" name="acceptedCurrencies"

View File

@@ -30,7 +30,7 @@ import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies' import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes' import { getEventTypeInfo } from '../../constants/eventTypes'
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels' import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
import { serviceVisibilitiesById } from '../../constants/serviceVisibility' import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating' import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
import { getUserSentimentInfo } from '../../constants/userSentiment' import { getUserSentimentInfo } from '../../constants/userSentiment'
import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus' import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus'
@@ -240,7 +240,11 @@ const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.
if (!service) return Astro.rewrite('/404') if (!service) return Astro.rewrite('/404')
if (service.serviceVisibility !== 'PUBLIC' && service.serviceVisibility !== 'UNLISTED') { if (
service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'UNLISTED' &&
service.serviceVisibility !== 'ARCHIVED'
) {
return Astro.rewrite('/404') return Astro.rewrite('/404')
} }
@@ -356,6 +360,8 @@ const ogImageTemplateData = {
score: service.overallScore, score: service.overallScore,
imageUrl: service.imageUrl, imageUrl: service.imageUrl,
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
--- ---
<BaseLayout <BaseLayout
@@ -457,16 +463,17 @@ const ogImageTemplateData = {
]} ]}
> >
{ {
service.serviceVisibility === 'UNLISTED' && ( (serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
<div class={cn('mb-4 rounded-md bg-yellow-900/50 p-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')}>
<Icon <Icon
name={serviceVisibilitiesById.UNLISTED.icon} name={serviceVisibilityInfo.icon}
class={cn('me-1.5 inline-block size-4 align-[-0.15em]', serviceVisibilitiesById.UNLISTED.iconClass)} class="me-1.5 inline-block size-4 align-[-0.15em] text-yellow-500"
/> />
Unlisted service, only accessible via direct link and won't appear in searches. {serviceVisibilityInfo.longDescription}
</div> </div>
) )
} }
<VerificationWarningBanner service={service} /> <VerificationWarningBanner service={service} />
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -1245,7 +1252,8 @@ const ogImageTemplateData = {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{ {
service.verificationStatus !== 'VERIFICATION_SUCCESS' && service.verificationStatus !== 'VERIFICATION_SUCCESS' &&
service.verificationStatus !== 'VERIFICATION_FAILED' && ( service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.serviceVisibility !== 'ARCHIVED' && (
<form <form
method="POST" method="POST"
action={actions.service.requestVerification} action={actions.service.requestVerification}

View File

@@ -103,6 +103,10 @@
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent)); drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
} }
@utility scrollbar-w-none {
scrollbar-width: none;
}
@utility checkbox-force-checked { @utility checkbox-force-checked {
&:not(:checked) { &:not(:checked) {
@apply border-transparent! bg-current/50!; @apply border-transparent! bg-current/50!;