Release 2025-05-19

This commit is contained in:
pluja
2025-05-19 10:19:49 +00:00
parent 046c4559e5
commit 2657f936bc
267 changed files with 0 additions and 49432 deletions

View File

@@ -1,751 +0,0 @@
---
import { AttributeCategory, AttributeType, type Prisma } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import SortArrowIcon from '../../components/SortArrowIcon.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { formatNumber } from '../../lib/numbers'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
const search = Astro.url.searchParams.get('search') ?? ''
const categoryFilter = z
.nativeEnum(AttributeCategory)
.nullable()
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.parse(Astro.url.searchParams.get('category') || null)
const typeFilter = z
.nativeEnum(AttributeType)
.nullable()
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.parse(Astro.url.searchParams.get('type') || null)
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z
.enum(['title', 'category', 'type', 'privacyPoints', 'trustPoints', 'serviceCount'])
.default('title'),
'sort-order': z.enum(['asc', 'desc']).default('asc'),
},
Astro
)
const createResult = Astro.getActionResult(actions.admin.attribute.create)
Astro.locals.banners.addIfSuccess(createResult, 'Attribute created successfully')
const createInputErrors = isInputError(createResult?.error) ? createResult.error.fields : {}
const updateResult = Astro.getActionResult(actions.admin.attribute.update)
Astro.locals.banners.addIfSuccess(updateResult, 'Attribute updated successfully')
const updatedAttributeId = updateResult?.data?.attribute.id ?? null
const sortBy = filters['sort-by']
const sortOrder = filters['sort-order']
const whereClause: Prisma.AttributeWhereInput = {
...(search
? {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
],
}
: {}),
category: categoryFilter ?? undefined,
type: typeFilter ?? undefined,
}
let prismaOrderBy: Record<string, 'asc' | 'desc'> = { title: 'asc' } // Default sort
// Ensure sortBy is a valid key for Attribute model for Prisma ordering
const validPrismaSortKeys = ['title', 'slug', 'privacyPoints', 'trustPoints', 'createdAt', 'updatedAt'] // Add other valid direct fields if needed
if (validPrismaSortKeys.includes(sortBy)) {
prismaOrderBy = { [sortBy]: sortOrder }
} else {
// If sortBy is not a direct DB field (like category, type, serviceCount),
// Prisma will use its default (title: asc) or the last valid key set.
// The actual sorting for these custom cases will happen in JS after fetch.
if (sortBy === 'category' || sortBy === 'type' || sortBy === 'serviceCount') {
// Keep default prisma sort, JS will handle it
} else {
// Fallback if an unexpected sort key is provided, perhaps log this or handle error
console.warn(`Unsupported Prisma sort key: ${sortBy}. Defaulting to title sort.`)
prismaOrderBy = { title: 'asc' }
}
}
let attributes = await Astro.locals.banners.try(
'Error fetching attributes',
async () =>
prisma.attribute.findMany({
where: whereClause,
orderBy: prismaOrderBy,
include: {
services: {
select: {
serviceId: true,
},
},
},
}),
[]
)
let attributesWithDetails = attributes.map((attribute) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
serviceCount: attribute.services.length,
}))
if (sortBy === 'category') {
attributesWithDetails = lodashOrderBy(
attributesWithDetails,
[(item) => item.categoryInfo.order],
[sortOrder]
)
} else if (sortBy === 'type') {
attributesWithDetails = lodashOrderBy(attributesWithDetails, [(item) => item.typeInfo.order], [sortOrder])
} else if (sortBy === 'serviceCount') {
attributesWithDetails = lodashOrderBy(attributesWithDetails, ['serviceCount'], [sortOrder])
}
const attributeCount = attributesWithDetails.length
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/attributes?${searchParams.toString()}`
}
---
<BaseLayout pageTitle="Admin | Attributes" 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">Attribute Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{attributeCount} attributes</span>
<button
type="button"
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"
onclick="document.getElementById('create-attribute-form').classList.toggle('hidden')"
>
<Icon name="ri:add-line" class="size-4" />
<span>New Attribute</span>
</button>
</div>
</div>
<!-- Create Attribute Form (hidden by default, shown via button) -->
<div
id="create-attribute-form"
class="mb-6 hidden rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg"
>
<h2 class="font-title mb-3 text-lg font-semibold text-green-400">Create New Attribute</h2>
<form method="POST" action={actions.admin.attribute.create} class="space-y-4">
<div>
<label for="title-create" class="block text-sm font-medium text-zinc-300">Title</label>
<input
transition:persist
type="text"
name="title"
id="title-create"
required
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-green-500 focus:ring-green-500 focus:outline-none"
/>
{
createInputErrors.title && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.title.join(', ')}</p>
)
}
</div>
<div>
<label for="description-create" class="block text-sm font-medium text-zinc-300">Description</label>
<textarea
transition:persist
name="description"
id="description-create"
required
rows="3"
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-green-500 focus:ring-green-500 focus:outline-none"
></textarea>
{
createInputErrors.description && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.description.join(', ')}</p>
)
}
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="slug-create" class="block text-sm font-medium text-zinc-300">Slug</label>
<input
transition:persist
type="text"
name="slug"
id="slug-create"
required
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-green-500 focus:ring-green-500 focus:outline-none"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="privacyPoints-create" class="block text-sm font-medium text-zinc-300">Privacy</label>
<input
transition:persist
type="number"
name="privacyPoints"
id="privacyPoints-create"
min="-100"
max="100"
value="0"
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-green-500 focus:ring-green-500 focus:outline-none"
/>
{
createInputErrors.privacyPoints && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.privacyPoints.join(', ')}</p>
)
}
</div>
<div>
<label for="trustPoints-create" class="block text-sm font-medium text-zinc-300">Trust</label>
<input
transition:persist
type="number"
name="trustPoints"
id="trustPoints-create"
min="-100"
max="100"
value="0"
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-green-500 focus:ring-green-500 focus:outline-none"
/>
{
createInputErrors.trustPoints && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.trustPoints.join(', ')}</p>
)
}
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="category-create" class="block text-sm font-medium text-zinc-300">Category</label>
<select
transition:persist
name="category"
id="category-create"
required
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-green-500 focus:ring-green-500 focus:outline-none"
>
{
Object.values(AttributeCategory).map((category) => (
<option value={category}>{getAttributeCategoryInfo(category).label}</option>
))
}
</select>
{
createInputErrors.category && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.category.join(', ')}</p>
)
}
</div>
<div>
<label for="type-create" class="block text-sm font-medium text-zinc-300">Type</label>
<select
transition:persist
name="type"
id="type-create"
required
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-green-500 focus:ring-green-500 focus:outline-none"
>
{
Object.values(AttributeType).map((type) => (
<option value={type}>{getAttributeTypeInfo(type).label}</option>
))
}
</select>
{
createInputErrors.type && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.type.join(', ')}</p>
)
}
</div>
</div>
<button
type="submit"
class="w-full rounded-md bg-green-600 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Create Attribute
</button>
</form>
</div>
<!-- Filters Bar -->
<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-4" autocomplete="off">
<div class="lg:col-span-2">
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<input
type="text"
name="search"
id="search"
value={search}
placeholder="Search by title, description, slug..."
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"
/>
</div>
<div>
<label for="category-filter" class="block text-xs font-medium text-zinc-400">Category</label>
<select
name="category"
id="category-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 Categories</option>
{
Object.values(AttributeCategory).map((cat) => (
<option value={cat} selected={categoryFilter === cat}>
{getAttributeCategoryInfo(cat).label}
</option>
))
}
</select>
</div>
<div>
<label for="type-filter" class="block text-xs font-medium text-zinc-400">Type</label>
<div class="mt-1 flex">
<select
name="type"
id="type-filter"
class="w-full rounded-l-md border border-r-0 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>
{
Object.values(AttributeType).map((attrType) => (
<option value={attrType} selected={typeFilter === attrType}>
{getAttributeTypeInfo(attrType).label}
</option>
))
}
</select>
<button
type="submit"
class="inline-flex items-center rounded-r-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>
<!-- Attribute List -->
<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">Attributes 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('title')} class="flex items-center hover:text-zinc-200">
Title <SortArrowIcon
active={filters['sort-by'] === 'title'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('category')} class="flex items-center hover:text-zinc-200">
Category <SortArrowIcon
active={filters['sort-by'] === 'category'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[15%] 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
scope="col"
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('privacyPoints')}
class="flex items-center justify-center hover:text-zinc-200"
>
<span class="hidden sm:inline">Privacy</span>
<span class="sm:hidden">Priv</span>
<SortArrowIcon
active={filters['sort-by'] === 'privacyPoints'}
sortOrder={filters['sort-order']}
/>
</a>
</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('trustPoints')}
class="flex items-center justify-center hover:text-zinc-200"
>
<span class="hidden sm:inline">Trust</span>
<span class="sm:hidden">Tr</span>
<SortArrowIcon
active={filters['sort-by'] === 'trustPoints'}
sortOrder={filters['sort-order']}
/>
</a>
</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('serviceCount')}
class="flex items-center justify-center hover:text-zinc-200"
>
<span class="hidden sm:inline">Services</span>
<span class="sm:hidden">Svcs</span>
<SortArrowIcon
active={filters['sort-by'] === 'serviceCount'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[10%] 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">
{
attributesWithDetails.map((attribute, index) => (
<>
<tr id={`attribute-${attribute.id}`} class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div class="truncate" title={attribute.title}>
{attribute.title}
</div>
<div class="text-2xs text-zinc-500">{attribute.slug}</div>
</td>
<td class="px-4 py-3">
<span
class={cn(
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
attribute.categoryInfo.classNames.icon
)}
>
<Icon name={attribute.categoryInfo.icon} class="mr-1 size-3" />
{attribute.categoryInfo.label}
</span>
</td>
<td class="px-4 py-3">
<span
class={cn(
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
attribute.typeInfo.classNames.icon
)}
>
<Icon name={attribute.typeInfo.icon} class="mr-1 size-3" />
{attribute.typeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn('font-medium', {
'text-red-400': attribute.privacyPoints < 0,
'text-green-400': attribute.privacyPoints > 0,
'text-zinc-500': attribute.privacyPoints === 0,
})}
>
{formatNumber(attribute.privacyPoints, { showSign: true })}
</span>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn('font-medium', {
'text-red-400': attribute.trustPoints < 0,
'text-green-400': attribute.trustPoints > 0,
'text-zinc-500': attribute.trustPoints === 0,
})}
>
{formatNumber(attribute.trustPoints, { showSign: true })}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center rounded-full bg-zinc-700 px-2.5 py-0.5 text-xs font-medium text-zinc-300">
{attribute.serviceCount}
</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-blue-500/50 bg-blue-500/20 p-1.5 text-blue-400 transition-colors hover:bg-blue-500/30 focus:outline-none"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
title="Edit attribute"
>
<Icon name="ri:edit-line" class="size-3.5" />
</button>
<form method="POST" action={actions.admin.attribute.delete} class="inline-block">
<input type="hidden" name="id" value={attribute.id} />
<button
type="submit"
class="inline-flex items-center justify-center rounded-md border border-red-500/50 bg-red-500/20 p-1.5 text-red-400 transition-colors hover:bg-red-500/30 focus:outline-none"
onclick="return confirm('Are you sure you want to delete this attribute?')"
title="Delete attribute"
>
<Icon name="ri:delete-bin-line" class="size-3.5" />
</button>
</form>
</div>
</td>
</tr>
<tr id={`edit-form-${index}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-2 font-semibold text-blue-300">
Edit: {attribute.title}
</h3>
<form method="POST" action={actions.admin.attribute.update} class="space-y-4">
<input type="hidden" name="id" value={attribute.id} />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
for={`title-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Title
</label>
<input
type="text"
name="title"
id={`title-edit-${index}`}
required
value={attribute.title}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 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>
<label for={`slug-edit-${index}`} class="block text-sm font-medium text-zinc-300">
Slug
</label>
<input
type="text"
name="slug"
id={`slug-edit-${index}`}
required
value={attribute.slug}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 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={`description-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Description
</label>
<textarea
name="description"
id={`description-edit-${index}`}
required
rows="3"
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{attribute.description}
</textarea>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<label
for={`privacyPoints-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Privacy
</label>
<input
type="number"
name="privacyPoints"
id={`privacyPoints-edit-${index}`}
min="-100"
max="100"
value={attribute.privacyPoints}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 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>
<label
for={`trustPoints-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Trust
</label>
<input
type="number"
name="trustPoints"
id={`trustPoints-edit-${index}`}
min="-100"
max="100"
value={attribute.trustPoints}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 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>
<label
for={`category-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Category
</label>
<select
name="category"
id={`category-edit-${index}`}
required
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{Object.values(AttributeCategory).map((category) => (
<option value={category} selected={attribute.category === category}>
{getAttributeCategoryInfo(category).label}
</option>
))}
</select>
</div>
<div>
<label for={`type-edit-${index}`} class="block text-sm font-medium text-zinc-300">
Type
</label>
<select
name="type"
id={`type-edit-${index}`}
required
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{Object.values(AttributeType).map((type) => (
<option value={type} selected={attribute.type === type}>
{getAttributeTypeInfo(type).label}
</option>
))}
</select>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
class="rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:outline-none"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 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"
>
Save Changes
</button>
</div>
</form>
</td>
</tr>
</>
))
}
</tbody>
</table>
</div>
</div>
</div>
</BaseLayout>
<script define:vars={{ updatedAttributeId }}></script>
<style>
@keyframes highlight {
0% {
background-color: rgba(59, 130, 246, 0.1);
}
50% {
background-color: rgba(59, 130, 246, 0.3);
}
100% {
background-color: transparent;
}
}
/* Base CSS text size utility */
.text-2xs {
font-size: 0.6875rem; /* 11px */
line-height: 1rem; /* 16px */
}
/* Scrollbar styling for better mobile experience */
.scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 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;
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2); /* thumb track for firefox*/
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
}
</style>

View File

@@ -1,237 +0,0 @@
---
import { z } from 'astro/zod'
import CommentModeration from '../../components/CommentModeration.astro'
import {
commentStatusFilters,
commentStatusFiltersZodEnum,
getCommentStatusFilterInfo,
getCommentStatusFilterValue,
} from '../../constants/commentStatusFilters'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
import { urlWithParams } from '../../lib/urls'
const user = Astro.locals.user
if (!user || (!user.admin && !user.verifier)) {
return Astro.rewrite('/404')
}
const { data: params } = zodParseQueryParamsStoringErrors(
{
status: commentStatusFiltersZodEnum.default('all'),
page: z.number().int().positive().default(1),
},
Astro
)
const PAGE_SIZE = 20
const statusFilter = getCommentStatusFilterInfo(params.status)
const [comments = [], totalComments = 0] = await Astro.locals.banners.try(
'Error fetching comments',
async () =>
prisma.comment.findManyAndCount({
where: statusFilter.whereClause,
include: {
author: true,
service: {
select: {
name: true,
slug: true,
},
},
parent: {
include: {
author: true,
},
},
votes: true,
},
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
skip: (params.page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
[]
)
const totalPages = Math.ceil(totalComments / PAGE_SIZE)
---
<BaseLayout pageTitle="Comment Moderation" widthClassName="max-w-screen-xl">
<div class="mb-8">
<h1 class="font-title mb-4 text-2xl text-green-500">&gt; comments.moderate</h1>
<!-- Status Filters -->
<div class="flex flex-wrap gap-2">
{
commentStatusFilters.map((filter) => (
<a
href={urlWithParams(Astro.url, { status: filter.value })}
class={cn([
'font-title rounded-md border px-3 py-1 text-sm',
params.status === filter.value
? filter.styles.filter
: 'border-zinc-700 transition-colors hover:border-green-500/50',
])}
>
{filter.label}
</a>
))
}
</div>
</div>
<!-- Comments List -->
<div class="space-y-6">
{
comments
.map((comment) => ({
...comment,
statusFilterInfo: getCommentStatusFilterInfo(getCommentStatusFilterValue(comment)),
}))
.map((comment) => (
<div
id={`comment-${comment.id.toString()}`}
class="rounded-lg border border-zinc-800 bg-black/40 p-6 backdrop-blur-xs"
>
<div class="mb-4 flex flex-wrap items-center gap-2">
{/* Author Info */}
<span class="font-title text-sm">{comment.author.name}</span>
{comment.author.admin && (
<span class="rounded-sm bg-yellow-500/20 px-2 py-0.5 text-[12px] font-medium text-yellow-500">
admin
</span>
)}
{comment.author.verified && !comment.author.admin && (
<span class="rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500">
verified
</span>
)}
{comment.author.verifier && !comment.author.admin && (
<span class="rounded-sm bg-green-500/20 px-2 py-0.5 text-[12px] font-medium text-green-500">
verifier
</span>
)}
{/* Service Link */}
<span class="text-xs text-zinc-500">•</span>
<a
href={`/service/${comment.service.slug}#comment-${comment.id.toString()}`}
class="text-sm text-blue-400 transition-colors hover:text-blue-300"
>
{comment.service.name}
</a>
{/* Date */}
<span class="text-xs text-zinc-500">•</span>
<span class="text-sm text-zinc-400">{new Date(comment.createdAt).toLocaleString()}</span>
{/* Status Badges */}
<span class={comment.statusFilterInfo.styles.badge}>{comment.statusFilterInfo.label}</span>
</div>
{/* Parent Comment Context */}
{comment.parent && (
<div class="mb-4 border-l-2 border-zinc-700 pl-4">
<div class="mb-1 text-sm text-zinc-500">Replying to {comment.parent.author.name}:</div>
<div class="text-sm text-zinc-400">{comment.parent.content}</div>
</div>
)}
{/* Comment Content */}
<div class="mb-4 text-sm">{comment.content}</div>
{/* Notes */}
{comment.communityNote && (
<div class="mt-2">
<div class="text-sm font-medium text-green-500">Community Note</div>
<p class="text-sm text-gray-300">{comment.communityNote}</p>
</div>
)}
{comment.internalNote && (
<div class="mt-2">
<div class="text-sm font-medium text-yellow-500">Internal Note</div>
<p class="text-sm text-gray-300">{comment.internalNote}</p>
</div>
)}
{comment.privateContext && (
<div class="mt-2">
<div class="text-sm font-medium text-blue-500">Private Context</div>
<p class="text-sm text-gray-300">{comment.privateContext}</p>
</div>
)}
{comment.orderId && (
<div class="mt-2">
<div class="text-sm font-medium text-purple-500">Order ID</div>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-300">{comment.orderId}</p>
{comment.orderIdStatus && (
<span
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs',
comment.orderIdStatus === 'APPROVED' && 'bg-green-500/20 text-green-400',
comment.orderIdStatus === 'REJECTED' && 'bg-red-500/20 text-red-400',
comment.orderIdStatus === 'PENDING' && 'bg-blue-500/20 text-blue-400'
)}
>
{comment.orderIdStatus}
</span>
)}
</div>
</div>
)}
{(comment.kycRequested || comment.fundsBlocked) && (
<div class="mt-2">
<div class="text-sm font-medium text-red-500">Issue Flags</div>
<div class="flex flex-wrap gap-2">
{comment.kycRequested && (
<span class="rounded-sm bg-red-500/20 px-1.5 py-0.5 text-xs text-red-400">
KYC Requested
</span>
)}
{comment.fundsBlocked && (
<span class="rounded-sm bg-red-500/20 px-1.5 py-0.5 text-xs text-red-400">
Funds Blocked
</span>
)}
</div>
</div>
)}
<CommentModeration class="mt-2" comment={comment} />
</div>
))
}
</div>
<!-- Pagination -->
{
totalPages > 1 && (
<div class="mt-8 flex justify-center gap-2">
{params.page > 1 && (
<a
href={urlWithParams(Astro.url, { page: params.page - 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Previous
</a>
)}
<span class="font-title px-3 py-1 text-sm">
Page {params.page} of {totalPages}
</span>
{params.page < totalPages && (
<a
href={urlWithParams(Astro.url, { page: params.page + 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Next
</a>
)}
</div>
)
}
</BaseLayout>

View File

@@ -1,76 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import BaseLayout from '../../layouts/BaseLayout.astro'
import type { ComponentProps } from 'astro/types'
type AdminLink = {
icon: ComponentProps<typeof Icon>['name']
title: string
href: string
description: string
}
const adminLinks: AdminLink[] = [
{
icon: 'ri:box-3-line',
title: 'Services',
href: '/admin/services',
description: 'Manage your available services',
},
{
icon: 'ri:file-list-3-line',
title: 'Attributes',
href: '/admin/attributes',
description: 'Configure service attributes',
},
{
icon: 'ri:user-3-line',
title: 'Users',
href: '/admin/users',
description: 'Manage user accounts',
},
{
icon: 'ri:chat-settings-line',
title: 'Comments',
href: '/admin/comments',
description: 'Moderate user comments',
},
{
icon: 'ri:lightbulb-line',
title: 'Service suggestions',
href: '/admin/service-suggestions',
description: 'Review and manage service suggestions',
},
]
---
<BaseLayout pageTitle="Admin Dashboard" widthClassName="max-w-screen-xl">
<h1 class="font-title mb-8 text-3xl font-bold text-zinc-100">
<Icon name="ri:home-gear-line" class="me-1 inline-block size-10 align-[-0.35em]" />
Admin Dashboard
</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{
adminLinks.map((link) => (
<a
href={link.href}
class="group block rounded-lg border border-zinc-800 bg-gradient-to-br from-zinc-900/90 to-zinc-900/50 p-6 shadow-lg backdrop-blur-xs transition-all duration-300 hover:-translate-y-0.5 hover:from-zinc-800/90 hover:to-zinc-800/50 hover:shadow-xl hover:shadow-zinc-900/20"
>
<div class="mb-4 flex items-center gap-3">
<Icon
name={link.icon}
class="h-6 w-6 text-zinc-400 transition-colors group-hover:text-green-400"
/>
<h2 class="font-title text-xl font-semibold text-zinc-100 transition-colors group-hover:text-green-400">
{link.title}
</h2>
</div>
<p class="text-sm text-zinc-400">{link.description}</p>
</a>
))
}
</div>
</BaseLayout>

View File

@@ -1,195 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls'
const user = Astro.locals.user
if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
}
const { id: serviceSuggestionIdRaw } = Astro.params
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
if (!serviceSuggestionId) {
return Astro.rewrite('/404')
}
const serviceSuggestion = await Astro.locals.banners.try('Error fetching service suggestion', async () =>
prisma.serviceSuggestion.findUnique({
where: {
id: serviceSuggestionId,
},
select: {
id: true,
status: true,
notes: true,
createdAt: true,
type: true,
user: {
select: {
id: true,
name: true,
},
},
service: {
select: {
id: true,
name: true,
slug: true,
description: true,
overallScore: true,
kycLevel: true,
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
categories: {
select: {
name: true,
icon: true,
},
},
},
},
messages: {
select: {
id: true,
content: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
picture: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
})
)
if (!serviceSuggestion) {
return Astro.rewrite('/404')
}
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
---
<BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
htmx
widthClassName="max-w-screen-md"
>
<div class="mb-4 flex items-center gap-4">
<a
href="/admin/service-suggestions"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-3 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"
>
<Icon name="ri:arrow-left-s-line" class="mr-1 size-4" />
Back
</a>
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
</div>
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<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="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<span class="font-title text-gray-400">Status:</span>
<span
class={cn(
'inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
<span class="font-title text-gray-400">Submitted by:</span>
<span class="text-gray-300">
<a href={`/admin/users?name=${serviceSuggestion.user.name}`} class="hover:text-green-500">
{serviceSuggestion.user.name}
</a>
</span>
<span class="font-title text-gray-400">Submitted at:</span>
<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">
View Service <Icon
name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]"
/>
</a>
</div>
{
serviceSuggestion.notes && (
<div class="mb-4">
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
<div class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm whitespace-pre-wrap text-gray-300">
{serviceSuggestion.notes}
</div>
</div>
)
}
</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="flex items-center justify-between">
<h2 class="font-title text-lg text-green-500">Messages</h2>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<select
name="status"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50"
>
<option value="PENDING" selected={serviceSuggestion.status === 'PENDING'}> Pending </option>
<option value="APPROVED" selected={serviceSuggestion.status === 'APPROVED'}> Approve </option>
<option value="REJECTED" selected={serviceSuggestion.status === 'REJECTED'}> Reject </option>
<option value="WITHDRAWN" selected={serviceSuggestion.status === 'WITHDRAWN'}> Withdrawn </option>
</select>
<button
type="submit"
class="font-title inline-flex items-center 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"
>
<Icon name="ri:save-line" class="mr-1 size-4" /> Update
</button>
</form>
</div>
<Chat
messages={serviceSuggestion.messages}
userId={user.id}
action={actions.admin.serviceSuggestions.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
/>
</div>
</BaseLayout>

View File

@@ -1,385 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus'
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'
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'),
'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 }
}
let suggestions = await prisma.serviceSuggestion.findMany({
where: {
...(search
? {
OR: [
{ service: { name: { contains: search, mode: 'insensitive' } } },
{ user: { name: { contains: search, mode: 'insensitive' } } },
{ notes: { contains: search, mode: 'insensitive' } },
],
}
: {}),
status: statusFilter ?? undefined,
},
orderBy: prismaOrderBy,
select: {
id: true,
status: true,
notes: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
},
},
service: {
select: {
id: true,
name: true,
slug: true,
description: true,
imageUrl: true,
verificationStatus: true,
categories: {
select: {
name: true,
icon: true,
},
},
},
},
messages: {
select: {
id: true,
content: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
_count: {
select: {
messages: true,
},
},
},
})
let suggestionsWithDetails = suggestions.map((s) => ({
...s,
statusInfo: getServiceSuggestionStatusInfo(s.status),
messageCount: s._count.messages,
lastMessage: s.messages[0],
}))
if (sortBy === 'service') {
suggestionsWithDetails = lodashOrderBy(
suggestionsWithDetails,
[(s) => s.service.name.toLowerCase()],
[sortOrder]
)
} else if (sortBy === 'status') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
} else if (sortBy === 'user') {
suggestionsWithDetails = lodashOrderBy(
suggestionsWithDetails,
[(s) => s.user.name.toLowerCase()],
[sortOrder]
)
} else if (sortBy === 'messageCount') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
}
const suggestionCount = suggestionsWithDetails.length
const makeSortUrl = (slug: string) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/service-suggestions?${searchParams.toString()}`
}
---
<BaseLayout pageTitle="Service Suggestions" 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 Suggestions</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{suggestionCount} suggestions</span>
</div>
</div>
<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-4" autocomplete="off">
<div class="lg:col-span-2">
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<input
type="text"
name="search"
id="search"
value={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"
/>
</div>
<div>
<label for="status-filter" class="block text-xs font-medium text-zinc-400">Status</label>
<select
name="status"
id="status-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 Statuses</option>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={statusFilter === status.value}>
{status.label}
</option>
))
}
</select>
</div>
<div class="flex items-end">
<button
type="submit"
class="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>
</form>
</div>
<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">Suggestions 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-[900px]">
<table class="w-full divide-y divide-zinc-700">
<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"
>
<a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
Service <SortArrowIcon
active={filters['sort-by'] === 'service'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] 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
active={filters['sort-by'] === 'user'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] 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
active={filters['sort-by'] === 'status'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('createdAt')} class="flex items-center hover:text-zinc-200">
Created <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('messageCount')}
class="flex items-center justify-center hover:text-zinc-200"
>
Messages <SortArrowIcon
active={filters['sort-by'] === 'messageCount'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[20%] 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">
{
suggestionsWithDetails.map((suggestion) => (
<tr id={`suggestion-${suggestion.id}`} class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div class="truncate" title={suggestion.service.name}>
<a
href={`/service/${suggestion.service.slug}`}
class="flex items-center gap-1 hover:text-green-500"
>
{suggestion.service.name}
<Icon name="ri:external-link-line" class="inline-block size-4 align-[-0.05em]" />
</a>
</div>
<div
class="text-2xs max-w-[220px] truncate text-zinc-500"
title={suggestion.service.description}
>
{suggestion.service.description}
</div>
</td>
<td class="px-4 py-3">
<a href={`/admin/users?name=${suggestion.user.name}`} class="hover:text-green-500">
{suggestion.user.name}
</a>
</td>
<td class="px-4 py-3">
<form method="POST" action={actions.admin.serviceSuggestions.update}>
<input type="hidden" name="suggestionId" value={suggestion.id} />
<select
name="status"
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
value={suggestion.status}
onchange="this.form.submit()"
title="Change status"
>
{serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={suggestion.status === status.value}>
{status.label}
</option>
))}
</select>
</form>
</td>
<td class="px-4 py-3 text-sm">
<TimeFormatted date={suggestion.createdAt} hourPrecision />
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center rounded-full bg-zinc-700 px-2.5 py-0.5 text-xs font-medium text-zinc-300">
{suggestion.messageCount}
</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-1">
<a
href={`/admin/service-suggestions/${suggestion.id}`}
class="inline-flex items-center justify-center rounded-full border border-green-500/40 bg-green-500/10 p-1.5 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"
title="View"
>
<Icon name="ri:external-link-line" class="size-4" />
</a>
</div>
</td>
</tr>
))
}
</tbody>
</table>
</div>
</div>
</div>
</BaseLayout>
<style>
@keyframes highlight {
0% {
background-color: rgba(59, 130, 246, 0.1);
}
50% {
background-color: rgba(59, 130, 246, 0.3);
}
100% {
background-color: transparent;
}
}
.text-2xs {
font-size: 0.6875rem;
line-height: 1rem;
}
.scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 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;
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2);
-webkit-overflow-scrolling: touch;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,626 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import Tooltip from '../../../components/Tooltip.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { prisma } from '../../../lib/prisma'
import { transformCase } from '../../../lib/strings'
import { timeAgo } from '../../../lib/timeAgo'
const { username } = Astro.params
if (!username) return Astro.rewrite('/404')
const updateResult = Astro.getActionResult(actions.admin.user.update)
Astro.locals.banners.addIfSuccess(updateResult, 'User updated successfully')
if (updateResult && !updateResult.error && username !== updateResult.data.updatedUser.name) {
return Astro.redirect(`/admin/users/${updateResult.data.updatedUser.name}`)
}
const updateInputErrors = isInputError(updateResult?.error) ? updateResult.error.fields : {}
const addAffiliationResult = Astro.getActionResult(actions.admin.user.serviceAffiliations.add)
Astro.locals.banners.addIfSuccess(addAffiliationResult, 'Service affiliation added successfully')
const removeAffiliationResult = Astro.getActionResult(actions.admin.user.serviceAffiliations.remove)
Astro.locals.banners.addIfSuccess(removeAffiliationResult, 'Service affiliation removed successfully')
const [user, allServices] = await Astro.locals.banners.tryMany([
[
'Failed to load user profile',
async () => {
if (!username) return null
return await prisma.user.findUnique({
where: { name: username },
select: {
id: true,
name: true,
displayName: true,
picture: true,
link: true,
admin: true,
verified: true,
verifier: true,
spammer: true,
verifiedLink: true,
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
addedByUser: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
serviceAffiliations: {
select: {
id: true,
role: true,
createdAt: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
})
},
null,
],
[
'Failed to load services',
async () => {
return await prisma.service.findMany({
select: {
id: true,
name: true,
},
orderBy: {
name: 'asc',
},
})
},
[],
],
])
if (!user) return Astro.rewrite('/404')
---
<BaseLayout pageTitle={`User: ${user.name}`} htmx>
<div class="container mx-auto max-w-2xl py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="font-title text-2xl text-green-400">User Profile: {user.name}</h1>
<a
href="/admin/users"
class="font-title inline-flex items-center 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"
>
<Icon name="ri:arrow-left-line" class="mr-1 size-4" />
Back to Users
</a>
</div>
<section
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="mb-6 flex items-center gap-4">
{
user.picture ? (
<img src={user.picture} alt="" class="h-16 w-16 rounded-full ring-2 ring-green-500/30" />
) : (
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10 ring-2 ring-green-500/30">
<span class="font-title text-2xl text-green-500">{user.name.charAt(0) || 'A'}</span>
</div>
)
}
<div>
<h2 class="font-title text-lg text-green-400">{user.name}</h2>
<div class="mt-1 flex gap-2">
{
user.admin && (
<span class="font-title rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="font-title rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="font-title rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
)
}
</div>
</div>
</div>
<form
method="POST"
action={actions.admin.user.update}
class="space-y-4 border-t border-green-500/30 pt-6"
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={user.id} />
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="name"> Name </label>
<input
transition:persist
type="text"
name="name"
id="name"
value={user.name}
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.name && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.name.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="displayName"> Display Name </label>
<input
transition:persist
type="text"
name="displayName"
maxlength={50}
id="displayName"
value={user.displayName ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
Array.isArray(updateInputErrors.displayName) && updateInputErrors.displayName.length > 0 && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.displayName.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="link"> Link </label>
<input
transition:persist
type="url"
name="link"
id="link"
value={user.link ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.link && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.link.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="picture">
Picture url or path
</label>
<input
transition:persist
type="text"
name="picture"
id="picture"
value={user.picture ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.picture && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.picture.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="pictureFile">
Profile Picture Upload
</label>
<input
transition:persist
type="file"
name="pictureFile"
id="pictureFile"
accept="image/*"
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, AVIF, JXL. Max size:
5MB.
</p>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="admin"
checked={user.admin}
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 text-sm text-green-500">Admin</span>
</label>
<Tooltip
as="label"
class="flex cursor-not-allowed items-center gap-2"
text="Automatically set based on verified link"
>
<input
type="checkbox"
name="verified"
checked={user.verified}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
disabled
/>
<span class="font-title text-sm text-green-500">Verified</span>
</Tooltip>
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="verifier"
checked={user.verifier}
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 text-sm text-green-500">Verifier</span>
</label>
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="spammer"
checked={user.spammer}
class="rounded-sm border-red-500/30 bg-black/50 text-red-500 focus:ring-red-500 focus:ring-offset-black"
/>
<span class="font-title text-sm text-red-500">Spammer</span>
</label>
</div>
{
updateInputErrors.admin && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.admin.join(', ')}</p>
)
}
{
updateInputErrors.verifier && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.verifier.join(', ')}</p>
)
}
{
updateInputErrors.spammer && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.spammer.join(', ')}</p>
)
}
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verifiedLink">
Verified Link
</label>
<input
transition:persist
type="url"
name="verifiedLink"
id="verifiedLink"
value={user.verifiedLink}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.verifiedLink && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.verifiedLink.join(', ')}</p>
)
}
</div>
<div class="flex gap-4 pt-4">
<button
type="submit"
class="font-title inline-flex items-center 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"
>
<Icon name="ri:save-line" class="mr-2 size-4" />
Save
</button>
</div>
</form>
{
Astro.locals.user && user.id !== Astro.locals.user.id && (
<a
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
class="font-title mt-4 inline-flex items-center justify-center rounded-md border border-yellow-500/30 bg-yellow-500/10 px-4 py-2 text-sm text-yellow-400 shadow-xs transition-colors duration-200 hover:bg-yellow-500/20 focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:spy-line" class="mr-2 size-4" />
Impersonate
</a>
)
}
</section>
<section
class="mt-8 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"
>
<h2 class="font-title mb-4 text-lg text-green-500">Internal Notes</h2>
{
user.internalNotes.length === 0 ? (
<p class="text-gray-400">No internal notes yet.</p>
) : (
<div class="space-y-4">
{user.internalNotes.map((note) => (
<div data-note-id={note.id} class="rounded-lg border border-green-500/30 bg-black/50 p-4">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-title text-xs text-gray-200">
{note.addedByUser ? note.addedByUser.name : 'System'}
</span>
<span class="font-title text-xs text-gray-500">
{transformCase(timeAgo.format(note.createdAt, 'twitter-minute-now'), 'sentence')}
</span>
</div>
<div class="flex items-center gap-2">
<label class="font-title inline-flex cursor-pointer items-center justify-center rounded-md border border-yellow-500/30 bg-yellow-500/10 px-2 py-1 text-xs text-yellow-400 shadow-xs transition-colors duration-200 hover:bg-yellow-500/20 focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden">
<input
type="checkbox"
class="peer sr-only"
data-edit-note-checkbox
data-note-id={note.id}
/>
<Icon name="ri:edit-line" class="size-3" />
</label>
<form method="POST" action={actions.admin.user.internalNotes.delete} class="inline-flex">
<input type="hidden" name="noteId" value={note.id} />
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:delete-bin-line" class="size-3" />
</button>
</form>
</div>
</div>
<div data-note-content>
<p class="font-title text-sm whitespace-pre-wrap text-gray-300">{note.content}</p>
</div>
<form
method="POST"
action={actions.admin.user.internalNotes.update}
data-note-edit-form
class="mt-2 hidden"
>
<input type="hidden" name="noteId" value={note.id} />
<textarea
name="content"
rows="3"
class="font-title min-h-12 w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
data-trim-content
>
{note.content}
</textarea>
<div class="mt-2 flex justify-end gap-2">
<button
type="button"
data-cancel-edit
class="font-title inline-flex items-center justify-center rounded-md border border-gray-500/30 bg-gray-500/10 px-3 py-1 text-xs text-gray-400 shadow-xs transition-colors duration-200 hover:bg-gray-500/20 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
Cancel
</button>
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-3 py-1 text-xs 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"
>
Save
</button>
</div>
</form>
</div>
))}
</div>
)
}
<form method="POST" action={actions.admin.user.internalNotes.add} class="mt-4 space-y-2">
<input type="hidden" name="userId" value={user.id} />
<textarea
name="content"
placeholder="Add a note..."
rows="3"
class="font-title min-h-12 w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
></textarea>
<button
type="submit"
class="font-title inline-flex items-center 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"
>
<Icon name="ri:add-line" class="mr-1 size-4" />
Add
</button>
</form>
</section>
<section
class="mt-8 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"
>
<h2 class="font-title mb-4 text-lg text-green-500">Service Affiliations</h2>
{
user.serviceAffiliations.length === 0 ? (
<p class="text-gray-400">No service affiliations yet.</p>
) : (
<div class="space-y-4">
{user.serviceAffiliations.map((affiliation) => (
<div class="flex items-center justify-between rounded-lg border border-green-500/30 bg-black/50 p-4">
<div>
<div class="flex items-center gap-2">
<a
href={`/service/${affiliation.service.slug}`}
class="font-title text-sm text-green-400 hover:underline"
>
{affiliation.service.name}
</a>
<span
class={`font-title rounded-full px-2 py-0.5 text-xs ${
affiliation.role === 'OWNER'
? 'border border-purple-500/50 bg-purple-500/20 text-purple-400'
: affiliation.role === 'ADMIN'
? 'border border-red-500/50 bg-red-500/20 text-red-400'
: affiliation.role === 'MODERATOR'
? 'border border-orange-500/50 bg-orange-500/20 text-orange-400'
: affiliation.role === 'SUPPORT'
? 'border border-blue-500/50 bg-blue-500/20 text-blue-400'
: 'border border-green-500/50 bg-green-500/20 text-green-400'
}`}
>
{affiliation.role.toLowerCase()}
</span>
</div>
<div class="mt-1 flex items-center gap-2">
<span class="font-title text-xs text-gray-500">
{transformCase(timeAgo.format(affiliation.createdAt, 'twitter-minute-now'), 'sentence')}
</span>
</div>
</div>
<form
method="POST"
action={actions.admin.user.serviceAffiliations.remove}
class="inline-flex"
data-astro-reload
>
<input type="hidden" name="id" value={affiliation.id} />
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:delete-bin-line" class="size-3" />
</button>
</form>
</div>
))}
</div>
)
}
<form
method="POST"
action={actions.admin.user.serviceAffiliations.add}
class="mt-6 space-y-4 border-t border-green-500/30 pt-6"
data-astro-reload
>
<input type="hidden" name="userId" value={user.id} />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="serviceId"> Service </label>
<select
name="serviceId"
id="serviceId"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500"
>
<option value="">Select a service</option>
{allServices.map((service) => <option value={service.id}>{service.name}</option>)}
</select>
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="role"> Role </label>
<select
name="role"
id="role"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500"
>
<option value="OWNER">Owner</option>
<option value="ADMIN">Admin</option>
<option value="MODERATOR">Moderator</option>
<option value="SUPPORT">Support</option>
<option value="TEAM_MEMBER">Team Member</option>
</select>
</div>
</div>
<div>
<button
type="submit"
class="font-title inline-flex items-center 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"
>
<Icon name="ri:link" class="mr-1 size-4" />
Add Service Affiliation
</button>
</div>
</form>
</section>
</div>
</BaseLayout>
<script>
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLDivElement>('[data-note-id]').forEach((noteDiv) => {
const checkbox = noteDiv.querySelector<HTMLInputElement>('input[data-edit-note-checkbox]')
if (!checkbox) return
checkbox.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement
if (!target) return
const noteContent = noteDiv.querySelector<HTMLDivElement>('[data-note-content]')
const editForm = noteDiv.querySelector<HTMLFormElement>('[data-note-edit-form]')
const cancelButton = noteDiv.querySelector<HTMLButtonElement>('[data-cancel-edit]')
if (noteContent && editForm) {
if (target.checked) {
noteContent.classList.add('hidden')
editForm.classList.remove('hidden')
} else {
noteContent.classList.remove('hidden')
editForm.classList.add('hidden')
}
}
if (cancelButton) {
cancelButton.addEventListener('click', () => {
target.checked = false
noteContent?.classList.remove('hidden')
editForm?.classList.add('hidden')
})
}
})
})
})
</script>
<script>
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLTextAreaElement>('[data-trim-content]').forEach((textarea) => {
textarea.value = textarea.value.trim()
})
})
</script>

View File

@@ -1,377 +0,0 @@
---
import { Icon } from 'astro-icon/components'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { pluralize } from '../../../lib/pluralize'
import { prisma } from '../../../lib/prisma'
import { formatDateShort } from '../../../lib/timeAgo'
import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
role: z.enum(['user', 'admin', 'verifier', 'verified', 'spammer']).optional(),
},
Astro
)
// Set up Prisma orderBy with correct typing
const prismaOrderBy =
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma'
? {
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
}
: { createdAt: 'desc' as const }
// Build where clause based on role filter
const whereClause: Prisma.UserWhereInput = {}
if (filters.search) {
whereClause.OR = [{ name: { contains: filters.search, mode: 'insensitive' } }]
}
if (filters.role) {
switch (filters.role) {
case 'user': {
whereClause.admin = false
whereClause.verifier = false
whereClause.verified = false
whereClause.spammer = false
break
}
case 'admin': {
whereClause.admin = true
break
}
case 'verifier': {
whereClause.verifier = true
break
}
case 'verified': {
whereClause.verified = true
break
}
case 'spammer': {
whereClause.spammer = true
break
}
}
}
// Retrieve users from the database
const dbUsers = await prisma.user.findMany({
where: whereClause,
select: {
id: true,
name: true,
verified: true,
admin: true,
verifier: true,
spammer: true,
totalKarma: true,
createdAt: true,
updatedAt: true,
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
},
},
_count: {
select: {
suggestions: true,
comments: true,
},
},
},
orderBy: prismaOrderBy,
})
const users =
filters['sort-by'] === 'role'
? lodashOrderBy(dbUsers, [(u) => (u.admin ? 'admin' : 'user')], [filters['sort-order']])
: dbUsers
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/users?${searchParams.toString()}`
}
---
<BaseLayout pageTitle="User Management" 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">User Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{users.length} users</span>
</div>
</div>
<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" autocomplete="off">
<div>
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<input
type="text"
name="search"
id="search"
value={filters.search}
placeholder="Search by name..."
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"
/>
</div>
<div>
<label for="role-filter" class="block text-xs font-medium text-zinc-400">Filter by Role/Status</label>
<div class="mt-1 flex">
<select
name="role"
id="role-filter"
class="w-full rounded-l-md border border-r-0 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="" selected={!filters.role}>All Users</option>
<option value="user" selected={filters.role === 'user'}>Regular Users</option>
<option value="admin" selected={filters.role === 'admin'}>Admins</option>
<option value="verifier" selected={filters.role === 'verifier'}>Verifiers</option>
<option value="verified" selected={filters.role === 'verified'}>Verified Users</option>
<option value="spammer" selected={filters.role === 'spammer'}>Spammers</option>
</select>
<button
type="submit"
class="inline-flex items-center rounded-r-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>
<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">Users 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-[900px]">
<table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30">
<tr>
<th
class="w-[15%] 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">
Name <SortArrowIcon
active={filters['sort-by'] === 'name'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Status
</th>
<th
class="w-[10%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('role')} class="flex items-center hover:text-zinc-200">
Role <SortArrowIcon
active={filters['sort-by'] === 'role'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('karma')} class="flex items-center justify-center hover:text-zinc-200">
Karma <SortArrowIcon
active={filters['sort-by'] === 'karma'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] 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"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Activity
</th>
<th
class="w-[15%] px-4 py-3 text-center 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">
{
users.map((user) => (
<tr
id={`user-${user.id}`}
class={`group hover:bg-zinc-700/30 ${user.spammer ? 'bg-red-900/10' : ''}`}
>
<td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div>{user.name}</div>
{user.internalNotes.length > 0 && (
<Tooltip
class="text-2xs mt-1 text-yellow-400"
position="right"
text={user.internalNotes
.map(
(note) =>
`${formatDateShort(note.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})}: ${note.content}`
)
.join('\n\n')}
>
<Icon name="ri:sticky-note-line" class="mr-1 inline-block size-3" />
{user.internalNotes.length} internal {pluralize('note', user.internalNotes.length)}
</Tooltip>
)}
</td>
<td class="px-4 py-3 text-sm">
<div class="flex flex-col items-center gap-1.5">
{user.spammer && (
<span class="inline-flex items-center gap-1 rounded-md bg-red-900/30 px-2 py-0.5 text-xs font-medium text-red-400">
<Icon name="ri:spam-2-fill" class="size-3.5" />
Spammer
</span>
)}
{user.verified && (
<span class="inline-flex items-center gap-1 rounded-md bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-400">
<Icon name="ri:checkbox-circle-fill" class="size-3.5" />
Verified
</span>
)}
{user.verifier && (
<span class="inline-flex items-center gap-1 rounded-md bg-blue-900/30 px-2 py-0.5 text-xs font-medium text-blue-400">
<Icon name="ri:shield-check-fill" class="size-3.5" />
Verifier
</span>
)}
</div>
</td>
<td class="px-4 py-3 text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${user.admin ? 'bg-purple-900/30 text-purple-300' : 'bg-zinc-700 text-zinc-300'}`}
>
{user.admin ? 'Admin' : 'User'}
</span>
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
>
{user.totalKarma}
</span>
</td>
<td class="px-4 py-3 text-center text-sm text-zinc-400">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} />
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-3">
<div class="flex flex-col items-center" title="Suggestions">
<span class="text-2xs text-zinc-400">Suggestions</span>
<span class="inline-flex items-center rounded-full bg-green-900/30 px-2.5 py-0.5 text-xs font-medium text-green-300">
{user._count.suggestions}
</span>
</div>
<div class="flex flex-col items-center" title="Comments">
<span class="text-2xs text-zinc-400">Comments</span>
<span class="inline-flex items-center rounded-full bg-purple-900/30 px-2.5 py-0.5 text-xs font-medium text-purple-300">
{user._count.comments}
</span>
</div>
</div>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
class="inline-flex items-center rounded-md border border-orange-500/50 bg-orange-500/20 px-1 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/30"
text="Impersonate"
>
<Icon name="ri:spy-line" class="size-4" />
</Tooltip>
<Tooltip
as="a"
href={`/admin/users/${user.name}`}
class="inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
</div>
</td>
</tr>
))
}
</tbody>
</table>
</div>
</div>
</div>
</BaseLayout>
<style>
.text-2xs {
font-size: 0.6875rem;
line-height: 1rem;
}
.scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 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;
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2);
-webkit-overflow-scrolling: touch;
}
}
</style>