Compare commits
5 Commits
release-34
...
release-39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea40f17d3c | ||
|
|
7e0d41cc7a | ||
|
|
70a097054b | ||
|
|
e536ca6519 | ||
|
|
b361ed3aa8 |
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
41
web/src/components/BadgeStandardFilter.astro
Normal file
41
web/src/components/BadgeStandardFilter.astro
Normal 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}
|
||||||
|
/>
|
||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
export const SUPPORT_EMAIL = 'contact@kycnot.me'
|
||||||
|
|||||||
@@ -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>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user