Release 2025-05-25-ELtG

This commit is contained in:
pluja
2025-05-25 10:07:02 +00:00
parent 970622d061
commit ac9a2f428a
24 changed files with 776 additions and 564 deletions

View File

@@ -0,0 +1,20 @@
---
description:
globs: web/src/actions,web/src/pages
alwaysApply: false
---
- In the astro actions return, generaly don't return anythig unless the caller doesn't needs it. Specially don't `return { success: true }`, or similar. If needed, just return an object with the newly created/edited objects (Like: `return { newService: service }` or don't return anything if not needed).
- When importing actions, use `import { actions } from 'astro:actions'`. Example:
```ts
import { actions } from 'astro:actions'; /* CORRECT */
import { server } from '~/actions'; /* WRONG!!!! DON'T DO THIS */
import { adminAttributeActions } from '~/actions/admin/attribute.ts'; /* WRONG!!!! DON'T DO THIS */
const result = Astro.getActionResult(actions.admin.attribute.create);
```
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
- When throwing errors in Astro actions use ActionError.
- Always use Astro actions instead of with API routes and instead of `if (Astro.request.method === "POST")`.
- Generally call the actions using html forms. But if you need to, you can call them from the server-side code with Astro.callAction(), or [callActionWithUrlParams.ts](mdc:web/src/lib/callActionWithUrlParams.ts).
- The admin actions go into a separate folder.

View File

@@ -0,0 +1,26 @@
---
description:
globs: web/src/pages,web/src/components
alwaysApply: false
---
- Avoid sending JavaScript to the client. The JS send should always be optional.
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
- To avoid using JavaScript, you can use HTML and CSS features such as hidden checkboxes, deltails tag, etc.
- The admin pages can use client-side JavaScript.
- When adding clientside JS do it with HTMX.
- When adding HTMX, the layout component BaseLayout [BaseLayout.astro](mdc:web/src/layouts/BaseLayout.astro) [BaseHead.astro](mdc:web/src/components/BaseHead.astro) accepts a prop htmx to load it in that page. No need to use a cdn.
- When adding client scripts remember to use the event `astro:page-load`, `querySelectorAll<Type>` and add an explanation comment, like so:
```tsx
<script>
////////////////////////////////////////////////////////////
// Optional script for __________. //
// Desctiption goes here... //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLDivElement>('[data-my-div]').forEach((myDiv) => {
// Do something
})
})
</script>
```

View File

@@ -0,0 +1,8 @@
---
description:
globs:
alwaysApply: true
---
- Instead of using the syntax`Array<T>`, use `T[]`.
- Use TypeScript `type` over `interface`.
- You should never add unnecessary or unuseful comments, if you add a comment it must provide some value to the code.

View File

@@ -0,0 +1,54 @@
---
description: Querying the database, editing the database, needing to import types from the database, or anything related to the database or Prisma.
globs:
alwaysApply: false
---
- We use Prisma as ORM.
- Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database.
- Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this:
```ts
type Props = {
user: Prisma.UserGetPayload<{
select: {
name: true
displayName: true
picture: true
}
}>
}
```
- Only `select` the necessary fields, no more.
- In prisma preffer `select` over `include` when making queries.
- Avoid hardcoding enums from the database, import them from prisma.
- To query the database from Astro pages, use Astro.locals.try() or Astro.locals.tryMany([]) [errorBanners.ts](mdc:web/src/lib/errorBanners.ts) [middleware.ts](mdc:web/src/middleware.ts) , like so:
```ts
const [user, services] = await Astro.locals.banners.tryMany([
[
'Error fetching user',
() =>
prisma.user.findUnique({
where: { id: userId },
select: {
name: true,
displayName: true,
picture: true,
},
}),
],
[
'Error fetching services',
() =>
prisma.service.findMany({
where: { categories: { some: { id: categoryId } } },
select: {
id: true,
name: true,
slug: true,
},
}),
[] as [],
],
])
```
- When editing the database, remember to edit the db seeding file [faker.ts](mdc:web/scripts/faker.ts) to generate data for the new schema.

View File

