Release 202505261804

This commit is contained in:
pluja
2025-05-26 18:04:45 +00:00
parent b361ed3aa8
commit e536ca6519
13 changed files with 244 additions and 79 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,10 +71,12 @@ All new listings begin as **unlisted** — they're only accessible via direct li
To list a new service, it must fulfill these requirements: To list a new service, it must fulfill these requirements:
- Offer a service.
- Publicly available website explaining what the service is about - Publicly available website explaining what the service is about
- Terms of service or FAQ document - Terms of service or FAQ document
For example, just a Telegram link is not a valid service. For examples:
- Just a Telegram link or a criptocurrency itself is not a valid service.
### Suggestion Review Process ### Suggestion Review Process

View File

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

View File

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

View File

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

View File

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

View File

@@ -225,7 +225,6 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
label="Onion URLs" label="Onion URLs"
name="onionUrls" name="onionUrls"
inputProps={{ inputProps={{
required: true,
placeholder: 'http://example1.onion\nhttp://example2.onion', placeholder: 'http://example1.onion\nhttp://example2.onion',
}} }}
error={inputErrors.onionUrls} error={inputErrors.onionUrls}
@@ -324,7 +323,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) => {