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

@@ -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:
- Offer a service.
- Publicly available website explaining what the service is about
- 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

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
@@ -10,6 +11,7 @@ import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
@@ -57,6 +59,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
serviceVisibility: true,
categories: {
select: {
name: true,
@@ -92,6 +95,7 @@ if (!serviceSuggestion) {
}
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
---
<BaseLayout
@@ -110,7 +114,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
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 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" />
</div>
<div
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 mb-3 text-lg text-green-500">Suggestion Details</h2>
<div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
<h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
<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={cn(
@@ -142,7 +149,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</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
name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]"
@@ -164,11 +171,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
</div>
</div>
<div
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="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
<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">
<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 { orderBy } from 'lodash-es'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
@@ -12,59 +13,67 @@ import UserBadge from '../../../components/UserBadge.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
serviceSuggestionStatusesZodEnumBySlug,
serviceSuggestionStatusSlugToId,
} from '../../../constants/serviceSuggestionStatus'
import {
getServiceSuggestionTypeInfo,
serviceSuggestionTypes,
serviceSuggestionTypeSlugToId,
serviceSuggestionTypesZodEnumBySlug,
} from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls'
import type { Prisma, ServiceSuggestionStatus } from '@prisma/client'
import type { Prisma } from '@prisma/client'
const user = Astro.locals.user
if (!user?.admin) {
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(
{
'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'),
},
Astro
)
const sortBy = filters['sort-by']
const sortOrder = filters['sort-order']
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
if (sortBy === 'createdAt') {
prismaOrderBy = { createdAt: sortOrder }
if (filters['sort-by'] === 'createdAt') {
prismaOrderBy = { createdAt: filters['sort-order'] }
}
let suggestions = await prisma.serviceSuggestion.findMany({
where: {
...(search
...(filters.search
? {
OR: [
{ service: { name: { contains: search, mode: 'insensitive' } } },
{ user: { name: { contains: search, mode: 'insensitive' } } },
{ notes: { contains: search, mode: 'insensitive' } },
{ service: { name: { contains: filters.search, mode: 'insensitive' } } },
{ user: { name: { contains: filters.search, mode: 'insensitive' } } },
{ notes: { contains: filters.search, mode: 'insensitive' } },
],
}
: {}),
status: statusFilter ?? undefined,
status: filters.status,
type: filters.type,
},
orderBy: prismaOrderBy,
select: {
id: true,
type: true,
status: true,
notes: true,
createdAt: true,
@@ -119,18 +128,33 @@ let suggestions = await prisma.serviceSuggestion.findMany({
let suggestionsWithDetails = suggestions.map((s) => ({
...s,
statusInfo: getServiceSuggestionStatusInfo(s.status),
typeInfo: getServiceSuggestionTypeInfo(s.type),
messageCount: s._count.messages,
lastMessage: s.messages[0],
}))
if (sortBy === 'service') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder])
} else if (sortBy === 'status') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
} else if (sortBy === 'user') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder])
} else if (sortBy === 'messageCount') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
if (filters['sort-by'] === 'service') {
suggestionsWithDetails = orderBy(
suggestionsWithDetails,
[(s) => s.service.name.toLowerCase()],
[filters['sort-order']]
)
} else if (filters['sort-by'] === 'status') {
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
@@ -162,7 +186,7 @@ const makeSortUrl = (slug: string) => {
type="text"
name="search"
id="search"
value={search}
value={filters.search}
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"
/>
@@ -177,13 +201,30 @@ const makeSortUrl = (slug: string) => {
<option value="">All Statuses</option>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={statusFilter === status.value}>
<option value={status.slug} selected={filters.status === status.value}>
{status.label}
</option>
))
}
</select>
</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">
<Button
as="button"
@@ -212,7 +253,7 @@ const makeSortUrl = (slug: string) => {
<thead class="bg-zinc-900/30">
<tr>
<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">
Service <SortArrowIcon
@@ -222,7 +263,7 @@ const makeSortUrl = (slug: string) => {
</a>
</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">
User <SortArrowIcon
@@ -232,7 +273,17 @@ const makeSortUrl = (slug: string) => {
</a>
</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">
Status <SortArrowIcon
@@ -295,6 +346,13 @@ const makeSortUrl = (slug: string) => {
<td class="px-4 py-3">
<UserBadge user={suggestion.user} size="md" />
</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">
<form method="POST" action={actions.admin.serviceSuggestions.update}>
<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 AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro'
import Button from '../../components/Button.astro'
import Chat from '../../components/Chat.astro'
import ServiceCard from '../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { parseIntWithFallback } from '../../lib/numbers'
@@ -28,6 +31,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
prisma.serviceSuggestion.findUnique({
select: {
id: true,
type: true,
status: true,
notes: true,
createdAt: true,
@@ -42,6 +46,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
serviceVisibility: true,
categories: {
select: {
name: true,
@@ -59,6 +64,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
select: {
id: true,
name: true,
displayName: true,
picture: true,
},
},
@@ -81,6 +87,7 @@ if (!serviceSuggestion) {
}
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
---
<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>
<a
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>
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
</div>
<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 { z } from 'astro:content'
import BadgeSmall from '../../components/BadgeSmall.astro'
import BadgeStandardFilter from '../../components/BadgeStandardFilter.astro'
import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
@@ -10,10 +12,16 @@ import Tooltip from '../../components/Tooltip.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
serviceSuggestionStatusesZodEnumBySlug,
serviceSuggestionStatusSlugToId,
} from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import {
getServiceSuggestionTypeInfo,
serviceSuggestionTypes,
serviceSuggestionTypeSlugToId,
serviceSuggestionTypesZodEnumBySlug,
} from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { zodEnumFromConstant } from '../../lib/arrays'
import { cn } from '../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
@@ -26,8 +34,13 @@ if (!user) {
const { data: filters } = zodParseQueryParamsStoringErrors(
{
serviceId: z.array(z.number().int().positive()).default([]),
status: z.array(zodEnumFromConstant(serviceSuggestionStatuses, 'value')).default([]),
serviceId: z.array(z.number().int().positive()),
status: z.array(
serviceSuggestionStatusesZodEnumBySlug.transform((slug) => serviceSuggestionStatusSlugToId(slug))
),
type: z.array(
serviceSuggestionTypesZodEnumBySlug.transform((slug) => serviceSuggestionTypeSlugToId(slug))
),
},
Astro
)
@@ -52,6 +65,7 @@ const serviceSuggestions = await Astro.locals.banners.try('Error fetching servic
where: {
id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined,
status: filters.status.length > 0 ? { in: filters.status } : undefined,
type: filters.type.length > 0 ? { in: filters.type } : undefined,
userId: user.id,
},
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 ? (
<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>
</a>
<Tooltip
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>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<Tooltip
as="span"

View File

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