@@ -0,0 +1,98 @@
---
description:
globs:
alwaysApply: true
---
- The main libraries used are: Astro, TypeScript, Tailwind 4, HTMX, Prisma, npm, zod, lodash-es, date-fns, ts-toolbelt. Full list in: [package.json](mdc:web/package.json)
- When creating constants or enums, use the `makeHelpersForOptions` function [makeHelpersForOptions.ts](mdc:web/src/lib/makeHelpersForOptions.ts) like in this example. Save the file in the `web/src/constants` folder (like [attributeTypes.ts](mdc:web/src/constants/attributeTypes.ts)). Note that it's not necessary to use all the options or export all the variables that the example has, just the ones you need.
```ts
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions';
import { transformCase } from '../lib/strings';
import type { AttributeType } from '@prisma/client';
type AttributeTypeInfo<T extends string | null | undefined = string> = {
value: T;
slug: string;
label: string;
icon: string;
order: number;
classNames: {
text: string;
icon: string;
};
};
export const {
dataArray: attributeTypes,
dataObject: attributeTypesById,
getFn: getAttributeTypeInfo,
getFnSlug: getAttributeTypeInfoBySlug,
zodEnumBySlug: attributeTypesZodEnumBySlug,
zodEnumById: attributeTypesZodEnumById,
keyToSlug: attributeTypeIdToSlug,
slugToKey: attributeTypeSlugToId,
} = makeHelpersForOptions(
'value',
(value): AttributeTypeInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value
? transformCase(value.replace('_', ' '), 'title')
: String(value),
icon: 'ri:question-line',
order: Infinity,
classNames: {
text: 'text-current/60',
icon: 'text-current/60',
},
}),
[
{
value: 'BAD',
slug: 'bad',
label: 'Bad',
icon: 'ri:close-line',
order: 1,
classNames: {
text: 'text-red-200',
icon: 'text-red-400',
},
},
{
value: 'WARNING',
slug: 'warning',
label: 'Warning',
icon: 'ri:alert-line',
order: 2,
classNames: {
text: 'text-yellow-200',
icon: 'text-yellow-400',
},
},
{
value: 'GOOD',
slug: 'good',
label: 'Good',
icon: 'ri:check-line',
order: 3,
classNames: {
text: 'text-green-200',
icon: 'text-green-400',
},
},
{
value: 'INFO',
slug: 'info',
label: 'Info',
icon: 'ri:information-line',
order: 4,
classNames: {
text: 'text-blue-200',
icon: 'text-blue-400',
},
},
] as const satisfies AttributeTypeInfo<AttributeType>[]
);
```

View File

