Release 2025-05-19
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,616 +0,0 @@
|
||||
---
|
||||
import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
import defaultImage from '../../../assets/fallback-service-image.jpg'
|
||||
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
||||
import { getKycLevelInfo } from '../../../constants/kycLevels'
|
||||
import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../../lib/cn'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
|
||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
search: z.string().optional(),
|
||||
verificationStatus: z.nativeEnum(VerificationStatus).optional(),
|
||||
visibility: z.nativeEnum(ServiceVisibility).optional(),
|
||||
sort: z.enum(['name', 'createdAt', 'overallScore', 'verificationRequests']).default('name'),
|
||||
order: z.enum(['asc', 'desc']).default('asc'),
|
||||
page: z.coerce.number().int().positive().optional().default(1),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
const itemsPerPage = 20
|
||||
|
||||
const sortProperty = filters.sort
|
||||
const sortDirection = filters.order
|
||||
|
||||
let orderBy: Prisma.ServiceOrderByWithRelationInput
|
||||
|
||||
switch (sortProperty) {
|
||||
case 'verificationRequests':
|
||||
orderBy = { verificationRequests: { _count: sortDirection } }
|
||||
break
|
||||
case 'createdAt': // createdAt can be ambiguous without a specific direction
|
||||
case 'name':
|
||||
case 'overallScore':
|
||||
orderBy = { [sortProperty]: sortDirection }
|
||||
break
|
||||
default:
|
||||
orderBy = { name: 'asc' } // Default sort
|
||||
}
|
||||
|
||||
const whereClause: Prisma.ServiceWhereInput = {
|
||||
...(filters.search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ description: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ serviceUrls: { has: filters.search } },
|
||||
{ tosUrls: { has: filters.search } },
|
||||
{ onionUrls: { has: filters.search } },
|
||||
{ i2pUrls: { has: filters.search } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
verificationStatus: filters.verificationStatus ?? undefined,
|
||||
serviceVisibility: filters.visibility ?? undefined,
|
||||
}
|
||||
|
||||
const totalServicesCount = await Astro.locals.banners.try(
|
||||
'Error counting services',
|
||||
async () => prisma.service.count({ where: whereClause }),
|
||||
0
|
||||
)
|
||||
|
||||
const totalPages = Math.ceil(totalServicesCount / itemsPerPage)
|
||||
const validPage = Math.max(1, Math.min(filters.page, totalPages || 1))
|
||||
const skip = (validPage - 1) * itemsPerPage
|
||||
|
||||
const services = await Astro.locals.banners.try(
|
||||
'Error fetching services',
|
||||
async () =>
|
||||
prisma.service.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
kycLevel: true,
|
||||
overallScore: true,
|
||||
privacyScore: true,
|
||||
trustScore: true,
|
||||
verificationStatus: true,
|
||||
imageUrl: true,
|
||||
serviceUrls: true,
|
||||
tosUrls: true,
|
||||
onionUrls: true,
|
||||
i2pUrls: true,
|
||||
serviceVisibility: true,
|
||||
createdAt: true,
|
||||
categories: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
include: {
|
||||
attribute: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
verificationRequests: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
take: itemsPerPage,
|
||||
skip,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const servicesWithInfo = services.map((service) => {
|
||||
const verificationStatusInfo = getVerificationStatusInfo(service.verificationStatus)
|
||||
const kycLevelInfo = getKycLevelInfo(String(service.kycLevel))
|
||||
|
||||
return {
|
||||
...service,
|
||||
verificationStatusInfo,
|
||||
kycLevelInfo,
|
||||
kycColor:
|
||||
service.kycLevel === 0
|
||||
? '22, 163, 74' // green-600
|
||||
: service.kycLevel === 1
|
||||
? '180, 83, 9' // amber-700
|
||||
: service.kycLevel === 2
|
||||
? '220, 38, 38' // red-600
|
||||
: service.kycLevel === 3
|
||||
? '185, 28, 28' // red-700
|
||||
: service.kycLevel === 4
|
||||
? '153, 27, 27' // red-800
|
||||
: '107, 114, 128', // gray-500 fallback
|
||||
formattedDate: new Date(service.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort']>) => {
|
||||
const newOrder = filters.sort === slug && filters.order === 'asc' ? 'desc' : 'asc'
|
||||
const searchParams = new URLSearchParams(Astro.url.search)
|
||||
searchParams.set('sort', slug)
|
||||
searchParams.set('order', newOrder)
|
||||
return `/admin/services?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
const getPaginationUrl = (pageNum: number) => {
|
||||
const url = new URL(Astro.url)
|
||||
url.searchParams.set('page', pageNum.toString())
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const truncate = (text: string, length: number) => {
|
||||
if (text.length <= length) return text
|
||||
return text.substring(0, length) + '...'
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout pageTitle="Services Admin" widthClassName="max-w-screen-xl">
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="font-title text-2xl font-bold text-white">Service Management</h1>
|
||||
<div class="mt-2 flex items-center gap-4 sm:mt-0">
|
||||
<span class="text-sm text-zinc-400">{totalServicesCount} services</span>
|
||||
<a
|
||||
href="/admin/services/new"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none"
|
||||
>
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>New Service</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="mb-6 rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg">
|
||||
<form method="GET" class="grid gap-3 md:grid-cols-2 lg:grid-cols-5" autocomplete="off">
|
||||
<div class="lg:col-span-2">
|
||||
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
|
||||
<div class="mt-1 flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
value={filters.search}
|
||||
placeholder="Search by name, description, URL..."
|
||||
class="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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="visibility" class="block text-xs font-medium text-zinc-400">Visibility</label>
|
||||
<select
|
||||
name="visibility"
|
||||
id="visibility"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Visibilities</option>
|
||||
{
|
||||
Object.values(ServiceVisibility).map((status) => (
|
||||
<option value={status} selected={filters.visibility === status}>
|
||||
{status}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="verificationStatus" class="block text-xs font-medium text-zinc-400">Status</label>
|
||||
<select
|
||||
name="verificationStatus"
|
||||
id="verificationStatus"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{
|
||||
Object.values(VerificationStatus).map((status) => (
|
||||
<option value={status} selected={filters.verificationStatus === status}>
|
||||
{status}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="sort" class="block text-xs font-medium text-zinc-400">Sort By</label>
|
||||
<div class="mt-1 flex rounded-md shadow-sm">
|
||||
<select
|
||||
name="sort"
|
||||
id="sort"
|
||||
class="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"
|
||||
>
|
||||
{
|
||||
['name', 'createdAt', 'overallScore', 'verificationRequests'].map((option) => (
|
||||
<option value={option} selected={filters.sort === option}>
|
||||
{option}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="ml-2 inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
|
||||
>
|
||||
<Icon name="ri:search-2-line" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Services Table -->
|
||||
<div class="rounded-lg border border-zinc-700 bg-zinc-800/50 shadow-lg">
|
||||
<div class="sticky top-0 z-10 border-b border-zinc-700 bg-zinc-800/90 px-4 py-3 backdrop-blur-sm">
|
||||
<h2 class="font-title font-semibold text-blue-400">Services List</h2>
|
||||
<div class="mt-1 text-xs text-zinc-400 md:hidden">
|
||||
<span>Scroll horizontally to see more →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-thin max-w-full overflow-x-auto">
|
||||
<div class="min-w-[750px]">
|
||||
<table class="w-full divide-y divide-zinc-700">
|
||||
<thead class="bg-zinc-900/30">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-[30%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a href={makeSortUrl('name')} class="flex items-center hover:text-zinc-200">
|
||||
Service <SortArrowIcon active={filters.sort === 'name'} sortOrder={filters.order} />
|
||||
</a>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-[8%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>KYC</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-[8%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a
|
||||
href={makeSortUrl('overallScore')}
|
||||
class="flex items-center justify-center hover:text-zinc-200"
|
||||
>
|
||||
Score <SortArrowIcon active={filters.sort === 'overallScore'} sortOrder={filters.order} />
|
||||
</a>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-[14%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>Status</th
|
||||
>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a
|
||||
href={makeSortUrl('verificationRequests')}
|
||||
class="flex items-center justify-center hover:text-zinc-200"
|
||||
>
|
||||
<span class="hidden sm:inline">Requests</span>
|
||||
<span class="sm:hidden">Reqs</span>
|
||||
<SortArrowIcon active={filters.sort === 'verificationRequests'} sortOrder={filters.order} />
|
||||
</a>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-[14%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a
|
||||
href={makeSortUrl('createdAt')}
|
||||
class="flex items-center justify-center hover:text-zinc-200"
|
||||
>
|
||||
Date <SortArrowIcon active={filters.sort === 'createdAt'} sortOrder={filters.order} />
|
||||
</a>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-[12%] px-4 py-3 text-right text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700 bg-zinc-800/10">
|
||||
{
|
||||
servicesWithInfo.map((service) => (
|
||||
<tr id={`service-${service.id}`} class="group hover:bg-zinc-700/30">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 flex-shrink-0">
|
||||
{service.imageUrl ? (
|
||||
<Image
|
||||
src={service.imageUrl}
|
||||
alt={service.name}
|
||||
width={40}
|
||||
height={40}
|
||||
class="h-10 w-10 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={defaultImage}
|
||||
alt={service.name}
|
||||
width={40}
|
||||
height={40}
|
||||
class="h-10 w-10 rounded-md object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-zinc-200">{service.name}</div>
|
||||
<div class="truncate text-xs text-zinc-400" title={service.description}>
|
||||
{truncate(service.description, 45)}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{service.categories.slice(0, 2).map((category) => (
|
||||
<span class="text-2xs inline-flex items-center rounded-sm bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300">
|
||||
<Icon name={category.icon} class="mr-0.5 size-2.5" />
|
||||
{category.name}
|
||||
</span>
|
||||
))}
|
||||
{service.categories.length > 2 && (
|
||||
<span class="text-2xs inline-flex items-center rounded-sm bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300">
|
||||
+{service.categories.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-xs font-bold"
|
||||
style={`background-color: rgba(${service.kycColor}, 0.3); color: rgb(${service.kycColor})`}
|
||||
title={service.kycLevelInfo.name}
|
||||
>
|
||||
{service.kycLevel}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class={cn('text-sm font-medium', {
|
||||
'text-green-400': service.overallScore >= 7,
|
||||
'text-yellow-400': service.overallScore >= 4 && service.overallScore < 7,
|
||||
'text-red-400': service.overallScore < 4,
|
||||
})}
|
||||
>
|
||||
{service.overallScore}
|
||||
</span>
|
||||
<div class="mt-0.5 grid grid-cols-2 gap-0.5">
|
||||
<span title="Privacy Score" class="text-2xs font-medium text-blue-400">
|
||||
{service.privacyScore}
|
||||
</span>
|
||||
<span title="Trust Score" class="text-2xs font-medium text-yellow-400">
|
||||
{service.trustScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
class={cn('inline-flex items-center rounded-md px-2 py-1 text-xs font-medium', {
|
||||
'bg-green-500/20 text-green-400':
|
||||
service.verificationStatus === 'VERIFICATION_SUCCESS',
|
||||
'bg-red-500/20 text-red-400': service.verificationStatus === 'VERIFICATION_FAILED',
|
||||
'bg-blue-500/20 text-blue-400': service.verificationStatus === 'APPROVED',
|
||||
'bg-gray-500/20 text-gray-400':
|
||||
service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
||||
})}
|
||||
>
|
||||
<Icon name={service.verificationStatusInfo.icon} class="mr-1 size-3" />
|
||||
<span class="hidden sm:inline">{service.verificationStatus}</span>
|
||||
<span class="sm:hidden">{service.verificationStatus.substring(0, 4)}</span>
|
||||
</span>
|
||||
<div class="text-2xs mt-1 font-medium text-zinc-500">
|
||||
<span
|
||||
class={cn('text-2xs inline-flex items-center rounded-sm px-1.5 py-0.5', {
|
||||
'bg-green-900/30 text-green-300': service.serviceVisibility === 'PUBLIC',
|
||||
'bg-yellow-900/30 text-yellow-300': service.serviceVisibility === 'UNLISTED',
|
||||
'bg-red-900/30 text-red-300': service.serviceVisibility === 'HIDDEN',
|
||||
})}
|
||||
>
|
||||
{service.serviceVisibility}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
|
||||
{service._count.verificationRequests}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex justify-end space-x-2">
|
||||
<a
|
||||
href={`/service/${service.slug}`}
|
||||
target="_blank"
|
||||
class="inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-2 py-1 text-xs text-zinc-300 transition-colors hover:bg-zinc-700"
|
||||
title="View on site"
|
||||
>
|
||||
<Icon name="ri:external-link-line" class="size-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href={`/admin/services/${service.slug}/edit`}
|
||||
class="inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{
|
||||
totalPages > 1 && (
|
||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="text-sm text-zinc-500">
|
||||
Showing {services.length > 0 ? skip + 1 : 0} to {Math.min(skip + itemsPerPage, totalServicesCount)}{' '}
|
||||
of {totalServicesCount} services
|
||||
</div>
|
||||
|
||||
<nav class="inline-flex rounded-md shadow-sm" aria-label="Pagination">
|
||||
{validPage > 1 && (
|
||||
<a
|
||||
href={getPaginationUrl(validPage - 1)}
|
||||
class="inline-flex items-center rounded-l-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
<Icon name="ri:arrow-left-s-line" class="size-5" />
|
||||
<span class="sr-only">Previous</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{validPage > 3 && (
|
||||
<a
|
||||
href={getPaginationUrl(1)}
|
||||
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
1
|
||||
</a>
|
||||
)}
|
||||
|
||||
{validPage > 4 && (
|
||||
<span class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-500">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
|
||||
{validPage > 2 && (
|
||||
<a
|
||||
href={getPaginationUrl(validPage - 2)}
|
||||
class="hidden items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700 md:inline-flex"
|
||||
>
|
||||
{validPage - 2}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{validPage > 1 && (
|
||||
<a
|
||||
href={getPaginationUrl(validPage - 1)}
|
||||
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
{validPage - 1}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={getPaginationUrl(validPage)}
|
||||
class="inline-flex items-center border border-blue-500 bg-blue-500/20 px-3 py-1 text-sm font-medium text-blue-400"
|
||||
aria-current="page"
|
||||
>
|
||||
{validPage}
|
||||
</a>
|
||||
|
||||
{validPage < totalPages && (
|
||||
<a
|
||||
href={getPaginationUrl(validPage + 1)}
|
||||
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
{validPage + 1}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{validPage < totalPages - 1 && (
|
||||
<a
|
||||
href={getPaginationUrl(validPage + 2)}
|
||||
class="hidden items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700 md:inline-flex"
|
||||
>
|
||||
{validPage + 2}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{validPage < totalPages - 3 && (
|
||||
<span class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-500">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
|
||||
{validPage < totalPages - 2 && (
|
||||
<a
|
||||
href={getPaginationUrl(totalPages)}
|
||||
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
{totalPages}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{validPage < totalPages && (
|
||||
<a
|
||||
href={getPaginationUrl(validPage + 1)}
|
||||
class="inline-flex items-center rounded-r-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
<Icon name="ri:arrow-right-s-line" class="size-5" />
|
||||
<span class="sr-only">Next</span>
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* Base CSS text size utility for super small text */
|
||||
.text-2xs {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for better mobile experience */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: rgba(30, 41, 59, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,366 +0,0 @@
|
||||
---
|
||||
import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../../lib/cn'
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
|
||||
const categories = await Astro.locals.banners.try('Failed to fetch categories', () =>
|
||||
prisma.category.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
)
|
||||
|
||||
const attributes = await Astro.locals.banners.try('Failed to fetch attributes', () =>
|
||||
prisma.attribute.findMany({
|
||||
orderBy: { category: 'asc' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.service.create)
|
||||
Astro.locals.banners.addIfSuccess(result, 'Service created successfully')
|
||||
if (result && !result.error) {
|
||||
return Astro.redirect(`/admin/services/${result.data.service.slug}/edit`)
|
||||
}
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
---
|
||||
|
||||
<BaseLayout pageTitle="Create Service" widthClassName="max-w-screen-sm">
|
||||
<section class="mb-8">
|
||||
<div class="font-title mb-4">
|
||||
<span class="text-sm text-green-500">service.create</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.admin.service.create}
|
||||
class="space-y-4 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"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<div>
|
||||
<label for="name" class="font-title mb-2 block text-sm text-green-500">name</label>
|
||||
<input
|
||||
transition:persist
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
{
|
||||
inputErrors.name && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.name.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="font-title mb-2 block text-sm text-green-500">description</label>
|
||||
<textarea
|
||||
transition:persist
|
||||
name="description"
|
||||
id="description"
|
||||
required
|
||||
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"
|
||||
></textarea>
|
||||
{
|
||||
inputErrors.description && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</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="serviceUrls"
|
||||
id="serviceUrls"
|
||||
rows={3}
|
||||
placeholder="https://example1.com https://example2.com"></textarea>
|
||||
{
|
||||
inputErrors.serviceUrls && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tosUrls" class="font-title mb-2 block text-sm text-green-500">tosUrls</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="tosUrls"
|
||||
id="tosUrls"
|
||||
rows={3}
|
||||
placeholder="https://example1.com/tos https://example2.com/tos"></textarea>
|
||||
{
|
||||
inputErrors.tosUrls && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</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"></textarea>
|
||||
{
|
||||
inputErrors.onionUrls && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
transition:persist
|
||||
type="file"
|
||||
name="imageFile"
|
||||
id="imageFile"
|
||||
accept="image/*"
|
||||
required
|
||||
class="font-title file:font-title block w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 file:mr-3 file:rounded-md file:border-0 file:bg-green-500/30 file:px-3 file:py-1 file:text-gray-300 focus:border-green-500 focus:ring-green-500"
|
||||
/>
|
||||
<p class="font-title text-xs text-gray-400">
|
||||
Upload a square image for best results. Supported formats: JPG, PNG, WebP, SVG.
|
||||
</p>
|
||||
</div>
|
||||
{
|
||||
inputErrors.imageFile && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.imageFile.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="categories">categories</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
{
|
||||
categories?.map((category) => (
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name="categories"
|
||||
value={category.id}
|
||||
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
|
||||
/>
|
||||
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
|
||||
<Icon class="h-3 w-3 text-green-500" name={category.icon} />
|
||||
{category.name}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
inputErrors.categories && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.categories.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="kycLevel" class="font-title mb-2 block text-sm text-green-500">kycLevel</label>
|
||||
<input
|
||||
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"
|
||||
type="number"
|
||||
name="kycLevel"
|
||||
id="kycLevel"
|
||||
min={0}
|
||||
max={4}
|
||||
value={4}
|
||||
required
|
||||
/>
|
||||
{
|
||||
inputErrors.kycLevel && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.kycLevel.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="attributes">attributes</label>
|
||||
<div class="space-y-4">
|
||||
{
|
||||
Object.values(AttributeCategory).map((category) => (
|
||||
<div class="rounded-md border border-green-500/20 bg-black/30 p-4">
|
||||
<h4 class="font-title mb-3 text-green-400">{category}</h4>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
{attributes
|
||||
?.filter((attr) => attr.category === category)
|
||||
.map((attr) => (
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name="attributes"
|
||||
value={attr.id}
|
||||
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
|
||||
/>
|
||||
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
|
||||
{attr.title}
|
||||
<span
|
||||
class={cn('font-title rounded-sm px-1.5 py-0.5 text-xs', {
|
||||
'border border-green-500/50 bg-green-500/20 text-green-400':
|
||||
attr.type === 'GOOD',
|
||||
'border border-red-500/50 bg-red-500/20 text-red-400': attr.type === 'BAD',
|
||||
'border border-yellow-500/50 bg-yellow-500/20 text-yellow-400':
|
||||
attr.type === 'WARNING',
|
||||
'border border-blue-500/50 bg-blue-500/20 text-blue-400': attr.type === 'INFO',
|
||||
})}
|
||||
>
|
||||
{attr.type}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{inputErrors.attributes && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.attributes.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="verificationStatus"
|
||||
>verificationStatus</label
|
||||
>
|
||||
<select
|
||||
transition:persist
|
||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
|
||||
name="verificationStatus"
|
||||
id="verificationStatus"
|
||||
required
|
||||
>
|
||||
{Object.values(VerificationStatus).map((status) => <option value={status}>{status}</option>)}
|
||||
</select>
|
||||
{
|
||||
inputErrors.verificationStatus && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationStatus.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="verificationSummary"
|
||||
>verificationSummary</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="verificationSummary"
|
||||
id="verificationSummary"
|
||||
rows={3}></textarea>
|
||||
{
|
||||
inputErrors.verificationSummary && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="verificationProofMd"
|
||||
>verificationProofMd</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="verificationProofMd"
|
||||
id="verificationProofMd"
|
||||
rows={10}></textarea>
|
||||
{
|
||||
inputErrors.verificationProofMd && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="acceptedCurrencies"
|
||||
>acceptedCurrencies</label
|
||||
>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
{
|
||||
Object.values(Currency).map((currency) => (
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name="acceptedCurrencies"
|
||||
value={currency}
|
||||
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
|
||||
/>
|
||||
<span class="font-title ml-2 text-gray-300">{currency}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
inputErrors.acceptedCurrencies && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.acceptedCurrencies.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="overallScore">overallScore</label>
|
||||
<input
|
||||
transition:persist
|
||||
type="number"
|
||||
name="overallScore"
|
||||
id="overallScore"
|
||||
value={0}
|
||||
min={0}
|
||||
max={10}
|
||||
required
|
||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
|
||||
/>
|
||||
{
|
||||
inputErrors.overallScore && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.overallScore.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral</label>
|
||||
<input
|
||||
transition:persist
|
||||
type="text"
|
||||
name="referral"
|
||||
id="referral"
|
||||
placeholder="Optional referral code/link"
|
||||
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"
|
||||
/>
|
||||
{
|
||||
inputErrors.referral && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.referral.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
||||
>
|
||||
Create Service
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user