Compare commits
17 Commits
release-20
...
release-39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea40f17d3c | ||
|
|
7e0d41cc7a | ||
|
|
70a097054b | ||
|
|
e536ca6519 | ||
|
|
b361ed3aa8 | ||
|
|
50ede46d50 | ||
|
|
ba809840c6 | ||
|
|
f2021a3027 | ||
|
|
6b86a72d1e | ||
|
|
8f2b2c34ff | ||
|
|
ac9a2f428a | ||
|
|
970622d061 | ||
|
|
4806a7fd4e | ||
|
|
85605de8aa | ||
|
|
7a22629c55 | ||
|
|
8deb9acb93 | ||
|
|
61a5448ff5 |
20
.cursor/rules/astro-actions-api.mdc
Normal file
20
.cursor/rules/astro-actions-api.mdc
Normal 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.
|
||||||
26
.cursor/rules/client-side-javascript.mdc
Normal file
26
.cursor/rules/client-side-javascript.mdc
Normal 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>
|
||||||
|
```
|
||||||
8
.cursor/rules/code-style.mdc
Normal file
8
.cursor/rules/code-style.mdc
Normal 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.
|
||||||
55
.cursor/rules/database.mdc
Normal file
55
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
- After making changes to the [schema.prisma](mdc:web/prisma/schema.prisma) database or [seed.ts](mdc:web/prisma/seed.ts), you run `npm run db-reset` (from `/web/` folder) [package.json](mdc:web/package.json). And never do the migrations manually.
|
||||||
|
- 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 [seed.ts](mdc:web/prisma/seed.ts) to generate data for the new schema.
|
||||||
98
.cursor/rules/design-patterns.mdc
Normal file
98
.cursor/rules/design-patterns.mdc
Normal 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>[]
|
||||||
|
);
|
||||||
|
```
|
||||||
161
.cursor/rules/pages-and-components.mdc
Normal file
161
.cursor/rules/pages-and-components.mdc
Normal 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
10
.cursor/rules/styles.mdc
Normal 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.
|
||||||
312
.cursorrules
312
.cursorrules
@@ -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>[]
|
|
||||||
);
|
|
||||||
```
|
|
||||||
0
.env.example
Normal file
0
.env.example
Normal file
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,4 +13,5 @@ dump*.sql
|
|||||||
*.log
|
*.log
|
||||||
*.bak
|
*.bak
|
||||||
migrate.py
|
migrate.py
|
||||||
sync-all.sh
|
sync-all.sh
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -23,13 +23,12 @@ cd web
|
|||||||
nvm install
|
nvm install
|
||||||
npm i
|
npm i
|
||||||
cp -n .env.example .env
|
cp -n .env.example .env
|
||||||
npm run db-push
|
npm run db-reset
|
||||||
npm run db-fill-clean
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Now open the [.env](web/.env) file and fill in the missing values.
|
Now open the [.env](web/.env) file and fill in the missing values.
|
||||||
|
|
||||||
> Default users are created with tokens: `admin`, `verifier`, `verified`, `normal` (configurable via env vars)
|
> Default users are created with tokens: `admin`, `moderator`, `verified`, `normal` (configurable via env vars)
|
||||||
|
|
||||||
### Running the project
|
### Running the project
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ services:
|
|||||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
||||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
||||||
REDIS_URL: "redis://redis:6379"
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
|
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
|
||||||
REDIS_URL="redis://localhost:6379"
|
REDIS_URL="redis://localhost:6379"
|
||||||
SOURCE_CODE_URL="https://github.com"
|
SOURCE_CODE_URL="https://github.com"
|
||||||
|
DATABASE_UI_URL="http://localhost:5555"
|
||||||
SITE_URL="https://localhost:4321"
|
SITE_URL="https://localhost:4321"
|
||||||
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
|
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
|
||||||
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
||||||
|
RELEASE_NUMBER=123
|
||||||
|
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
||||||
|
|||||||
@@ -6,24 +6,23 @@
|
|||||||
|
|
||||||
All commands are run from the root of the project, from a terminal:
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
| Command | Action |
|
| Command | Action |
|
||||||
| :------------------------ | :------------------------------------------------------------------- |
|
| :------------------------ | :------------------------------------------------------------------ |
|
||||||
| `nvm install` | Installs and uses the correct version of node |
|
| `nvm install` | Installs and uses the correct version of node |
|
||||||
| `npm install` | Installs dependencies |
|
| `npm install` | Installs dependencies |
|
||||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
| `npm run build` | Build your production site to `./dist/` |
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
| `npm run preview` | Preview your build locally, before deploying |
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
| `npm run db-admin` | Runs Prisma Studio (database admin) |
|
| `npm run db-admin` | Runs Prisma Studio (database admin) |
|
||||||
| `npm run db-gen` | Generates the Prisma client without running migrations |
|
| `npm run db-gen` | Generates the Prisma client without running migrations |
|
||||||
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
|
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
|
||||||
| `npm run db-fill` | Fills the database with fake data (development mode) |
|
| `npm run db-seed` | Seeds the database with fake data (development mode) |
|
||||||
| `npm run db-fill-clean` | Cleans existing data and fills with new fake data (development mode) |
|
| `npm run format` | Formats the code with Prettier |
|
||||||
| `npm run format` | Formats the code with Prettier |
|
| `npm run lint` | Lints the code with ESLint |
|
||||||
| `npm run lint` | Lints the code with ESLint |
|
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
|
||||||
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
|
|
||||||
|
|
||||||
> **Note**: `db-fill` and `db-fill-clean` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-fill -- --services=5` will add 5 fake services.
|
> **Note**: `db-seed` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-seed -- --services=5` will add 5 fake services.
|
||||||
|
|
||||||
> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `verifier`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
> **Note**: `db-seed` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
||||||
|
|||||||
@@ -131,11 +131,11 @@ export default defineConfig({
|
|||||||
min: 1,
|
min: 1,
|
||||||
default: 'admin',
|
default: 'admin',
|
||||||
}),
|
}),
|
||||||
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({
|
DEV_MODERATOR_USER_SECRET_TOKEN: envField.string({
|
||||||
context: 'server',
|
context: 'server',
|
||||||
access: 'secret',
|
access: 'secret',
|
||||||
min: 1,
|
min: 1,
|
||||||
default: 'verifier',
|
default: 'moderator',
|
||||||
}),
|
}),
|
||||||
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
|
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
|
||||||
context: 'server',
|
context: 'server',
|
||||||
@@ -170,6 +170,31 @@ export default defineConfig({
|
|||||||
url: true,
|
url: true,
|
||||||
optional: false,
|
optional: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
DATABASE_UI_URL: envField.string({
|
||||||
|
context: 'server',
|
||||||
|
access: 'secret',
|
||||||
|
url: true,
|
||||||
|
optional: false,
|
||||||
|
}),
|
||||||
|
LOGS_UI_URL: envField.string({
|
||||||
|
context: 'server',
|
||||||
|
access: 'secret',
|
||||||
|
url: true,
|
||||||
|
optional: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
RELEASE_NUMBER: envField.number({
|
||||||
|
context: 'server',
|
||||||
|
access: 'public',
|
||||||
|
int: true,
|
||||||
|
optional: true,
|
||||||
|
}),
|
||||||
|
RELEASE_DATE: envField.string({
|
||||||
|
context: 'server',
|
||||||
|
access: 'public',
|
||||||
|
optional: true,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,13 +12,15 @@
|
|||||||
"db-push": "prisma migrate dev",
|
"db-push": "prisma migrate dev",
|
||||||
"db-triggers": "just import-triggers",
|
"db-triggers": "just import-triggers",
|
||||||
"db-update": "prisma migrate dev && just import-triggers",
|
"db-update": "prisma migrate dev && just import-triggers",
|
||||||
"db-reset": "prisma migrate reset && prisma migrate dev && just import-triggers && tsx scripts/faker.ts",
|
"db-reset": "prisma migrate reset -f && prisma migrate dev",
|
||||||
"db-fill": "tsx scripts/faker.ts",
|
"db-seed": "prisma db seed",
|
||||||
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
|
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint-fix": "eslint . --fix && prettier --write ."
|
"lint-fix": "eslint . --fix && prettier --write ."
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "0.9.4",
|
"@astrojs/check": "0.9.4",
|
||||||
"@astrojs/db": "0.14.14",
|
"@astrojs/db": "0.14.14",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
Manully edited to be a rename migration.
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
BEGIN;
|
||||||
|
ALTER TYPE "AccountStatusChange" RENAME VALUE 'VERIFIER_TRUE' TO 'MODERATOR_TRUE';
|
||||||
|
ALTER TYPE "AccountStatusChange" RENAME VALUE 'VERIFIER_FALSE' TO 'MODERATOR_FALSE';
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User"
|
||||||
|
RENAME COLUMN "verifier" TO "moderator"
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "InternalServiceNote" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"serviceId" INTEGER NOT NULL,
|
||||||
|
"addedByUserId" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "InternalServiceNote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "InternalServiceNote_serviceId_idx" ON "InternalServiceNote"("serviceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "InternalServiceNote_addedByUserId_idx" ON "InternalServiceNote"("addedByUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "InternalServiceNote_createdAt_idx" ON "InternalServiceNote"("createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_addedByUserId_fkey" FOREIGN KEY ("addedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -87,6 +87,7 @@ enum ServiceVisibility {
|
|||||||
PUBLIC
|
PUBLIC
|
||||||
UNLISTED
|
UNLISTED
|
||||||
HIDDEN
|
HIDDEN
|
||||||
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Currency {
|
enum Currency {
|
||||||
@@ -120,8 +121,8 @@ enum AccountStatusChange {
|
|||||||
ADMIN_FALSE
|
ADMIN_FALSE
|
||||||
VERIFIED_TRUE
|
VERIFIED_TRUE
|
||||||
VERIFIED_FALSE
|
VERIFIED_FALSE
|
||||||
VERIFIER_TRUE
|
MODERATOR_TRUE
|
||||||
VERIFIER_FALSE
|
MODERATOR_FALSE
|
||||||
SPAMMER_TRUE
|
SPAMMER_TRUE
|
||||||
SPAMMER_FALSE
|
SPAMMER_FALSE
|
||||||
}
|
}
|
||||||
@@ -376,6 +377,7 @@ model Service {
|
|||||||
attributes ServiceAttribute[]
|
attributes ServiceAttribute[]
|
||||||
verificationSteps VerificationStep[]
|
verificationSteps VerificationStep[]
|
||||||
suggestions ServiceSuggestion[]
|
suggestions ServiceSuggestion[]
|
||||||
|
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||||
|
|
||||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||||
@@ -441,6 +443,7 @@ model Attribute {
|
|||||||
|
|
||||||
model InternalUserNote {
|
model InternalUserNote {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
/// Markdown
|
||||||
content String
|
content String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -455,6 +458,23 @@ model InternalUserNote {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InternalServiceNote {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// Markdown
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
service Service @relation("ServiceRecievedNotes", fields: [serviceId], references: [id], onDelete: Cascade)
|
||||||
|
serviceId Int
|
||||||
|
addedByUser User? @relation("UserAddedServiceNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
addedByUserId Int?
|
||||||
|
|
||||||
|
@@index([serviceId])
|
||||||
|
@@index([addedByUserId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
@@ -464,7 +484,7 @@ model User {
|
|||||||
spammer Boolean @default(false)
|
spammer Boolean @default(false)
|
||||||
verified Boolean @default(false)
|
verified Boolean @default(false)
|
||||||
admin Boolean @default(false)
|
admin Boolean @default(false)
|
||||||
verifier Boolean @default(false)
|
moderator Boolean @default(false)
|
||||||
verifiedLink String?
|
verifiedLink String?
|
||||||
secretTokenHash String @unique
|
secretTokenHash String @unique
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
@@ -481,6 +501,7 @@ model User {
|
|||||||
suggestionMessages ServiceSuggestionMessage[]
|
suggestionMessages ServiceSuggestionMessage[]
|
||||||
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
|
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
|
||||||
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
|
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
|
||||||
|
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
|
||||||
verificationRequests ServiceVerificationRequest[]
|
verificationRequests ServiceVerificationRequest[]
|
||||||
notifications Notification[] @relation("NotificationOwner")
|
notifications Notification[] @relation("NotificationOwner")
|
||||||
notificationPreferences NotificationPreferences?
|
notificationPreferences NotificationPreferences?
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { parseArgs } from 'node:util'
|
||||||
|
|
||||||
import { faker } from '@faker-js/faker'
|
import { faker } from '@faker-js/faker'
|
||||||
import {
|
import {
|
||||||
|
AnnouncementType,
|
||||||
AttributeCategory,
|
AttributeCategory,
|
||||||
AttributeType,
|
AttributeType,
|
||||||
CommentStatus,
|
CommentStatus,
|
||||||
Currency,
|
Currency,
|
||||||
|
EventType,
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
ServiceSuggestionStatus,
|
ServiceSuggestionStatus,
|
||||||
ServiceSuggestionType,
|
ServiceUserRole,
|
||||||
VerificationStatus,
|
VerificationStatus,
|
||||||
type Prisma,
|
type Prisma,
|
||||||
EventType,
|
|
||||||
type User,
|
type User,
|
||||||
ServiceUserRole,
|
type ServiceVisibility,
|
||||||
AnnouncementType,
|
ServiceSuggestionType,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import { uniqBy } from 'lodash-es'
|
import { uniqBy } from 'lodash-es'
|
||||||
import { generateUsername } from 'unique-username-generator'
|
import { generateUsername } from 'unique-username-generator'
|
||||||
@@ -85,7 +88,7 @@ async function createAccount(preGeneratedToken?: string) {
|
|||||||
verifiedLink,
|
verifiedLink,
|
||||||
verified: !!verifiedLink,
|
verified: !!verifiedLink,
|
||||||
admin: faker.datatype.boolean({ probability: 0.1 }),
|
admin: faker.datatype.boolean({ probability: 0.1 }),
|
||||||
verifier: faker.datatype.boolean({ probability: 0.1 }),
|
moderator: faker.datatype.boolean({ probability: 0.1 }),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
serviceAffiliations: true,
|
serviceAffiliations: true,
|
||||||
@@ -95,20 +98,6 @@ async function createAccount(preGeneratedToken?: string) {
|
|||||||
return { token, user }
|
return { token, user }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
const shouldCleanup = args.includes('--cleanup') || args.includes('-c')
|
|
||||||
const onlyCleanup = args.includes('--only-cleanup') || args.includes('-oc')
|
|
||||||
|
|
||||||
// Parse number of services from --services or -s flag
|
|
||||||
const servicesArg = args.find((arg) => arg.startsWith('--services=') || arg.startsWith('-s='))
|
|
||||||
const numServices = parseIntWithFallback(servicesArg?.split('=')[1], 100) // Default to 100 if not specified
|
|
||||||
|
|
||||||
if (isNaN(numServices) || numServices < 1) {
|
|
||||||
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
const generateFakeAttribute = () => {
|
const generateFakeAttribute = () => {
|
||||||
@@ -611,7 +600,12 @@ const generateFakeEvent = (serviceId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generateFakeService = (users: User[]) => {
|
const generateFakeService = (users: User[]) => {
|
||||||
const status = faker.helpers.arrayElement(Object.values(VerificationStatus))
|
const status = faker.helpers.weightedArrayElement<VerificationStatus>([
|
||||||
|
{ weight: 20, value: 'VERIFICATION_SUCCESS' },
|
||||||
|
{ weight: 30, value: 'APPROVED' },
|
||||||
|
{ weight: 40, value: 'COMMUNITY_CONTRIBUTED' },
|
||||||
|
{ weight: 10, value: 'VERIFICATION_FAILED' },
|
||||||
|
])
|
||||||
const name = faker.helpers.arrayElement(serviceNames)
|
const name = faker.helpers.arrayElement(serviceNames)
|
||||||
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
||||||
|
|
||||||
@@ -623,6 +617,12 @@ const generateFakeService = (users: User[]) => {
|
|||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
|
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||||
|
{ weight: 80, value: 'PUBLIC' },
|
||||||
|
{ weight: 10, value: 'UNLISTED' },
|
||||||
|
{ weight: 5, value: 'HIDDEN' },
|
||||||
|
{ weight: 5, value: 'ARCHIVED' },
|
||||||
|
]),
|
||||||
verificationStatus: status,
|
verificationStatus: status,
|
||||||
verificationSummary:
|
verificationSummary:
|
||||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||||
@@ -636,25 +636,37 @@ const generateFakeService = (users: User[]) => {
|
|||||||
},
|
},
|
||||||
verificationProofMd:
|
verificationProofMd:
|
||||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||||
referral: `?ref=${faker.string.alphanumeric(6)}`,
|
referral: faker.helpers.arrayElement([
|
||||||
|
`?ref=${faker.string.alphanumeric(6)}`,
|
||||||
|
`/ref/${faker.string.alphanumeric(6)}`,
|
||||||
|
]),
|
||||||
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
|
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
|
||||||
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()),
|
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
|
||||||
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()),
|
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
|
||||||
onionUrls: Array.from(
|
onionUrls: faker.helpers.multiple(
|
||||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
|
||||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`
|
{ count: { min: 0, max: 2 } }
|
||||||
),
|
),
|
||||||
i2pUrls: Array.from(
|
i2pUrls: faker.helpers.multiple(
|
||||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
|
||||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`
|
{ count: { min: 0, max: 2 } }
|
||||||
),
|
),
|
||||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||||
listedAt: faker.date.past(),
|
listedAt: faker.date.past(),
|
||||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
||||||
tosReviewAt: faker.date.past(),
|
tosReviewAt: faker.date.past(),
|
||||||
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined,
|
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||||
userSentimentAt: faker.date.recent(),
|
userSentimentAt: faker.date.recent(),
|
||||||
|
internalNotes: faker.helpers.maybe(
|
||||||
|
() => ({
|
||||||
|
create: {
|
||||||
|
content: faker.lorem.paragraph(),
|
||||||
|
addedByUserId: faker.helpers.arrayElement(users.filter((user) => user.admin)).id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ probability: 0.33 }
|
||||||
|
),
|
||||||
} as const satisfies Prisma.ServiceCreateInput
|
} as const satisfies Prisma.ServiceCreateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,6 +871,22 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
{
|
{
|
||||||
value: `https://x.com/${faker.internet.username()}`,
|
value: `https://x.com/${faker.internet.username()}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: `https://matrix.to/#/@${faker.internet.username()}:${faker.internet.domainName()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: `https://instagram.com/${faker.internet.username()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: faker.lorem.word({ length: 2 }),
|
||||||
|
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
@@ -867,7 +895,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
|
value: `https://linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
|
||||||
},
|
},
|
||||||
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
|
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
|
||||||
|
|
||||||
@@ -883,31 +911,31 @@ const specialUsersData = {
|
|||||||
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
|
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
|
||||||
defaultToken: 'admin',
|
defaultToken: 'admin',
|
||||||
admin: true,
|
admin: true,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
verifiedLink: 'https://kycnot.me',
|
verifiedLink: 'https://kycnot.me',
|
||||||
totalKarma: 1001,
|
totalKarma: 1001,
|
||||||
link: 'https://kycnot.me',
|
link: 'https://kycnot.me',
|
||||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||||
},
|
},
|
||||||
verifier: {
|
moderator: {
|
||||||
name: 'verifier_dev',
|
name: 'moderator_dev',
|
||||||
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
|
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
|
||||||
defaultToken: 'verifier',
|
defaultToken: 'moderator',
|
||||||
admin: false,
|
admin: false,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
verifiedLink: 'https://kycnot.me',
|
verifiedLink: 'https://kycnot.me',
|
||||||
totalKarma: 1001,
|
totalKarma: 1001,
|
||||||
link: 'https://kycnot.me',
|
link: 'https://kycnot.me',
|
||||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||||
},
|
},
|
||||||
verified: {
|
verified: {
|
||||||
name: 'verified_dev',
|
name: 'verified_dev',
|
||||||
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
|
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
|
||||||
defaultToken: 'verified',
|
defaultToken: 'verified',
|
||||||
admin: false,
|
admin: false,
|
||||||
verifier: false,
|
moderator: false,
|
||||||
verified: true,
|
verified: true,
|
||||||
verifiedLink: 'https://kycnot.me',
|
verifiedLink: 'https://kycnot.me',
|
||||||
totalKarma: 1001,
|
totalKarma: 1001,
|
||||||
@@ -917,7 +945,7 @@ const specialUsersData = {
|
|||||||
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
|
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
|
||||||
defaultToken: 'normal',
|
defaultToken: 'normal',
|
||||||
admin: false,
|
admin: false,
|
||||||
verifier: false,
|
moderator: false,
|
||||||
verified: false,
|
verified: false,
|
||||||
},
|
},
|
||||||
spam: {
|
spam: {
|
||||||
@@ -925,7 +953,7 @@ const specialUsersData = {
|
|||||||
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
|
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
|
||||||
defaultToken: 'spam',
|
defaultToken: 'spam',
|
||||||
admin: false,
|
admin: false,
|
||||||
verifier: false,
|
moderator: false,
|
||||||
verified: false,
|
verified: false,
|
||||||
totalKarma: -100,
|
totalKarma: -100,
|
||||||
spammer: true,
|
spammer: true,
|
||||||
@@ -987,359 +1015,378 @@ const generateFakeAnnouncement = () => {
|
|||||||
} as const satisfies Prisma.AnnouncementCreateInput
|
} as const satisfies Prisma.AnnouncementCreateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFaker() {
|
async function cleanup() {
|
||||||
await prisma.$transaction(
|
console.info('🧹 Cleaning up existing data...')
|
||||||
async (tx) => {
|
|
||||||
// ---- Clean up existing data if requested ----
|
|
||||||
if (shouldCleanup || onlyCleanup) {
|
|
||||||
console.info('🧹 Cleaning up existing data...')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await tx.commentVote.deleteMany()
|
|
||||||
await tx.karmaTransaction.deleteMany()
|
|
||||||
await tx.comment.deleteMany()
|
|
||||||
await tx.serviceAttribute.deleteMany()
|
|
||||||
await tx.serviceContactMethod.deleteMany()
|
|
||||||
await tx.event.deleteMany()
|
|
||||||
await tx.verificationStep.deleteMany()
|
|
||||||
await tx.serviceSuggestionMessage.deleteMany()
|
|
||||||
await tx.serviceSuggestion.deleteMany()
|
|
||||||
await tx.serviceVerificationRequest.deleteMany()
|
|
||||||
await tx.service.deleteMany()
|
|
||||||
await tx.attribute.deleteMany()
|
|
||||||
await tx.category.deleteMany()
|
|
||||||
await tx.internalUserNote.deleteMany()
|
|
||||||
await tx.user.deleteMany()
|
|
||||||
await tx.announcement.deleteMany()
|
|
||||||
console.info('✅ Existing data cleaned up')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error cleaning up data:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
if (onlyCleanup) return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Get or create categories ----
|
|
||||||
const categories = await Promise.all(
|
|
||||||
categoriesToCreate.map(async (cat) => {
|
|
||||||
const existing = await tx.category.findUnique({
|
|
||||||
where: { name: cat.name },
|
|
||||||
})
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
return await tx.category.create({
|
|
||||||
data: cat,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Create users ----
|
|
||||||
const specialUsersUntyped = Object.fromEntries(
|
|
||||||
await Promise.all(
|
|
||||||
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
|
||||||
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
|
||||||
const secretTokenHash = hashUserSecretToken(secretToken)
|
|
||||||
|
|
||||||
const { envToken, defaultToken, ...userCreateData } = userData
|
|
||||||
const user = await tx.user.create({
|
|
||||||
data: {
|
|
||||||
notificationPreferences: { create: {} },
|
|
||||||
...userCreateData,
|
|
||||||
secretTokenHash,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
|
||||||
|
|
||||||
return [key, user] as const
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const specialUsers = specialUsersUntyped as {
|
|
||||||
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
|
||||||
}
|
|
||||||
|
|
||||||
let users = await Promise.all(
|
|
||||||
Array.from({ length: 10 }, async () => {
|
|
||||||
const { user } = await createAccount()
|
|
||||||
return user
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Create attributes ----
|
|
||||||
const attributes = await Promise.all(
|
|
||||||
Array.from({ length: 16 }, async () => {
|
|
||||||
return await tx.attribute.create({
|
|
||||||
data: generateFakeAttribute(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Create services ----
|
|
||||||
const services = await Promise.all(
|
|
||||||
Array.from({ length: numServices }, async () => {
|
|
||||||
const serviceData = generateFakeService(users)
|
|
||||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
|
||||||
|
|
||||||
const service = await tx.service.create({
|
|
||||||
data: {
|
|
||||||
...serviceData,
|
|
||||||
categories: {
|
|
||||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create contact methods for each service
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
|
||||||
tx.serviceContactMethod.create({
|
|
||||||
data: generateFakeServiceContactMethod(service.id),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Link random attributes to the service
|
|
||||||
await Promise.all(
|
|
||||||
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
|
||||||
tx.serviceAttribute.create({
|
|
||||||
data: {
|
|
||||||
serviceId: service.id,
|
|
||||||
attributeId: attr.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create events for the service
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
|
||||||
tx.event.create({
|
|
||||||
data: generateFakeEvent(service.id),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return service
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Create service user affiliations for the service ----
|
|
||||||
await Promise.all(
|
|
||||||
users
|
|
||||||
.filter((user) => user.verified)
|
|
||||||
.map(async (user) => {
|
|
||||||
const servicesToAddAffiliations = uniqBy(
|
|
||||||
faker.helpers.arrayElements(services, {
|
|
||||||
min: 1,
|
|
||||||
max: 3,
|
|
||||||
}),
|
|
||||||
'id'
|
|
||||||
)
|
|
||||||
|
|
||||||
return tx.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
serviceAffiliations: {
|
|
||||||
createMany: {
|
|
||||||
data: servicesToAddAffiliations.map((service) => ({
|
|
||||||
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
|
||||||
serviceId: service.id,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
users = await tx.user.findMany({
|
|
||||||
include: {
|
|
||||||
serviceAffiliations: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Create comments and replies ----
|
|
||||||
await Promise.all(
|
|
||||||
services.map(async (service) => {
|
|
||||||
// Create parent comments
|
|
||||||
const commentCount = faker.number.int({ min: 1, max: 10 })
|
|
||||||
const commentData = Array.from({ length: commentCount }, () =>
|
|
||||||
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
|
||||||
)
|
|
||||||
const indexesToUpdate = users.map((user) => {
|
|
||||||
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
|
||||||
})
|
|
||||||
commentData.forEach((comment, index) => {
|
|
||||||
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
|
||||||
})
|
|
||||||
|
|
||||||
await tx.comment.createMany({
|
|
||||||
data: commentData,
|
|
||||||
})
|
|
||||||
|
|
||||||
const comments = await tx.comment.findMany({
|
|
||||||
where: {
|
|
||||||
serviceId: service.id,
|
|
||||||
parentId: null,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
take: commentCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
const affiliatedUsers = undefinedIfEmpty(
|
|
||||||
users.filter((user) =>
|
|
||||||
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create replies to comments
|
|
||||||
await Promise.all(
|
|
||||||
comments.map(async (comment) => {
|
|
||||||
const replyCount = faker.number.int({ min: 0, max: 3 })
|
|
||||||
return Promise.all(
|
|
||||||
Array.from({ length: replyCount }, () => {
|
|
||||||
const user = faker.helpers.arrayElement(
|
|
||||||
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
|
||||||
)
|
|
||||||
|
|
||||||
return tx.comment.create({
|
|
||||||
data: generateFakeComment(user.id, service.id, comment.id),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Create service suggestions for normal_dev user ----
|
|
||||||
// First create 3 CREATE_SERVICE suggestions with their services
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const serviceData = generateFakeService(users)
|
|
||||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
|
||||||
|
|
||||||
const service = await tx.service.create({
|
|
||||||
data: {
|
|
||||||
...serviceData,
|
|
||||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
|
||||||
categories: {
|
|
||||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
|
||||||
data: generateFakeServiceSuggestion({
|
|
||||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
|
||||||
userId: specialUsers.normal.id,
|
|
||||||
serviceId: service.id,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create some messages for each suggestion
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
|
||||||
tx.serviceSuggestionMessage.create({
|
|
||||||
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
|
||||||
specialUsers.normal.id,
|
|
||||||
specialUsers.admin.id,
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then create 5 EDIT_SERVICE suggestions
|
|
||||||
await Promise.all(
|
|
||||||
services.slice(0, 5).map(async (service) => {
|
|
||||||
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
|
||||||
const suggestion = await tx.serviceSuggestion.create({
|
|
||||||
data: generateFakeServiceSuggestion({
|
|
||||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
|
||||||
status,
|
|
||||||
userId: specialUsers.normal.id,
|
|
||||||
serviceId: service.id,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create some messages for each suggestion
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
|
||||||
tx.serviceSuggestionMessage.create({
|
|
||||||
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
|
||||||
specialUsers.normal.id,
|
|
||||||
specialUsers.admin.id,
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Create internal notes for users ----
|
|
||||||
await Promise.all(
|
|
||||||
users.map(async (user) => {
|
|
||||||
// Create 1-3 notes for each user
|
|
||||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
|
||||||
return Promise.all(
|
|
||||||
Array.from({ length: numNotes }, () =>
|
|
||||||
tx.internalUserNote.create({
|
|
||||||
data: generateFakeInternalNote(
|
|
||||||
user.id,
|
|
||||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Add some notes to special users as well
|
|
||||||
await Promise.all(
|
|
||||||
Object.values(specialUsers).map(async (user) => {
|
|
||||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
|
||||||
return Promise.all(
|
|
||||||
Array.from({ length: numNotes }, () =>
|
|
||||||
tx.internalUserNote.create({
|
|
||||||
data: generateFakeInternalNote(
|
|
||||||
user.id,
|
|
||||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Create announcement ----
|
|
||||||
await tx.announcement.create({
|
|
||||||
data: generateFakeAnnouncement(),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timeout: 1000 * 60 * 10, // 10 minutes
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
try {
|
||||||
await runFaker()
|
await prisma.commentVote.deleteMany()
|
||||||
|
await prisma.karmaTransaction.deleteMany()
|
||||||
console.info('✅ Fake data generated successfully')
|
await prisma.comment.deleteMany()
|
||||||
|
await prisma.serviceAttribute.deleteMany()
|
||||||
|
await prisma.serviceContactMethod.deleteMany()
|
||||||
|
await prisma.event.deleteMany()
|
||||||
|
await prisma.verificationStep.deleteMany()
|
||||||
|
await prisma.serviceSuggestionMessage.deleteMany()
|
||||||
|
await prisma.serviceSuggestion.deleteMany()
|
||||||
|
await prisma.serviceVerificationRequest.deleteMany()
|
||||||
|
await prisma.service.deleteMany()
|
||||||
|
await prisma.attribute.deleteMany()
|
||||||
|
await prisma.category.deleteMany()
|
||||||
|
await prisma.internalUserNote.deleteMany()
|
||||||
|
await prisma.user.deleteMany()
|
||||||
|
await prisma.announcement.deleteMany()
|
||||||
|
console.info('✅ Existing data cleaned up')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error generating fake data:', error)
|
console.error('❌ Error cleaning up data:', error)
|
||||||
process.exit(1)
|
throw error
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
function importTriggers() {
|
||||||
console.error('❌ Fatal error:', error)
|
console.info('🔄 Importing SQL triggers...')
|
||||||
process.exit(1)
|
try {
|
||||||
})
|
execSync('just import-triggers', { stdio: 'inherit' })
|
||||||
|
console.info('✅ Triggers imported')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error importing triggers:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { values: options } = parseArgs({
|
||||||
|
options: {
|
||||||
|
services: { type: 'string', short: 's', default: '100' },
|
||||||
|
cleanup: { type: 'boolean', short: 'c', default: true },
|
||||||
|
'only-cleanup': { type: 'boolean', short: 'o', default: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const numServices = parseIntWithFallback(options.services, 100)
|
||||||
|
if (isNaN(numServices) || numServices < 1) {
|
||||||
|
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
importTriggers()
|
||||||
|
|
||||||
|
if (options.cleanup || options['only-cleanup']) {
|
||||||
|
await cleanup()
|
||||||
|
if (options['only-cleanup']) return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Get or create categories ----
|
||||||
|
const categories = await Promise.all(
|
||||||
|
categoriesToCreate.map(async (cat) => {
|
||||||
|
const existing = await prisma.category.findUnique({
|
||||||
|
where: { name: cat.name },
|
||||||
|
})
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
return await prisma.category.create({
|
||||||
|
data: cat,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Create users ----
|
||||||
|
const specialUsersUntyped = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
||||||
|
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
||||||
|
const secretTokenHash = hashUserSecretToken(secretToken)
|
||||||
|
|
||||||
|
const { envToken, defaultToken, ...userCreateData } = userData
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
notificationPreferences: { create: {} },
|
||||||
|
...userCreateData,
|
||||||
|
secretTokenHash,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
||||||
|
|
||||||
|
return [key, user] as const
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const specialUsers = specialUsersUntyped as {
|
||||||
|
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = await Promise.all(
|
||||||
|
Array.from({ length: 10 }, async () => {
|
||||||
|
const { user } = await createAccount()
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Create attributes ----
|
||||||
|
const attributes = await Promise.all(
|
||||||
|
Array.from({ length: 16 }, async () => {
|
||||||
|
return await prisma.attribute.create({
|
||||||
|
data: generateFakeAttribute(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Create services ----
|
||||||
|
const services = await Promise.all(
|
||||||
|
Array.from({ length: numServices }, async () => {
|
||||||
|
const serviceData = generateFakeService(users)
|
||||||
|
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||||
|
|
||||||
|
const service = await prisma.service.create({
|
||||||
|
data: {
|
||||||
|
...serviceData,
|
||||||
|
categories: {
|
||||||
|
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create contact methods for each service
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||||
|
prisma.serviceContactMethod.create({
|
||||||
|
data: generateFakeServiceContactMethod(service.id),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Link random attributes to the service
|
||||||
|
await Promise.all(
|
||||||
|
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
||||||
|
prisma.serviceAttribute.create({
|
||||||
|
data: {
|
||||||
|
serviceId: service.id,
|
||||||
|
attributeId: attr.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create events for the service
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
||||||
|
prisma.event.create({
|
||||||
|
data: generateFakeEvent(service.id),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return service
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Create service user affiliations for the service ----
|
||||||
|
await Promise.all(
|
||||||
|
users
|
||||||
|
.filter((user) => user.verified)
|
||||||
|
.map(async (user) => {
|
||||||
|
const servicesToAddAffiliations = uniqBy(
|
||||||
|
faker.helpers.arrayElements(services, {
|
||||||
|
min: 1,
|
||||||
|
max: 3,
|
||||||
|
}),
|
||||||
|
'id'
|
||||||
|
)
|
||||||
|
|
||||||
|
return prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
serviceAffiliations: {
|
||||||
|
createMany: {
|
||||||
|
data: servicesToAddAffiliations.map((service) => ({
|
||||||
|
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
||||||
|
serviceId: service.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
users = await prisma.user.findMany({
|
||||||
|
include: {
|
||||||
|
serviceAffiliations: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Create comments and replies ----
|
||||||
|
await Promise.all(
|
||||||
|
services.map(async (service) => {
|
||||||
|
// Create parent comments
|
||||||
|
const commentCount = faker.number.int({ min: 1, max: 10 })
|
||||||
|
const commentData = Array.from({ length: commentCount }, () =>
|
||||||
|
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
||||||
|
)
|
||||||
|
const indexesToUpdate = users.map((user) => {
|
||||||
|
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
||||||
|
})
|
||||||
|
commentData.forEach((comment, index) => {
|
||||||
|
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.comment.createMany({
|
||||||
|
data: commentData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const comments = await prisma.comment.findMany({
|
||||||
|
where: {
|
||||||
|
serviceId: service.id,
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: commentCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
const affiliatedUsers = undefinedIfEmpty(
|
||||||
|
users.filter((user) =>
|
||||||
|
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create replies to comments
|
||||||
|
await Promise.all(
|
||||||
|
comments.map(async (comment) => {
|
||||||
|
const replyCount = faker.number.int({ min: 0, max: 3 })
|
||||||
|
return Promise.all(
|
||||||
|
Array.from({ length: replyCount }, () => {
|
||||||
|
const user = faker.helpers.arrayElement(
|
||||||
|
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
||||||
|
)
|
||||||
|
|
||||||
|
return prisma.comment.create({
|
||||||
|
data: generateFakeComment(user.id, service.id, comment.id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Create service suggestions for normal_dev user ----
|
||||||
|
// First create 3 CREATE_SERVICE suggestions with their services
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const serviceData = generateFakeService(users)
|
||||||
|
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||||
|
|
||||||
|
const service = await prisma.service.create({
|
||||||
|
data: {
|
||||||
|
...serviceData,
|
||||||
|
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||||
|
categories: {
|
||||||
|
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||||
|
data: generateFakeServiceSuggestion({
|
||||||
|
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||||
|
userId: specialUsers.normal.id,
|
||||||
|
serviceId: service.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create some messages for each suggestion
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||||
|
prisma.serviceSuggestionMessage.create({
|
||||||
|
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
||||||
|
specialUsers.normal.id,
|
||||||
|
specialUsers.admin.id,
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then create 5 EDIT_SERVICE suggestions
|
||||||
|
await Promise.all(
|
||||||
|
services.slice(0, 5).map(async (service) => {
|
||||||
|
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
||||||
|
const suggestion = await prisma.serviceSuggestion.create({
|
||||||
|
data: generateFakeServiceSuggestion({
|
||||||
|
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||||
|
status,
|
||||||
|
userId: specialUsers.normal.id,
|
||||||
|
serviceId: service.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create some messages for each suggestion
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
||||||
|
prisma.serviceSuggestionMessage.create({
|
||||||
|
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
||||||
|
specialUsers.normal.id,
|
||||||
|
specialUsers.admin.id,
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Create internal notes for users ----
|
||||||
|
await Promise.all(
|
||||||
|
users.map(async (user) => {
|
||||||
|
// Create 1-3 notes for each user
|
||||||
|
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||||
|
return Promise.all(
|
||||||
|
Array.from({ length: numNotes }, () =>
|
||||||
|
prisma.internalUserNote.create({
|
||||||
|
data: generateFakeInternalNote(
|
||||||
|
user.id,
|
||||||
|
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add some notes to special users as well
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(specialUsers).map(async (user) => {
|
||||||
|
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||||
|
return Promise.all(
|
||||||
|
Array.from({ length: numNotes }, () =>
|
||||||
|
prisma.internalUserNote.create({
|
||||||
|
data: generateFakeInternalNote(
|
||||||
|
user.id,
|
||||||
|
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Create announcement ----
|
||||||
|
await prisma.announcement.create({
|
||||||
|
data: generateFakeAnnouncement(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
console.info('✅ Fake data generated successfully')
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
.catch(async (error: unknown) => {
|
||||||
|
console.error(
|
||||||
|
'❌ Fatal error:',
|
||||||
|
typeof error === 'object' && error !== null && 'message' in error ? error.message : 'Unknown error'
|
||||||
|
)
|
||||||
|
console.error(error)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -25,12 +25,12 @@ BEGIN
|
|||||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Check for verifier status change
|
-- Check for moderator status change
|
||||||
IF OLD.verifier IS DISTINCT FROM NEW.verifier THEN
|
IF OLD.moderator IS DISTINCT FROM NEW.moderator THEN
|
||||||
IF NEW.verifier = true THEN
|
IF NEW.moderator = true THEN
|
||||||
status_change := 'VERIFIER_TRUE';
|
status_change := 'MODERATOR_TRUE';
|
||||||
ELSE
|
ELSE
|
||||||
status_change := 'VERIFIER_FALSE';
|
status_change := 'MODERATOR_FALSE';
|
||||||
END IF;
|
END IF;
|
||||||
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
||||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||||
@@ -57,6 +57,6 @@ DROP TRIGGER IF EXISTS user_status_change_notifications_trigger ON "User";
|
|||||||
|
|
||||||
-- Create the trigger to fire after updates on specific status columns
|
-- Create the trigger to fire after updates on specific status columns
|
||||||
CREATE TRIGGER user_status_change_notifications_trigger
|
CREATE TRIGGER user_status_change_notifications_trigger
|
||||||
AFTER UPDATE OF admin, verified, verifier, spammer ON "User"
|
AFTER UPDATE OF admin, verified, moderator, spammer ON "User"
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION trigger_user_status_change_notifications();
|
EXECUTE FUNCTION trigger_user_status_change_notifications();
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ import slugify from 'slugify'
|
|||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import {
|
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||||
imageFileSchema,
|
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
|
||||||
stringListOfUrlsSchema,
|
|
||||||
stringListOfUrlsSchemaRequired,
|
|
||||||
zodCohercedNumber,
|
|
||||||
} from '../../lib/zodUtils'
|
|
||||||
|
|
||||||
const serviceSchemaBase = z.object({
|
const serviceSchemaBase = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
@@ -19,11 +15,10 @@ const serviceSchemaBase = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||||
.optional(),
|
.optional(),
|
||||||
name: z.string().min(1).max(20),
|
name: z.string().min(1).max(40),
|
||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
onionUrls: stringListOfUrlsSchema,
|
|
||||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||||
attributes: z.array(z.coerce.number().int().positive()),
|
attributes: z.array(z.coerce.number().int().positive()),
|
||||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||||
@@ -31,10 +26,16 @@ const serviceSchemaBase = z.object({
|
|||||||
verificationSummary: z.string().optional().nullable().default(null),
|
verificationSummary: z.string().optional().nullable().default(null),
|
||||||
verificationProofMd: z.string().optional().nullable().default(null),
|
verificationProofMd: z.string().optional().nullable().default(null),
|
||||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
||||||
referral: z.string().optional().nullable().default(null),
|
referral: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.default(null),
|
||||||
imageFile: imageFileSchema,
|
imageFile: imageFileSchema,
|
||||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||||
|
internalNote: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const addSlugIfMissing = <
|
const addSlugIfMissing = <
|
||||||
@@ -61,7 +62,7 @@ export const adminServiceActions = {
|
|||||||
accept: 'form',
|
accept: 'form',
|
||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
||||||
handler: async (input) => {
|
handler: async (input, context) => {
|
||||||
const existing = await prisma.service.findUnique({
|
const existing = await prisma.service.findUnique({
|
||||||
where: {
|
where: {
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
@@ -75,12 +76,33 @@ export const adminServiceActions = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { imageFile, ...serviceData } = input
|
const imageUrl = input.imageFile
|
||||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const {
|
||||||
|
web: serviceUrls,
|
||||||
|
onion: onionUrls,
|
||||||
|
i2p: i2pUrls,
|
||||||
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||||
|
|
||||||
const service = await prisma.service.create({
|
const service = await prisma.service.create({
|
||||||
data: {
|
data: {
|
||||||
...serviceData,
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
serviceUrls,
|
||||||
|
tosUrls: input.tosUrls,
|
||||||
|
onionUrls,
|
||||||
|
i2pUrls,
|
||||||
|
kycLevel: input.kycLevel,
|
||||||
|
verificationStatus: input.verificationStatus,
|
||||||
|
verificationSummary: input.verificationSummary,
|
||||||
|
verificationProofMd: input.verificationProofMd,
|
||||||
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
|
referral: input.referral,
|
||||||
|
serviceVisibility: input.serviceVisibility,
|
||||||
|
slug: input.slug,
|
||||||
|
overallScore: input.overallScore,
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
@@ -92,6 +114,14 @@ export const adminServiceActions = {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
internalNotes: input.internalNote
|
||||||
|
? {
|
||||||
|
create: {
|
||||||
|
content: input.internalNote,
|
||||||
|
addedByUserId: context.locals.user.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -106,29 +136,34 @@ export const adminServiceActions = {
|
|||||||
update: defineProtectedAction({
|
update: defineProtectedAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: serviceSchemaBase.transform(addSlugIfMissing),
|
input: serviceSchemaBase
|
||||||
|
.extend({
|
||||||
|
removeImage: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.transform(addSlugIfMissing),
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
const { id, categories, attributes, imageFile, ...data } = input
|
const anotherServiceWithNewSlug = await prisma.service.findUnique({
|
||||||
|
|
||||||
const existing = await prisma.service.findUnique({
|
|
||||||
where: {
|
where: {
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
NOT: { id },
|
NOT: { id: input.id },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (existing) {
|
if (anotherServiceWithNewSlug) {
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
code: 'CONFLICT',
|
code: 'CONFLICT',
|
||||||
message: 'A service with this slug already exists',
|
message: 'A service with this slug already exists',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
const imageUrl = input.removeImage
|
||||||
|
? null
|
||||||
|
: input.imageFile
|
||||||
|
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
|
: undefined
|
||||||
|
|
||||||
// Get existing attributes and categories to compute differences
|
|
||||||
const existingService = await prisma.service.findUnique({
|
const existingService = await prisma.service.findUnique({
|
||||||
where: { id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
categories: true,
|
categories: true,
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -146,89 +181,198 @@ export const adminServiceActions = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find categories to connect and disconnect
|
|
||||||
const existingCategoryIds = existingService.categories.map((c) => c.id)
|
const existingCategoryIds = existingService.categories.map((c) => c.id)
|
||||||
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
|
const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
|
const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId))
|
||||||
|
|
||||||
// Find attributes to connect and disconnect
|
|
||||||
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
|
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
|
||||||
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||||
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
|
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
|
||||||
|
|
||||||
|
const {
|
||||||
|
web: serviceUrls,
|
||||||
|
onion: onionUrls,
|
||||||
|
i2p: i2pUrls,
|
||||||
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||||
|
|
||||||
const service = await prisma.service.update({
|
const service = await prisma.service.update({
|
||||||
where: { id },
|
where: { id: input.id },
|
||||||
data: {
|
data: {
|
||||||
...data,
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
serviceUrls,
|
||||||
|
tosUrls: input.tosUrls,
|
||||||
|
onionUrls,
|
||||||
|
i2pUrls,
|
||||||
|
kycLevel: input.kycLevel,
|
||||||
|
verificationStatus: input.verificationStatus,
|
||||||
|
verificationSummary: input.verificationSummary,
|
||||||
|
verificationProofMd: input.verificationProofMd,
|
||||||
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
|
referral: input.referral,
|
||||||
|
serviceVisibility: input.serviceVisibility,
|
||||||
|
slug: input.slug,
|
||||||
|
overallScore: input.overallScore,
|
||||||
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories: {
|
categories: {
|
||||||
connect: categoriesToAdd.map((id) => ({ id })),
|
connect: categoriesToAdd.map((id) => ({ id })),
|
||||||
disconnect: categoriesToRemove.map((id) => ({ id })),
|
disconnect: categoriesToRemove.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
attributes: {
|
attributes: {
|
||||||
// Connect new attributes
|
|
||||||
create: attributesToAdd.map((attributeId) => ({
|
create: attributesToAdd.map((attributeId) => ({
|
||||||
attribute: {
|
attribute: {
|
||||||
connect: { id: attributeId },
|
connect: { id: attributeId },
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
// Delete specific attributes that are no longer needed
|
|
||||||
deleteMany: attributesToRemove.map((attributeId) => ({
|
deleteMany: attributesToRemove.map((attributeId) => ({
|
||||||
attributeId,
|
attributeId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return { service }
|
return { service }
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createContactMethod: defineProtectedAction({
|
contactMethod: {
|
||||||
accept: 'form',
|
add: defineProtectedAction({
|
||||||
permissions: 'admin',
|
accept: 'form',
|
||||||
input: z.object({
|
permissions: 'admin',
|
||||||
label: z.string().min(1).max(50).optional(),
|
input: z.object({
|
||||||
value: z.string().url(),
|
label: z.string().min(1).max(50).nullable(),
|
||||||
serviceId: z.number().int().positive(),
|
value: z.string().url(),
|
||||||
|
serviceId: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
const contactMethod = await prisma.serviceContactMethod.create({
|
||||||
|
data: {
|
||||||
|
label: input.label,
|
||||||
|
value: input.value,
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { contactMethod }
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
|
||||||
const contactMethod = await prisma.serviceContactMethod.create({
|
|
||||||
data: input,
|
|
||||||
})
|
|
||||||
return { contactMethod }
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateContactMethod: defineProtectedAction({
|
update: defineProtectedAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: z.object({
|
input: z.object({
|
||||||
id: z.number().int().positive().optional(),
|
id: z.number().int().positive(),
|
||||||
label: z.string().min(1).max(50).optional(),
|
label: z.string().min(1).max(50).nullable(),
|
||||||
value: z.string().url(),
|
value: z.string().url(),
|
||||||
serviceId: z.number().int().positive(),
|
serviceId: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
const contactMethod = await prisma.serviceContactMethod.update({
|
||||||
|
where: { id: input.id },
|
||||||
|
data: {
|
||||||
|
label: input.label,
|
||||||
|
value: input.value,
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { contactMethod }
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
|
||||||
const { id, ...data } = input
|
|
||||||
const contactMethod = await prisma.serviceContactMethod.update({
|
|
||||||
where: { id },
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
return { contactMethod }
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteContactMethod: defineProtectedAction({
|
delete: defineProtectedAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: z.object({
|
input: z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
await prisma.serviceContactMethod.delete({
|
||||||
|
where: { id: input.id },
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
},
|
||||||
await prisma.serviceContactMethod.delete({
|
|
||||||
where: { id: input.id },
|
internalNote: {
|
||||||
})
|
add: defineProtectedAction({
|
||||||
return { success: true }
|
accept: 'form',
|
||||||
},
|
permissions: 'admin',
|
||||||
}),
|
input: z.object({
|
||||||
|
serviceId: z.number().int().positive(),
|
||||||
|
content: z.string().min(1),
|
||||||
|
}),
|
||||||
|
handler: async (input, { locals }) => {
|
||||||
|
const service = await prisma.service.findUnique({
|
||||||
|
where: { id: input.serviceId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Service not found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.internalServiceNote.create({
|
||||||
|
data: {
|
||||||
|
content: input.content,
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
addedByUserId: locals.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: defineProtectedAction({
|
||||||
|
accept: 'form',
|
||||||
|
permissions: 'admin',
|
||||||
|
input: z.object({
|
||||||
|
noteId: z.number().int().positive(),
|
||||||
|
content: z.string().min(1),
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
const note = await prisma.internalServiceNote.findUnique({
|
||||||
|
where: { id: input.noteId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Note not found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.internalServiceNote.update({
|
||||||
|
where: { id: input.noteId },
|
||||||
|
data: { content: input.content },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: defineProtectedAction({
|
||||||
|
accept: 'form',
|
||||||
|
permissions: 'admin',
|
||||||
|
input: z.object({
|
||||||
|
noteId: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
const note = await prisma.internalServiceNote.findUnique({
|
||||||
|
where: { id: input.noteId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Note not found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.internalServiceNote.delete({
|
||||||
|
where: { id: input.noteId },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const selectUserReturnFields = {
|
|||||||
picture: true,
|
picture: true,
|
||||||
admin: true,
|
admin: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
verifiedLink: true,
|
verifiedLink: true,
|
||||||
secretTokenHash: true,
|
secretTokenHash: true,
|
||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
@@ -55,7 +55,7 @@ export const adminUserActions = {
|
|||||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
.transform((val) => val || null),
|
.transform((val) => val || null),
|
||||||
pictureFile: z.instanceof(File).optional(),
|
pictureFile: z.instanceof(File).optional(),
|
||||||
type: z.array(z.enum(['admin', 'verifier', 'spammer'])),
|
type: z.array(z.enum(['admin', 'moderator', 'spammer'])),
|
||||||
verifiedLink: z
|
verifiedLink: z
|
||||||
.string()
|
.string()
|
||||||
.url('Invalid URL')
|
.url('Invalid URL')
|
||||||
@@ -101,7 +101,7 @@ export const adminUserActions = {
|
|||||||
verified: !!valuesToUpdate.verifiedLink,
|
verified: !!valuesToUpdate.verifiedLink,
|
||||||
picture: pictureUrl,
|
picture: pictureUrl,
|
||||||
admin: type.includes('admin'),
|
admin: type.includes('admin'),
|
||||||
verifier: type.includes('verifier'),
|
moderator: type.includes('moderator'),
|
||||||
spammer: type.includes('spammer'),
|
spammer: type.includes('spammer'),
|
||||||
},
|
},
|
||||||
select: selectUserReturnFields,
|
select: selectUserReturnFields,
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ export const commentActions = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
moderate: defineProtectedAction({
|
moderate: defineProtectedAction({
|
||||||
permissions: ['admin', 'verifier'],
|
permissions: ['admin', 'moderator'],
|
||||||
input: z.object({
|
input: z.object({
|
||||||
commentId: z.number(),
|
commentId: z.number(),
|
||||||
userId: z.number(),
|
userId: z.number(),
|
||||||
|
|||||||
@@ -14,12 +14,8 @@ import { defineProtectedAction } from '../lib/defineProtectedAction'
|
|||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import {
|
import { separateServiceUrlsByType } from '../lib/urls'
|
||||||
imageFileSchemaRequired,
|
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
|
||||||
stringListOfUrlsSchema,
|
|
||||||
stringListOfUrlsSchemaRequired,
|
|
||||||
zodCohercedNumber,
|
|
||||||
} from '../lib/zodUtils'
|
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
@@ -161,9 +157,8 @@ export const serviceSuggestionActions = {
|
|||||||
{ message: 'Slug must be unique, try a different one' }
|
{ message: 'Slug must be unique, try a different one' }
|
||||||
),
|
),
|
||||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
onionUrls: stringListOfUrlsSchema,
|
|
||||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||||
attributes: z.array(z.coerce.number().int().positive()),
|
attributes: z.array(z.coerce.number().int().positive()),
|
||||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||||
@@ -210,6 +205,12 @@ export const serviceSuggestionActions = {
|
|||||||
|
|
||||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
|
|
||||||
|
const {
|
||||||
|
web: serviceUrls,
|
||||||
|
onion: onionUrls,
|
||||||
|
i2p: i2pUrls,
|
||||||
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||||
|
|
||||||
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
||||||
const serviceSelect = {
|
const serviceSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -221,9 +222,10 @@ export const serviceSuggestionActions = {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
serviceUrls: input.serviceUrls,
|
serviceUrls,
|
||||||
tosUrls: input.tosUrls,
|
tosUrls: input.tosUrls,
|
||||||
onionUrls: input.onionUrls,
|
onionUrls,
|
||||||
|
i2pUrls,
|
||||||
kycLevel: input.kycLevel,
|
kycLevel: input.kycLevel,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
|||||||
@@ -126,11 +126,13 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
|||||||
VariantProps<typeof badge> & {
|
VariantProps<typeof badge> & {
|
||||||
as: Tag
|
as: Tag
|
||||||
icon?: string
|
icon?: string
|
||||||
|
endIcon?: string
|
||||||
text: string
|
text: string
|
||||||
inlineIcon?: boolean
|
inlineIcon?: boolean
|
||||||
classNames?: {
|
classNames?: {
|
||||||
icon?: string
|
icon?: string
|
||||||
text?: string
|
text?: string
|
||||||
|
endIcon?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -138,6 +140,7 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
|||||||
const {
|
const {
|
||||||
as: Tag = 'div',
|
as: Tag = 'div',
|
||||||
icon: iconName,
|
icon: iconName,
|
||||||
|
endIcon: endIconName,
|
||||||
text: textContent,
|
text: textContent,
|
||||||
inlineIcon,
|
inlineIcon,
|
||||||
classNames,
|
classNames,
|
||||||
@@ -159,4 +162,9 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
||||||
|
{
|
||||||
|
!!endIconName && (
|
||||||
|
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
|
||||||
|
)
|
||||||
|
}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
|
|||||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
|
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
|
||||||
as: Tag
|
as: Tag
|
||||||
icon: string
|
icon: string
|
||||||
|
endIcon?: string
|
||||||
text: string
|
text: string
|
||||||
inlineIcon?: boolean
|
inlineIcon?: boolean
|
||||||
}>
|
}>
|
||||||
|
|
||||||
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
|
const { icon, text, class: className, inlineIcon, endIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<Tag
|
<Tag
|
||||||
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
|
|||||||
>
|
>
|
||||||
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
|
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
|
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
41
web/src/components/BadgeStandardFilter.astro
Normal file
41
web/src/components/BadgeStandardFilter.astro
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
import { uniq } from 'lodash-es'
|
||||||
|
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import BadgeStandard from './BadgeStandard.astro'
|
||||||
|
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = Omit<
|
||||||
|
ComponentProps<typeof BadgeStandard>,
|
||||||
|
'as' | 'endIcon' | 'href' | 'icon' | 'text' | 'variant'
|
||||||
|
> & {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, value, label, icon, ...props } = Astro.props
|
||||||
|
|
||||||
|
const selectedValues = Astro.url.searchParams.getAll(name)
|
||||||
|
const isSelected = selectedValues.includes(value)
|
||||||
|
|
||||||
|
const url = new URL(Astro.url)
|
||||||
|
url.searchParams.delete(name)
|
||||||
|
const valuesToSet = uniq(isSelected ? selectedValues.filter((v) => v !== value) : [...selectedValues, value])
|
||||||
|
for (const value of valuesToSet) {
|
||||||
|
url.searchParams.set(name, value)
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<BadgeStandard
|
||||||
|
as="a"
|
||||||
|
href={url.href}
|
||||||
|
class={cn(isSelected && 'bg-green-950 text-green-500')}
|
||||||
|
text={label}
|
||||||
|
icon={icon}
|
||||||
|
endIcon={isSelected ? 'ri:close-fill' : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
@@ -2,13 +2,15 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { tv, type VariantProps } from 'tailwind-variants'
|
import { tv, type VariantProps } from 'tailwind-variants'
|
||||||
|
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
||||||
|
|
||||||
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
||||||
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
||||||
VariantProps<typeof button> & {
|
VariantProps<typeof button> & {
|
||||||
as: Tag
|
as: Tag
|
||||||
label?: string
|
label: string
|
||||||
icon?: string
|
icon?: string
|
||||||
endIcon?: string
|
endIcon?: string
|
||||||
classNames?: {
|
classNames?: {
|
||||||
@@ -55,30 +57,21 @@ const button = tv({
|
|||||||
iconOnly: {
|
iconOnly: {
|
||||||
true: {
|
true: {
|
||||||
base: 'p-0',
|
base: 'p-0',
|
||||||
|
label: 'sr-only',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
black: {
|
black: '',
|
||||||
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
white: '',
|
||||||
},
|
gray: '',
|
||||||
white: {
|
success: '',
|
||||||
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
danger: '',
|
||||||
},
|
warning: '',
|
||||||
gray: {
|
info: '',
|
||||||
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
},
|
||||||
},
|
variant: {
|
||||||
success: {
|
solid: '',
|
||||||
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
faded: '',
|
||||||
},
|
|
||||||
error: {
|
|
||||||
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
shadow: {
|
shadow: {
|
||||||
true: {
|
true: {
|
||||||
@@ -92,6 +85,107 @@ const button = tv({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
compoundVariants: [
|
compoundVariants: [
|
||||||
|
// Color variants - solid
|
||||||
|
{
|
||||||
|
color: 'black',
|
||||||
|
variant: 'solid',
|
||||||
|
class: {
|
||||||
|
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'white',
|
||||||
|
variant: 'solid',
|
||||||
|
class: {
|
||||||
|
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'gray',
|
||||||
|
variant: 'solid',
|
||||||
|
class: {
|
||||||
|
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'success',
|
||||||
|
variant: 'solid',
|
||||||
|
class: {
|
||||||
|
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'danger',
|
||||||
|
variant: 'solid',
|
||||||
|
class: {
|
||||||
|
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'warning',
|
||||||
|
variant: 'solid',
|
||||||
|
class: {
|
||||||
|
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'info',
|
||||||
|
variant: 'solid',
|
||||||
|
class: {
|
||||||
|
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Color variants - faded
|
||||||
|
{
|
||||||
|
color: 'black',
|
||||||
|
variant: 'faded',
|
||||||
|
class: {
|
||||||
|
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: '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: '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-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-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-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-current/20 bg-blue-500/15 text-blue-300 hover:bg-blue-500/30 hover:text-blue-100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Shadow variants
|
||||||
{
|
{
|
||||||
color: 'black',
|
color: 'black',
|
||||||
shadow: true,
|
shadow: true,
|
||||||
@@ -113,7 +207,7 @@ const button = tv({
|
|||||||
class: 'shadow-green-500/30',
|
class: 'shadow-green-500/30',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: 'error',
|
color: 'danger',
|
||||||
shadow: true,
|
shadow: true,
|
||||||
class: 'shadow-red-500/30',
|
class: 'shadow-red-500/30',
|
||||||
},
|
},
|
||||||
@@ -127,6 +221,7 @@ const button = tv({
|
|||||||
shadow: true,
|
shadow: true,
|
||||||
class: 'shadow-blue-500/30',
|
class: 'shadow-blue-500/30',
|
||||||
},
|
},
|
||||||
|
// Icon only variants
|
||||||
{
|
{
|
||||||
iconOnly: true,
|
iconOnly: true,
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
@@ -146,6 +241,7 @@ const button = tv({
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
size: 'md',
|
size: 'md',
|
||||||
color: 'black',
|
color: 'black',
|
||||||
|
variant: 'solid',
|
||||||
shadow: false,
|
shadow: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
iconOnly: false,
|
iconOnly: false,
|
||||||
@@ -159,6 +255,7 @@ const {
|
|||||||
endIcon,
|
endIcon,
|
||||||
size,
|
size,
|
||||||
color,
|
color,
|
||||||
|
variant,
|
||||||
shadow,
|
shadow,
|
||||||
class: className,
|
class: className,
|
||||||
classNames,
|
classNames,
|
||||||
@@ -166,6 +263,7 @@ const {
|
|||||||
dataAstroReload,
|
dataAstroReload,
|
||||||
disabled,
|
disabled,
|
||||||
inlineIcon,
|
inlineIcon,
|
||||||
|
iconOnly,
|
||||||
...htmlProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
@@ -174,13 +272,20 @@ const {
|
|||||||
icon: iconSlot,
|
icon: iconSlot,
|
||||||
label: labelSlot,
|
label: labelSlot,
|
||||||
endIcon: endIconSlot,
|
endIcon: endIconSlot,
|
||||||
} = button({ size, color, shadow, disabled, iconOnly: !label && !(!!icon && !!endIcon) })
|
} = button({
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
variant,
|
||||||
|
shadow,
|
||||||
|
disabled,
|
||||||
|
iconOnly: iconOnly ?? (!label && !(!!icon && !!endIcon)),
|
||||||
|
})
|
||||||
|
|
||||||
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||||
---
|
---
|
||||||
|
|
||||||
<ActualTag
|
<ActualTag
|
||||||
class={base({ class: className })}
|
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||||
role={role ??
|
role={role ??
|
||||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
@@ -188,7 +293,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
|||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
>
|
>
|
||||||
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
|
{!!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 && (
|
!!endIcon && (
|
||||||
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>
|
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>
|
||||||
|
|||||||
@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip text="Send">
|
<Tooltip text="Send">
|
||||||
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
|
<Button
|
||||||
|
type="submit"
|
||||||
|
icon="ri:send-plane-fill"
|
||||||
|
size="lg"
|
||||||
|
color="success"
|
||||||
|
class="h-16"
|
||||||
|
label="Send"
|
||||||
|
iconOnly
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</form>
|
</form>
|
||||||
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
|
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ const isHighlighted = comment.id === highlightedCommentId
|
|||||||
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
|
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
|
||||||
|
|
||||||
const isAuthor = user?.id === comment.author.id
|
const isAuthor = user?.id === comment.author.id
|
||||||
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
|
const isAdminOrModerator = !!user && (user.admin || user.moderator)
|
||||||
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
|
const isAuthorOrPrivileged = isAuthor || isAdminOrModerator
|
||||||
|
|
||||||
// Check if user is new (less than 1 week old)
|
// Check if user is new (less than 1 week old)
|
||||||
const isNewUser =
|
const isNewUser =
|
||||||
@@ -75,7 +75,7 @@ const isRatingActive =
|
|||||||
!comment.suspicious &&
|
!comment.suspicious &&
|
||||||
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
|
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
|
||||||
|
|
||||||
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
|
// Skip rendering if comment is not approved/verified and user is not the author or admin/moderator
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
comment.status === 'APPROVED' ||
|
comment.status === 'APPROVED' ||
|
||||||
comment.status === 'VERIFIED' ||
|
comment.status === 'VERIFIED' ||
|
||||||
@@ -150,7 +150,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
checked={comment.suspicious}
|
checked={comment.suspicious}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="comment-header flex items-center gap-2 text-sm">
|
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
|
||||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||||
<span class="collapse-symbol text-xs"></span>
|
<span class="collapse-symbol text-xs"></span>
|
||||||
<span class="sr-only">Toggle comment visibility</span>
|
<span class="sr-only">Toggle comment visibility</span>
|
||||||
@@ -164,10 +164,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
(comment.author.verified || comment.author.admin || comment.author.verifier) && (
|
(comment.author.verified || comment.author.admin || comment.author.moderator) && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={`${
|
text={`${
|
||||||
comment.author.admin || comment.author.verifier
|
comment.author.admin || comment.author.moderator
|
||||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||||
: ''
|
: ''
|
||||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
||||||
@@ -186,7 +186,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
comment.author.verifier && !comment.author.admin && (
|
comment.author.moderator && !comment.author.admin && (
|
||||||
<BadgeSmall
|
<BadgeSmall
|
||||||
icon="ri:graduation-cap-fill"
|
icon="ri:graduation-cap-fill"
|
||||||
color="teal"
|
color="teal"
|
||||||
@@ -198,14 +198,14 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
isNewUser && !comment.author.admin && !comment.author.verifier && (
|
isNewUser && !comment.author.admin && !comment.author.moderator && (
|
||||||
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
|
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
|
||||||
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
|
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
|
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.moderator && (
|
||||||
<BadgeSmall
|
<BadgeSmall
|
||||||
icon={karmaUnlocksById.highKarmaBadge.icon}
|
icon={karmaUnlocksById.highKarmaBadge.icon}
|
||||||
color="lime"
|
color="lime"
|
||||||
@@ -380,7 +380,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
user && (user.admin || user.verifier) && comment.internalNote && (
|
user && (user.admin || user.moderator) && comment.internalNote && (
|
||||||
<div class="mt-2 peer-checked/collapse:hidden">
|
<div class="mt-2 peer-checked/collapse:hidden">
|
||||||
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
|
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
|
||||||
<span class="font-medium text-red-400">Internal note:</span>
|
<span class="font-medium text-red-400">Internal note:</span>
|
||||||
@@ -391,7 +391,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
user && (user.admin || user.verifier) && comment.privateContext && (
|
user && (user.admin || user.moderator) && comment.privateContext && (
|
||||||
<div class="mt-2 peer-checked/collapse:hidden">
|
<div class="mt-2 peer-checked/collapse:hidden">
|
||||||
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
|
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
|
||||||
<span class="font-medium text-blue-400">Private context:</span>
|
<span class="font-medium text-blue-400">Private context:</span>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ const { comment, class: className, ...divProps } = Astro.props
|
|||||||
|
|
||||||
const user = Astro.locals.user
|
const user = Astro.locals.user
|
||||||
|
|
||||||
// Only render for admin/verifier users
|
// Only render for admin/moderator users
|
||||||
if (!user || !user.admin || !user.verifier) return null
|
if (!user || !user.admin || !user.moderator) return null
|
||||||
---
|
---
|
||||||
|
|
||||||
<div {...divProps} class={cn('text-xs', className)}>
|
<div {...divProps} class={cn('text-xs', className)}>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
|||||||
Most Upvotes
|
Most Upvotes
|
||||||
</a>
|
</a>
|
||||||
{
|
{
|
||||||
user && (user.admin || user.verifier) && (
|
user && (user.admin || user.moderator) && (
|
||||||
<a
|
<a
|
||||||
href={getSortUrl('status')}
|
href={getSortUrl('status')}
|
||||||
class={cn([
|
class={cn([
|
||||||
|
|||||||
@@ -47,12 +47,9 @@ const averageUserRatingFromQuery =
|
|||||||
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
|
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
|
||||||
|
|
||||||
if (averageUserRatingFromProps !== undefined) {
|
if (averageUserRatingFromProps !== undefined) {
|
||||||
if (
|
const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
|
||||||
averageUserRatingFromQuery !== averageUserRatingFromProps ||
|
const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
|
||||||
(averageUserRatingFromQuery !== null &&
|
if (a !== b) {
|
||||||
averageUserRatingFromProps !== null &&
|
|
||||||
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
|
|
||||||
) {
|
|
||||||
console.error(
|
console.error(
|
||||||
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
|
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const { class: className, ...htmlProps } = Astro.props
|
|||||||
href={href}
|
href={href}
|
||||||
target={external ? '_blank' : undefined}
|
target={external ? '_blank' : undefined}
|
||||||
rel={external ? 'noopener noreferrer' : 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" />
|
<Icon name={icon} class="h-4 w-4" />
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
24
web/src/components/FormSection.astro
Normal file
24
web/src/components/FormSection.astro
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<'section'> & {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
children: AstroChildren
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, subtitle, class: className, heading = 'h2', ...props } = Astro.props
|
||||||
|
|
||||||
|
const HeadingTag = heading
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class={cn('mt-24 space-y-2 first:mt-0', className)} {...props}>
|
||||||
|
<HeadingTag class="font-title text-center text-3xl leading-none font-bold">{title}</HeadingTag>
|
||||||
|
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
24
web/src/components/FormSubSection.astro
Normal file
24
web/src/components/FormSubSection.astro
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<'section'> & {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
children: AstroChildren
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, subtitle, class: className, heading = 'h3', ...props } = Astro.props
|
||||||
|
|
||||||
|
const HeadingTag = heading
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class={cn('mt-6 space-y-2 first:mt-0', className)} {...props}>
|
||||||
|
<HeadingTag class="font-title text-day-400 text-center text-lg font-medium">{title}</HeadingTag>
|
||||||
|
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
@@ -160,6 +160,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
|||||||
<a
|
<a
|
||||||
href={makeUnimpersonateUrl(Astro.url)}
|
href={makeUnimpersonateUrl(Astro.url)}
|
||||||
data-astro-reload
|
data-astro-reload
|
||||||
|
data-astro-prefetch="tap"
|
||||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||||
transition:name="header-unimpersonate-link"
|
transition:name="header-unimpersonate-link"
|
||||||
aria-label="Unimpersonate"
|
aria-label="Unimpersonate"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
transition:persist={option.noTransitionPersist ? undefined : true}
|
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
||||||
type={multiple ? 'checkbox' : 'radio'}
|
type={multiple ? 'checkbox' : 'radio'}
|
||||||
name={wrapperProps.name}
|
name={wrapperProps.name}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
|
|||||||
options: {
|
options: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
icon?: string
|
icon?: string[] | string
|
||||||
|
iconClassName?: string[] | string
|
||||||
}[]
|
}[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
selectedValues?: string[]
|
selectedValues?: string[]
|
||||||
|
size?: 'lg' | 'md'
|
||||||
}
|
}
|
||||||
|
|
||||||
const { options, disabled, selectedValues = [], ...wrapperProps } = Astro.props
|
const { options, disabled, selectedValues = [], size = 'md', ...wrapperProps } = Astro.props
|
||||||
|
|
||||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||||
@@ -26,23 +28,38 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
|
|
||||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||||
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
|
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
|
||||||
<div class="h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5">
|
<div
|
||||||
|
class={cn('h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5', {
|
||||||
|
'h-96': size === 'lg',
|
||||||
|
'h-48': size === 'md',
|
||||||
|
})}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
options.map((option) => (
|
options.map((option) => {
|
||||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
|
const icons = option.icon ? (Array.isArray(option.icon) ? option.icon : [option.icon]) : []
|
||||||
<input
|
const iconClassName = option.iconClassName
|
||||||
transition:persist
|
? Array.isArray(option.iconClassName)
|
||||||
type="checkbox"
|
? option.iconClassName
|
||||||
name={wrapperProps.name}
|
: Array.from({ length: icons.length }, () => option.iconClassName)
|
||||||
value={option.value}
|
: []
|
||||||
checked={selectedValues.includes(option.value)}
|
return (
|
||||||
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1 has-checked:bg-green-800/20 has-checked:hover:bg-green-800/30">
|
||||||
disabled={disabled}
|
<input
|
||||||
/>
|
transition:persist
|
||||||
{option.icon && <Icon name={option.icon} class="size-4" />}
|
type="checkbox"
|
||||||
<span class="text-sm leading-none">{option.label}</span>
|
name={wrapperProps.name}
|
||||||
</label>
|
value={option.value}
|
||||||
))
|
checked={selectedValues.includes(option.value)}
|
||||||
|
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{icons.map((icon, index) => (
|
||||||
|
<Icon name={icon} class={cn('size-4 shrink-0', iconClassName[index])} />
|
||||||
|
))}
|
||||||
|
<span class="truncate text-sm leading-none">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,13 +16,20 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accept, disabled, multiple, removeCheckbox, ...wrapperProps } = Astro.props
|
const { accept, disabled, multiple, removeCheckbox, classNames, ...wrapperProps } = Astro.props
|
||||||
|
|
||||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||||
---
|
---
|
||||||
|
|
||||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
<InputWrapper
|
||||||
|
inputId={inputId}
|
||||||
|
classNames={{
|
||||||
|
...classNames,
|
||||||
|
description: cn(classNames?.description, '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'),
|
||||||
|
}}
|
||||||
|
{...wrapperProps}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
!!removeCheckbox && (
|
!!removeCheckbox && (
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -2,16 +2,24 @@
|
|||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
||||||
|
|
||||||
|
import Button from './Button.astro'
|
||||||
import InputFile from './InputFile.astro'
|
import InputFile from './InputFile.astro'
|
||||||
|
import Tooltip from './Tooltip.astro'
|
||||||
|
|
||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
||||||
square?: boolean
|
square?: boolean
|
||||||
value?: string | null
|
value?: string | null
|
||||||
|
downloadButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className, square, value, ...inputFileProps } = Astro.props
|
const { class: className, square, value, downloadButton, ...inputFileProps } = Astro.props
|
||||||
|
|
||||||
|
function makeDownloadFilename(value: string) {
|
||||||
|
const url = new URL(value, Astro.url.origin)
|
||||||
|
return url.pathname.split('/').pop() ?? 'service-image'
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
|
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
|
||||||
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
|
|||||||
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
|
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{
|
||||||
|
downloadButton && value && (
|
||||||
|
<Tooltip
|
||||||
|
text="Download"
|
||||||
|
classNames={{
|
||||||
|
tooltip: 'min-2xs:[&:is(:has([data-remove-checkbox]:checked)_~_*_*)]:hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href={value}
|
||||||
|
download={makeDownloadFilename(value)}
|
||||||
|
icon="ri:download-line"
|
||||||
|
size="sm"
|
||||||
|
label="Download"
|
||||||
|
class={cn(
|
||||||
|
'bg-night-600 border-night-400 text-day-200 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:h-24 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:px-0 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:w-8 shrink-0 rounded-md border'
|
||||||
|
)}
|
||||||
|
classNames={{
|
||||||
|
label: '2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:hidden block ',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
|||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
||||||
inputProps?: Omit<HTMLAttributes<'textarea'>, 'name'>
|
inputProps?: Omit<HTMLAttributes<'textarea'>, 'name'>
|
||||||
value?: string
|
value?: string | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const { inputProps, value, ...wrapperProps } = Astro.props
|
const { inputProps, value, ...wrapperProps } = Astro.props
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
icon?: string
|
icon?: string
|
||||||
inputId?: string
|
inputId?: string
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
|
classNames?: {
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -32,13 +35,14 @@ const {
|
|||||||
class: className,
|
class: className,
|
||||||
inputId,
|
inputId,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
|
classNames,
|
||||||
...htmlProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
const hasError = !!error && error.length > 0
|
const hasError = !!error && error.length > 0
|
||||||
---
|
---
|
||||||
|
|
||||||
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
|
<fieldset class={cn('min-w-0 space-y-1', className)} {...htmlProps}>
|
||||||
{
|
{
|
||||||
!hideLabel && (
|
!hideLabel && (
|
||||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||||
@@ -71,7 +75,12 @@ const hasError = !!error && error.length > 0
|
|||||||
|
|
||||||
{
|
{
|
||||||
!!description && (
|
!!description && (
|
||||||
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty">
|
<div
|
||||||
|
class={cn(
|
||||||
|
'prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty',
|
||||||
|
classNames?.description
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Markdown content={description} />
|
<Markdown content={description} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
|
||||||
import { currencies } from '../constants/currencies'
|
import { currencies } from '../constants/currencies'
|
||||||
|
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||||
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
|
|||||||
kycLevel: true
|
kycLevel: true
|
||||||
imageUrl: true
|
imageUrl: true
|
||||||
verificationStatus: true
|
verificationStatus: true
|
||||||
|
serviceVisibility: true
|
||||||
acceptedCurrencies: true
|
acceptedCurrencies: true
|
||||||
categories: {
|
categories: {
|
||||||
select: {
|
select: {
|
||||||
@@ -43,11 +45,11 @@ const {
|
|||||||
slug,
|
slug,
|
||||||
description,
|
description,
|
||||||
overallScore,
|
overallScore,
|
||||||
|
|
||||||
kycLevel,
|
kycLevel,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories,
|
categories,
|
||||||
verificationStatus,
|
verificationStatus,
|
||||||
|
serviceVisibility,
|
||||||
acceptedCurrencies,
|
acceptedCurrencies,
|
||||||
},
|
},
|
||||||
class: className,
|
class: className,
|
||||||
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
|||||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||||
{...aProps}
|
{...aProps}
|
||||||
class={cn(
|
class={cn(
|
||||||
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||||
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
|
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
|||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
fallback="service"
|
fallback="service"
|
||||||
alt={name || 'Service logo'}
|
alt={name || 'Service logo'}
|
||||||
class="size-12 shrink-0 rounded-sm object-contain text-white"
|
class={cn(
|
||||||
|
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||||
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
|
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||||
|
)}
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
/>
|
/>
|
||||||
@@ -110,6 +118,23 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
|||||||
]}
|
]}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
}{
|
||||||
|
serviceVisibility === 'ARCHIVED' && (
|
||||||
|
<Tooltip
|
||||||
|
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||||
|
position="right"
|
||||||
|
class="-my-2 shrink-0 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
is:inline={inlineIcons}
|
||||||
|
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||||
|
class={cn(
|
||||||
|
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||||
|
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="max-h-2 flex-1"></div>
|
<div class="max-h-2 flex-1"></div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const {
|
|||||||
hx-select={`#${searchResultsId}`}
|
hx-select={`#${searchResultsId}`}
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-indicator="#search-indicator"
|
hx-indicator="#search-indicator"
|
||||||
|
hx-swap="outerHTML"
|
||||||
data-services-filters-form
|
data-services-filters-form
|
||||||
data-default-verification-filter={options.verification
|
data-default-verification-filter={options.verification
|
||||||
.filter((verification) => verification.default)
|
.filter((verification) => verification.default)
|
||||||
@@ -107,7 +108,7 @@ const {
|
|||||||
{
|
{
|
||||||
options.categories?.map((category) => (
|
options.categories?.map((category) => (
|
||||||
<li data-show-always={category.showAlways ? '' : undefined}>
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="peer text-green-500"
|
class="peer text-green-500"
|
||||||
@@ -116,6 +117,7 @@ const {
|
|||||||
checked={category.checked}
|
checked={category.checked}
|
||||||
data-trigger-on-change
|
data-trigger-on-change
|
||||||
/>
|
/>
|
||||||
|
<Icon name={category.icon} class="size-4" />
|
||||||
<span class="peer-checked:font-bold">
|
<span class="peer-checked:font-bold">
|
||||||
{category.name}
|
{category.name}
|
||||||
<span class="text-day-500 font-normal">{category._count.services}</span>
|
<span class="text-day-500 font-normal">{category._count.services}</span>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
sortSeed?: string
|
sortSeed?: string
|
||||||
filters: ServicesFiltersObject
|
filters: ServicesFiltersObject
|
||||||
includeScams: boolean
|
|
||||||
countCommunityOnly: number | null
|
countCommunityOnly: number | null
|
||||||
inlineIcons?: boolean
|
inlineIcons?: boolean
|
||||||
}
|
}
|
||||||
@@ -35,15 +34,12 @@ const {
|
|||||||
sortSeed,
|
sortSeed,
|
||||||
class: className,
|
class: className,
|
||||||
filters,
|
filters,
|
||||||
includeScams,
|
|
||||||
countCommunityOnly,
|
countCommunityOnly,
|
||||||
inlineIcons,
|
inlineIcons,
|
||||||
...divProps
|
...divProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
const hasScams =
|
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
||||||
filters.verification.includes('VERIFICATION_FAILED') || includeScams
|
|
||||||
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
|
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
|
||||||
|
|
||||||
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
|
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
|
||||||
@@ -205,6 +201,13 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
inlineIcon={inlineIcons}
|
inlineIcon={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href="/service-suggestion/new"
|
||||||
|
label="Add service"
|
||||||
|
icon="ri:add-line"
|
||||||
|
inlineIcon={inlineIcons}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -241,14 +244,18 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<div class="mt-4 text-center">
|
{
|
||||||
<Button
|
services && services.length > 0 && (
|
||||||
as="a"
|
<div class="mt-4 text-center">
|
||||||
href="/service-suggestion/new"
|
<Button
|
||||||
label="Add service"
|
as="a"
|
||||||
icon="ri:add-line"
|
href="/service-suggestion/new"
|
||||||
inlineIcon={inlineIcons}
|
label="Add service"
|
||||||
class="mx-auto"
|
icon="ri:add-line"
|
||||||
/>
|
inlineIcon={inlineIcons}
|
||||||
</div>
|
class="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ export const {
|
|||||||
notificationTitle: 'Your account is no longer verified',
|
notificationTitle: 'Your account is no longer verified',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'VERIFIER_TRUE',
|
value: 'MODERATOR_TRUE',
|
||||||
label: 'Verifier role granted',
|
label: 'Moderator role granted',
|
||||||
notificationTitle: 'Verifier role granted',
|
notificationTitle: 'Moderator role granted',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'VERIFIER_FALSE',
|
value: 'MODERATOR_FALSE',
|
||||||
label: 'Verifier role revoked',
|
label: 'Moderator role revoked',
|
||||||
notificationTitle: 'Verifier role revoked',
|
notificationTitle: 'Moderator role revoked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'SPAMMER_TRUE',
|
value: 'SPAMMER_TRUE',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
|
|||||||
label: string
|
label: string
|
||||||
/** Notice that the first capture group is then used to format the value */
|
/** Notice that the first capture group is then used to format the value */
|
||||||
matcher: RegExp
|
matcher: RegExp
|
||||||
formatter: (value: string) => string | null
|
formatter: (match: RegExpMatchArray) => string | null
|
||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,92 +24,107 @@ export const {
|
|||||||
label: type ? transformCase(type, 'title') : String(type),
|
label: type ? transformCase(type, 'title') : String(type),
|
||||||
icon: 'ri:shield-fill',
|
icon: 'ri:shield-fill',
|
||||||
matcher: /(.*)/,
|
matcher: /(.*)/,
|
||||||
formatter: (value) => value,
|
formatter: ([, value]) => value ?? String(value),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
type: 'email',
|
type: 'email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
matcher: /mailto:(.*)/,
|
matcher: /mailto:(.+)/,
|
||||||
formatter: (value) => value,
|
formatter: ([, value]) => value ?? 'Email',
|
||||||
icon: 'ri:mail-line',
|
icon: 'ri:mail-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'telephone',
|
type: 'telephone',
|
||||||
label: 'Telephone',
|
label: 'Telephone',
|
||||||
matcher: /tel:(.*)/,
|
matcher: /tel:(.+)/,
|
||||||
formatter: (value) => {
|
formatter: ([, value]) => {
|
||||||
return parsePhoneNumberWithError(value).formatInternational()
|
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
|
||||||
},
|
},
|
||||||
icon: 'ri:phone-line',
|
icon: 'ri:phone-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'whatsapp',
|
type: 'whatsapp',
|
||||||
label: 'WhatsApp',
|
label: 'WhatsApp',
|
||||||
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.*)\/?/,
|
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
||||||
formatter: (value) => {
|
formatter: ([, value]) => {
|
||||||
return parsePhoneNumberWithError(value).formatInternational()
|
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
|
||||||
},
|
},
|
||||||
icon: 'ri:whatsapp-line',
|
icon: 'ri:whatsapp-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'telegram',
|
type: 'telegram',
|
||||||
label: 'Telegram',
|
label: 'Telegram',
|
||||||
matcher: /https?:\/\/(?:www\.)?t\.me\/(.*)\/?/,
|
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
||||||
formatter: (value) => `t.me/${value}`,
|
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
|
||||||
icon: 'ri:telegram-line',
|
icon: 'ri:telegram-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'linkedin',
|
type: 'linkedin',
|
||||||
label: 'LinkedIn',
|
label: 'LinkedIn',
|
||||||
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/,
|
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
||||||
formatter: (value) => `in/${value}`,
|
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
|
||||||
icon: 'ri:linkedin-box-line',
|
icon: 'ri:linkedin-box-line',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: 'website',
|
|
||||||
label: 'Website',
|
|
||||||
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
|
||||||
formatter: (value) => value,
|
|
||||||
icon: 'ri:global-line',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'x',
|
type: 'x',
|
||||||
label: 'X',
|
label: 'X',
|
||||||
matcher: /https?:\/\/(?:www\.)?x\.com\/(.*)\/?/,
|
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
||||||
formatter: (value) => `@${value}`,
|
formatter: ([, value]) => (value ? `@${value}` : 'X'),
|
||||||
icon: 'ri:twitter-x-line',
|
icon: 'ri:twitter-x-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'instagram',
|
type: 'instagram',
|
||||||
label: 'Instagram',
|
label: 'Instagram',
|
||||||
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.*)\/?/,
|
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
||||||
formatter: (value) => `@${value}`,
|
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
|
||||||
icon: 'ri:instagram-line',
|
icon: 'ri:instagram-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'matrix',
|
type: 'matrix',
|
||||||
label: 'Matrix',
|
label: 'Matrix',
|
||||||
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.*)\/?/,
|
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||||
formatter: (value) => value,
|
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
||||||
icon: 'ri:hashtag',
|
icon: 'ri:hashtag',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'bitcointalk',
|
type: 'bitcointalk',
|
||||||
label: 'BitcoinTalk',
|
label: 'BitcoinTalk',
|
||||||
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/,
|
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||||
formatter: () => 'BitcoinTalk',
|
formatter: () => 'BitcoinTalk',
|
||||||
icon: 'ri:btc-line',
|
icon: 'ri:btc-line',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'simplex',
|
||||||
|
label: 'SimpleX Chat',
|
||||||
|
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
||||||
|
formatter: () => 'SimpleX Chat',
|
||||||
|
icon: 'simplex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'nostr',
|
||||||
|
label: 'Nostr',
|
||||||
|
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
|
||||||
|
formatter: () => 'Nostr',
|
||||||
|
icon: 'nostr',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Website must go last because it's a catch-all
|
||||||
|
type: 'website',
|
||||||
|
label: 'Website',
|
||||||
|
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||||
|
formatter: ([, value]) => value ?? 'Website',
|
||||||
|
icon: 'ri:global-line',
|
||||||
|
},
|
||||||
] as const satisfies ContactMethodInfo[]
|
] as const satisfies ContactMethodInfo[]
|
||||||
)
|
)
|
||||||
|
|
||||||
export function formatContactMethod(url: string) {
|
export function formatContactMethod(url: string) {
|
||||||
for (const contactMethod of contactMethods) {
|
for (const contactMethod of contactMethods) {
|
||||||
const captureGroup = url.match(contactMethod.matcher)?.[1]
|
const match = url.match(contactMethod.matcher)
|
||||||
if (!captureGroup) continue
|
if (!match) continue
|
||||||
|
|
||||||
const formattedValue = contactMethod.formatter(captureGroup)
|
const formattedValue = contactMethod.formatter(match)
|
||||||
if (!formattedValue) continue
|
if (!formattedValue) continue
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
|
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||||
import type { EventType } from '@prisma/client'
|
import type { EventType } from '@prisma/client'
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
type EventTypeInfo<T extends string | null | undefined = string> = {
|
type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||||
id: T
|
id: T
|
||||||
@@ -12,6 +14,9 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
|||||||
dot: string
|
dot: string
|
||||||
}
|
}
|
||||||
icon: string
|
icon: string
|
||||||
|
color: ComponentProps<typeof BadgeSmall>['color']
|
||||||
|
isSolved: boolean
|
||||||
|
showBanner: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -32,6 +37,9 @@ export const {
|
|||||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:question-fill',
|
icon: 'ri:question-fill',
|
||||||
|
color: 'gray',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -42,7 +50,10 @@ export const {
|
|||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:error-warning-fill',
|
icon: 'ri:alert-fill',
|
||||||
|
color: 'yellow',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'WARNING_SOLVED',
|
id: 'WARNING_SOLVED',
|
||||||
@@ -50,9 +61,12 @@ export const {
|
|||||||
label: 'Warning Solved',
|
label: 'Warning Solved',
|
||||||
description: 'A previously reported warning has been solved',
|
description: 'A previously reported warning has been solved',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:check-fill',
|
icon: 'ri:alert-fill',
|
||||||
|
color: 'green',
|
||||||
|
isSolved: true,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ALERT',
|
id: 'ALERT',
|
||||||
@@ -62,7 +76,10 @@ export const {
|
|||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:alert-fill',
|
icon: 'ri:spam-fill',
|
||||||
|
color: 'red',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ALERT_SOLVED',
|
id: 'ALERT_SOLVED',
|
||||||
@@ -70,9 +87,12 @@ export const {
|
|||||||
label: 'Alert Solved',
|
label: 'Alert Solved',
|
||||||
description: 'A previously reported alert has been solved',
|
description: 'A previously reported alert has been solved',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:check-fill',
|
icon: 'ri:spam-fill',
|
||||||
|
color: 'green',
|
||||||
|
isSolved: true,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'INFO',
|
id: 'INFO',
|
||||||
@@ -83,6 +103,9 @@ export const {
|
|||||||
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:information-fill',
|
icon: 'ri:information-fill',
|
||||||
|
color: 'sky',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'NORMAL',
|
id: 'NORMAL',
|
||||||
@@ -93,6 +116,9 @@ export const {
|
|||||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:notification-fill',
|
icon: 'ri:notification-fill',
|
||||||
|
color: 'green',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'UPDATE',
|
id: 'UPDATE',
|
||||||
@@ -103,6 +129,9 @@ export const {
|
|||||||
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:pencil-fill',
|
icon: 'ri:pencil-fill',
|
||||||
|
color: 'sky',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
] as const satisfies EventTypeInfo<EventType>[]
|
] as const satisfies EventTypeInfo<EventType>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
export const SUPPORT_EMAIL = 'contact@kycnot.me'
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
|
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||||
import type { ServiceSuggestionType } from '@prisma/client'
|
import type { ServiceSuggestionType } from '@prisma/client'
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
||||||
value: T
|
value: T
|
||||||
slug: string
|
slug: string
|
||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: string
|
||||||
|
order: number
|
||||||
default: boolean
|
default: boolean
|
||||||
|
color: ComponentProps<typeof BadgeSmall>['color']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -25,9 +29,11 @@ export const {
|
|||||||
(value): ServiceSuggestionTypeInfo<typeof value> => ({
|
(value): ServiceSuggestionTypeInfo<typeof value> => ({
|
||||||
value,
|
value,
|
||||||
slug: value ? value.toLowerCase() : '',
|
slug: value ? value.toLowerCase() : '',
|
||||||
label: value ? transformCase(value, 'title') : String(value),
|
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||||
icon: 'ri:question-line',
|
icon: 'ri:question-line',
|
||||||
|
order: Infinity,
|
||||||
default: false,
|
default: false,
|
||||||
|
color: 'zinc',
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -35,14 +41,18 @@ export const {
|
|||||||
slug: 'create',
|
slug: 'create',
|
||||||
label: 'Create',
|
label: 'Create',
|
||||||
icon: 'ri:add-line',
|
icon: 'ri:add-line',
|
||||||
|
order: 1,
|
||||||
default: true,
|
default: true,
|
||||||
|
color: 'green',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'EDIT_SERVICE',
|
value: 'EDIT_SERVICE',
|
||||||
slug: 'edit',
|
slug: 'edit',
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
icon: 'ri:pencil-line',
|
icon: 'ri:pencil-line',
|
||||||
|
order: 2,
|
||||||
default: false,
|
default: false,
|
||||||
|
color: 'blue',
|
||||||
},
|
},
|
||||||
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
|
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
|
|||||||
slug: string
|
slug: string
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
|
longDescription: string
|
||||||
icon: string
|
icon: string
|
||||||
iconClass: string
|
iconClass: string
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,7 @@ export const {
|
|||||||
slug: value ? value.toLowerCase() : '',
|
slug: value ? value.toLowerCase() : '',
|
||||||
label: value ? transformCase(value, 'title') : String(value),
|
label: value ? transformCase(value, 'title') : String(value),
|
||||||
description: '',
|
description: '',
|
||||||
|
longDescription: '',
|
||||||
icon: 'ri:eye-line',
|
icon: 'ri:eye-line',
|
||||||
iconClass: 'text-current/60',
|
iconClass: 'text-current/60',
|
||||||
}),
|
}),
|
||||||
@@ -37,6 +39,7 @@ export const {
|
|||||||
slug: 'public',
|
slug: 'public',
|
||||||
label: 'Public',
|
label: 'Public',
|
||||||
description: 'Listed in search and browse.',
|
description: 'Listed in search and browse.',
|
||||||
|
longDescription: 'Listed in search and browse.',
|
||||||
icon: 'ri:global-line',
|
icon: 'ri:global-line',
|
||||||
iconClass: 'text-green-500',
|
iconClass: 'text-green-500',
|
||||||
},
|
},
|
||||||
@@ -45,6 +48,7 @@ export const {
|
|||||||
slug: 'unlisted',
|
slug: 'unlisted',
|
||||||
label: 'Unlisted',
|
label: 'Unlisted',
|
||||||
description: 'Only accessible via direct link.',
|
description: 'Only accessible via direct link.',
|
||||||
|
longDescription: "Unlisted service, only accessible via direct link and won't appear in searches.",
|
||||||
icon: 'ri:link',
|
icon: 'ri:link',
|
||||||
iconClass: 'text-yellow-500',
|
iconClass: 'text-yellow-500',
|
||||||
},
|
},
|
||||||
@@ -53,8 +57,19 @@ export const {
|
|||||||
slug: 'hidden',
|
slug: 'hidden',
|
||||||
label: 'Hidden',
|
label: 'Hidden',
|
||||||
description: 'Only visible to moderators.',
|
description: 'Only visible to moderators.',
|
||||||
|
longDescription: 'Hidden service, only visible to moderators.',
|
||||||
icon: 'ri:lock-line',
|
icon: 'ri:lock-line',
|
||||||
iconClass: 'text-red-500',
|
iconClass: 'text-red-500',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'ARCHIVED',
|
||||||
|
slug: 'archived',
|
||||||
|
label: 'Archived',
|
||||||
|
description: 'No longer operational',
|
||||||
|
longDescription:
|
||||||
|
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
||||||
|
icon: 'ri:archive-line',
|
||||||
|
iconClass: 'text-day-100',
|
||||||
|
},
|
||||||
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
|
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
|
||||||
)
|
)
|
||||||
|
|||||||
53
web/src/constants/verificationStepStatus.ts
Normal file
53
web/src/constants/verificationStepStatus.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
|
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||||
|
import type { VerificationStepStatus } from '@prisma/client'
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
|
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
color: ComponentProps<typeof BadgeSmall>['color']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
dataArray: verificationStepStatuses,
|
||||||
|
dataObject: verificationStepStatusesByValue,
|
||||||
|
getFn: getVerificationStepStatusInfo,
|
||||||
|
} = makeHelpersForOptions(
|
||||||
|
'value',
|
||||||
|
(value): VerificationStepStatusInfo<typeof value> => ({
|
||||||
|
value,
|
||||||
|
label: value ? transformCase(value, 'title') : String(value),
|
||||||
|
icon: 'ri:question-line',
|
||||||
|
color: 'gray',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: 'PASSED',
|
||||||
|
label: 'Passed',
|
||||||
|
icon: 'ri:verified-badge-fill',
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'IN_PROGRESS',
|
||||||
|
label: 'In Progress',
|
||||||
|
icon: 'ri:loader-line',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FAILED',
|
||||||
|
label: 'Failed',
|
||||||
|
icon: 'ri:alert-line',
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'PENDING',
|
||||||
|
label: 'Pending',
|
||||||
|
icon: 'ri:time-line',
|
||||||
|
color: 'sky',
|
||||||
|
},
|
||||||
|
] as const satisfies VerificationStepStatusInfo<VerificationStepStatus>[]
|
||||||
|
)
|
||||||
4
web/src/icons/nostr.svg
Normal file
4
web/src/icons/nostr.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 620 620">
|
||||||
|
<path
|
||||||
|
d="M620 270v328c0 12-10 22-22 22H332c-12 0-22-10-22-22v-61c1-75 9-147 26-179 9-20 26-30 44-36 36-11 98-4 124-5 0 0 80 3 80-42 0-37-36-34-36-34-39 1-69-1-88-9-33-13-34-36-34-44-1-91-134-102-252-79-128 24 2 209 2 456v33c0 12-10 22-22 22H22c-12 0-22-10-22-22V31C0 19 10 9 22 9h124c12 0 22 10 22 22 0 19 20 29 35 18C248 17 305 0 369 0c143 0 251 84 251 270Zm-238-66c0-27-21-48-47-48s-47 21-47 48c0 26 21 48 47 48s47-22 47-48Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 526 B |
5
web/src/icons/simplex.svg
Normal file
5
web/src/icons/simplex.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="m2.14 6.31 3.95 3.79 3.97-3.81L6.11 2.5 8.13.57l3.94 3.79L16.1.5l1.98 1.9-4.03 3.85L18 10.03l4.03-3.85L24 8.07l-4.03 3.86 3.95 3.78-2.01 1.93-3.95-3.78-4.03 3.85 3.95 3.79-2 1.93-3.96-3.79L7.9 23.5l-1.98-1.9 4.06-3.89-3.95-3.78-4.06 3.89L0 15.92l4.06-3.88L.1 8.26 2.14 6.3Zm13.85 5.65L12 15.77 8.06 12l3.98-3.81 3.95 3.78Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
@@ -8,7 +8,7 @@ import BaseLayout from './BaseLayout.astro'
|
|||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
||||||
layoutHeader: { icon: string; title: string; subtitle: string }
|
layoutHeader: { icon: string; title: string; subtitle?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||||
@@ -28,10 +28,19 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
|
|||||||
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="font-title text-day-200 mt-1 text-center text-3xl font-semibold">
|
<h1
|
||||||
|
class={cn(
|
||||||
|
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
||||||
|
!layoutHeader.subtitle && 'xs:mb-8 mb-6'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{layoutHeader.title}
|
{layoutHeader.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
{
|
||||||
|
!!layoutHeader.subtitle && (
|
||||||
|
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { orderBy } from 'lodash-es'
|
|||||||
|
|
||||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||||
|
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
|
|
||||||
import { formatDateShort } from './timeAgo'
|
import { formatDateShort } from './timeAgo'
|
||||||
@@ -36,6 +37,7 @@ export function makeNonDbAttributes(
|
|||||||
service: Prisma.ServiceGetPayload<{
|
service: Prisma.ServiceGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
verificationStatus: true
|
verificationStatus: true
|
||||||
|
serviceVisibility: true
|
||||||
isRecentlyListed: true
|
isRecentlyListed: true
|
||||||
listedAt: true
|
listedAt: true
|
||||||
createdAt: true
|
createdAt: true
|
||||||
@@ -134,6 +136,16 @@ export function makeNonDbAttributes(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||||
|
show: service.serviceVisibility === 'ARCHIVED',
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Recently listed',
|
title: 'Recently listed',
|
||||||
show: service.isRecentlyListed,
|
show: service.isRecentlyListed,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const commentReplyQuery = {
|
|||||||
name: true,
|
name: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
admin: true,
|
admin: true,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
picture: true,
|
picture: true,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import type { MaybePromise } from 'astro/actions/runtime/utils.js'
|
import type { MaybePromise } from 'astro/actions/runtime/utils.js'
|
||||||
import type { z } from 'astro/zod'
|
import type { z } from 'astro/zod'
|
||||||
|
|
||||||
type SpecialUserPermission = 'admin' | 'verified' | 'verifier'
|
type SpecialUserPermission = 'admin' | 'moderator' | 'verified'
|
||||||
type Permission = SpecialUserPermission | 'guest' | 'not-spammer' | 'user'
|
type Permission = SpecialUserPermission | 'guest' | 'not-spammer' | 'user'
|
||||||
|
|
||||||
type ActionAPIContextWithUser = ActionAPIContext & {
|
type ActionAPIContextWithUser = ActionAPIContext & {
|
||||||
@@ -87,8 +87,8 @@ export function defineProtectedAction<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(permissions === 'verifier' || (Array.isArray(permissions) && permissions.includes('verifier'))) &&
|
(permissions === 'moderator' || (Array.isArray(permissions) && permissions.includes('moderator'))) &&
|
||||||
!context.locals.user.verifier
|
!context.locals.user.moderator
|
||||||
) {
|
) {
|
||||||
if (context.locals.user.spammer) {
|
if (context.locals.user.spammer) {
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
@@ -98,7 +98,7 @@ export function defineProtectedAction<
|
|||||||
}
|
}
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'Verifier privileges required.',
|
message: 'Moderator privileges required.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
|
|||||||
}
|
}
|
||||||
return url.origin
|
return url.origin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function separateServiceUrlsByType(allServiceUrls: string[]) {
|
||||||
|
const result: {
|
||||||
|
web: string[]
|
||||||
|
onion: string[]
|
||||||
|
i2p: string[]
|
||||||
|
} = {
|
||||||
|
web: [],
|
||||||
|
onion: [],
|
||||||
|
i2p: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of allServiceUrls) {
|
||||||
|
const parsedUrl = new URL(url)
|
||||||
|
if (parsedUrl.origin.endsWith('.onion')) {
|
||||||
|
result.onion.push(url)
|
||||||
|
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
|
||||||
|
result.i2p.push(url)
|
||||||
|
} else {
|
||||||
|
result.web.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
|
|||||||
defaultToken: 'admin',
|
defaultToken: 'admin',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
|
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
|
||||||
defaultToken: 'verifier',
|
defaultToken: 'moderator',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
|
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
|
||||||
@@ -50,7 +50,7 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
|
|||||||
}[]
|
}[]
|
||||||
|
|
||||||
const env =
|
const env =
|
||||||
// This file can also be called from faker.ts, where import.meta.env is not available
|
// This file can also be called from seed.ts, where import.meta.env is not available
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
(import.meta.env
|
(import.meta.env
|
||||||
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))
|
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
|
|||||||
export const zodUrlOptionalProtocol = z.preprocess(
|
export const zodUrlOptionalProtocol = z.preprocess(
|
||||||
(input) => {
|
(input) => {
|
||||||
if (typeof input !== 'string') return input
|
if (typeof input !== 'string') return input
|
||||||
const trimmedVal = input.trim()
|
const cleanInput = input.trim().replace(/\/$/, '')
|
||||||
return !/^\w+:\/\//i.test(trimmedVal) ? `https://${trimmedVal}` : trimmedVal
|
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
||||||
},
|
},
|
||||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||||
message: 'Invalid URL',
|
message: 'Invalid URL',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ Once submitted, you get a unique tracking page where you can monitor its status
|
|||||||
|
|
||||||
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
|
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
|
||||||
|
To list a new service, it must fulfill these requirements:
|
||||||
|
|
||||||
|
- Offer a service.
|
||||||
|
- Publicly available website explaining what the service is about
|
||||||
|
- Terms of service or FAQ document
|
||||||
|
|
||||||
|
For examples:
|
||||||
|
- Just a Telegram link or a criptocurrency itself is not a valid service.
|
||||||
|
|
||||||
### Suggestion Review Process
|
### Suggestion Review Process
|
||||||
|
|
||||||
#### First Review
|
#### First Review
|
||||||
@@ -206,7 +217,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)
|
- [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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
|
|||||||
<a
|
<a
|
||||||
href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })}
|
href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })}
|
||||||
data-astro-reload
|
data-astro-reload
|
||||||
|
data-astro-prefetch="tap"
|
||||||
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -62,6 +63,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
|
|||||||
Astro.locals.actualUser && (
|
Astro.locals.actualUser && (
|
||||||
<a
|
<a
|
||||||
href={makeUnimpersonateUrl(Astro.url, { redirect })}
|
href={makeUnimpersonateUrl(Astro.url, { redirect })}
|
||||||
|
data-astro-prefetch="tap"
|
||||||
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
spammer: true,
|
spammer: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
admin: true,
|
admin: true,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
verifiedLink: true,
|
verifiedLink: true,
|
||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -206,7 +206,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
</h1>
|
</h1>
|
||||||
{user.displayName && <p class="text-day-200 font-title">{user.name}</p>}
|
{user.displayName && <p class="text-day-200 font-title">{user.name}</p>}
|
||||||
{
|
{
|
||||||
(user.admin || user.verified || user.verifier) && (
|
(user.admin || user.verified || user.moderator) && (
|
||||||
<div class="mt-1 flex gap-2">
|
<div class="mt-1 flex gap-2">
|
||||||
{user.admin && (
|
{user.admin && (
|
||||||
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
|
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
|
||||||
@@ -218,9 +218,9 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
verified
|
verified
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{user.verifier && (
|
{user.moderator && (
|
||||||
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
||||||
verifier
|
moderator
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -356,14 +356,14 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
user.verifier && (
|
user.moderator && (
|
||||||
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
||||||
Verifier
|
Moderator
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!user.admin && !user.verified && !user.verifier && (
|
!user.admin && !user.verified && !user.moderator && (
|
||||||
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
|
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
|
||||||
Standard User
|
Standard User
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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">
|
<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">
|
<div class="mt-2 flex items-center gap-4 sm:mt-0">
|
||||||
<span class="text-sm text-zinc-400">{announcements.length} announcements</span>
|
<span class="text-sm text-zinc-400">{announcements.length} announcements</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,12 +193,17 @@ if (toggleResult?.error) {
|
|||||||
<option value="active" selected={filters.status === 'active'}>Active</option>
|
<option value="active" selected={filters.status === 'active'}>Active</option>
|
||||||
<option value="inactive" selected={filters.status === 'inactive'}>Inactive</option>
|
<option value="inactive" selected={filters.status === 'inactive'}>Inactive</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="submit"
|
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"
|
color="info"
|
||||||
>
|
variant="solid"
|
||||||
<Icon name="ri:search-2-line" class="h-4 w-4" />
|
size="md"
|
||||||
</button>
|
iconOnly
|
||||||
|
icon="ri:search-2-line"
|
||||||
|
label="Search"
|
||||||
|
class="rounded-l-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -206,13 +211,16 @@ if (toggleResult?.error) {
|
|||||||
|
|
||||||
<!-- Create New Announcement Form -->
|
<!-- Create New Announcement Form -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
id="toggle-new-announcement-form"
|
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"
|
color="success"
|
||||||
>
|
variant="solid"
|
||||||
<Icon name="ri:add-line" class="mr-1 h-4 w-4" />
|
size="md"
|
||||||
Create New Announcement
|
icon="ri:add-line"
|
||||||
</button>
|
label="Create"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="new-announcement-form"
|
id="new-announcement-form"
|
||||||
@@ -306,13 +314,16 @@ if (toggleResult?.error) {
|
|||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<InputSubmitButton label="Create Announcement" icon="ri:save-line" hideCancel />
|
<InputSubmitButton label="Create Announcement" icon="ri:save-line" hideCancel />
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
id="cancel-create"
|
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"
|
color="gray"
|
||||||
>
|
variant="faded"
|
||||||
Cancel
|
size="md"
|
||||||
</button>
|
label="Cancel"
|
||||||
|
class="ml-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -339,19 +350,24 @@ if (toggleResult?.error) {
|
|||||||
<form method="POST" action={actions.admin.announcement.delete} id="delete-form">
|
<form method="POST" action={actions.admin.announcement.delete} id="delete-form">
|
||||||
<input type="hidden" name="id" id="delete-id" />
|
<input type="hidden" name="id" id="delete-id" />
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="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"
|
class="close-modal"
|
||||||
>
|
color="gray"
|
||||||
Cancel
|
variant="faded"
|
||||||
</button>
|
size="md"
|
||||||
<button
|
label="Cancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
as="button"
|
||||||
type="submit"
|
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"
|
color="danger"
|
||||||
>
|
variant="solid"
|
||||||
<Icon name="ri:delete-bin-line" class="mr-1 h-4 w-4" />
|
size="md"
|
||||||
Delete
|
icon="ri:delete-bin-line"
|
||||||
</button>
|
label="Delete"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,60 +513,64 @@ if (toggleResult?.error) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex justify-center gap-2">
|
<div class="flex justify-center gap-2">
|
||||||
<Tooltip
|
<Tooltip text="Edit">
|
||||||
as="button"
|
<Button
|
||||||
type="button"
|
as="button"
|
||||||
data-id={announcement.id}
|
type="button"
|
||||||
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"
|
data-id={announcement.id}
|
||||||
text="Edit"
|
class="edit-button"
|
||||||
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
|
color="info"
|
||||||
>
|
variant="faded"
|
||||||
<Icon name="ri:edit-line" class="size-4" />
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:edit-line"
|
||||||
|
label="Edit"
|
||||||
|
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<form
|
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
|
||||||
method="POST"
|
<form
|
||||||
action={actions.admin.announcement.toggleActive}
|
method="POST"
|
||||||
class="inline-block"
|
action={actions.admin.announcement.toggleActive}
|
||||||
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
|
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'}>
|
<input type="hidden" name="id" value={announcement.id} />
|
||||||
<Icon
|
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
|
||||||
name={
|
<Button
|
||||||
announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'
|
as="button"
|
||||||
}
|
type="submit"
|
||||||
class="size-4"
|
color={announcement.isActive ? 'warning' : 'success'}
|
||||||
/>
|
variant="faded"
|
||||||
</Tooltip>
|
size="sm"
|
||||||
</button>
|
iconOnly
|
||||||
</form>
|
icon={announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'}
|
||||||
|
label={announcement.isActive ? 'Deactivate' : 'Activate'}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<form
|
<Tooltip text="Delete">
|
||||||
method="POST"
|
<form
|
||||||
action={actions.admin.announcement.delete}
|
method="POST"
|
||||||
class="inline-block"
|
action={actions.admin.announcement.delete}
|
||||||
data-confirm="Are you sure you want to delete this announcement?"
|
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">
|
<input type="hidden" name="id" value={announcement.id} />
|
||||||
<Icon name="ri:delete-bin-line" class="size-4" />
|
<Button
|
||||||
</Tooltip>
|
as="button"
|
||||||
</button>
|
type="submit"
|
||||||
</form>
|
color="danger"
|
||||||
|
variant="faded"
|
||||||
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:delete-bin-line"
|
||||||
|
label="Delete"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -648,9 +668,12 @@ if (toggleResult?.error) {
|
|||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel />
|
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel />
|
||||||
<Button
|
<Button
|
||||||
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
variant="faded"
|
||||||
|
size="md"
|
||||||
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
|
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { actions, isInputError } from 'astro:actions'
|
|||||||
import { z } from 'astro:content'
|
import { z } from 'astro:content'
|
||||||
import { orderBy as lodashOrderBy } from 'lodash-es'
|
import { orderBy as lodashOrderBy } from 'lodash-es'
|
||||||
|
|
||||||
|
import Button from '../../components/Button.astro'
|
||||||
import SortArrowIcon from '../../components/SortArrowIcon.astro'
|
import SortArrowIcon from '../../components/SortArrowIcon.astro'
|
||||||
|
import Tooltip from '../../components/Tooltip.astro'
|
||||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
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>
|
<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">
|
<div class="mt-2 flex items-center gap-4 sm:mt-0">
|
||||||
<span class="text-sm text-zinc-400">{attributeCount} attributes</span>
|
<span class="text-sm text-zinc-400">{attributeCount} attributes</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
as="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"
|
color="success"
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
icon="ri:add-line"
|
||||||
|
label="New Attribute"
|
||||||
onclick="document.getElementById('create-attribute-form').classList.toggle('hidden')"
|
onclick="document.getElementById('create-attribute-form').classList.toggle('hidden')"
|
||||||
>
|
/>
|
||||||
<Icon name="ri:add-line" class="size-4" />
|
|
||||||
<span>New Attribute</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -286,12 +289,15 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="submit"
|
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"
|
color="success"
|
||||||
>
|
variant="solid"
|
||||||
Create Attribute
|
size="md"
|
||||||
</button>
|
label="Create Attribute"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -343,12 +349,17 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="submit"
|
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"
|
color="info"
|
||||||
>
|
variant="solid"
|
||||||
<Icon name="ri:search-2-line" class="h-4 w-4" />
|
size="md"
|
||||||
</button>
|
iconOnly
|
||||||
|
icon="ri:search-2-line"
|
||||||
|
label="Search"
|
||||||
|
class="rounded-l-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -519,25 +530,34 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button
|
<Tooltip text="Edit">
|
||||||
type="button"
|
<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"
|
as="button"
|
||||||
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
|
color="info"
|
||||||
title="Edit attribute"
|
variant="faded"
|
||||||
>
|
size="sm"
|
||||||
<Icon name="ri:edit-line" class="size-3.5" />
|
iconOnly
|
||||||
</button>
|
icon="ri:edit-line"
|
||||||
<form method="POST" action={actions.admin.attribute.delete} class="inline-block">
|
label="Edit"
|
||||||
<input type="hidden" name="id" value={attribute.id} />
|
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
|
||||||
<button
|
/>
|
||||||
type="submit"
|
</Tooltip>
|
||||||
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"
|
<Tooltip text="Delete">
|
||||||
onclick="return confirm('Are you sure you want to delete this attribute?')"
|
<form method="POST" action={actions.admin.attribute.delete} class="inline-block">
|
||||||
title="Delete attribute"
|
<input type="hidden" name="id" value={attribute.id} />
|
||||||
>
|
<Button
|
||||||
<Icon name="ri:delete-bin-line" class="size-3.5" />
|
as="button"
|
||||||
</button>
|
type="submit"
|
||||||
</form>
|
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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -673,19 +693,23 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
as="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"
|
color="gray"
|
||||||
|
variant="faded"
|
||||||
|
size="sm"
|
||||||
|
label="Cancel"
|
||||||
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
|
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
|
||||||
>
|
/>
|
||||||
Cancel
|
<Button
|
||||||
</button>
|
as="button"
|
||||||
<button
|
|
||||||
type="submit"
|
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"
|
color="info"
|
||||||
>
|
variant="solid"
|
||||||
Save Changes
|
size="sm"
|
||||||
</button>
|
icon="ri:save-line"
|
||||||
|
label="Save"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { prisma } from '../../lib/prisma'
|
|||||||
import { urlWithParams } from '../../lib/urls'
|
import { urlWithParams } from '../../lib/urls'
|
||||||
|
|
||||||
const user = Astro.locals.user
|
const user = Astro.locals.user
|
||||||
if (!user || (!user.admin && !user.verifier)) {
|
if (!user || (!user.admin && !user.moderator)) {
|
||||||
return Astro.rewrite('/404')
|
return Astro.rewrite('/404')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
|
||||||
|
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
@@ -9,7 +11,9 @@ type AdminLink = {
|
|||||||
icon: ComponentProps<typeof Icon>['name']
|
icon: ComponentProps<typeof Icon>['name']
|
||||||
title: string
|
title: string
|
||||||
href: string
|
href: string
|
||||||
description: string
|
classNames: {
|
||||||
|
base?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminLinks: AdminLink[] = [
|
const adminLinks: AdminLink[] = [
|
||||||
@@ -17,66 +21,112 @@ const adminLinks: AdminLink[] = [
|
|||||||
icon: 'ri:box-3-line',
|
icon: 'ri:box-3-line',
|
||||||
title: 'Services',
|
title: 'Services',
|
||||||
href: '/admin/services',
|
href: '/admin/services',
|
||||||
description: 'Manage your available services',
|
classNames: {
|
||||||
},
|
base: 'text-green-300',
|
||||||
{
|
},
|
||||||
icon: 'ri:file-list-3-line',
|
|
||||||
title: 'Attributes',
|
|
||||||
href: '/admin/attributes',
|
|
||||||
description: 'Configure service attributes',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'ri:user-3-line',
|
icon: 'ri:user-3-line',
|
||||||
title: 'Users',
|
title: 'Users',
|
||||||
href: '/admin/users',
|
href: '/admin/users',
|
||||||
description: 'Manage user accounts',
|
classNames: {
|
||||||
|
base: 'text-red-300',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'ri:chat-settings-line',
|
icon: 'ri:chat-4-line',
|
||||||
title: 'Comments',
|
title: 'Comments',
|
||||||
href: '/admin/comments',
|
href: '/admin/comments',
|
||||||
description: 'Moderate user comments',
|
classNames: {
|
||||||
|
base: 'text-yellow-300',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'ri:lightbulb-line',
|
icon: 'ri:lightbulb-line',
|
||||||
title: 'Service suggestions',
|
title: 'Suggestions',
|
||||||
href: '/admin/service-suggestions',
|
href: '/admin/service-suggestions',
|
||||||
description: 'Review and manage service suggestions',
|
classNames: {
|
||||||
|
base: 'text-purple-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'ri:price-tag-3-line',
|
||||||
|
title: 'Attributes',
|
||||||
|
href: '/admin/attributes',
|
||||||
|
classNames: {
|
||||||
|
base: 'text-blue-300',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'ri:megaphone-line',
|
icon: 'ri:megaphone-line',
|
||||||
title: 'Announcements',
|
title: 'Announcements',
|
||||||
href: '/admin/announcements',
|
href: '/admin/announcements',
|
||||||
description: 'Manage site announcements',
|
classNames: {
|
||||||
|
base: 'text-pink-300',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: 'ri:rocket-2-line',
|
||||||
|
title: 'Releases',
|
||||||
|
href: '/admin/releases',
|
||||||
|
classNames: {
|
||||||
|
base: 'text-orange-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'ri:database-2-line',
|
||||||
|
title: 'Database',
|
||||||
|
href: DATABASE_UI_URL,
|
||||||
|
classNames: {
|
||||||
|
base: 'text-gray-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(LOGS_UI_URL
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: 'ri:menu-search-line',
|
||||||
|
title: 'Logs',
|
||||||
|
href: LOGS_UI_URL,
|
||||||
|
classNames: {
|
||||||
|
base: 'text-cyan-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout pageTitle="Admin Dashboard" widthClassName="max-w-screen-xl">
|
<BaseLayout pageTitle="Admin Dashboard" widthClassName="max-w-screen-xl">
|
||||||
<h1 class="font-title mb-8 text-3xl font-bold text-zinc-100">
|
<h1 class="font-title text-day-100 mb-8 text-3xl font-bold">
|
||||||
<Icon name="ri:home-gear-line" class="me-1 inline-block size-10 align-[-0.35em]" />
|
<Icon name="ri:home-gear-line" class="me-1 inline-block size-10 align-[-0.35em]" />
|
||||||
Admin Dashboard
|
Admin Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<nav>
|
||||||
{
|
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
|
||||||
adminLinks.map((link) => (
|
{
|
||||||
<a
|
adminLinks
|
||||||
href={link.href}
|
.filter((link) => 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"
|
.map((link) => (
|
||||||
>
|
<li
|
||||||
<div class="mb-4 flex items-center gap-3">
|
class={cn(
|
||||||
<Icon
|
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
|
||||||
name={link.icon}
|
link.classNames.base
|
||||||
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">
|
<a
|
||||||
{link.title}
|
href={link.href}
|
||||||
</h2>
|
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
|
||||||
</div>
|
>
|
||||||
<p class="text-sm text-zinc-400">{link.description}</p>
|
<Icon
|
||||||
</a>
|
name={link.icon}
|
||||||
))
|
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
|
||||||
}
|
/>
|
||||||
</div>
|
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
44
web/src/pages/admin/releases.astro
Normal file
44
web/src/pages/admin/releases.astro
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
|
||||||
|
|
||||||
|
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||||
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
|
||||||
|
const releaseDate =
|
||||||
|
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
|
||||||
|
---
|
||||||
|
|
||||||
|
<MiniLayout
|
||||||
|
pageTitle="Releases"
|
||||||
|
description="Manage releases"
|
||||||
|
layoutHeader={{
|
||||||
|
icon: 'ri:rocket-2-line',
|
||||||
|
title: 'Releases',
|
||||||
|
subtitle: 'Current release',
|
||||||
|
}}
|
||||||
|
className={{
|
||||||
|
main: 'flex flex-col items-center justify-center text-center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
|
||||||
|
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
|
||||||
|
</p>
|
||||||
|
<time class="text-day-400 mt-4 block text-center text-xl" datetime={releaseDate?.toISOString()}>
|
||||||
|
{
|
||||||
|
releaseDate?.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}) ?? 'Unknown release date'
|
||||||
|
}
|
||||||
|
</time>
|
||||||
|
{
|
||||||
|
!!releaseDate && (
|
||||||
|
<p class="text-day-500 mt-2">
|
||||||
|
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</MiniLayout>
|
||||||
@@ -2,10 +2,16 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||||
|
import Button from '../../../components/Button.astro'
|
||||||
import Chat from '../../../components/Chat.astro'
|
import Chat from '../../../components/Chat.astro'
|
||||||
import ServiceCard from '../../../components/ServiceCard.astro'
|
import ServiceCard from '../../../components/ServiceCard.astro'
|
||||||
import UserBadge from '../../../components/UserBadge.astro'
|
import UserBadge from '../../../components/UserBadge.astro'
|
||||||
import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus'
|
import {
|
||||||
|
getServiceSuggestionStatusInfo,
|
||||||
|
serviceSuggestionStatuses,
|
||||||
|
} from '../../../constants/serviceSuggestionStatus'
|
||||||
|
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
|
||||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../../../lib/cn'
|
import { cn } from '../../../lib/cn'
|
||||||
import { parseIntWithFallback } from '../../../lib/numbers'
|
import { parseIntWithFallback } from '../../../lib/numbers'
|
||||||
@@ -53,6 +59,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
|||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
verificationStatus: true,
|
verificationStatus: true,
|
||||||
acceptedCurrencies: true,
|
acceptedCurrencies: true,
|
||||||
|
serviceVisibility: true,
|
||||||
categories: {
|
categories: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
@@ -88,6 +95,7 @@ if (!serviceSuggestion) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||||
|
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -96,15 +104,19 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
|||||||
widthClassName="max-w-screen-md"
|
widthClassName="max-w-screen-md"
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex items-center gap-4">
|
<div class="mb-4 flex items-center gap-4">
|
||||||
<a
|
<Button
|
||||||
|
as="a"
|
||||||
href="/admin/service-suggestions"
|
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"
|
color="success"
|
||||||
>
|
variant="faded"
|
||||||
<Icon name="ri:arrow-left-s-line" class="mr-1 size-4" />
|
size="md"
|
||||||
Back
|
icon="ri:arrow-left-s-line"
|
||||||
</a>
|
label="Back"
|
||||||
|
/>
|
||||||
|
|
||||||
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
|
<h1 class="font-title text-day-200 text-xl">Service suggestion</h1>
|
||||||
|
|
||||||
|
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
@@ -112,12 +124,13 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
|||||||
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
|
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
|
||||||
class="rounded-lg border border-green-500/30 bg-black/40 p-4 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
|
<h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
|
||||||
>
|
|
||||||
<h2 class="font-title mb-3 text-lg text-green-500">Suggestion Details</h2>
|
|
||||||
|
|
||||||
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||||
|
<span class="font-title text-gray-400">Type:</span>
|
||||||
|
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||||
|
|
||||||
<span class="font-title text-gray-400">Status:</span>
|
<span class="font-title text-gray-400">Status:</span>
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -136,7 +149,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
|||||||
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
|
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
|
||||||
|
|
||||||
<span class="font-title text-gray-400">Service page:</span>
|
<span class="font-title text-gray-400">Service page:</span>
|
||||||
<a href={`/service/${serviceSuggestion.service.slug}`} class="text-green-400 hover:text-green-500">
|
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
|
||||||
View Service <Icon
|
View Service <Icon
|
||||||
name="ri:external-link-line"
|
name="ri:external-link-line"
|
||||||
class="ml-0.5 inline-block size-3 align-[-0.05em]"
|
class="ml-0.5 inline-block size-3 align-[-0.05em]"
|
||||||
@@ -158,11 +171,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
|
||||||
class="rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-title text-lg text-green-500">Messages</h2>
|
<h2 class="font-title text-day-200 text-lg">Messages</h2>
|
||||||
|
|
||||||
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
|
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
|
||||||
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
|
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
|
||||||
@@ -170,17 +181,23 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
|||||||
name="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"
|
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>
|
serviceSuggestionStatuses.map((status) => (
|
||||||
<option value="REJECTED" selected={serviceSuggestion.status === 'REJECTED'}> Reject </option>
|
<option value={status.value} selected={serviceSuggestion.status === status.value}>
|
||||||
<option value="WITHDRAWN" selected={serviceSuggestion.status === 'WITHDRAWN'}> Withdrawn </option>
|
{status.label}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="submit"
|
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"
|
color="success"
|
||||||
>
|
variant="faded"
|
||||||
<Icon name="ri:save-line" class="mr-1 size-4" /> Update
|
size="md"
|
||||||
</button>
|
icon="ri:save-line"
|
||||||
|
label="Update"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,65 +4,76 @@ import { actions } from 'astro:actions'
|
|||||||
import { z } from 'astro:content'
|
import { z } from 'astro:content'
|
||||||
import { orderBy } from 'lodash-es'
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
|
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||||
|
import Button from '../../../components/Button.astro'
|
||||||
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
||||||
import TimeFormatted from '../../../components/TimeFormatted.astro'
|
import TimeFormatted from '../../../components/TimeFormatted.astro'
|
||||||
|
import Tooltip from '../../../components/Tooltip.astro'
|
||||||
import UserBadge from '../../../components/UserBadge.astro'
|
import UserBadge from '../../../components/UserBadge.astro'
|
||||||
import {
|
import {
|
||||||
getServiceSuggestionStatusInfo,
|
getServiceSuggestionStatusInfo,
|
||||||
serviceSuggestionStatuses,
|
serviceSuggestionStatuses,
|
||||||
|
serviceSuggestionStatusesZodEnumBySlug,
|
||||||
|
serviceSuggestionStatusSlugToId,
|
||||||
} from '../../../constants/serviceSuggestionStatus'
|
} from '../../../constants/serviceSuggestionStatus'
|
||||||
|
import {
|
||||||
|
getServiceSuggestionTypeInfo,
|
||||||
|
serviceSuggestionTypes,
|
||||||
|
serviceSuggestionTypeSlugToId,
|
||||||
|
serviceSuggestionTypesZodEnumBySlug,
|
||||||
|
} from '../../../constants/serviceSuggestionType'
|
||||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||||
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
|
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
|
||||||
import { prisma } from '../../../lib/prisma'
|
import { prisma } from '../../../lib/prisma'
|
||||||
import { makeLoginUrl } from '../../../lib/redirectUrls'
|
import { makeLoginUrl } from '../../../lib/redirectUrls'
|
||||||
|
|
||||||
import type { Prisma, ServiceSuggestionStatus } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
const user = Astro.locals.user
|
const user = Astro.locals.user
|
||||||
if (!user?.admin) {
|
if (!user?.admin) {
|
||||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
|
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = Astro.url.searchParams.get('search') ?? ''
|
|
||||||
const statusEnumValues = serviceSuggestionStatuses.map((s) => s.value) as [string, ...string[]]
|
|
||||||
const statusParam = Astro.url.searchParams.get('status')
|
|
||||||
const statusFilter = z
|
|
||||||
.enum(statusEnumValues)
|
|
||||||
.nullable()
|
|
||||||
.parse(statusParam === '' ? null : statusParam) as ServiceSuggestionStatus | null
|
|
||||||
|
|
||||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||||
{
|
{
|
||||||
'sort-by': z.enum(['service', 'status', 'user', 'createdAt', 'messageCount']).default('createdAt'),
|
search: z.string().optional(),
|
||||||
|
status: serviceSuggestionStatusesZodEnumBySlug
|
||||||
|
.transform((slug) => serviceSuggestionStatusSlugToId(slug))
|
||||||
|
.optional(),
|
||||||
|
type: serviceSuggestionTypesZodEnumBySlug
|
||||||
|
.transform((slug) => serviceSuggestionTypeSlugToId(slug))
|
||||||
|
.optional(),
|
||||||
|
'sort-by': z
|
||||||
|
.enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount'])
|
||||||
|
.default('createdAt'),
|
||||||
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
||||||
},
|
},
|
||||||
Astro
|
Astro
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortBy = filters['sort-by']
|
|
||||||
const sortOrder = filters['sort-order']
|
|
||||||
|
|
||||||
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
|
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
|
||||||
if (sortBy === 'createdAt') {
|
if (filters['sort-by'] === 'createdAt') {
|
||||||
prismaOrderBy = { createdAt: sortOrder }
|
prismaOrderBy = { createdAt: filters['sort-order'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
let suggestions = await prisma.serviceSuggestion.findMany({
|
let suggestions = await prisma.serviceSuggestion.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(search
|
...(filters.search
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ service: { name: { contains: search, mode: 'insensitive' } } },
|
{ service: { name: { contains: filters.search, mode: 'insensitive' } } },
|
||||||
{ user: { name: { contains: search, mode: 'insensitive' } } },
|
{ user: { name: { contains: filters.search, mode: 'insensitive' } } },
|
||||||
{ notes: { contains: search, mode: 'insensitive' } },
|
{ notes: { contains: filters.search, mode: 'insensitive' } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
status: statusFilter ?? undefined,
|
status: filters.status,
|
||||||
|
type: filters.type,
|
||||||
},
|
},
|
||||||
orderBy: prismaOrderBy,
|
orderBy: prismaOrderBy,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
type: true,
|
||||||
status: true,
|
status: true,
|
||||||
notes: true,
|
notes: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -117,18 +128,33 @@ let suggestions = await prisma.serviceSuggestion.findMany({
|
|||||||
let suggestionsWithDetails = suggestions.map((s) => ({
|
let suggestionsWithDetails = suggestions.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
statusInfo: getServiceSuggestionStatusInfo(s.status),
|
statusInfo: getServiceSuggestionStatusInfo(s.status),
|
||||||
|
typeInfo: getServiceSuggestionTypeInfo(s.type),
|
||||||
messageCount: s._count.messages,
|
messageCount: s._count.messages,
|
||||||
lastMessage: s.messages[0],
|
lastMessage: s.messages[0],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (sortBy === 'service') {
|
if (filters['sort-by'] === 'service') {
|
||||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder])
|
suggestionsWithDetails = orderBy(
|
||||||
} else if (sortBy === 'status') {
|
suggestionsWithDetails,
|
||||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
|
[(s) => s.service.name.toLowerCase()],
|
||||||
} else if (sortBy === 'user') {
|
[filters['sort-order']]
|
||||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder])
|
)
|
||||||
} else if (sortBy === 'messageCount') {
|
} else if (filters['sort-by'] === 'status') {
|
||||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
|
suggestionsWithDetails = orderBy(
|
||||||
|
suggestionsWithDetails,
|
||||||
|
[(s) => s.statusInfo.label],
|
||||||
|
[filters['sort-order']]
|
||||||
|
)
|
||||||
|
} else if (filters['sort-by'] === 'type') {
|
||||||
|
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.typeInfo.label], [filters['sort-order']])
|
||||||
|
} else if (filters['sort-by'] === 'user') {
|
||||||
|
suggestionsWithDetails = orderBy(
|
||||||
|
suggestionsWithDetails,
|
||||||
|
[(s) => s.user.name.toLowerCase()],
|
||||||
|
[filters['sort-order']]
|
||||||
|
)
|
||||||
|
} else if (filters['sort-by'] === 'messageCount') {
|
||||||
|
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [filters['sort-order']])
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestionCount = suggestionsWithDetails.length
|
const suggestionCount = suggestionsWithDetails.length
|
||||||
@@ -160,7 +186,7 @@ const makeSortUrl = (slug: string) => {
|
|||||||
type="text"
|
type="text"
|
||||||
name="search"
|
name="search"
|
||||||
id="search"
|
id="search"
|
||||||
value={search}
|
value={filters.search}
|
||||||
placeholder="Search by service, user, notes..."
|
placeholder="Search by service, user, notes..."
|
||||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -175,20 +201,41 @@ const makeSortUrl = (slug: string) => {
|
|||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{
|
{
|
||||||
serviceSuggestionStatuses.map((status) => (
|
serviceSuggestionStatuses.map((status) => (
|
||||||
<option value={status.value} selected={statusFilter === status.value}>
|
<option value={status.slug} selected={filters.status === status.value}>
|
||||||
{status.label}
|
{status.label}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div>
|
||||||
<button
|
<label for="type-filter" class="block text-xs font-medium text-zinc-400">Type</label>
|
||||||
type="submit"
|
<select
|
||||||
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"
|
name="type"
|
||||||
|
id="type-filter"
|
||||||
|
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<Icon name="ri:search-2-line" class="h-4 w-4" />
|
<option value="">All Types</option>
|
||||||
</button>
|
{
|
||||||
|
serviceSuggestionTypes.map((type) => (
|
||||||
|
<option value={type.slug} selected={filters.type === type.value}>
|
||||||
|
{type.label}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Button
|
||||||
|
as="button"
|
||||||
|
type="submit"
|
||||||
|
color="info"
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:search-2-line"
|
||||||
|
label="Search"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +253,7 @@ const makeSortUrl = (slug: string) => {
|
|||||||
<thead class="bg-zinc-900/30">
|
<thead class="bg-zinc-900/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
class="w-[25%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
class="w-[20%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
<a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
|
<a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
|
||||||
Service <SortArrowIcon
|
Service <SortArrowIcon
|
||||||
@@ -216,7 +263,7 @@ const makeSortUrl = (slug: string) => {
|
|||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
class="w-[12%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
<a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200">
|
<a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200">
|
||||||
User <SortArrowIcon
|
User <SortArrowIcon
|
||||||
@@ -226,7 +273,17 @@ const makeSortUrl = (slug: string) => {
|
|||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
class="w-[10%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
|
>
|
||||||
|
<a href={makeSortUrl('type')} class="flex items-center hover:text-zinc-200">
|
||||||
|
Type <SortArrowIcon
|
||||||
|
active={filters['sort-by'] === 'type'}
|
||||||
|
sortOrder={filters['sort-order']}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="w-[13%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
<a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200">
|
<a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200">
|
||||||
Status <SortArrowIcon
|
Status <SortArrowIcon
|
||||||
@@ -289,6 +346,13 @@ const makeSortUrl = (slug: string) => {
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<UserBadge user={suggestion.user} size="md" />
|
<UserBadge user={suggestion.user} size="md" />
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<BadgeSmall
|
||||||
|
color={suggestion.typeInfo.color}
|
||||||
|
text={suggestion.typeInfo.label}
|
||||||
|
icon={suggestion.typeInfo.icon}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<form method="POST" action={actions.admin.serviceSuggestions.update}>
|
<form method="POST" action={actions.admin.serviceSuggestions.update}>
|
||||||
<input type="hidden" name="suggestionId" value={suggestion.id} />
|
<input type="hidden" name="suggestionId" value={suggestion.id} />
|
||||||
@@ -317,13 +381,18 @@ const makeSortUrl = (slug: string) => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<a
|
<Tooltip text="Manage">
|
||||||
href={`/admin/service-suggestions/${suggestion.id}`}
|
<Button
|
||||||
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"
|
as="a"
|
||||||
title="View"
|
href={`/admin/service-suggestions/${suggestion.id}`}
|
||||||
>
|
color="success"
|
||||||
<Icon name="ri:external-link-line" class="size-4" />
|
variant="faded"
|
||||||
</a>
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:arrow-right-line"
|
||||||
|
label="Manage"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,13 @@ import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/clie
|
|||||||
import { z } from 'astro/zod'
|
import { z } from 'astro/zod'
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
|
||||||
|
import Button from '../../../components/Button.astro'
|
||||||
import MyPicture from '../../../components/MyPicture.astro'
|
import MyPicture from '../../../components/MyPicture.astro'
|
||||||
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
||||||
|
import Tooltip from '../../../components/Tooltip.astro'
|
||||||
import { getKycLevelInfo } from '../../../constants/kycLevels'
|
import { getKycLevelInfo } from '../../../constants/kycLevels'
|
||||||
import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
|
import { serviceVisibilities } from '../../../constants/serviceVisibility'
|
||||||
|
import { getVerificationStatusInfo, verificationStatuses } from '../../../constants/verificationStatus'
|
||||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../../../lib/cn'
|
import { cn } from '../../../lib/cn'
|
||||||
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
|
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
|
||||||
@@ -108,6 +111,7 @@ const services = await Astro.locals.banners.try(
|
|||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
verificationRequests: true,
|
verificationRequests: true,
|
||||||
|
internalNotes: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -171,13 +175,15 @@ const truncate = (text: string, length: number) => {
|
|||||||
<h1 class="font-title text-2xl font-bold text-white">Service Management</h1>
|
<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">
|
<div class="mt-2 flex items-center gap-4 sm:mt-0">
|
||||||
<span class="text-sm text-zinc-400">{totalServicesCount} services</span>
|
<span class="text-sm text-zinc-400">{totalServicesCount} services</span>
|
||||||
<a
|
<Button
|
||||||
|
as="a"
|
||||||
href="/admin/services/new"
|
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"
|
color="success"
|
||||||
>
|
variant="solid"
|
||||||
<Icon name="ri:add-line" class="size-4" />
|
size="md"
|
||||||
<span>New Service</span>
|
icon="ri:add-line"
|
||||||
</a>
|
label="New Service"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -205,11 +211,11 @@ const truncate = (text: string, length: number) => {
|
|||||||
id="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"
|
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>
|
<option value="">All</option>
|
||||||
{
|
{
|
||||||
Object.values(ServiceVisibility).map((status) => (
|
serviceVisibilities.map((visibility) => (
|
||||||
<option value={status} selected={filters.visibility === status}>
|
<option value={visibility.value} selected={filters.visibility === visibility.value}>
|
||||||
{status}
|
{visibility.label}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -223,11 +229,11 @@ const truncate = (text: string, length: number) => {
|
|||||||
id="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"
|
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>
|
<option value="">All</option>
|
||||||
{
|
{
|
||||||
Object.values(VerificationStatus).map((status) => (
|
verificationStatuses.map((status) => (
|
||||||
<option value={status} selected={filters.verificationStatus === status}>
|
<option value={status.value} selected={filters.verificationStatus === status.value}>
|
||||||
{status}
|
{status.label}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -250,12 +256,17 @@ const truncate = (text: string, length: number) => {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="submit"
|
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"
|
color="info"
|
||||||
>
|
variant="solid"
|
||||||
<Icon name="ri:search-2-line" class="h-4 w-4" />
|
size="md"
|
||||||
</button>
|
iconOnly
|
||||||
|
icon="ri:search-2-line"
|
||||||
|
label="Search"
|
||||||
|
class="ml-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -430,27 +441,46 @@ const truncate = (text: string, length: number) => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<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">
|
<div class="flex flex-col items-center justify-center gap-1">
|
||||||
{service._count.verificationRequests}
|
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
|
||||||
</span>
|
{service._count.verificationRequests}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{service._count.internalNotes > 0 && (
|
||||||
|
<span class="inline-flex items-center text-purple-400">
|
||||||
|
<Icon name="ri:sticky-note-line" class="mr-0.5 size-4" />
|
||||||
|
{service._count.internalNotes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</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">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="flex justify-end space-x-2">
|
<div class="flex justify-end space-x-2">
|
||||||
<a
|
<Tooltip text="View">
|
||||||
href={`/service/${service.slug}`}
|
<Button
|
||||||
target="_blank"
|
as="a"
|
||||||
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"
|
href={`/service/${service.slug}`}
|
||||||
title="View on site"
|
color="success"
|
||||||
>
|
variant="faded"
|
||||||
<Icon name="ri:external-link-line" class="size-3.5" />
|
size="sm"
|
||||||
</a>
|
iconOnly
|
||||||
<a
|
icon="ri:global-line"
|
||||||
href={`/admin/services/${service.slug}/edit`}
|
label="View"
|
||||||
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"
|
/>
|
||||||
>
|
</Tooltip>
|
||||||
Edit
|
<Tooltip text="Edit">
|
||||||
</a>
|
<Button
|
||||||
|
as="a"
|
||||||
|
href={`/admin/services/${service.slug}/edit`}
|
||||||
|
color="info"
|
||||||
|
variant="faded"
|
||||||
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:edit-line"
|
||||||
|
label="Edit"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label>
|
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
|
||||||
<textarea
|
<textarea
|
||||||
transition:persist
|
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"
|
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"
|
name="allServiceUrls"
|
||||||
id="serviceUrls"
|
id="allServiceUrls"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="https://example1.com https://example2.com"
|
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
|
||||||
set:text=""
|
set:text=""
|
||||||
/>
|
/>
|
||||||
{
|
{
|
||||||
inputErrors.serviceUrls && (
|
inputErrors.allServiceUrls && (
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
|
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
}
|
}
|
||||||
</div>
|
</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"
|
|
||||||
set:text=""
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.onionUrls && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
|
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -350,13 +332,13 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral</label>
|
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral link path</label>
|
||||||
<input
|
<input
|
||||||
transition:persist
|
transition:persist
|
||||||
type="text"
|
type="text"
|
||||||
name="referral"
|
name="referral"
|
||||||
id="referral"
|
id="referral"
|
||||||
placeholder="Optional referral code/link"
|
placeholder="e.g. ?ref=123 or /ref/123"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
{
|
{
|
||||||
@@ -366,6 +348,26 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="internalNote" class="font-title mb-2 block text-sm text-green-500">internalNote</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<textarea
|
||||||
|
transition:persist
|
||||||
|
name="internalNote"
|
||||||
|
id="internalNote"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Markdown supported"
|
||||||
|
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"
|
||||||
|
set:text=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
inputErrors.internalNote && (
|
||||||
|
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.internalNote.join(', ')}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { Markdown } from 'astro-remote'
|
||||||
import { actions, isInputError } from 'astro:actions'
|
import { actions, isInputError } from 'astro:actions'
|
||||||
|
|
||||||
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||||
import Button from '../../../components/Button.astro'
|
import Button from '../../../components/Button.astro'
|
||||||
|
import FormSection from '../../../components/FormSection.astro'
|
||||||
import InputCardGroup from '../../../components/InputCardGroup.astro'
|
import InputCardGroup from '../../../components/InputCardGroup.astro'
|
||||||
import InputImageFile from '../../../components/InputImageFile.astro'
|
import InputImageFile from '../../../components/InputImageFile.astro'
|
||||||
import InputSelect from '../../../components/InputSelect.astro'
|
import InputSelect from '../../../components/InputSelect.astro'
|
||||||
@@ -52,7 +54,7 @@ const [user, allServices] = await Astro.locals.banners.tryMany([
|
|||||||
link: true,
|
link: true,
|
||||||
admin: true,
|
admin: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
spammer: true,
|
spammer: true,
|
||||||
verifiedLink: true,
|
verifiedLink: true,
|
||||||
internalNotes: {
|
internalNotes: {
|
||||||
@@ -141,7 +143,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
<div class="mb-4 flex flex-wrap justify-center gap-2">
|
<div class="mb-4 flex flex-wrap justify-center gap-2">
|
||||||
{user.admin && <BadgeSmall color="green" text="Admin" icon="ri:shield-star-fill" />}
|
{user.admin && <BadgeSmall color="green" text="Admin" icon="ri:shield-star-fill" />}
|
||||||
{user.verified && <BadgeSmall color="cyan" text="Verified" icon="ri:verified-badge-fill" />}
|
{user.verified && <BadgeSmall color="cyan" text="Verified" icon="ri:verified-badge-fill" />}
|
||||||
{user.verifier && <BadgeSmall color="blue" text="Moderator" icon="ri:graduation-cap-fill" />}
|
{user.moderator && <BadgeSmall color="blue" text="Moderator" icon="ri:graduation-cap-fill" />}
|
||||||
{user.spammer && <BadgeSmall color="red" text="Spammer" icon="ri:alert-fill" />}
|
{user.spammer && <BadgeSmall color="red" text="Spammer" icon="ri:alert-fill" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,90 +173,88 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<FormSection title="Edit profile">
|
||||||
method="POST"
|
<form
|
||||||
action={actions.admin.user.update}
|
method="POST"
|
||||||
enctype="multipart/form-data"
|
action={actions.admin.user.update}
|
||||||
class="space-y-2"
|
enctype="multipart/form-data"
|
||||||
data-astro-reload
|
class="space-y-2"
|
||||||
>
|
data-astro-reload
|
||||||
<h2 class="font-title text-center text-3xl leading-none font-bold">Edit profile</h2>
|
>
|
||||||
|
<input type="hidden" name="id" value={user.id} />
|
||||||
|
|
||||||
<input type="hidden" name="id" value={user.id} />
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<InputText
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
error={updateInputErrors.name}
|
||||||
|
inputProps={{ value: user.name, required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
<InputText
|
||||||
<InputText
|
label="Display Name"
|
||||||
label="Name"
|
name="displayName"
|
||||||
name="name"
|
error={updateInputErrors.displayName}
|
||||||
error={updateInputErrors.name}
|
inputProps={{ value: user.displayName ?? '', maxlength: 50 }}
|
||||||
inputProps={{ value: user.name, required: true }}
|
/>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
label="Link"
|
||||||
|
name="link"
|
||||||
|
error={updateInputErrors.link}
|
||||||
|
inputProps={{ value: user.link ?? '', type: 'url' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
label="Verified Link"
|
||||||
|
name="verifiedLink"
|
||||||
|
error={updateInputErrors.verifiedLink}
|
||||||
|
inputProps={{ value: user.verifiedLink, type: 'url' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputImageFile
|
||||||
|
label="Profile Picture Upload"
|
||||||
|
name="pictureFile"
|
||||||
|
value={user.picture}
|
||||||
|
error={updateInputErrors.pictureFile}
|
||||||
|
square
|
||||||
|
description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF. Max size: 5MB."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputCardGroup
|
||||||
label="Display Name"
|
name="type"
|
||||||
name="displayName"
|
label="Type"
|
||||||
error={updateInputErrors.displayName}
|
options={[
|
||||||
inputProps={{ value: user.displayName ?? '', maxlength: 50 }}
|
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' },
|
||||||
|
{ label: 'Moderator', value: 'moderator', icon: 'ri:graduation-cap-fill' },
|
||||||
|
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
|
||||||
|
{
|
||||||
|
label: 'Verified',
|
||||||
|
value: 'verified',
|
||||||
|
icon: 'ri:verified-badge-fill',
|
||||||
|
disabled: true,
|
||||||
|
noTransitionPersist: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
selectedValue={[
|
||||||
|
user.admin ? 'admin' : null,
|
||||||
|
user.verified ? 'verified' : null,
|
||||||
|
user.moderator ? 'moderator' : null,
|
||||||
|
user.spammer ? 'spammer' : null,
|
||||||
|
].filter((v) => v !== null)}
|
||||||
|
required
|
||||||
|
cardSize="sm"
|
||||||
|
iconSize="sm"
|
||||||
|
multiple
|
||||||
|
error={updateInputErrors.type}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
|
||||||
label="Link"
|
</form>
|
||||||
name="link"
|
</FormSection>
|
||||||
error={updateInputErrors.link}
|
|
||||||
inputProps={{ value: user.link ?? '', type: 'url' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputText
|
|
||||||
label="Verified Link"
|
|
||||||
name="verifiedLink"
|
|
||||||
error={updateInputErrors.verifiedLink}
|
|
||||||
inputProps={{ value: user.verifiedLink, type: 'url' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InputImageFile
|
|
||||||
label="Profile Picture Upload"
|
|
||||||
name="pictureFile"
|
|
||||||
value={user.picture}
|
|
||||||
error={updateInputErrors.pictureFile}
|
|
||||||
square
|
|
||||||
description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF. Max size: 5MB."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputCardGroup
|
|
||||||
name="type"
|
|
||||||
label="Type"
|
|
||||||
options={[
|
|
||||||
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' },
|
|
||||||
{ label: 'Moderator', value: 'verifier', icon: 'ri:graduation-cap-fill' },
|
|
||||||
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
|
|
||||||
{
|
|
||||||
label: 'Verified',
|
|
||||||
value: 'verified',
|
|
||||||
icon: 'ri:verified-badge-fill',
|
|
||||||
disabled: true,
|
|
||||||
noTransitionPersist: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
selectedValue={[
|
|
||||||
user.admin ? 'admin' : null,
|
|
||||||
user.verified ? 'verified' : null,
|
|
||||||
user.verifier ? 'verifier' : null,
|
|
||||||
user.spammer ? 'spammer' : null,
|
|
||||||
].filter((v) => v !== null)}
|
|
||||||
required
|
|
||||||
cardSize="sm"
|
|
||||||
iconSize="sm"
|
|
||||||
multiple
|
|
||||||
error={updateInputErrors.type}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<section class="space-y-2">
|
|
||||||
<h2 class="font-title text-center text-3xl leading-none font-bold">Internal Notes</h2>
|
|
||||||
|
|
||||||
|
<FormSection title="Internal Notes">
|
||||||
{
|
{
|
||||||
user.internalNotes.length === 0 ? (
|
user.internalNotes.length === 0 ? (
|
||||||
<p class="text-day-300 text-center">No internal notes yet.</p>
|
<p class="text-day-300 text-center">No internal notes yet.</p>
|
||||||
@@ -294,7 +294,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={actions.admin.user.internalNotes.delete}
|
action={actions.admin.user.internalNotes.delete}
|
||||||
class="contents"
|
class="space-y-2"
|
||||||
data-astro-reload
|
data-astro-reload
|
||||||
>
|
>
|
||||||
<input type="hidden" name="noteId" value={note.id} />
|
<input type="hidden" name="noteId" value={note.id} />
|
||||||
@@ -305,8 +305,8 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-note-content>
|
<div data-note-content class="prose prose-sm text-day-200 prose-invert max-w-none text-pretty">
|
||||||
<p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
|
<Markdown content={note.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -347,11 +347,9 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
/>
|
/>
|
||||||
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
|
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</FormSection>
|
||||||
|
|
||||||
<section class="space-y-2">
|
|
||||||
<h2 class="font-title text-center text-3xl leading-none font-bold">Service Affiliations</h2>
|
|
||||||
|
|
||||||
|
<FormSection title="Service Affiliations">
|
||||||
{
|
{
|
||||||
user.serviceAffiliations.length === 0 ? (
|
user.serviceAffiliations.length === 0 ? (
|
||||||
<p class="text-day-200 text-center">No service affiliations yet.</p>
|
<p class="text-day-200 text-center">No service affiliations yet.</p>
|
||||||
@@ -380,7 +378,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
method="POST"
|
method="POST"
|
||||||
action={actions.admin.user.serviceAffiliations.remove}
|
action={actions.admin.user.serviceAffiliations.remove}
|
||||||
data-astro-reload
|
data-astro-reload
|
||||||
class="contents"
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={affiliation.id} />
|
<input type="hidden" name="id" value={affiliation.id} />
|
||||||
<button type="submit" class="text-day-300 transition-colors hover:text-red-400">
|
<button type="submit" class="text-day-300 transition-colors hover:text-red-400">
|
||||||
@@ -429,29 +427,29 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
|
|
||||||
<InputSubmitButton label="Add Affiliation" icon="ri:link" hideCancel />
|
<InputSubmitButton label="Add Affiliation" icon="ri:link" hideCancel />
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</FormSection>
|
||||||
|
|
||||||
<form method="POST" action={actions.admin.user.karmaTransactions.add} data-astro-reload class="space-y-2">
|
<FormSection title="Grant/Remove Karma">
|
||||||
<h2 class="font-title text-center text-3xl leading-none font-bold">Grant/Remove Karma</h2>
|
<form method="POST" action={actions.admin.user.karmaTransactions.add} data-astro-reload class="space-y-2">
|
||||||
|
<input type="hidden" name="userId" value={user.id} />
|
||||||
|
|
||||||
<input type="hidden" name="userId" value={user.id} />
|
<InputText
|
||||||
|
label="Points"
|
||||||
|
name="points"
|
||||||
|
error={addKarmaTransactionResult?.error?.message}
|
||||||
|
inputProps={{ type: 'number', required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputTextArea
|
||||||
label="Points"
|
label="Description"
|
||||||
name="points"
|
name="description"
|
||||||
error={addKarmaTransactionResult?.error?.message}
|
error={addKarmaTransactionResult?.error?.message}
|
||||||
inputProps={{ type: 'number', required: true }}
|
inputProps={{ required: true }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputTextArea
|
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
|
||||||
label="Description"
|
</form>
|
||||||
name="description"
|
</FormSection>
|
||||||
error={addKarmaTransactionResult?.error?.message}
|
|
||||||
inputProps={{ required: true }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
|
|
||||||
</form>
|
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { z } from 'astro:content'
|
import { z } from 'astro:content'
|
||||||
import { orderBy as lodashOrderBy } from 'lodash-es'
|
import { orderBy as lodashOrderBy } from 'lodash-es'
|
||||||
|
|
||||||
|
import Button from '../../../components/Button.astro'
|
||||||
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
||||||
import TimeFormatted from '../../../components/TimeFormatted.astro'
|
import TimeFormatted from '../../../components/TimeFormatted.astro'
|
||||||
import Tooltip from '../../../components/Tooltip.astro'
|
import Tooltip from '../../../components/Tooltip.astro'
|
||||||
@@ -18,17 +19,20 @@ import type { Prisma } from '@prisma/client'
|
|||||||
|
|
||||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||||
{
|
{
|
||||||
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
|
'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
|
||||||
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
role: z.enum(['user', 'admin', 'verifier', 'verified', 'spammer']).optional(),
|
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
|
||||||
},
|
},
|
||||||
Astro
|
Astro
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set up Prisma orderBy with correct typing
|
// Set up Prisma orderBy with correct typing
|
||||||
const prismaOrderBy =
|
const prismaOrderBy =
|
||||||
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma'
|
filters['sort-by'] === 'name' ||
|
||||||
|
filters['sort-by'] === 'createdAt' ||
|
||||||
|
filters['sort-by'] === 'lastLoginAt' ||
|
||||||
|
filters['sort-by'] === 'karma'
|
||||||
? {
|
? {
|
||||||
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
|
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
|
||||||
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
|
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
|
||||||
@@ -46,7 +50,7 @@ if (filters.role) {
|
|||||||
switch (filters.role) {
|
switch (filters.role) {
|
||||||
case 'user': {
|
case 'user': {
|
||||||
whereClause.admin = false
|
whereClause.admin = false
|
||||||
whereClause.verifier = false
|
whereClause.moderator = false
|
||||||
whereClause.verified = false
|
whereClause.verified = false
|
||||||
whereClause.spammer = false
|
whereClause.spammer = false
|
||||||
break
|
break
|
||||||
@@ -55,8 +59,8 @@ if (filters.role) {
|
|||||||
whereClause.admin = true
|
whereClause.admin = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'verifier': {
|
case 'moderator': {
|
||||||
whereClause.verifier = true
|
whereClause.moderator = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'verified': {
|
case 'verified': {
|
||||||
@@ -80,11 +84,12 @@ const dbUsers = await prisma.user.findMany({
|
|||||||
picture: true,
|
picture: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
admin: true,
|
admin: true,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
spammer: true,
|
spammer: true,
|
||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
internalNotes: {
|
internalNotes: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -147,16 +152,21 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
<option value="" selected={!filters.role}>All Users</option>
|
<option value="" selected={!filters.role}>All Users</option>
|
||||||
<option value="user" selected={filters.role === 'user'}>Regular Users</option>
|
<option value="user" selected={filters.role === 'user'}>Regular Users</option>
|
||||||
<option value="admin" selected={filters.role === 'admin'}>Admins</option>
|
<option value="admin" selected={filters.role === 'admin'}>Admins</option>
|
||||||
<option value="verifier" selected={filters.role === 'verifier'}>Verifiers</option>
|
<option value="moderator" selected={filters.role === 'moderator'}>Moderators</option>
|
||||||
<option value="verified" selected={filters.role === 'verified'}>Verified Users</option>
|
<option value="verified" selected={filters.role === 'verified'}>Verified Users</option>
|
||||||
<option value="spammer" selected={filters.role === 'spammer'}>Spammers</option>
|
<option value="spammer" selected={filters.role === 'spammer'}>Spammers</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<Button
|
||||||
|
as="button"
|
||||||
type="submit"
|
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"
|
color="info"
|
||||||
>
|
variant="solid"
|
||||||
<Icon name="ri:search-2-line" class="h-4 w-4" />
|
size="md"
|
||||||
</button>
|
iconOnly
|
||||||
|
icon="ri:search-2-line"
|
||||||
|
label="Search"
|
||||||
|
class="rounded-l-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -212,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
<th
|
<th
|
||||||
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
<a
|
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||||
href={makeSortUrl('createdAt')}
|
<a
|
||||||
class="flex items-center justify-center hover:text-zinc-200"
|
href={makeSortUrl('lastLoginAt')}
|
||||||
>
|
class="flex items-center justify-center hover:text-zinc-200"
|
||||||
Joined <SortArrowIcon
|
>
|
||||||
active={filters['sort-by'] === 'createdAt'}
|
Login <SortArrowIcon
|
||||||
sortOrder={filters['sort-order']}
|
active={filters['sort-by'] === 'lastLoginAt'}
|
||||||
/>
|
sortOrder={filters['sort-order']}
|
||||||
</a>
|
/>
|
||||||
|
</a>
|
||||||
|
<span class="text-zinc-600">/</span>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th
|
<th
|
||||||
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
@@ -277,10 +300,10 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
Verified
|
Verified
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{user.verifier && (
|
{user.moderator && (
|
||||||
<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">
|
<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" />
|
<Icon name="ri:shield-check-fill" class="size-3.5" />
|
||||||
Verifier
|
Moderator
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
{user.totalKarma}
|
{user.totalKarma}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-sm text-zinc-400">
|
<td class="px-4 py-3 text-center text-sm">
|
||||||
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} />
|
<div class="flex flex-wrap items-center justify-center gap-1 text-center">
|
||||||
|
<TimeFormatted
|
||||||
|
class="text-zinc-300"
|
||||||
|
date={user.lastLoginAt}
|
||||||
|
hourPrecision
|
||||||
|
hoursShort
|
||||||
|
prefix={false}
|
||||||
|
/>
|
||||||
|
<span class="text-zinc-600">/</span>
|
||||||
|
<TimeFormatted
|
||||||
|
class="text-zinc-400"
|
||||||
|
date={user.createdAt}
|
||||||
|
hourPrecision
|
||||||
|
hoursShort
|
||||||
|
prefix={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex justify-center gap-3">
|
<div class="flex justify-center gap-3">
|
||||||
@@ -320,30 +359,43 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex justify-center gap-2">
|
<div class="flex justify-center gap-2">
|
||||||
<Tooltip
|
<Tooltip text="Impersonate">
|
||||||
as="a"
|
<Button
|
||||||
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
|
as="a"
|
||||||
data-astro-prefetch="tap"
|
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"
|
data-astro-prefetch="tap"
|
||||||
text="Impersonate"
|
color="warning"
|
||||||
>
|
variant="faded"
|
||||||
<Icon name="ri:spy-line" class="size-4" />
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:spy-line"
|
||||||
|
label="Impersonate"
|
||||||
|
disabled={user.admin}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip text="Edit">
|
||||||
as="a"
|
<Button
|
||||||
href={`/admin/users/${user.name}`}
|
as="a"
|
||||||
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"
|
href={`/admin/users/${user.name}`}
|
||||||
text="Edit"
|
color="info"
|
||||||
>
|
variant="faded"
|
||||||
<Icon name="ri:edit-line" class="size-4" />
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:edit-line"
|
||||||
|
label="Edit"
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip text="Public profile">
|
||||||
as="a"
|
<Button
|
||||||
href={`/u/${user.name}`}
|
as="a"
|
||||||
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"
|
href={`/u/${user.name}`}
|
||||||
text="Public profile"
|
color="success"
|
||||||
>
|
variant="faded"
|
||||||
<Icon name="ri:global-line" class="size-4" />
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon="ri:global-line"
|
||||||
|
label="Public profile"
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
import { ServiceVisibility } from '@prisma/client'
|
|
||||||
import { z } from 'astro:schema'
|
import { z } from 'astro:schema'
|
||||||
import { groupBy, omit, orderBy, uniq } from 'lodash-es'
|
import { groupBy, omit, orderBy, uniq } from 'lodash-es'
|
||||||
import seedrandom from 'seedrandom'
|
import seedrandom from 'seedrandom'
|
||||||
@@ -222,7 +221,9 @@ const where = {
|
|||||||
verificationStatus: {
|
verificationStatus: {
|
||||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||||
},
|
},
|
||||||
serviceVisibility: ServiceVisibility.PUBLIC,
|
serviceVisibility: {
|
||||||
|
in: ['PUBLIC', 'ARCHIVED'],
|
||||||
|
},
|
||||||
overallScore: { gte: filters['min-score'] },
|
overallScore: { gte: filters['min-score'] },
|
||||||
acceptedCurrencies: filters.currencies.length
|
acceptedCurrencies: filters.currencies.length
|
||||||
? filters['currency-mode'] === 'and'
|
? filters['currency-mode'] === 'and'
|
||||||
@@ -322,7 +323,13 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
icon: true,
|
icon: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
services: true,
|
services: {
|
||||||
|
where: {
|
||||||
|
serviceVisibility: {
|
||||||
|
in: ['PUBLIC', 'ARCHIVED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -368,6 +375,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
verificationStatus: true,
|
verificationStatus: true,
|
||||||
acceptedCurrencies: true,
|
acceptedCurrencies: true,
|
||||||
|
serviceVisibility: true,
|
||||||
attributes: {
|
attributes: {
|
||||||
select: {
|
select: {
|
||||||
attribute: {
|
attribute: {
|
||||||
@@ -530,6 +538,7 @@ const showFiltersId = 'show-filters'
|
|||||||
hx-trigger="input delay:500ms, keyup[key=='Enter']"
|
hx-trigger="input delay:500ms, keyup[key=='Enter']"
|
||||||
hx-target={`#${searchResultsId}`}
|
hx-target={`#${searchResultsId}`}
|
||||||
hx-select={`#${searchResultsId}`}
|
hx-select={`#${searchResultsId}`}
|
||||||
|
hx-swap="outerHTML"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-indicator="#search-indicator"
|
hx-indicator="#search-indicator"
|
||||||
class="contents"
|
class="contents"
|
||||||
@@ -703,7 +712,6 @@ const showFiltersId = 'show-filters'
|
|||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
sortSeed={filters['sort-seed']}
|
sortSeed={filters['sort-seed']}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
includeScams={includeScams}
|
|
||||||
countCommunityOnly={countCommunityOnly}
|
countCommunityOnly={countCommunityOnly}
|
||||||
inlineIcons
|
inlineIcons
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
import AdminOnly from '../../components/AdminOnly.astro'
|
import AdminOnly from '../../components/AdminOnly.astro'
|
||||||
|
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||||
|
import Button from '../../components/Button.astro'
|
||||||
import Chat from '../../components/Chat.astro'
|
import Chat from '../../components/Chat.astro'
|
||||||
import ServiceCard from '../../components/ServiceCard.astro'
|
import ServiceCard from '../../components/ServiceCard.astro'
|
||||||
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
|
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
|
||||||
|
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
import { parseIntWithFallback } from '../../lib/numbers'
|
import { parseIntWithFallback } from '../../lib/numbers'
|
||||||
@@ -28,6 +31,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
|||||||
prisma.serviceSuggestion.findUnique({
|
prisma.serviceSuggestion.findUnique({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
type: true,
|
||||||
status: true,
|
status: true,
|
||||||
notes: true,
|
notes: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -42,6 +46,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
|||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
verificationStatus: true,
|
verificationStatus: true,
|
||||||
acceptedCurrencies: true,
|
acceptedCurrencies: true,
|
||||||
|
serviceVisibility: true,
|
||||||
categories: {
|
categories: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
@@ -59,6 +64,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
displayName: true,
|
||||||
picture: true,
|
picture: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -81,6 +87,7 @@ if (!serviceSuggestion) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||||
|
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -104,17 +111,22 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
|
<div class="mt-12 mb-6 flex flex-col items-center justify-center gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||||
|
<AdminOnly>
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href={`/admin/service-suggestions/${serviceSuggestionIdRaw}`}
|
||||||
|
size="sm"
|
||||||
|
icon="ri:lock-line"
|
||||||
|
label="View in admin"
|
||||||
|
/>
|
||||||
|
</AdminOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AdminOnly>
|
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
|
||||||
<a
|
</div>
|
||||||
href={`/admin/service-suggestions/${serviceSuggestionIdRaw}`}
|
|
||||||
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
|
||||||
>
|
|
||||||
<Icon name="ri:lock-line" class="size-4" />
|
|
||||||
View in admin
|
|
||||||
</a>
|
|
||||||
</AdminOnly>
|
|
||||||
|
|
||||||
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
|
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const service = await Astro.locals.banners.try(
|
|||||||
icon: true,
|
icon: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
serviceVisibility: true,
|
||||||
},
|
},
|
||||||
where: { id: params.serviceId },
|
where: { id: params.serviceId },
|
||||||
}),
|
}),
|
||||||
@@ -101,7 +102,7 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
error={inputErrors.notes}
|
error={inputErrors.notes}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Captcha action={actions.serviceSuggestion.createService} />
|
<Captcha action={actions.serviceSuggestion.editService} />
|
||||||
|
|
||||||
<InputHoneypotTrap name="message" />
|
<InputHoneypotTrap name="message" />
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
import { z } from 'astro:content'
|
import { z } from 'astro:content'
|
||||||
|
|
||||||
|
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||||
|
import BadgeStandardFilter from '../../components/BadgeStandardFilter.astro'
|
||||||
import Button from '../../components/Button.astro'
|
import Button from '../../components/Button.astro'
|
||||||
import MyPicture from '../../components/MyPicture.astro'
|
import MyPicture from '../../components/MyPicture.astro'
|
||||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||||
@@ -10,10 +12,16 @@ import Tooltip from '../../components/Tooltip.astro'
|
|||||||
import {
|
import {
|
||||||
getServiceSuggestionStatusInfo,
|
getServiceSuggestionStatusInfo,
|
||||||
serviceSuggestionStatuses,
|
serviceSuggestionStatuses,
|
||||||
|
serviceSuggestionStatusesZodEnumBySlug,
|
||||||
|
serviceSuggestionStatusSlugToId,
|
||||||
} from '../../constants/serviceSuggestionStatus'
|
} from '../../constants/serviceSuggestionStatus'
|
||||||
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
|
import {
|
||||||
|
getServiceSuggestionTypeInfo,
|
||||||
|
serviceSuggestionTypes,
|
||||||
|
serviceSuggestionTypeSlugToId,
|
||||||
|
serviceSuggestionTypesZodEnumBySlug,
|
||||||
|
} from '../../constants/serviceSuggestionType'
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
import { zodEnumFromConstant } from '../../lib/arrays'
|
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
|
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
@@ -26,8 +34,13 @@ if (!user) {
|
|||||||
|
|
||||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||||
{
|
{
|
||||||
serviceId: z.array(z.number().int().positive()).default([]),
|
serviceId: z.array(z.number().int().positive()),
|
||||||
status: z.array(zodEnumFromConstant(serviceSuggestionStatuses, 'value')).default([]),
|
status: z.array(
|
||||||
|
serviceSuggestionStatusesZodEnumBySlug.transform((slug) => serviceSuggestionStatusSlugToId(slug))
|
||||||
|
),
|
||||||
|
type: z.array(
|
||||||
|
serviceSuggestionTypesZodEnumBySlug.transform((slug) => serviceSuggestionTypeSlugToId(slug))
|
||||||
|
),
|
||||||
},
|
},
|
||||||
Astro
|
Astro
|
||||||
)
|
)
|
||||||
@@ -52,6 +65,7 @@ const serviceSuggestions = await Astro.locals.banners.try('Error fetching servic
|
|||||||
where: {
|
where: {
|
||||||
id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined,
|
id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined,
|
||||||
status: filters.status.length > 0 ? { in: filters.status } : undefined,
|
status: filters.status.length > 0 ? { in: filters.status } : undefined,
|
||||||
|
type: filters.type.length > 0 ? { in: filters.type } : undefined,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -104,6 +118,23 @@ const success = !!createResult && !createResult.error
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="text-day-200 mb-2 font-medium">Filter by:</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{
|
||||||
|
serviceSuggestionTypes.map((type) => (
|
||||||
|
<BadgeStandardFilter name="type" value={type.slug} label={type.label} icon={type.icon} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
serviceSuggestionStatuses.map((status) => (
|
||||||
|
<BadgeStandardFilter name="status" value={status.slug} label={status.label} icon={status.icon} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
serviceSuggestions.length === 0 ? (
|
serviceSuggestions.length === 0 ? (
|
||||||
<p class="text-day-400">No suggestions yet.</p>
|
<p class="text-day-400">No suggestions yet.</p>
|
||||||
@@ -137,15 +168,7 @@ const success = !!createResult && !createResult.error
|
|||||||
<span class="shrink truncate">{suggestion.service.name}</span>
|
<span class="shrink truncate">{suggestion.service.name}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<Tooltip
|
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||||
as="span"
|
|
||||||
class="inline-flex items-center gap-1"
|
|
||||||
text={typeInfo.label}
|
|
||||||
classNames={{ tooltip: 'md:hidden!' }}
|
|
||||||
>
|
|
||||||
<Icon name={typeInfo.icon} class="size-4" />
|
|
||||||
<span class="hidden md:inline">{typeInfo.label}</span>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
as="span"
|
as="span"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { actions, isInputError } from 'astro:actions'
|
import { actions, isInputError } from 'astro:actions'
|
||||||
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SUGGESTION_DESCRIPTION_MAX_LENGTH,
|
SUGGESTION_DESCRIPTION_MAX_LENGTH,
|
||||||
@@ -16,6 +17,8 @@ import InputImageFile from '../../components/InputImageFile.astro'
|
|||||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||||
import InputText from '../../components/InputText.astro'
|
import InputText from '../../components/InputText.astro'
|
||||||
import InputTextArea from '../../components/InputTextArea.astro'
|
import InputTextArea from '../../components/InputTextArea.astro'
|
||||||
|
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||||
|
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||||
import { currencies } from '../../constants/currencies'
|
import { currencies } from '../../constants/currencies'
|
||||||
import { kycLevels } from '../../constants/kycLevels'
|
import { kycLevels } from '../../constants/kycLevels'
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
@@ -55,6 +58,8 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
category: true,
|
||||||
|
type: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@@ -196,35 +201,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
error={inputErrors.description}
|
error={inputErrors.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputTextArea
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
label="Service URLs"
|
<InputTextArea
|
||||||
name="serviceUrls"
|
label="Service URLs"
|
||||||
inputProps={{
|
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
|
||||||
required: true,
|
name="allServiceUrls"
|
||||||
placeholder: 'https://example1.com\nhttps://example2.org',
|
inputProps={{
|
||||||
}}
|
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
|
||||||
error={inputErrors.serviceUrls}
|
class: 'min-h-24',
|
||||||
/>
|
required: true,
|
||||||
|
}}
|
||||||
<InputTextArea
|
class="row-span-2 flex flex-col self-stretch"
|
||||||
label="Terms of Service URLs"
|
error={inputErrors.allServiceUrls}
|
||||||
name="tosUrls"
|
/>
|
||||||
inputProps={{
|
<InputTextArea
|
||||||
required: true,
|
label="ToS URLs"
|
||||||
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms',
|
description="One per line"
|
||||||
}}
|
name="tosUrls"
|
||||||
error={inputErrors.tosUrls}
|
inputProps={{
|
||||||
/>
|
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||||
|
class: 'md:min-h-24',
|
||||||
<InputTextArea
|
required: true,
|
||||||
label="Onion URLs"
|
}}
|
||||||
name="onionUrls"
|
error={inputErrors.tosUrls}
|
||||||
inputProps={{
|
/>
|
||||||
required: true,
|
</div>
|
||||||
placeholder: 'http://example1.onion\nhttp://example2.onion',
|
|
||||||
}}
|
|
||||||
error={inputErrors.onionUrls}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputCardGroup
|
<InputCardGroup
|
||||||
name="kycLevel"
|
name="kycLevel"
|
||||||
@@ -241,27 +242,41 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
error={inputErrors.kycLevel}
|
error={inputErrors.kycLevel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputCheckboxGroup
|
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
||||||
name="categories"
|
<InputCheckboxGroup
|
||||||
label="Categories"
|
name="categories"
|
||||||
required
|
label="Categories"
|
||||||
options={categories.map((category) => ({
|
required
|
||||||
label: category.name,
|
options={categories.map((category) => ({
|
||||||
value: category.id.toString(),
|
label: category.name,
|
||||||
icon: category.icon,
|
value: category.id.toString(),
|
||||||
}))}
|
icon: category.icon,
|
||||||
error={inputErrors.categories}
|
}))}
|
||||||
/>
|
size="lg"
|
||||||
|
error={inputErrors.categories}
|
||||||
|
class="min-w-auto"
|
||||||
|
/>
|
||||||
|
|
||||||
<InputCheckboxGroup
|
<InputCheckboxGroup
|
||||||
name="attributes"
|
name="attributes"
|
||||||
label="Attributes"
|
label="Attributes"
|
||||||
options={attributes.map((attribute) => ({
|
options={orderBy(
|
||||||
label: attribute.title,
|
attributes.map((attribute) => ({
|
||||||
value: attribute.id.toString(),
|
...attribute,
|
||||||
}))}
|
categoryInfo: getAttributeCategoryInfo(attribute.category),
|
||||||
error={inputErrors.attributes}
|
typeInfo: getAttributeTypeInfo(attribute.type),
|
||||||
/>
|
})),
|
||||||
|
['categoryInfo.order', 'typeInfo.order']
|
||||||
|
).map((attribute) => ({
|
||||||
|
label: attribute.title,
|
||||||
|
value: attribute.id.toString(),
|
||||||
|
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
|
||||||
|
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
|
||||||
|
}))}
|
||||||
|
error={inputErrors.attributes}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<InputCardGroup
|
<InputCardGroup
|
||||||
name="acceptedCurrencies"
|
name="acceptedCurrencies"
|
||||||
@@ -305,7 +320,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('astro:page-load', () => {
|
document.addEventListener('astro:page-load', () => {
|
||||||
const triggerInputs = document.querySelectorAll<HTMLInputElement>('[data-generate-slug] input')
|
const triggerInputs = document.querySelectorAll<HTMLInputElement>('input[data-generate-slug]')
|
||||||
const slugInputs = document.querySelectorAll<HTMLInputElement>('input[name="slug"]')
|
const slugInputs = document.querySelectorAll<HTMLInputElement>('input[name="slug"]')
|
||||||
|
|
||||||
triggerInputs.forEach((triggerInput) => {
|
triggerInputs.forEach((triggerInput) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { VerificationStepStatus } from '@prisma/client'
|
import { VerificationStepStatus, EventType } from '@prisma/client'
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { Markdown } from 'astro-remote'
|
import { Markdown } from 'astro-remote'
|
||||||
import { Schema } from 'astro-seo-schema'
|
import { Schema } from 'astro-seo-schema'
|
||||||
@@ -16,6 +16,8 @@ import DropdownButton from '../../components/DropdownButton.astro'
|
|||||||
import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro'
|
import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro'
|
||||||
import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro'
|
import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro'
|
||||||
import FormatTimeInterval from '../../components/FormatTimeInterval.astro'
|
import FormatTimeInterval from '../../components/FormatTimeInterval.astro'
|
||||||
|
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||||
|
import InputTextArea from '../../components/InputTextArea.astro'
|
||||||
import MyPicture from '../../components/MyPicture.astro'
|
import MyPicture from '../../components/MyPicture.astro'
|
||||||
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage'
|
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage'
|
||||||
import ScoreGauge from '../../components/ScoreGauge.astro'
|
import ScoreGauge from '../../components/ScoreGauge.astro'
|
||||||
@@ -23,6 +25,7 @@ import ScoreSquare from '../../components/ScoreSquare.astro'
|
|||||||
import ServiceLinkButton from '../../components/ServiceLinkButton.astro'
|
import ServiceLinkButton from '../../components/ServiceLinkButton.astro'
|
||||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||||
import Tooltip from '../../components/Tooltip.astro'
|
import Tooltip from '../../components/Tooltip.astro'
|
||||||
|
import UserBadge from '../../components/UserBadge.astro'
|
||||||
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
|
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
|
||||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||||
@@ -30,7 +33,7 @@ import { formatContactMethod } from '../../constants/contactMethods'
|
|||||||
import { currencies, getCurrencyInfo } from '../../constants/currencies'
|
import { currencies, getCurrencyInfo } from '../../constants/currencies'
|
||||||
import { getEventTypeInfo } from '../../constants/eventTypes'
|
import { getEventTypeInfo } from '../../constants/eventTypes'
|
||||||
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
|
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
|
||||||
import { serviceVisibilitiesById } from '../../constants/serviceVisibility'
|
import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
|
||||||
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
|
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
|
||||||
import { getUserSentimentInfo } from '../../constants/userSentiment'
|
import { getUserSentimentInfo } from '../../constants/userSentiment'
|
||||||
import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus'
|
import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus'
|
||||||
@@ -159,6 +162,21 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
internalNotes: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
addedByUser: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
picture: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
comments: {
|
comments: {
|
||||||
@@ -240,7 +258,11 @@ const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.
|
|||||||
|
|
||||||
if (!service) return Astro.rewrite('/404')
|
if (!service) return Astro.rewrite('/404')
|
||||||
|
|
||||||
if (service.serviceVisibility !== 'PUBLIC' && service.serviceVisibility !== 'UNLISTED') {
|
if (
|
||||||
|
service.serviceVisibility !== 'PUBLIC' &&
|
||||||
|
service.serviceVisibility !== 'UNLISTED' &&
|
||||||
|
service.serviceVisibility !== 'ARCHIVED'
|
||||||
|
) {
|
||||||
return Astro.rewrite('/404')
|
return Astro.rewrite('/404')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +378,12 @@ const ogImageTemplateData = {
|
|||||||
score: service.overallScore,
|
score: service.overallScore,
|
||||||
imageUrl: service.imageUrl,
|
imageUrl: service.imageUrl,
|
||||||
} satisfies OgImageAllTemplatesWithProps
|
} satisfies OgImageAllTemplatesWithProps
|
||||||
|
|
||||||
|
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
|
||||||
|
|
||||||
|
const activeAlertOrWarningEvents = service.events.filter(
|
||||||
|
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
|
||||||
|
)
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -457,16 +485,43 @@ const ogImageTemplateData = {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
service.serviceVisibility === 'UNLISTED' && (
|
activeAlertOrWarningEvents.length > 0 && (
|
||||||
<div class={cn('mb-4 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400')}>
|
<a
|
||||||
|
href="#events"
|
||||||
|
class={cn(
|
||||||
|
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
|
||||||
|
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||||
|
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
|
||||||
|
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name={serviceVisibilitiesById.UNLISTED.icon}
|
name={
|
||||||
class={cn('me-1.5 inline-block size-4 align-[-0.15em]', serviceVisibilitiesById.UNLISTED.iconClass)}
|
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||||
|
? 'ri:alert-fill'
|
||||||
|
: 'ri:alarm-warning-fill'
|
||||||
|
}
|
||||||
|
class="me-1.5 inline-block size-4 align-[-0.15em]"
|
||||||
/>
|
/>
|
||||||
Unlisted service, only accessible via direct link and won't appear in searches.
|
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||||
|
? 'There is an active alert for this service. Click to see details.'
|
||||||
|
: 'There is an active warning for this service. Click to see details.'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
|
||||||
|
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
|
||||||
|
<Icon
|
||||||
|
name={serviceVisibilityInfo.icon}
|
||||||
|
class="me-1.5 inline-block size-4 align-[-0.15em] text-yellow-500"
|
||||||
|
/>
|
||||||
|
{serviceVisibilityInfo.longDescription}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
<VerificationWarningBanner service={service} />
|
<VerificationWarningBanner service={service} />
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -742,6 +797,61 @@ const ogImageTemplateData = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<AdminOnly>
|
||||||
|
<div class="border-day-500 mt-6 mb-3 flex items-center justify-between border-b">
|
||||||
|
<h2 class="font-title text-day-100 text-lg font-bold">Internal Notes</h2>
|
||||||
|
<a
|
||||||
|
href={`/admin/services/${service.slug}/edit#internal-notes`}
|
||||||
|
class="text-day-500 hover:text-day-200 inline-flex items-center gap-1 text-xs leading-none transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
<Icon name="ri:arrow-right-s-line" class="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
service.internalNotes.length === 0 ? (
|
||||||
|
<p class="text-day-400 mt-2 text-center text-xs">No internal notes yet.</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'grid grid-cols-1 items-start gap-2',
|
||||||
|
service.internalNotes.length > 1 && 'sm:grid-cols-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{service.internalNotes.map((note) => (
|
||||||
|
<div class="border-night-600 bg-night-800 rounded-lg border p-3">
|
||||||
|
<div class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty">
|
||||||
|
<Markdown content={note.content} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-day-500 mt-2 flex items-center gap-1 text-xs">
|
||||||
|
<TimeFormatted date={note.createdAt} hourPrecision />
|
||||||
|
{note.addedByUser && (
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
by <UserBadge user={note.addedByUser} size="sm" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={actions.admin.service.internalNote.add}
|
||||||
|
data-note-edit-form
|
||||||
|
class="mt-4 space-y-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="serviceId" value={service.id} />
|
||||||
|
|
||||||
|
<InputTextArea label="Add a note" name="content" />
|
||||||
|
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
|
||||||
|
</form>
|
||||||
|
</AdminOnly>
|
||||||
|
|
||||||
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="scores">
|
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="scores">
|
||||||
Scores
|
Scores
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1102,6 +1212,7 @@ const ogImageTemplateData = {
|
|||||||
|
|
||||||
<div class="mt-3 max-w-md pe-8">
|
<div class="mt-3 max-w-md pe-8">
|
||||||
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
|
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
|
||||||
|
{typeInfo.isSolved && <BadgeSmall text="Solved" icon="ri:check-line" color="green" />}
|
||||||
{event.title}
|
{event.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -1245,7 +1356,8 @@ const ogImageTemplateData = {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{
|
{
|
||||||
service.verificationStatus !== 'VERIFICATION_SUCCESS' &&
|
service.verificationStatus !== 'VERIFICATION_SUCCESS' &&
|
||||||
service.verificationStatus !== 'VERIFICATION_FAILED' && (
|
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||||
|
service.serviceVisibility !== 'ARCHIVED' && (
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={actions.service.requestVerification}
|
action={actions.service.requestVerification}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { Markdown } from 'astro-remote'
|
||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
import { sortBy } from 'lodash-es'
|
import { sortBy } from 'lodash-es'
|
||||||
|
|
||||||
@@ -43,10 +44,11 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
spammer: true,
|
spammer: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
admin: true,
|
admin: true,
|
||||||
verifier: true,
|
moderator: true,
|
||||||
verifiedLink: true,
|
verifiedLink: true,
|
||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
comments: true,
|
comments: true,
|
||||||
@@ -208,7 +210,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
sameAs: user.link ? [user.link] : undefined,
|
sameAs: user.link ? [user.link] : undefined,
|
||||||
description: `User profile page for ${user.displayName ?? user.name} on KYCnot.me`,
|
description: `User profile page for ${user.displayName ?? user.name} on KYCnot.me`,
|
||||||
identifier: [user.name, user.id.toString()],
|
identifier: [user.name, user.id.toString()],
|
||||||
jobTitle: user.admin ? 'Administrator' : user.verifier ? 'Moderator' : undefined,
|
jobTitle: user.admin ? 'Administrator' : user.moderator ? 'Moderator' : undefined,
|
||||||
memberOf: KYCNOTME_SCHEMA_MINI,
|
memberOf: KYCNOTME_SCHEMA_MINI,
|
||||||
interactionStatistic: [
|
interactionStatistic: [
|
||||||
{
|
{
|
||||||
@@ -281,9 +283,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
user.verifier && (
|
user.moderator && (
|
||||||
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
||||||
verifier
|
moderator
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -415,14 +417,14 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
user.verifier && (
|
user.moderator && (
|
||||||
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
||||||
Verifier
|
Moderator
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!user.admin && !user.verified && !user.verifier && (
|
!user.admin && !user.verified && !user.moderator && (
|
||||||
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
|
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
|
||||||
Standard User
|
Standard User
|
||||||
</span>
|
</span>
|
||||||
@@ -468,6 +470,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<AdminOnly>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-day-500 text-xs">Last login</p>
|
||||||
|
<p class="text-day-300">
|
||||||
|
{
|
||||||
|
formatDateShort(user.lastLoginAt, {
|
||||||
|
prefix: false,
|
||||||
|
hourPrecision: true,
|
||||||
|
caseType: 'sentence',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</AdminOnly>
|
||||||
|
|
||||||
{
|
{
|
||||||
user.verifiedLink && (
|
user.verifiedLink && (
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
@@ -525,7 +545,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
<TimeFormatted date={note.createdAt} hourPrecision />
|
<TimeFormatted date={note.createdAt} hourPrecision />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
|
<div class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty">
|
||||||
|
<Markdown content={note.content} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,6 +103,10 @@
|
|||||||
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
|
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility scrollbar-w-none {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
@utility checkbox-force-checked {
|
@utility checkbox-force-checked {
|
||||||
&:not(:checked) {
|
&:not(:checked) {
|
||||||
@apply border-transparent! bg-current/50!;
|
@apply border-transparent! bg-current/50!;
|
||||||
|
|||||||
Reference in New Issue
Block a user