@@ -0,0 +1,161 @@
---
description:
globs: web/src/pages,web/src/components
alwaysApply: false
---
- On .astro files, don't forget to include the three dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
- For icons use the `Icon` component from `astro-icon/components`.
- For icons use the Remix Icon library preferably.
- Use the `MyPicture` component from `src/components/MyPicture.astro` for images.
- When redirecting to login use the `makeLoginUrl` function from [redirectUrls.ts](mdc:web/src/lib/redirectUrls.ts) and if the link is for an `<a>` tag, use the `data-astro-reload` attribute. Similar for the logout and impersonate.
- Don't use the `web/src/pages/admin` pages as example unless explicitly stated or you're creating/editing an admin page.
- Checkout the @errorBanners.ts @middleware.ts @env.d.ts to see the avilable Astro.locals values.
- Avoid duplicating similar html code. You can use jsx for loops, create variables in the constants folder, or create separate components.
- When redirecting to the 404 not found page, use `Astro.rewrite` (Like this example: `if (!user) return Astro.rewrite('/404')`)
- Include schema markup in the pages when it makes sense. Examples: [[slug].astro](mdc:web/src/pages/service/[slug].astro)
- When creating forms, we already have utilities, components and established design patterns. Follow this example. (Note that this example may come slightly outdaded, but the overall philosophy doesn't change)
```astro
---
import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:content'
import Captcha from '../../components/Captcha.astro'
import InputCardGroup from '../../components/InputCardGroup.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
}
const result = Astro.getActionResult(actions.serviceSuggestion.editService)
if (result && !result.error) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const { data: params } = zodParseQueryParamsStoringErrors(
{
serviceId: z.coerce.number().int().positive(),
notes: z.string().default(''),
},
Astro
)
if (!params.serviceId) return Astro.rewrite('/404')
const service = await Astro.locals.banners.try(
'Failed to fetch service',
async () =>
prisma.service.findUnique({
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,
},
},
},
where: { id: params.serviceId },
}),
null
)
if (!service) return Astro.rewrite('/404')
---
<BaseLayout
pageTitle="Edit service"
description="Suggest an edit to service"
ogImage={{
template: 'generic',
title: 'Edit service',
description: 'Suggest an edit to service',
icon: 'ri:edit-line',
}}
widthClassName="max-w-screen-md"
>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
<form method="POST" action={actions.serviceSuggestion.editService} class="space-y-6">
<input type="hidden" name="serviceId" value={params.serviceId} />
<InputText
label="Service name"
name="name"
value={service.name}
error={inputErrors.name}
inputProps={{ 'data-custom-value': true, required: true }}
/>
<InputCardGroup
name="kycLevel"
label="KYC Level"
options={kycLevels.map((kycLevel) => ({
label: kycLevel.name,
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/5_`,
}))}
iconSize="md"
cardSize="md"
required
error={inputErrors.kycLevel}
/>
<InputCheckboxGroup
name="categories"
label="Categories"
required
options={categories.map((category) => ({
label: category.name,
value: category.id.toString(),
icon: category.icon,
}))}
error={inputErrors.categories}
/>
<InputImageFile
label="Service Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported."
error={inputErrors.imageFile}
square
required
/>
<InputTextArea
label="Note for Moderators"
name="notes"
value={params.notes}
inputProps={{ rows: 10 }}
error={inputErrors.notes}
/>
<Captcha action={actions.serviceSuggestion.createService} />
<InputHoneypotTrap name="message" />
<InputSubmitButton hideCancel />
</form>
</BaseLayout>
```

10
.cursor/rules/styles.mdc Normal file
View File

@@ -0,0 +1,10 @@
---
description:
globs: /web/src/pages,/web/src/components,/web/src/constants
alwaysApply: false
---
- We use Tailwind 4 (the latest version), make sure to not use outdated classes from Tailwind 3.
- Checkout the custom tailwind theme [global.css](mdc:web/src/styles/global.css).
- When adding conditional styles or merging tailwind classes, use the `cn` function. Never use template strings. [cn.ts](mdc:web/src/lib/cn.ts)
- For the grayscale colors, try to use the custom color `day` for the light/foreground colors (50-500) and `night` for the dark/bakground (500-950).
- Generally avoid using opacity modifiers (In `text-red-500/50` the `/50`), but it's fine to also use it.

View File

@@ -1,312 +0,0 @@
# Cursor Rules
- When merging tailwind classes, use the `cn` function.
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
- Instead of using the syntax`Array<T>`, use `T[]`.
- Use TypeScript `type` over `interface`.
- You are forbiddent o add comments unless explicitly stated by the user.
- Avoid sending JavaScript to the client. The JS send should be optional.
- In prisma preffer `select` over `include` when making queries.
- Import the types from prisma instead of hardcoding duplicates.
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
- Remember to check the prisma schema when doing things related to the database.
- Avoid hardcoding enums from the database, import them from prisma.
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
- The admin pages can use client-side JavaScript.
- Keep README.md in sync with new capabilities.
- The package manager is npm.
- For icons use the `Icon` component from `astro-icon/components`.
- For icons use the Remix Icon library preferably.
- Use the `Image` component from `astro:assets` for images.
- Use the `zod` library for schema validation.
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
- Don't forget that the astro files have three dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
- The admin actions go into a separate folder.
- In Actro actions when throwing errors use ActionError.
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
```ts
import { actions } from 'astro:actions'; /* CORRECT */
import { server } from '~/actions'; /* WRONG!!!! DON'T DO THIS */
import { adminAttributeActions } from '~/actions/admin/attribute.ts'; /* WRONG!!!! DON'T DO THIS */
const result = Astro.getActionResult(actions.admin.attribute.create);
```
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
- When adding clientside js do it with HTMX.
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts and if the link is for an `<a>` tag, use the `data-astro-reload` attribute.
```ts
function makeLoginUrl(
currentUrl: URL,
options: {
redirect?: URL | string | null;
error?: string | null;
logout?: boolean;
message?: string | null;
} = {}
);
```
- When adding client scripts remember to use the event `astro:page-load`, `querySelectorAll<Type>` and add an explanation comment, like so:
```tsx
<script>
////////////////////////////////////////////////////////////
// Optional script for __________. //
// Desctiption goes here... //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLDivElement>('[data-my-div]').forEach((myDiv) => {
// Do something
})
})
</script>
```
- When creating forms, we already have utilities, components and established design patterns. Follow this example:
```astro
---
import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:content'
import Captcha from '../../components/Captcha.astro'
import InputCardGroup from '../../components/InputCardGroup.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
}
const result = Astro.getActionResult(actions.serviceSuggestion.editService)
if (result && !result.error) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const { data: params } = zodParseQueryParamsStoringErrors(
{
serviceId: z.coerce.number().int().positive(),
notes: z.string().default(''),
},
Astro
)
if (!params.serviceId) return Astro.rewrite('/404')
const service = await Astro.locals.banners.try(
'Failed to fetch service',
async () =>
prisma.service.findUnique({
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,
},
},
},
where: { id: params.serviceId },
}),
null
)
if (!service) return Astro.rewrite('/404')
---
<BaseLayout
pageTitle="Edit service"
description="Suggest an edit to service"
ogImage={{
template: 'generic',
title: 'Edit service',
description: 'Suggest an edit to service',
icon: 'ri:edit-line',
}}
widthClassName="max-w-screen-md"
>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
<form method="POST" action={actions.serviceSuggestion.editService} class="space-y-6">
<input type="hidden" name="serviceId" value={params.serviceId} />
<InputText
label="Service name"
name="name"
value={service.name}
error={inputErrors.name}
inputProps={{ 'data-custom-value': true, required: true }}
/>
<InputCardGroup
name="kycLevel"
label="KYC Level"
options={kycLevels.map((kycLevel) => ({
label: kycLevel.name,
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/5_`,
}))}
iconSize="md"
cardSize="md"
required
error={inputErrors.kycLevel}
/>
<InputCheckboxGroup
name="categories"
label="Categories"
required
options={categories.map((category) => ({
label: category.name,
value: category.id.toString(),
icon: category.icon,
}))}
error={inputErrors.categories}
/>
<InputImageFile
label="Service Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported."
error={inputErrors.imageFile}
square
required
/>
<InputTextArea
label="Note for Moderators"
name="notes"
value={params.notes}
inputProps={{ rows: 10 }}
error={inputErrors.notes}
/>
<Captcha action={actions.serviceSuggestion.createService} />
<InputHoneypotTrap name="message" />
<InputSubmitButton hideCancel />
</form>
</BaseLayout>
```
- Don't use the `web/src/pages/admin` pages as example unless explicitly stated or you're creating/editing an admin page.
- When creating constants or enums, use the `makeHelpersForOptions` function like in this example. Save the file in the `web/src/constants` folder. Note that it's not necessary to use all the options the example has, just the ones you need.
```ts
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions';
import { transformCase } from '../lib/strings';
import type { AttributeType } from '@prisma/client';
type AttributeTypeInfo<T extends string | null | undefined = string> = {
value: T;
slug: string;
label: string;
icon: string;
order: number;
classNames: {
text: string;
icon: string;
};
};
export const {
dataArray: attributeTypes,
dataObject: attributeTypesById,
getFn: getAttributeTypeInfo,
getFnSlug: getAttributeTypeInfoBySlug,
zodEnumBySlug: attributeTypesZodEnumBySlug,
zodEnumById: attributeTypesZodEnumById,
keyToSlug: attributeTypeIdToSlug,
slugToKey: attributeTypeSlugToId,
} = makeHelpersForOptions(
'value',
(value): AttributeTypeInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value
? transformCase(value.replace('_', ' '), 'title')
: String(value),
icon: 'ri:question-line',
order: Infinity,
classNames: {
text: 'text-current/60',
icon: 'text-current/60',
},
}),
[
{
value: 'BAD',
slug: 'bad',
label: 'Bad',
icon: 'ri:close-line',
order: 1,
classNames: {
text: 'text-red-200',
icon: 'text-red-400',
},
},
{
value: 'WARNING',
slug: 'warning',
label: 'Warning',
icon: 'ri:alert-line',
order: 2,
classNames: {
text: 'text-yellow-200',
icon: 'text-yellow-400',
},
},
{
value: 'GOOD',
slug: 'good',
label: 'Good',
icon: 'ri:check-line',
order: 3,
classNames: {
text: 'text-green-200',
icon: 'text-green-400',
},
},
{
value: 'INFO',
slug: 'info',
label: 'Info',
icon: 'ri:information-line',
order: 4,
classNames: {
text: 'text-blue-200',
icon: 'text-blue-400',
},
},
] as const satisfies AttributeTypeInfo<AttributeType>[]
);
```

3
.gitignore vendored
View File

@@ -13,4 +13,5 @@ dump*.sql
*.log
*.bak
migrate.py
sync-all.sh
sync-all.sh
.DS_Store

View File

@@ -62,6 +62,8 @@ services:
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
REDIS_URL: "redis://redis:6379"
env_file:
- .env
depends_on:
database:
condition: service_healthy

View File

@@ -2,13 +2,15 @@
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import { cn } from '../lib/cn'
import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & {
as: Tag
label?: string
label: string
icon?: string
endIcon?: string
classNames?: {
@@ -55,6 +57,7 @@ const button = tv({
iconOnly: {
true: {
base: 'p-0',
label: 'sr-only',
},
},
color: {
@@ -137,49 +140,49 @@ const button = tv({
color: 'black',
variant: 'faded',
class: {
base: 'border-night-300/30 bg-night-800/30 hover:bg-night-700/50 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
base: 'bg2night-800/15 hover:bg-night-700/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
},
},
{
color: 'white',
variant: 'faded',
class: {
base: 'border-day-300/30 bg-day-100/30 hover:bg-day-200/50 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
base: 'b2-day-100/15 hover:bg-day-200/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
},
},
{
color: 'gray',
variant: 'faded',
class: {
base: 'border-day-500/30 bg-day-400/30 hover:bg-day-500/50 text-day-300 hover:text-day-100 focus-visible:ring-white/50',
base: 'b2-day-400/15 hover:bg-day-500/30 text-day-300 hover:text-day-100 border-current/30 focus-visible:ring-white/50',
},
},
{
color: 'success',
variant: 'faded',
class: {
base: 'border-green-600/30 bg-green-500/30 text-green-300 hover:bg-green-500/50 hover:text-green-100',
base: 'border-current/20 bg-green-500/15 text-green-300 hover:bg-green-500/30 hover:text-green-100',
},
},
{
color: 'danger',
variant: 'faded',
class: {
base: 'border-red-600/30 bg-red-500/30 text-red-300 hover:bg-red-500/50 hover:text-red-100',
base: 'border-current/20 bg-red-500/15 text-red-300 hover:bg-red-500/30 hover:text-red-100',
},
},
{
color: 'warning',
variant: 'faded',
class: {
base: 'border-yellow-600/30 bg-yellow-500/30 text-yellow-300 hover:bg-yellow-500/50 hover:text-yellow-100',
base: 'border-current/20 bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/30 hover:text-yellow-100',
},
},
{
color: 'info',
variant: 'faded',
class: {
base: 'border-blue-600/30 bg-blue-500/30 text-blue-300 hover:bg-blue-500/50 hover:text-blue-100',
base: 'border-current/20 bg-blue-500/15 text-blue-300 hover:bg-blue-500/30 hover:text-blue-100',
},
},
// Shadow variants
@@ -260,6 +263,7 @@ const {
dataAstroReload,
disabled,
inlineIcon,
iconOnly,
...htmlProps
} = Astro.props
@@ -268,13 +272,20 @@ const {
icon: iconSlot,
label: labelSlot,
endIcon: endIconSlot,
} = button({ size, color, variant, shadow, disabled, iconOnly: !label && !(!!icon && !!endIcon) })
} = button({
size,
color,
variant,
shadow,
disabled,
iconOnly: iconOnly ?? (!label && !(!!icon && !!endIcon)),
})
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
---
<ActualTag
class={base({ class: className })}
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ??
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
aria-disabled={disabled}
@@ -282,7 +293,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
{...htmlProps}
>
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
<span class={labelSlot({ class: classNames?.label })}>{label}</span>
{
!!endIcon && (
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>

View File

@@ -52,7 +52,7 @@ const { class: className, ...htmlProps } = Astro.props
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
>
<Icon name={icon} class="h-4 w-4" />
{label}

View File

@@ -40,6 +40,7 @@ const {
hx-select={`#${searchResultsId}`}
hx-push-url="true"
hx-indicator="#search-indicator"
hx-swap="outerHTML"
data-services-filters-form
data-default-verification-filter={options.verification
.filter((verification) => verification.default)
@@ -107,7 +108,7 @@ const {
{
options.categories?.map((category) => (
<li data-show-always={category.showAlways ? '' : undefined}>
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white">
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input
type="checkbox"
class="peer text-green-500"
@@ -116,6 +117,7 @@ const {
checked={category.checked}
data-trigger-on-change
/>
<Icon name={category.icon} class="size-4" />
<span class="peer-checked:font-bold">
{category.name}
<span class="text-day-500 font-normal">{category._count.services}</span>

View File

@@ -205,6 +205,13 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
inlineIcon={inlineIcons}
/>
)}
<Button
as="a"
href="/service-suggestion/new"
label="Add service"
icon="ri:add-line"
inlineIcon={inlineIcons}
/>
</div>
</div>
) : (
@@ -241,14 +248,18 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
</>
)
}
<div class="mt-4 text-center">
<Button
as="a"
href="/service-suggestion/new"
label="Add service"
icon="ri:add-line"
inlineIcon={inlineIcons}
class="mx-auto"
/>
</div>
{
services && services.length > 0 && (
<div class="mt-4 text-center">
<Button
as="a"
href="/service-suggestion/new"
label="Add service"
icon="ri:add-line"
inlineIcon={inlineIcons}
class="mx-auto"
/>
</div>
)
}
</div>

View File

@@ -9,7 +9,7 @@ import {
import type { MaybePromise } from 'astro/actions/runtime/utils.js'
import type { z } from 'astro/zod'
type SpecialUserPermission = 'admin' | 'verified' | 'moderator'
type SpecialUserPermission = 'admin' | 'moderator' | 'verified'
type Permission = SpecialUserPermission | 'guest' | 'not-spammer' | 'user'
type ActionAPIContextWithUser = ActionAPIContext & {

View File

@@ -206,7 +206,6 @@ You can contact via direct chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
## Disclaimer
This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project.

View File

@@ -143,9 +143,9 @@ if (toggleResult?.error) {
}
---
<BaseLayout pageTitle="Announcement Management" widthClassName="max-w-screen-xl">
<BaseLayout pageTitle="Announcements" 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">Announcement Management</h1>
<h1 class="font-title text-2xl font-bold text-white">Announcements</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{announcements.length} announcements</span>
</div>
@@ -193,12 +193,17 @@ if (toggleResult?.error) {
<option value="active" selected={filters.status === 'active'}>Active</option>
<option value="inactive" selected={filters.status === 'inactive'}>Inactive</option>
</select>
<button
<Button
as="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>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="rounded-l-none"
/>
</div>
</div>
</form>
@@ -206,13 +211,16 @@ if (toggleResult?.error) {
<!-- Create New Announcement Form -->
<div class="mb-6">
<button
<Button
as="button"
id="toggle-new-announcement-form"
class="mb-4 inline-flex items-center 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:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:add-line" class="mr-1 h-4 w-4" />
Create New Announcement
</button>
color="success"
variant="solid"
size="md"
icon="ri:add-line"
label="Create"
class="mb-4"
/>
<div
id="new-announcement-form"
@@ -306,13 +314,16 @@ if (toggleResult?.error) {
<div class="pt-4">
<InputSubmitButton label="Create Announcement" icon="ri:save-line" hideCancel />
<button
<Button
as="button"
type="button"
id="cancel-create"
class="ml-2 inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Cancel
</button>
color="gray"
variant="faded"
size="md"
label="Cancel"
class="ml-2"
/>
</div>
</div>
</form>
@@ -339,19 +350,24 @@ if (toggleResult?.error) {
<form method="POST" action={actions.admin.announcement.delete} id="delete-form">
<input type="hidden" name="id" id="delete-id" />
<div class="flex justify-end gap-2">
<button
<Button
as="button"
type="button"
class="close-modal inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Cancel
</button>
<button
class="close-modal"
color="gray"
variant="faded"
size="md"
label="Cancel"
/>
<Button
as="button"
type="submit"
class="inline-flex items-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:delete-bin-line" class="mr-1 h-4 w-4" />
Delete
</button>
color="danger"
variant="solid"
size="md"
icon="ri:delete-bin-line"
label="Delete"
/>
</div>
</form>
</div>
@@ -497,60 +513,64 @@ if (toggleResult?.error) {
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="button"
type="button"
data-id={announcement.id}
class="edit-button 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"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
>
<Icon name="ri:edit-line" class="size-4" />
<Tooltip text="Edit">
<Button
as="button"
type="button"
data-id={announcement.id}
class="edit-button"
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
/>
</Tooltip>
<form
method="POST"
action={actions.admin.announcement.toggleActive}
class="inline-block"
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
>
<input type="hidden" name="id" value={announcement.id} />
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
<button
type="submit"
class={`rounded-md border px-1 py-1 text-xs transition-colors ${
announcement.isActive
? 'border-yellow-500/50 bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'border-green-500/50 bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
<form
method="POST"
action={actions.admin.announcement.toggleActive}
class="inline-block"
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
>
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
<Icon
name={
announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'
}
class="size-4"
/>
</Tooltip>
</button>
</form>
<input type="hidden" name="id" value={announcement.id} />
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
<Button
as="button"
type="submit"
color={announcement.isActive ? 'warning' : 'success'}
variant="faded"
size="sm"
iconOnly
icon={announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'}
label={announcement.isActive ? 'Deactivate' : 'Activate'}
/>
</form>
</Tooltip>
<form
method="POST"
action={actions.admin.announcement.delete}
class="inline-block"
data-confirm="Are you sure you want to delete this announcement?"
>
<input type="hidden" name="id" value={announcement.id} />
<button
type="submit"
class="rounded-md border border-red-500/50 bg-red-500/20 px-1 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/30"
<Tooltip text="Delete">
<form
method="POST"
action={actions.admin.announcement.delete}
class="inline-block"
data-confirm="Are you sure you want to delete this announcement?"
>
<Tooltip text="Delete">
<Icon name="ri:delete-bin-line" class="size-4" />
</Tooltip>
</button>
</form>
<input type="hidden" name="id" value={announcement.id} />
<Button
as="button"
type="submit"
color="danger"
variant="faded"
size="sm"
iconOnly
icon="ri:delete-bin-line"
label="Delete"
/>
</form>
</Tooltip>
</div>
</td>
</tr>
@@ -648,9 +668,12 @@ if (toggleResult?.error) {
<div class="pt-4">
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel />
<Button
as="button"
type="button"
label="Cancel"
color="gray"
variant="faded"
size="md"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
class="ml-2"
/>

View File

@@ -5,7 +5,9 @@ import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import Button from '../../components/Button.astro'
import SortArrowIcon from '../../components/SortArrowIcon.astro'
import Tooltip from '../../components/Tooltip.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import BaseLayout from '../../layouts/BaseLayout.astro'
@@ -136,14 +138,15 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<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"
<Button
as="button"
color="success"
variant="solid"
size="md"
icon="ri:add-line"
label="New Attribute"
onclick="document.getElementById('create-attribute-form').classList.toggle('hidden')"
>
<Icon name="ri:add-line" class="size-4" />
<span>New Attribute</span>
</button>
/>
</div>
</div>
@@ -286,12 +289,15 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div>
</div>
<button
<Button
as="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>
color="success"
variant="solid"
size="md"
label="Create Attribute"
class="w-full"
/>
</form>
</div>
@@ -343,12 +349,17 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
))
}
</select>
<button
<Button
as="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>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="rounded-l-none"
/>
</div>
</div>
</form>
@@ -519,25 +530,34 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</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>
<Tooltip text="Edit">
<Button
as="button"
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
/>
</Tooltip>
<Tooltip text="Delete">
<form method="POST" action={actions.admin.attribute.delete} class="inline-block">
<input type="hidden" name="id" value={attribute.id} />
<Button
as="button"
type="submit"
color="danger"
variant="faded"
size="sm"
iconOnly
icon="ri:delete-bin-line"
label="Delete"
onclick="return confirm('Are you sure you want to delete this attribute?')"
/>
</form>
</Tooltip>
</div>
</td>
</tr>
@@ -673,19 +693,23 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</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"
<Button
as="button"
color="gray"
variant="faded"
size="sm"
label="Cancel"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
>
Cancel
</button>
<button
/>
<Button
as="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>
color="info"
variant="solid"
size="sm"
icon="ri:save-line"
label="Save"
/>
</div>
</form>
</td>

View File

@@ -2,10 +2,14 @@
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
import UserBadge from '../../../components/UserBadge.astro'
import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
@@ -96,13 +100,15 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
widthClassName="max-w-screen-md"
>
<div class="mb-4 flex items-center gap-4">
<a
<Button
as="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>
color="success"
variant="faded"
size="md"
icon="ri:arrow-left-s-line"
label="Back"
/>
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
</div>
@@ -170,17 +176,23 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
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>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={serviceSuggestion.status === status.value}>
{status.label}
</option>
))
}
</select>
<button
<Button
as="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>
color="success"
variant="faded"
size="md"
icon="ri:save-line"
label="Update"
/>
</form>
</div>

View File

@@ -4,8 +4,10 @@ import { actions } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy } from 'lodash-es'
import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
import UserBadge from '../../../components/UserBadge.astro'
import {
getServiceSuggestionStatusInfo,
@@ -183,12 +185,16 @@ const makeSortUrl = (slug: string) => {
</select>
</div>
<div class="flex items-end">
<button
<Button
as="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>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
/>
</div>
</form>
</div>
@@ -317,13 +323,18 @@ const makeSortUrl = (slug: string) => {
</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>
<Tooltip text="Manage">
<Button
as="a"
href={`/admin/service-suggestions/${suggestion.id}`}
color="success"
variant="faded"
size="sm"
iconOnly
icon="ri:arrow-right-line"
label="Manage"
/>
</Tooltip>
</div>
</td>
</tr>

View File

@@ -27,46 +27,54 @@ import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevels } from '../../../../constants/kycLevels'
import { serviceVisibilities } from '../../../../constants/serviceVisibility'
import { verificationStatuses } from '../../../../constants/verificationStatus'
import { getVerificationStepStatusInfo } from '../../../../constants/verificationStepStatus'
import {
getVerificationStepStatusInfo,
verificationStepStatuses,
} from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma'
const { slug } = Astro.params
const serviceResult = Astro.getActionResult(actions.admin.service.update)
const eventCreateResult = Astro.getActionResult(actions.admin.event.create)
const eventToggleResult = Astro.getActionResult(actions.admin.event.toggle)
const eventDeleteResult = Astro.getActionResult(actions.admin.event.delete)
const eventUpdateResult = Astro.getActionResult(actions.admin.event.update)
const verificationStepCreateResult = Astro.getActionResult(actions.admin.verificationStep.create)
const verificationStepUpdateResult = Astro.getActionResult(actions.admin.verificationStep.update)
const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete)
if (!slug) return Astro.rewrite('/404')
const serviceResult = Astro.getActionResult(actions.admin.service.update)
Astro.locals.banners.addIfSuccess(serviceResult, 'Service updated successfully')
Astro.locals.banners.addIfSuccess(eventCreateResult, 'Event created successfully')
Astro.locals.banners.addIfSuccess(eventToggleResult, 'Event visibility updated successfully')
Astro.locals.banners.addIfSuccess(eventDeleteResult, 'Event deleted successfully')
Astro.locals.banners.addIfSuccess(eventUpdateResult, 'Event updated successfully')
Astro.locals.banners.addIfSuccess(verificationStepCreateResult, 'Verification step added successfully')
Astro.locals.banners.addIfSuccess(verificationStepUpdateResult, 'Verification step updated successfully')
Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully')
const serviceInputErrors = isInputError(serviceResult?.error) ? serviceResult.error.fields : {}
if (serviceResult && !serviceResult.error && slug !== serviceResult.data.service.slug) {
return Astro.redirect(`/admin/services/${serviceResult.data.service.slug}/edit`)
}
const serviceInputErrors = isInputError(serviceResult?.error) ? serviceResult.error.fields : {}
const eventCreateResult = Astro.getActionResult(actions.admin.event.create)
Astro.locals.banners.addIfSuccess(eventCreateResult, 'Event created successfully')
const eventInputErrors = isInputError(eventCreateResult?.error) ? eventCreateResult.error.fields : {}
const eventUpdateResult = Astro.getActionResult(actions.admin.event.update)
Astro.locals.banners.addIfSuccess(eventUpdateResult, 'Event updated successfully')
const eventUpdateInputErrors = isInputError(eventUpdateResult?.error) ? eventUpdateResult.error.fields : {}
const eventToggleResult = Astro.getActionResult(actions.admin.event.toggle)
Astro.locals.banners.addIfSuccess(eventToggleResult, 'Event visibility updated successfully')
const eventDeleteResult = Astro.getActionResult(actions.admin.event.delete)
Astro.locals.banners.addIfSuccess(eventDeleteResult, 'Event deleted successfully')
const verificationStepCreateResult = Astro.getActionResult(actions.admin.verificationStep.create)
Astro.locals.banners.addIfSuccess(verificationStepCreateResult, 'Verification step added successfully')
const verificationStepInputErrors = isInputError(verificationStepCreateResult?.error)
? verificationStepCreateResult.error.fields
: {}
const verificationStepUpdateResult = Astro.getActionResult(actions.admin.verificationStep.update)
Astro.locals.banners.addIfSuccess(verificationStepUpdateResult, 'Verification step updated successfully')
const verificationStepUpdateInputErrors = isInputError(verificationStepUpdateResult?.error)
? verificationStepUpdateResult.error.fields
: {}
if (!slug) return Astro.rewrite('/404')
const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete)
Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully')
const [service, categories, attributes] = await Astro.locals.banners.tryMany([
[
@@ -715,7 +723,7 @@ if (!service) return Astro.rewrite('/404')
<InputSelect
label="Status"
name="status"
options={verificationStatuses.map((status) => ({
options={verificationStepStatuses.map((status) => ({
label: status.label,
value: status.value,
}))}
@@ -763,7 +771,7 @@ if (!service) return Astro.rewrite('/404')
<InputSelect
label="Status"
name="status"
options={verificationStatuses.map((status) => ({
options={verificationStepStatuses.map((status) => ({
label: status.label,
value: status.value,
}))}

View File

@@ -3,8 +3,10 @@ import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/clie
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import Button from '../../../components/Button.astro'
import MyPicture from '../../../components/MyPicture.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import Tooltip from '../../../components/Tooltip.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
@@ -171,13 +173,15 @@ const truncate = (text: string, length: number) => {
<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
<Button
as="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>
color="success"
variant="solid"
size="md"
icon="ri:add-line"
label="New Service"
/>
</div>
</div>
@@ -250,12 +254,17 @@ const truncate = (text: string, length: number) => {
))
}
</select>
<button
<Button
as="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>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="ml-2"
/>
</div>
</div>
</form>
@@ -437,20 +446,30 @@ const truncate = (text: string, length: number) => {
<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>
<Tooltip text="View">
<Button
as="a"
href={`/service/${service.slug}`}
color="success"
variant="faded"
size="sm"
iconOnly
icon="ri:global-line"
label="View"
/>
</Tooltip>
<Tooltip text="Edit">
<Button
as="a"
href={`/admin/services/${service.slug}/edit`}
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
/>
</Tooltip>
</div>
</td>
</tr>

View File

@@ -3,6 +3,7 @@ import { Icon } from 'astro-icon/components'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
@@ -151,12 +152,17 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<option value="verified" selected={filters.role === 'verified'}>Verified Users</option>
<option value="spammer" selected={filters.role === 'spammer'}>Spammers</option>
</select>
<button
<Button
as="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>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="rounded-l-none"
/>
</div>
</div>
</form>
@@ -320,30 +326,43 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
</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`}
data-astro-prefetch="tap"
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 text="Impersonate">
<Button
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
data-astro-prefetch="tap"
color="warning"
variant="faded"
size="sm"
iconOnly
icon="ri:spy-line"
label="Impersonate"
disabled={user.admin}
/>
</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 text="Edit">
<Button
as="a"
href={`/admin/users/${user.name}`}
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
/>
</Tooltip>
<Tooltip
as="a"
href={`/u/${user.name}`}
class="inline-flex items-center rounded-md border border-green-500/50 bg-green-500/20 px-1 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/30"
text="Public profile"
>
<Icon name="ri:global-line" class="size-4" />
<Tooltip text="Public profile">
<Button
as="a"
href={`/u/${user.name}`}
color="success"
variant="faded"
size="sm"
iconOnly
icon="ri:global-line"
label="Public profile"
/>
</Tooltip>
</div>
</td>

View File

@@ -322,7 +322,11 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
icon: true,
_count: {
select: {
services: true,
services: {
where: {
serviceVisibility: 'PUBLIC',
},
},
},
},
},
@@ -530,6 +534,7 @@ const showFiltersId = 'show-filters'
hx-trigger="input delay:500ms, keyup[key=='Enter']"
hx-target={`#${searchResultsId}`}
hx-select={`#${searchResultsId}`}
hx-swap="outerHTML"
hx-push-url="true"
hx-indicator="#search-indicator"
class="contents"