Compare commits

..

53 Commits

Author SHA1 Message Date
pluja
bf30a6cb2b Release 202506130639 2025-06-13 06:39:29 +00:00
pluja
4ca9b9a5c2 Release 202506112249 2025-06-11 22:49:54 +00:00
pluja
03abdef4f1 Release 202506112228 2025-06-11 22:28:53 +00:00
pluja
d9880fd83d Release 202506112108 2025-06-11 21:08:32 +00:00
pluja
39afcad089 Release 202506111705 2025-06-11 17:05:58 +00:00
pluja
99cb730bc0 Release 202506111039 2025-06-11 10:39:20 +00:00
pluja
d43402e162 Release 202506111007 2025-06-11 10:07:51 +00:00
pluja
9bb316b85f Release 202506102027 2025-06-10 20:27:48 +00:00
pluja
4aea68ee58 Release 202506101914 2025-06-10 19:14:10 +00:00
pluja
2f88c43236 Release 202506101800 2025-06-10 18:00:24 +00:00
pluja
ad3c561419 Release 202506101748 2025-06-10 17:48:58 +00:00
pluja
812937d2c7 Release 202506101742 2025-06-10 17:42:42 +00:00
pluja
459d7c91f7 Release 202506091901 2025-06-09 19:01:08 +00:00
pluja
b8b2dee4a4 Release 202506091433 2025-06-09 14:33:57 +00:00
pluja
eb0af871e1 Release 202506091118 2025-06-09 11:18:06 +00:00
pluja
3ccd7fd395 Release 202506091053 2025-06-09 10:53:52 +00:00
pluja
87f0f36aa1 Release 202506091000 2025-06-09 10:00:55 +00:00
pluja
8b90b3eef6 Release 202506061009 2025-06-06 10:09:59 +00:00
pluja
2489e94b0e Release 202506042153 2025-06-04 21:53:07 +00:00
pluja
144af17a70 Release 202506042038 2025-06-04 20:38:49 +00:00
pluja
02e52d7351 Release 202506041937 2025-06-04 19:37:33 +00:00
pluja
dacf73a804 Release 202506041641 2025-06-04 16:41:32 +00:00
pluja
5812399e29 Release 202506031821 2025-06-03 18:21:55 +00:00
pluja
6a6908518d Release 202506020353 2025-06-02 03:53:03 +00:00
pluja
d065910ff3 Release 202506011533 2025-06-01 15:33:44 +00:00
pluja
490433b002 Release 202506011511 2025-06-01 15:11:37 +00:00
pluja
e17bc8a521 Release 202505312236 2025-05-31 22:36:39 +00:00
pluja
ec1215f2ae Release 202505311848 2025-05-31 18:48:33 +00:00
pluja
3afa824c18 Release 202505311149 2025-05-31 11:49:38 +00:00
pluja
9a68112e24 Release 202505311113 2025-05-31 11:13:24 +00:00
pluja
0c40d8eec5 Release 202505311002 2025-05-31 10:02:50 +00:00
pluja
e16c9b64ed Release 202505311001 2025-05-31 10:01:35 +00:00
pluja
22944fcdb3 Release 202505310921 2025-05-31 09:21:32 +00:00
pluja
f7f380c591 Release 202505302056 2025-05-30 20:56:04 +00:00
pluja
577c524ca2 Release 202505302029 2025-05-30 20:29:01 +00:00
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
pluja
e536ca6519 Release 202505261804 2025-05-26 18:04:45 +00:00
pluja
b361ed3aa8 Release 202505261604 2025-05-26 16:04:25 +00:00
pluja
50ede46d50 release-34 2025-05-26 14:49:37 +00:00
pluja
ba809840c6 Release 202505261445 2025-05-26 14:45:22 +00:00
pluja
f2021a3027 Release 202505261035 2025-05-26 10:35:43 +00:00
pluja
6b86a72d1e Release 2025-05-25-GgNU 2025-05-25 12:28:30 +00:00
pluja
8f2b2c34ff Release 2025-05-25-irZj 2025-05-25 11:21:35 +00:00
pluja
ac9a2f428a Release 2025-05-25-ELtG 2025-05-25 10:07:02 +00:00
pluja
970622d061 Release 2025-05-23-xzNR 2025-05-23 21:50:03 +00:00
pluja
4806a7fd4e Release 2025-05-23-nwlb 2025-05-23 18:23:14 +00:00
pluja
85605de8aa Release 2025-05-23-S6tT 2025-05-23 14:56:00 +00:00
pluja
7a22629c55 Release 2025-05-23-JSHi 2025-05-23 12:25:07 +00:00
pluja
8deb9acb93 Release 2025-05-23-9Gto 2025-05-23 12:09:33 +00:00
pluja
61a5448ff5 Release 2025-05-23-5vNZ 2025-05-23 12:05:29 +00:00
217 changed files with 14795 additions and 4583 deletions

View File

@@ -1,53 +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 thre 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
```ts
function makeLoginUrl(
currentUrl: URL,
options: {
redirect?: URL | string | null;
error?: string | null;
logout?: boolean;
message?: string | null;
} = {}
);
```

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

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

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

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

View File

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

46
.env.example Normal file
View File

@@ -0,0 +1,46 @@
# Database
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_db_password
POSTGRES_DATABASE=your_db_name
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
DATABASE_UI_URL="https://db.example.com"
# Generic Config
UPLOAD_DIR=/app/uploads
SITE_URL="https://your-site.example.com"
SOURCE_CODE_URL="https://your-source-code.example.com"
TIME_TRAP_SECRET=your_time_trap_secret
LOGS_UI_URL="https://logs.example.com"
# Release Info
RELEASE_NUMBER=
RELEASE_DATE=
# Redis
REDIS_URL="redis://redis:6379"
# Crawl4AI
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
CRAWL4AI_API_TOKEN=your_crawl4ai_token
# Tor and I2P
ONION_ADDRESS="http://youronionaddress.onion"
I2P_ADDRESS="http://youri2paddress.b32.i2p"
I2P_PASS=your_i2p_password
# Push Notifications
VAPID_PUBLIC_KEY=your_vapid_public_key
VAPID_PRIVATE_KEY=your_vapid_private_key
VAPID_SUBJECT="mailto:your-email@example.com"
# OpenAI
OPENAI_API_KEY=your_openai_api_key
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
OPENAI_MODEL=your_openai_model
OPENAI_RETRY=3
# Pyworker Crons
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day

3
.gitignore vendored
View File

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

View File

@@ -23,13 +23,12 @@ cd web
nvm install
npm i
cp -n .env.example .env
npm run db-push
npm run db-fill-clean
npm run db-reset
```
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

View File

@@ -7,12 +7,14 @@ services:
volumes:
- database:/var/lib/postgresql/data:z
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}',
]
interval: 10s
timeout: 5s
retries: 5
@@ -20,19 +22,17 @@ services:
pyworker:
build:
context: ./pyworker
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
restart: always
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
env_file:
- .env
crawl4ai:
image: unclecode/crawl4ai:basic-amd64
expose:
- "11235"
environment:
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
MAX_CONCURRENT_TASKS: 10
- '11235'
env_file:
- .env
volumes:
- /dev/shm:/dev/shm
deploy:
@@ -46,22 +46,18 @@ services:
image: redis:latest
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
astro:
build:
context: ./web
dockerfile: web/Dockerfile
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
REDIS_URL: "redis://redis:6379"
env_file:
- .env
depends_on:
database:
condition: service_healthy
@@ -70,7 +66,15 @@ services:
expose:
- 4321
healthcheck:
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321/health"]
test:
[
'CMD',
'curl',
'-k',
'--silent',
'--fail',
'http://localhost:4321/internal-api/healthcheck',
]
interval: 10s
timeout: 5s
retries: 5

View File

@@ -51,42 +51,6 @@ import-db file="":
fi
fi
echo "Restoring database from $BACKUP_FILE..."
# First drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
# Drop and recreate database
echo "Dropping and recreating the database..."
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
# Restore the database
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
echo "Database restored successfully!"
# Import triggers
echo "Importing triggers..."
just import-triggers
echo "Database import completed!"
# Check if migrations need to be run
cd web && npx prisma migrate status
#!/bin/bash
if [ -z "{{file}}" ]; then
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
if [ -z "$BACKUP_FILE" ]; then
echo "Error: No backup files found in the backups directory"
exit 1
fi
else
BACKUP_FILE="{{file}}"
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file '$BACKUP_FILE' not found"
exit 1
fi
fi
echo "=== STEP 1: PREPARING DATABASE ==="
# Drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres

View File

@@ -366,7 +366,7 @@ def run_worker_mode() -> int:
# Register service score recalculation task (every 5 minutes)
scheduler.register_task(
"service-score-recalc",
"service_score_recalc",
"*/5 * * * *",
run_service_score_recalc_task,
)

View File

@@ -332,29 +332,33 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
return remove_service_attribute(service_id, attribute_id)
def save_tos_review(service_id: int, review: TosReviewType):
"""
Save a TOS review for a specific service.
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
"""Persist a TOS review and/or update the timestamp for a service.
Args:
service_id: The ID of the service.
review: A TypedDict containing the review data.
If *review* is ``None`` the existing review (if any) is preserved while
only the ``tosReviewAt`` column is updated. This ensures we still track
when the review task last ran even if the review generation failed or
produced no changes.
"""
try:
# Serialize the dictionary to a JSON string for the database
review_json = json.dumps(review)
with get_db_connection() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(
"""
UPDATE "Service"
SET "tosReview" = %s, "tosReviewAt" = NOW()
WHERE id = %s
""",
(review_json, service_id),
)
if review is None:
cursor.execute(
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
(service_id,),
)
else:
review_json = json.dumps(review)
cursor.execute(
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
(review_json, service_id),
)
conn.commit()
logger.info(f"Successfully saved TOS review for service {service_id}")
logger.info(
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
)
except Exception as e:
logger.error(f"Error saving TOS review for service {service_id}: {e}")

View File

@@ -5,6 +5,8 @@ Task for retrieving Terms of Service (TOS) text.
import hashlib
from typing import Any, Dict, Optional
import requests
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
from pyworker.tasks.base import Task
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
@@ -52,65 +54,97 @@ class TosReviewTask(Task):
)
self.logger.info(f"TOS URLs: {tos_urls}")
review = self.get_tos_review(tos_urls, service.get("tosReview"))
# Always update the processed timestamp, even if review is None
save_tos_review(service_id, review)
if review is None:
self.logger.warning(
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
)
return None
# Update the KYC level based on the review, when present
if "kycLevel" in review:
new_level = review["kycLevel"]
old_level = service.get("kycLevel")
# Update DB
if update_kyc_level(service_id, new_level):
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
# Log to console
self.logger.info(msg)
# Send notification via ntfy
try:
requests.post(
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
)
except requests.RequestException as e:
self.logger.error(
f"Failed to send ntfy notification for KYC level change: {e}"
)
return review
def get_tos_review(
self, tos_urls: list[str], current_review: Optional[TosReviewType]
) -> Optional[TosReviewType]:
"""
Get TOS review from a list of URLs.
Args:
tos_urls: List of TOS URLs to check
current_review: Current review data from the database
Returns:
Dict containing:
- status: Literal["skipped", "failed", "success"]
- review: Optional[TosReviewType] - The review data if successful
"""
all_skipped = True
for tos_url in tos_urls:
api_url = f"{tos_url}"
self.logger.info(f"Fetching TOS from URL: {api_url}")
# Sleep for 1 second to avoid rate limiting
content = fetch_markdown(api_url)
if content:
# Hash the content to avoid repeating the same content
content_hash = hashlib.sha256(content.encode()).hexdigest()
self.logger.info(f"Content hash: {content_hash}")
# service.get("tosReview") can be None if the DB field is NULL.
# Default to an empty dict to prevent AttributeError on .get()
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get(
"tosReview"
)
tos_review: Dict[str, Any] = (
tos_review_data_from_service
if tos_review_data_from_service is not None
else {}
)
stored_hash = tos_review.get("contentHash")
# Skip processing if we've seen this content before
if stored_hash == content_hash:
self.logger.info(
f"Skipping already processed TOS content with hash: {content_hash}"
)
continue
# Skip incomplete TOS content
check = prompt_check_tos_review(content)
if not check:
continue
elif not check["isComplete"]:
continue
# Query OpenAI to summarize the content
review = prompt_tos_review(content)
if review:
review["contentHash"] = content_hash
# Save the review to the database
save_tos_review(service_id, review)
# Update the KYC level based on the review
if "kycLevel" in review:
kyc_level = review["kycLevel"]
self.logger.info(
f"Updating KYC level to {kyc_level} for service {service_name}"
)
update_kyc_level(service_id, kyc_level)
# no need to check other TOS URLs
break
return review
else:
if not content:
self.logger.warning(
f"Failed to retrieve TOS content for URL: {tos_url}"
)
all_skipped = False
continue
# Hash the content to avoid repeating the same content
content_hash = hashlib.sha256(content.encode()).hexdigest()
self.logger.info(f"Content hash: {content_hash}")
# Skip processing if we've seen this content before
if current_review and current_review.get("contentHash") == content_hash:
self.logger.info(
f"Skipping already processed TOS content with hash: {content_hash}"
)
continue
all_skipped = False
# Skip incomplete TOS content
check = prompt_check_tos_review(content)
if not check or not check["isComplete"]:
continue
# Query OpenAI to summarize the content
review = prompt_tos_review(content)
if review:
review["contentHash"] = content_hash
return review
if all_skipped:
return current_review
return None

View File

@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
{"role": "user", "content": content},
]
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
result_dict = query_openai_json(
messages, model="openai/gemini-2.5-flash-preview-05-20"
)
return cast(TosReviewCheck, result_dict)
@@ -173,27 +175,27 @@ type TosReview = {
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
rating: 'negative' | 'neutral' | 'positive'
}[]
}[] // max 8 highlights, try to provide at least 3.
}
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
Be concise but thorough, and make sure your output is properly formatted JSON.
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
"""
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
You will be given a list of user comments to a service.
Your task is to summarize the comments in a way that is easy to understand and to the point.
The summary should be concise and to the point, no more than 150 words.
The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
You must format your response as a valid JSON object with the following structure:
interface CommentSummary {
summary: string;
summary: string; // Concise, 100 words max
sentiment: 'positive'|'negative'|'neutral';
whatUsersLike: string[]; // Concise, 2-3 words, max 4
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
whatUsersLike: string[]; // Concise, 2-3 words max
whatUsersDislike: string[]; // Concise, 2-3 words max
}
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.

View File

@@ -1,6 +1,13 @@
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com"
SITE_URL="https://localhost:4321"
DATABASE_UI_URL="http://localhost:5555"
SITE_URL="http://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
RELEASE_NUMBER=123
RELEASE_DATE="2025-05-23T19:00:00.000Z"
# Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
VAPID_SUBJECT="mailto:no-reply@kycnot.me"

View File

@@ -1,26 +1,29 @@
FROM node:lts AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
COPY .env .env
COPY web/package.json web/package-lock.json ./
COPY .npmrc .npmrc
COPY web/.npmrc .npmrc
RUN npm ci
COPY . .
COPY web/ .
ARG ASTRO_BUILD_MODE=production
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
RUN npm run build:astro -- --mode ${ASTRO_BUILD_MODE} && npm run build:server-init
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
# Add knm-migrate command script and make it executable
COPY migrate.sh /usr/local/bin/knm-migrate
COPY web/migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/knm-migrate
CMD ["node", "./dist/server/entry.mjs"]
CMD ["sh", "-c", "node ./dist/server/server-init.js & node ./dist/server/entry.mjs"]

View File

@@ -6,24 +6,23 @@
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :------------------------------------------------------------------- |
| `nvm install` | Installs and uses the correct version of node |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run db-admin` | Runs Prisma Studio (database admin) |
| `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-fill` | Fills 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 lint` | Lints the code with ESLint |
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
| Command | Action |
| :------------------------ | :------------------------------------------------------------------ |
| `nvm install` | Installs and uses the correct version of node |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run db-admin` | Runs Prisma Studio (database admin) |
| `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-seed` | Seeds the database with fake data (development mode) |
| `npm run format` | Formats the code with Prettier |
| `npm run lint` | Lints the code with ESLint |
| `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)

View File

@@ -4,26 +4,80 @@ import mdx from '@astrojs/mdx'
import node from '@astrojs/node'
import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite'
import { minimal2023Preset } from '@vite-pwa/assets-generator/config'
import AstroPWA from '@vite-pwa/astro'
import { defineConfig, envField } from 'astro/config'
import icon from 'astro-icon'
import { loadEnv } from 'vite'
import devtoolsJson from 'vite-plugin-devtools-json'
// @ts-expect-error process.env actually exists
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
const SITE_URL = getServerEnvVariable('SITE_URL')
export default defineConfig({
site: SITE_URL,
vite: {
build: {
sourcemap: true,
sourcemap: true, // Enable sourcemaps on production, so users can inspect the code
},
plugins: [tailwindcss()],
plugins: [devtoolsJson(), tailwindcss()],
},
integrations: [
postgresListener(),
icon(),
mdx(),
AstroPWA({
mode: 'development',
base: '/',
scope: '/',
registerType: 'autoUpdate',
manifest: {
name: 'KYCnot.me',
short_name: 'KYCnot.me',
description: 'Find services that respect your privacy',
theme_color: '#040505',
background_color: '#171c1b',
display: 'minimal-ui',
},
pwaAssets: {
image: './public/favicon.svg',
preset: {
...minimal2023Preset,
maskable: {
...minimal2023Preset.maskable,
padding: 0.1,
resizeOptions: {
...minimal2023Preset.maskable.resizeOptions,
background: '#3bdb78',
},
},
apple: {
...minimal2023Preset.apple,
padding: 0.1,
resizeOptions: {
...minimal2023Preset.apple.resizeOptions,
background: '#3bdb78',
},
},
},
},
workbox: {
navigateFallback: '/404',
globPatterns: ['**/*.{js,css,html,ico,jpg,jpeg,png,svg,webp,avif}'],
},
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
devOptions: {
enabled: true,
type: 'module',
},
experimental: {
directoryAndTrailingSlashHandler: true,
},
}),
sitemap({
filter: (page) => {
const url = new URL(page)
@@ -56,6 +110,8 @@ export default defineConfig({
'/attribute/[...slug]': '/attributes',
'/attr/[...slug]': '/attributes',
// #endregion
'/service/[...slug]/review': '/service/[...slug]#comments',
},
env: {
schema: {
@@ -69,7 +125,7 @@ export default defineConfig({
}),
// Public URLs (can be accessed from both server and client)
SOURCE_CODE_URL: envField.string({
context: 'server',
context: 'client',
access: 'public',
url: true,
optional: false,
@@ -94,35 +150,6 @@ export default defineConfig({
startsWith: 'redis://',
default: 'redis://redis:6379',
}),
REDIS_USER_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 60 * 24, // 24 hours in seconds
}),
REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 60 * 24, // 24 hours in seconds
}),
REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 5, // 5 minutes in seconds
}),
REDIS_ACTIONS_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 5, // 5 minutes in seconds
}),
// Development tokens
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
@@ -131,11 +158,11 @@ export default defineConfig({
min: 1,
default: 'admin',
}),
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({
DEV_MODERATOR_USER_SECRET_TOKEN: envField.string({
context: 'server',
access: 'secret',
min: 1,
default: 'verifier',
default: 'moderator',
}),
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
context: 'server',
@@ -170,6 +197,52 @@ export default defineConfig({
url: true,
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,
}),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY: envField.string({
context: 'server',
access: 'public',
min: 1,
optional: false,
}),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PRIVATE_KEY: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
VAPID_SUBJECT: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
},
},
})

View File

@@ -121,7 +121,7 @@ export default tseslint.config(
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-duplicates': 'error',
'import/no-unresolved': ['error', { ignore: ['^astro:'] }],
'import/no-unresolved': ['error', { ignore: ['^astro:', '^virtual:'] }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
'import/namespace': 'off',

5420
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,35 +4,41 @@
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build --remote",
"preview": "astro preview",
"build": "npm run build:astro && npm run build:server-init",
"build:astro": "astro build --remote",
"build:server-init": "esbuild src/server-init.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server/server-init.js",
"preview": "node dist/server/server-init.js & astro preview",
"astro": "astro",
"db-admin": "prisma studio --browser=none",
"db-gen": "prisma generate",
"db-push": "prisma migrate dev",
"db-triggers": "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-fill": "tsx scripts/faker.ts",
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
"db-reset": "prisma migrate reset -f && prisma migrate dev",
"db-seed": "prisma db seed",
"format": "prettier --write .",
"lint": "eslint .",
"lint-fix": "eslint . --fix && prettier --write ."
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14",
"@astrojs/mdx": "4.2.6",
"@astrojs/node": "9.2.1",
"@astrojs/sitemap": "3.4.0",
"@fontsource-variable/space-grotesk": "5.2.7",
"@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2",
"@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5",
"@fontsource/space-grotesk": "5.2.7",
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.9.0",
"@tailwindcss/vite": "4.1.8",
"@types/mime-types": "3.0.0",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro": "5.9.0",
"astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0",
@@ -40,56 +46,65 @@
"clsx": "2.1.1",
"htmx.org": "1.9.12",
"javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.8",
"libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",
"redis": "5.5.6",
"schema-dts": "1.1.5",
"seedrandom": "3.0.5",
"sharp": "0.34.1",
"sharp": "0.34.2",
"slugify": "1.6.6",
"tailwind-merge": "3.3.0",
"tailwind-variants": "1.0.0",
"tailwindcss": "4.1.7",
"tailwindcss": "4.1.8",
"typescript": "5.8.3",
"unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7"
},
"devDependencies": {
"@eslint/js": "9.27.0",
"@eslint/js": "9.28.0",
"@faker-js/faker": "9.8.0",
"@iconify-json/material-symbols": "1.2.21",
"@iconify-json/material-symbols": "1.2.24",
"@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.2.0",
"@stylistic/eslint-plugin": "4.4.1",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.4",
"@types/react": "19.1.6",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/parser": "8.32.1",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.33.1",
"@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5",
"date-fns": "4.1.0",
"eslint": "9.27.0",
"eslint-import-resolver-typescript": "4.3.5",
"esbuild": "0.25.5",
"eslint": "9.28.0",
"eslint-import-resolver-typescript": "4.4.3",
"eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.1.0",
"globals": "16.2.0",
"prettier": "3.5.3",
"prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.11",
"prisma": "6.8.2",
"prisma-json-types-generator": "3.4.1",
"prettier-plugin-tailwindcss": "0.6.12",
"prisma": "6.9.0",
"prisma-json-types-generator": "3.4.2",
"tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4",
"ts-toolbelt": "9.6.0",
"tsx": "4.19.4",
"typescript-eslint": "8.32.1"
"typescript-eslint": "8.33.1",
"vite-plugin-devtools-json": "0.1.1",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
}
}

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';

View File

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

View File

@@ -0,0 +1,2 @@
-- Enable pg_trgm extension for similarity functions
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateIndex
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");

View File

@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "KycLevelClarification" AS ENUM ('NONE', 'DEPENDS_ON_PARTNERS');
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "kycLevelClarification" "KycLevelClarification",
ADD COLUMN "kycLevelDetailsId" INTEGER;

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- CreateIndex
CREATE INDEX "PushSubscription_endpoint_idx" ON "PushSubscription"("endpoint");
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `kycLevelDetailsId` on the `Service` table. All the data in the column will be lost.
- Made the column `kycLevelClarification` on table `Service` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Service" DROP COLUMN "kycLevelDetailsId",
ALTER COLUMN "kycLevelClarification" SET NOT NULL,
ALTER COLUMN "kycLevelClarification" SET DEFAULT 'NONE';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'TEST';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;

View File

@@ -25,6 +25,7 @@ enum OrderIdStatus {
PENDING
APPROVED
REJECTED
WITHDRAWN
}
model Comment {
@@ -87,6 +88,7 @@ enum ServiceVisibility {
PUBLIC
UNLISTED
HIDDEN
ARCHIVED
}
enum Currency {
@@ -120,18 +122,20 @@ enum AccountStatusChange {
ADMIN_FALSE
VERIFIED_TRUE
VERIFIED_FALSE
VERIFIER_TRUE
VERIFIER_FALSE
MODERATOR_TRUE
MODERATOR_FALSE
SPAMMER_TRUE
SPAMMER_FALSE
}
enum NotificationType {
TEST
COMMENT_STATUS_CHANGE
REPLY_COMMENT_CREATED
COMMUNITY_NOTE_ADDED
/// Comment that is not a reply. May include a rating.
ROOT_COMMENT_CREATED
SUGGESTION_CREATED
SUGGESTION_MESSAGE
SUGGESTION_STATUS_CHANGE
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
@@ -296,6 +300,11 @@ enum ServiceSuggestionType {
EDIT_SERVICE
}
enum KycLevelClarification {
NONE
DEPENDS_ON_PARTNERS
}
model ServiceSuggestion {
id Int @id @default(autoincrement())
type ServiceSuggestionType
@@ -335,9 +344,11 @@ model Service {
id Int @id @default(autoincrement())
name String
slug String @unique
previousSlugs String[] @default([])
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE)
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
@@ -376,6 +387,7 @@ model Service {
attributes ServiceAttribute[]
verificationSteps VerificationStep[]
suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
@@ -394,6 +406,7 @@ model Service {
@@index([createdAt])
@@index([updatedAt])
@@index([slug])
@@index([previousSlugs])
}
model ServiceContactMethod {
@@ -441,6 +454,7 @@ model Attribute {
model InternalUserNote {
id Int @id @default(autoincrement())
/// Markdown
content String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -455,6 +469,23 @@ model InternalUserNote {
@@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 {
id Int @id @default(autoincrement())
name String @unique
@@ -464,9 +495,10 @@ model User {
spammer Boolean @default(false)
verified Boolean @default(false)
admin Boolean @default(false)
verifier Boolean @default(false)
moderator Boolean @default(false)
verifiedLink String?
secretTokenHash String @unique
feedId String @unique @default(cuid(2))
/// Computed via trigger. Do not update through prisma.
totalKarma Int @default(0)
@@ -481,10 +513,12 @@ model User {
suggestionMessages ServiceSuggestionMessage[]
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
verificationRequests ServiceVerificationRequest[]
notifications Notification[] @relation("NotificationOwner")
notificationPreferences NotificationPreferences?
serviceAffiliations ServiceUser[] @relation("UserServices")
pushSubscriptions PushSubscription[]
@@index([createdAt])
@@index([totalKarma])
@@ -633,3 +667,21 @@ model Announcement {
@@index([isActive, startDate, endDate])
}
model PushSubscription {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique
/// Public key for encryption
p256dh String
/// Authentication secret
auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([endpoint])
}

View File

@@ -1,22 +1,27 @@
import crypto from 'crypto'
import { execSync } from 'node:child_process'
import { parseArgs } from 'node:util'
import { faker } from '@faker-js/faker'
import {
AnnouncementType,
AttributeCategory,
AttributeType,
CommentStatus,
Currency,
EventType,
PrismaClient,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceUserRole,
VerificationStatus,
type Prisma,
EventType,
type User,
ServiceUserRole,
AnnouncementType,
type ServiceVisibility,
ServiceSuggestionType,
KycLevelClarification,
VerificationStepStatus,
} from '@prisma/client'
import { uniqBy } from 'lodash-es'
import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
import { kycLevels } from '../src/constants/kycLevels'
@@ -85,7 +90,7 @@ async function createAccount(preGeneratedToken?: string) {
verifiedLink,
verified: !!verifiedLink,
admin: faker.datatype.boolean({ probability: 0.1 }),
verifier: faker.datatype.boolean({ probability: 0.1 }),
moderator: faker.datatype.boolean({ probability: 0.1 }),
},
include: {
serviceAffiliations: true,
@@ -95,20 +100,6 @@ async function createAccount(preGeneratedToken?: string) {
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 generateFakeAttribute = () => {
@@ -611,18 +602,39 @@ const generateFakeEvent = (serviceId: number) => {
}
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 slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8,
})
return {
name,
slug,
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
description: faker.helpers.arrayElement(serviceDescriptions),
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
kycLevelClarification: faker.helpers.maybe(
() =>
faker.helpers.arrayElement(omit(Object.values(KycLevelClarification), [KycLevelClarification.NONE])),
{ probability: 0.25 }
),
overallScore: 0,
privacyScore: 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,
verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -636,25 +648,52 @@ const generateFakeService = (users: User[]) => {
},
verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
referral: `?ref=${faker.string.alphanumeric(6)}`,
verificationSteps:
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
? {
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
title: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
evidenceMd: faker.lorem.paragraph(),
createdAt: faker.date.recent(),
updatedAt: faker.date.recent(),
})),
}
: undefined,
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 }),
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()),
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()),
onionUrls: Array.from(
{ length: faker.number.int({ min: 0, max: 2 }) },
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
onionUrls: faker.helpers.multiple(
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
{ count: { min: 0, max: 2 } }
),
i2pUrls: Array.from(
{ length: faker.number.int({ min: 0, max: 2 }) },
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`
i2pUrls: faker.helpers.multiple(
() => `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`,
listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples),
tosReviewAt: faker.date.past(),
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined,
tosReview,
tosReviewAt: tosReview
? faker.date.recent()
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
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
}
@@ -859,6 +898,22 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
{
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(),
},
@@ -867,7 +922,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
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>[]
@@ -883,31 +938,31 @@ const specialUsersData = {
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
defaultToken: 'admin',
admin: true,
verifier: true,
moderator: true,
verified: true,
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
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: {
name: 'verifier_dev',
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
defaultToken: 'verifier',
moderator: {
name: 'moderator_dev',
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
defaultToken: 'moderator',
admin: false,
verifier: true,
moderator: true,
verified: true,
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
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: {
name: 'verified_dev',
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
defaultToken: 'verified',
admin: false,
verifier: false,
moderator: false,
verified: true,
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
@@ -917,7 +972,7 @@ const specialUsersData = {
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
defaultToken: 'normal',
admin: false,
verifier: false,
moderator: false,
verified: false,
},
spam: {
@@ -925,7 +980,7 @@ const specialUsersData = {
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
defaultToken: 'spam',
admin: false,
verifier: false,
moderator: false,
verified: false,
totalKarma: -100,
spammer: true,
@@ -987,359 +1042,378 @@ const generateFakeAnnouncement = () => {
} as const satisfies Prisma.AnnouncementCreateInput
}
async function runFaker() {
await prisma.$transaction(
async (tx) => {
// ---- Clean up existing data if requested ----
if (shouldCleanup || onlyCleanup) {
console.info('🧹 Cleaning up existing data...')
async function cleanup() {
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 {
await runFaker()
console.info('✅ Fake data generated successfully')
await prisma.commentVote.deleteMany()
await prisma.karmaTransaction.deleteMany()
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) {
console.error('❌ Error generating fake data:', error)
process.exit(1)
} finally {
await prisma.$disconnect()
console.error('❌ Error cleaning up data:', error)
throw error
}
}
main().catch((error: unknown) => {
console.error('❌ Fatal error:', error)
process.exit(1)
})
function importTriggers() {
console.info('🔄 Importing SQL triggers...')
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: 570 }, 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, ...Object.values(specialUsers)])
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, ...Object.values(specialUsers)])
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)
})

View File

@@ -22,8 +22,9 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
RETURNS INT AS $$
DECLARE
privacy_score INT := 50; -- Start from middle value (50)
privacy_score INT := 0;
kyc_factor INT;
clarification_factor INT := 0;
onion_factor INT := 0;
i2p_factor INT := 0;
monero_factor INT := 0;
@@ -46,6 +47,16 @@ BEGIN
FROM "Service"
WHERE "id" = service_id;
-- Adjust score based on KYC level clarification modifiers
SELECT
CASE
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
ELSE 0 -- Default modifier when no clarification or unrecognized value
END
INTO clarification_factor
FROM "Service"
WHERE "id" = service_id;
-- Check for onion URLs
IF EXISTS (
SELECT 1 FROM "Service"
@@ -78,7 +89,7 @@ BEGIN
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
-- Calculate final privacy score (base 100)
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
privacy_score := 50 + kyc_factor + clarification_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
-- Ensure the score is in reasonable bounds (0-100)
privacy_score := GREATEST(0, LEAST(100, privacy_score));
@@ -91,9 +102,11 @@ $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
RETURNS INT AS $$
DECLARE
trust_score INT := 50; -- Start from middle value (50)
trust_score INT := 0;
verification_factor INT;
attributes_score INT := 0;
recently_listed_factor INT := 0;
tos_penalty_factor INT := 0;
BEGIN
-- Get verification status factor
SELECT
@@ -124,7 +137,7 @@ BEGIN
AND "verificationStatus" = 'APPROVED'
AND (NOW() - "listedAt") <= INTERVAL '15 days'
) THEN
trust_score := trust_score - 10;
recently_listed_factor := -10;
-- Update the isRecentlyListed flag to true
UPDATE "Service"
SET "isRecentlyListed" = TRUE
@@ -135,10 +148,21 @@ BEGIN
SET "isRecentlyListed" = FALSE
WHERE id = service_id;
END IF;
-- Apply penalty if ToS cannot be analyzed
IF EXISTS (
SELECT 1
FROM "Service"
WHERE id = service_id
AND "tosReviewAt" IS NOT NULL
AND "tosReview" IS NULL
) THEN
tos_penalty_factor := -3;
END IF;
-- Calculate final trust score (base 100)
trust_score := trust_score + verification_factor + attributes_score;
trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
-- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score));

View File

@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
DECLARE
suggestion_status_change "ServiceSuggestionStatusChange";
BEGIN
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert
IF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestion' THEN -- Corresponds to ServiceSuggestion insert
-- Notify all admins when a new suggestion is created
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId")
SELECT u."id", 'SUGGESTION_CREATED', NEW."id"
FROM "User" u
WHERE u."admin" = true
AND NOT EXISTS (
SELECT 1 FROM "Notification" n
WHERE n."userId" = u."id"
AND n."type" = 'SUGGESTION_CREATED'
AND n."aboutServiceSuggestionId" = NEW."id"
);
ELSIF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestionMessage' THEN -- Corresponds to ServiceSuggestionMessage insert
-- Notify suggestion author (if not the sender)
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
@@ -55,6 +68,13 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
-- Trigger for new suggestions
DROP TRIGGER IF EXISTS service_suggestion_created_notifications_trigger ON "ServiceSuggestion";
CREATE TRIGGER service_suggestion_created_notifications_trigger
AFTER INSERT ON "ServiceSuggestion"
FOR EACH ROW
EXECUTE FUNCTION trigger_service_suggestion_notifications();
-- Trigger for new messages
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
CREATE TRIGGER service_suggestion_message_notifications_trigger

View File

@@ -25,12 +25,12 @@ BEGIN
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
END IF;
-- Check for verifier status change
IF OLD.verifier IS DISTINCT FROM NEW.verifier THEN
IF NEW.verifier = true THEN
status_change := 'VERIFIER_TRUE';
-- Check for moderator status change
IF OLD.moderator IS DISTINCT FROM NEW.moderator THEN
IF NEW.moderator = true THEN
status_change := 'MODERATOR_TRUE';
ELSE
status_change := 'VERIFIER_FALSE';
status_change := 'MODERATOR_FALSE';
END IF;
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
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 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
EXECUTE FUNCTION trigger_user_status_change_notifications();

View File

@@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION trigger_notification_push()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('notification_created', json_build_object('id', NEW.id)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the trigger if it exists to ensure a clean setup
DROP TRIGGER IF EXISTS notification_push_trigger ON "Notification";
-- Create the trigger to fire after inserts
CREATE TRIGGER notification_push_trigger
AFTER INSERT ON "Notification"
FOR EACH ROW
EXECUTE FUNCTION trigger_notification_push();

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#00bfff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#3BDB78" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#FF0040" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#FF0040" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 613 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#FF0000" viewBox="0 0 32 32" height="32" width="32">
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#040505" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#33BE00" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#00ffff" class="a" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#0080ff" class="a" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" class="b" d="M4 4h24v24H4z" />
<path fill="#0080ff" class="a" fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -1,13 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<style>
@media (prefers-color-scheme: light) {
path {
fill: #0080ff;
}
}
</style>
<path fill="#00ffff" fill-rule="evenodd"
<path fill="#040505" class="b" d="M4 4h24v24H4z" />
<path fill="#00ffff" class="a" fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 642 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#040505" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill-rule="evenodd"
d="M30 26a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h20a4 4 0 0 1 4 4v20ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 566 B

View File

@@ -1,11 +1,9 @@
import { type Prisma, type PrismaClient } from '@prisma/client'
import { type Prisma } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
import { prisma } from '../../lib/prisma'
const selectAnnouncementReturnFields = {
id: true,

View File

@@ -1,15 +1,17 @@
import { adminAnnouncementActions } from './announcement'
import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event'
import { adminNotificationActions } from './notification'
import { adminServiceActions } from './service'
import { adminServiceSuggestionActions } from './serviceSuggestion'
import { adminUserActions } from './user'
import { verificationStep } from './verificationStep'
export const adminActions = {
attribute: adminAttributeActions,
announcement: adminAnnouncementActions,
attribute: adminAttributeActions,
event: adminEventActions,
notification: adminNotificationActions,
service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions,
user: adminUserActions,

View File

@@ -0,0 +1,33 @@
import { z } from 'astro/zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
}),
handler: async (input) => {
const users = await prisma.user.findMany({
where: { name: { in: input.userNames } },
select: { id: true },
})
const notifications = await prisma.notification.createManyAndReturn({
data: users.map((user) => ({
type: 'TEST',
userId: user.id,
})),
select: { id: true },
})
return {
message: `Created ${notifications.length.toString()} notifications.`,
}
},
}),
}

View File

@@ -1,42 +1,20 @@
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import { separateServiceUrlsByType } from '../../lib/urls'
import {
imageFileSchema,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
zodContactMethod,
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(20),
description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z.string().optional().nullable().default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
})
const addSlugIfMissing = <
T extends {
slug?: string | null | undefined
@@ -56,12 +34,61 @@ const addSlugIfMissing = <
}),
})
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(40),
description: z.string().min(1),
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
kycLevel: z.coerce.number().int().min(0).max(4),
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional()
.nullable()
.default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
})
// Define schema for the create action input
const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing)
// Define schema for the update action input
const updateServiceInputSchema = serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing)
const evidenceImageAddSchema = z.object({
serviceId: z.number().int().positive(),
imageFile: imageFileSchema,
})
const evidenceImageDeleteSchema = z.object({
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
})
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
handler: async (input) => {
input: createServiceInputSchema,
handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
const existing = await prisma.service.findUnique({
where: {
slug: input.slug,
@@ -75,12 +102,34 @@ export const adminServiceActions = {
})
}
const { imageFile, ...serviceData } = input
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
const imageUrl = input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({
data: {
...serviceData,
name: input.name,
description: input.description,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification ?? undefined,
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: {
connect: input.categories.map((id) => ({ id })),
},
@@ -92,6 +141,14 @@ export const adminServiceActions = {
})),
},
imageUrl,
internalNotes: input.internalNote
? {
create: {
content: input.internalNote,
addedByUserId: context.locals.user.id,
},
}
: undefined,
},
select: {
id: true,
@@ -106,34 +163,40 @@ export const adminServiceActions = {
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.transform(addSlugIfMissing),
handler: async (input) => {
const { id, categories, attributes, imageFile, ...data } = input
const existing = await prisma.service.findUnique({
input: updateServiceInputSchema,
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
const anotherServiceWithNewSlug = await prisma.service.findUnique({
where: {
slug: input.slug,
NOT: { id },
NOT: { id: input.id },
},
})
if (existing) {
if (anotherServiceWithNewSlug) {
throw new ActionError({
code: 'CONFLICT',
message: 'A service with this slug already exists',
})
}
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
// Get existing attributes and categories to compute differences
const existingService = await prisma.service.findUnique({
where: { id },
include: {
categories: true,
where: { id: input.id },
select: {
slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
attributes: {
include: {
attribute: true,
select: {
attributeId: true,
attribute: {
select: {
id: true,
},
},
},
},
},
@@ -146,89 +209,258 @@ export const adminServiceActions = {
})
}
// Find categories to connect and disconnect
const existingCategoryIds = existingService.categories.map((c) => c.id)
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.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 attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.update({
where: { id },
where: { id: input.id },
data: {
...data,
name: input.name,
description: input.description,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
previousSlugs:
existingService.slug !== input.slug
? {
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
(slug) => slug !== input.slug
),
}
: undefined,
imageUrl,
categories: {
connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })),
},
attributes: {
// Connect new attributes
create: attributesToAdd.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
// Delete specific attributes that are no longer needed
deleteMany: attributesToRemove.map((attributeId) => ({
attributeId,
})),
},
},
})
return { service }
},
}),
createContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).optional(),
value: z.string().url(),
serviceId: z.number().int().positive(),
contactMethod: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).nullable(),
value: zodContactMethod,
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({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive().optional(),
label: z.string().min(1).max(50).optional(),
value: z.string().url(),
serviceId: z.number().int().positive(),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
label: z.string().min(1).max(50).nullable(),
value: zodContactMethod,
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({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
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 },
})
return { success: true }
},
}),
},
internalNote: {
add: defineProtectedAction({
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 },
})
},
}),
},
evidenceImage: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageAddSchema,
handler: async (input) => {
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
select: { slug: true },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found to associate image with.',
})
}
if (!input.imageFile) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Image file is required.',
})
}
const imageUrl = await saveFileLocally(
input.imageFile,
input.imageFile.name,
`evidence/${service.slug}`
)
return { imageUrl }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageDeleteSchema,
handler: async (input) => {
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},
}

View File

@@ -1,12 +1,10 @@
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
import { type Prisma, type ServiceUserRole } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
import { prisma } from '../../lib/prisma'
const selectUserReturnFields = {
id: true,
@@ -16,7 +14,7 @@ const selectUserReturnFields = {
picture: true,
admin: true,
verified: true,
verifier: true,
moderator: true,
verifiedLink: true,
secretTokenHash: true,
totalKarma: true,
@@ -55,7 +53,7 @@ export const adminUserActions = {
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
pictureFile: z.instanceof(File).optional(),
type: z.array(z.enum(['admin', 'verifier', 'spammer'])),
type: z.array(z.enum(['admin', 'moderator', 'spammer'])),
verifiedLink: z
.string()
.url('Invalid URL')
@@ -101,7 +99,7 @@ export const adminUserActions = {
verified: !!valuesToUpdate.verifiedLink,
picture: pictureUrl,
admin: type.includes('admin'),
verifier: type.includes('verifier'),
moderator: type.includes('moderator'),
spammer: type.includes('spammer'),
},
select: selectUserReturnFields,

View File

@@ -0,0 +1,5 @@
import { apiServiceActions } from './service'
export const apiActions = {
service: apiServiceActions,
}

View File

@@ -0,0 +1,151 @@
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { pick } from 'lodash-es'
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
import { getKycLevelInfo } from '../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
import type { Prisma } from '@prisma/client'
export const apiServiceActions = {
get: defineProtectedAction({
accept: 'json',
permissions: 'guest',
input: z.object({
id: z.coerce.number().int().positive().optional(),
slug: z
.string()
.min(1)
.max(2048)
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input, context) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'At least one of the following parameters is required: id, slug, url',
})
}
const urlVariants = input.url
? [input.url]
.flatMap((url) =>
[
url,
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
].filter((url) => url !== undefined)
)
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined
const select = {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
kycLevelClarification: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
...(urlVariants
? ([
{ serviceUrls: { hasSome: urlVariants } },
{ onionUrls: { hasSome: urlVariants } },
{ i2pUrls: { hasSome: urlVariants } },
] satisfies Prisma.ServiceWhereInput[])
: []),
],
},
select,
})
if (!service && input.slug) {
service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug },
},
select,
})
}
if (
!service ||
(service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') ||
!service.listedAt ||
service.listedAt > new Date()
) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
return {
id: service.id,
slug: service.slug,
name: service.name,
description: service.description,
serviceVisibility: service.serviceVisibility,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
'slug',
'label',
'labelShort',
'description',
]),
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
kycLevelClarification: service.kycLevelClarification,
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
'value',
'name',
'description',
]),
categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
}
},
}),
}

View File

@@ -331,7 +331,7 @@ export const commentActions = {
}),
moderate: defineProtectedAction({
permissions: ['admin', 'verifier'],
permissions: ['admin', 'moderator'],
input: z.object({
commentId: z.number(),
userId: z.number(),
@@ -345,10 +345,11 @@ export const commentActions = {
'order-id-status',
'kyc-requested',
'funds-blocked',
'toggle-rating-active',
]),
value: z.union([
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
z.boolean(),
z.string(),
]),
@@ -411,7 +412,7 @@ export const commentActions = {
updateData.privateContext = input.value as string
break
case 'order-id-status':
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
break
case 'kyc-requested':
updateData.kycRequested = !!input.value
@@ -419,6 +420,9 @@ export const commentActions = {
case 'funds-blocked':
updateData.fundsBlocked = !!input.value
break
case 'toggle-rating-active':
updateData.ratingActive = !!input.value
break
}
// Update the comment

View File

@@ -1,5 +1,6 @@
import { accountActions } from './account'
import { adminActions } from './admin'
import { apiActions } from './api'
import { commentActions } from './comment'
import { notificationActions } from './notifications'
import { serviceActions } from './service'
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
export const server = {
account: accountActions,
admin: adminActions,
api: apiActions,
comment: commentActions,
notification: notificationActions,
service: serviceActions,

View File

@@ -23,6 +23,63 @@ export const notificationActions = {
})
},
}),
webPush: {
subscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string(),
p256dhKey: z.string(),
authKey: z.string(),
userAgent: z.string().optional(),
}),
handler: async (input, context) => {
await prisma.pushSubscription.upsert({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
update: {
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
create: {
userId: context.locals.user.id,
endpoint: input.endpoint,
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
})
},
}),
unsubscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string().optional(),
}),
handler: async (input, context) => {
if (input.endpoint) {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
})
} else {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
},
})
}
},
}),
},
preferences: {
update: defineProtectedAction({
accept: 'form',
@@ -31,7 +88,7 @@ export const notificationActions = {
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
enableAutowatchMyComments: z.coerce.boolean().optional(),
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(),
}),
handler: async (input, context) => {
await prisma.notificationPreferences.upsert({

View File

@@ -1,10 +1,4 @@
import {
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { Currency, KycLevelClarification } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns'
@@ -12,11 +6,13 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import { separateServiceUrlsByType } from '../lib/urls'
import {
imageFileSchemaRequired,
stringListOfUrlsSchema,
stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
@@ -33,11 +29,16 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => {
const possibleDuplicates = await prisma.service.findMany({
const matches = await findServicesBySimilarity(input.name, 0.3)
return await prisma.service.findMany({
where: {
name: {
contains: input.name,
mode: 'insensitive',
id: {
in: matches.map(({ id }) => id),
},
listedAt: { lte: new Date() },
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
},
},
select: {
@@ -47,8 +48,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true,
},
})
return possibleDuplicates
}
const serializeExtraNotes = <T extends Record<string, unknown>>(
@@ -63,6 +62,8 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
serializedValue = value
} else if (value === undefined || value === null) {
serializedValue = ''
} else if (Array.isArray(value)) {
serializedValue = value.map((item) => String(item)).join(', ')
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
serializedValue = value.toString()
@@ -122,9 +123,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({
data: {
type: ServiceSuggestionType.EDIT_SERVICE,
type: 'EDIT_SERVICE',
notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING,
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},
@@ -149,26 +150,22 @@ export const serviceSuggestionActions = {
.max(SUGGESTION_SLUG_MAX_LENGTH)
.regex(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
})
.refine(
async (slug) => {
const exists = await prisma.service.findUnique({
select: { id: true },
where: { slug },
})
return !exists
},
{ message: 'Slug must be unique, try a different one' }
),
}),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired,
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
contactMethods: stringListOfContactMethodsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
kycLevelClarification: z.nativeEnum(KycLevelClarification),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
imageFile: imageFileSchemaRequired,
rulesConfirm: z.literal('on', {
errorMap: () => ({
message: 'You must accept the suggestion rules and process to continue',
}),
}),
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
skipDuplicateCheck: z
@@ -188,8 +185,16 @@ export const serviceSuggestionActions = {
location: 'serviceSuggestion.createService',
})
const serviceWithSameSlug = await prisma.service.findUnique({
select: { id: true, name: true, slug: true, description: true },
where: { slug: input.slug },
})
if (!input.skipDuplicateCheck) {
const possibleDuplicates = await findPossibleDuplicates(input)
const possibleDuplicates = [
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
...(await findPossibleDuplicates(input)),
]
if (possibleDuplicates.length > 0) {
return {
@@ -201,15 +206,29 @@ export const serviceSuggestionActions = {
'imageFile',
'captcha-value',
'captcha-solution-hash',
'rulesConfirm',
]),
serviceSuggestion: undefined,
service: undefined,
} as const
}
} else {
if (serviceWithSameSlug) {
throw new ActionError({
message: 'Slug already in use, try a different one',
code: 'BAD_REQUEST',
})
}
}
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 serviceSelect = {
id: true,
@@ -221,18 +240,20 @@ export const serviceSuggestionActions = {
name: input.name,
slug: input.slug,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
acceptedCurrencies: input.acceptedCurrencies,
imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0,
privacyScore: 0,
trustScore: 0,
listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED,
serviceVisibility: 'UNLISTED',
categories: {
connect: input.categories.map((id) => ({ id })),
},
@@ -241,6 +262,11 @@ export const serviceSuggestionActions = {
attributeId: id,
})),
},
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
},
select: serviceSelect,
})
@@ -248,8 +274,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({
data: {
notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE,
status: ServiceSuggestionStatus.PENDING,
type: 'CREATE_SERVICE',
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -2,13 +2,14 @@
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import type { AstroChildren } from '../lib/astro'
import type { Polymorphic } from 'astro/types'
const badge = tv({
slots: {
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
icon: 'size-3 shrink-0',
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap [&>a]:hover:underline [&>a]:focus-visible:underline',
},
variants: {
color: {
@@ -122,22 +123,29 @@ const badge = tv({
},
})
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
type Props<
Tag extends 'a' | 'div' | 'li' = 'div',
Text extends string | undefined = string | undefined,
> = Polymorphic<
VariantProps<typeof badge> & {
as: Tag
icon?: string
text: string
endIcon?: string
text?: Text
inlineIcon?: boolean
classNames?: {
icon?: string
text?: string
endIcon?: string
}
children?: Text extends string ? never : AstroChildren
}
>
const {
as: Tag = 'div',
icon: iconName,
endIcon: endIconName,
text: textContent,
inlineIcon,
classNames,
@@ -158,5 +166,10 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
)
}
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
<span class={textSlot({ class: classNames?.text })}>{textContent ?? <slot />}</span>
{
!!endIconName && (
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
)
}
</Tag>

View File

@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
as: Tag
icon: string
endIcon?: string
text: string
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
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
>
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
<span>{text}</span>
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
</Tag>

View 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}
/>

View File

@@ -2,12 +2,19 @@
import LoadingIndicator from 'astro-loading-indicator/component'
import { Schema } from 'astro-seo-schema'
import { ClientRouter } from 'astro:transitions'
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro'
import NotificationEventsScript from './NotificationEventsScript.astro'
import { makeOgImageUrl } from './OgImage'
import ServerEventsScript from './ServerEventsScript.astro'
import ServiceWorkerScript from './ServiceWorkerScript.astro'
import TailwindJsPluggin from './TailwindJsPluggin.astro'
import type { ComponentProps } from 'astro/types'
@@ -70,12 +77,6 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
---
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
<!-- Primary Meta Tags -->
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
@@ -98,7 +99,13 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<!-- Other -->
<link rel="sitemap" href="/sitemap-index.xml" />
<meta name="theme-color" content="#040505" />
<!-- PWA -->
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
<DynamicFavicon />
<!-- Components -->
<ClientRouter />
@@ -131,3 +138,15 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
/>
))
}
{
Astro.locals.user && (
<>
<ServerEventsScript />
<ServiceWorkerScript />
<NotificationEventsScript />
</>
)
}
<DevToolsMessageScript />

View File

@@ -2,13 +2,15 @@
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import { cn } from '../lib/cn'
import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & {
as: Tag
label?: string
label: string
icon?: string
endIcon?: string
classNames?: {
@@ -55,30 +57,21 @@ const button = tv({
iconOnly: {
true: {
base: 'p-0',
label: 'sr-only',
},
},
color: {
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: {
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
},
gray: {
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
},
success: {
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
},
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',
},
black: '',
white: '',
gray: '',
success: '',
danger: '',
warning: '',
info: '',
},
variant: {
solid: '',
faded: '',
},
shadow: {
true: {
@@ -92,6 +85,107 @@ const button = tv({
},
},
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',
shadow: true,
@@ -113,7 +207,7 @@ const button = tv({
class: 'shadow-green-500/30',
},
{
color: 'error',
color: 'danger',
shadow: true,
class: 'shadow-red-500/30',
},
@@ -127,6 +221,7 @@ const button = tv({
shadow: true,
class: 'shadow-blue-500/30',
},
// Icon only variants
{
iconOnly: true,
size: 'sm',
@@ -146,6 +241,7 @@ const button = tv({
defaultVariants: {
size: 'md',
color: 'black',
variant: 'solid',
shadow: false,
disabled: false,
iconOnly: false,
@@ -153,12 +249,13 @@ const button = tv({
})
const {
as: Tag = 'button' as 'a' | 'button' | 'label',
as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
label,
icon,
endIcon,
size,
color,
variant,
shadow,
class: className,
classNames,
@@ -166,6 +263,7 @@ const {
dataAstroReload,
disabled,
inlineIcon,
iconOnly,
...htmlProps
} = Astro.props
@@ -174,21 +272,28 @@ const {
icon: iconSlot,
label: labelSlot,
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
---
<ActualTag
class={base({ class: className })}
role={role ??
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
aria-disabled={disabled}
aria-label={label}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps}
>
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
<span class={labelSlot({ class: classNames?.label })}>{label}</span>
{
!!endIcon && (
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>

View File

@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
/>
<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>
</form>
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}

View File

@@ -61,8 +61,8 @@ const isHighlighted = comment.id === highlightedCommentId
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
const isAuthor = user?.id === comment.author.id
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
const isAdminOrModerator = !!user && (user.admin || user.moderator)
const isAuthorOrPrivileged = isAuthor || isAdminOrModerator
// Check if user is new (less than 1 week old)
const isNewUser =
@@ -75,7 +75,7 @@ const isRatingActive =
!comment.suspicious &&
(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 =
comment.status === 'APPROVED' ||
comment.status === 'VERIFIED' ||
@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps}
id={`comment-${comment.id.toString()}`}
class={cn([
'group',
'group bg-night-700',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]',
@@ -156,7 +156,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
<span class="sr-only">Toggle comment visibility</span>
</label>
<span class="flex items-center gap-1">
<span class="flex min-w-16 items-center gap-1">
<UserBadge
user={comment.author}
size="md"
@@ -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
text={`${
comment.author.admin || comment.author.verifier
comment.author.admin || comment.author.moderator
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
@@ -179,14 +179,14 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
</span>
{/* User badges - more compact but still with text */}
<div class="flex flex-wrap items-center gap-1">
<div class="flex w-min grow flex-wrap items-center gap-1">
{
comment.author.admin && (
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
)
}
{
comment.author.verifier && !comment.author.admin && (
comment.author.moderator && !comment.author.admin && (
<BadgeSmall
icon="ri:graduation-cap-fill"
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 })}`}>
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
</Tooltip>
)
}
{
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.moderator && (
<BadgeSmall
icon={karmaUnlocksById.highKarmaBadge.icon}
color="lime"
@@ -240,18 +240,17 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
}
{
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall
icon={roleInfo.icon}
color={roleInfo.color}
text={`${roleInfo.label} at ${affiliation.service.name}`}
variant="faded"
inlineIcon
/>
)
})
comment.author.serviceAffiliations
.filter((affiliation) => affiliation.service.slug === serviceSlug)
.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
}
</div>
</div>
@@ -270,12 +269,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{
comment.rating !== null && !comment.parentId && (
<Tooltip
@@ -320,6 +313,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label}
inlineIcon
endIcon="ri:lock-line"
/>
)
}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall
icon="ri:alert-fill"
color="yellow"
text="Needs admin review"
inlineIcon
endIcon="ri:lock-line"
/>
)
}
@@ -380,7 +386,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="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>
@@ -391,7 +397,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="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>

View File

@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
privateContext: true
orderId: true
orderIdStatus: true
rating: true
ratingActive: true
}
}>
}
@@ -28,8 +30,8 @@ const { comment, class: className, ...divProps } = Astro.props
const user = Astro.locals.user
// Only render for admin/verifier users
if (!user || !user.admin || !user.verifier) return null
// Only render for admin/moderator users
if (!user || !user.admin || !user.moderator) return null
---
<div {...divProps} class={cn('text-xs', className)}>
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.verifier) return null
<div
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
>
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
<div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
<Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.suspicious ? 'Not Spam' : 'Spam'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'VERIFIED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
<Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
<Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
<span>
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</span>
</button>
<div class="bg-night-500 h-5 w-px"></div>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.kycRequested
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
<Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.fundsBlocked
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
<Icon name="ri:lock-line" class="h-3.5 w-3.5" />
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
</button>
<div class="bg-night-500 h-5 w-px"></div>
{
comment.rating && (
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.ratingActive
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)}
data-action="toggle-rating-active"
data-value={!comment.ratingActive}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
</button>
)
}
</div>
<div class="mt-2 space-y-1.5">
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.verifier) return null
<div class="flex gap-1">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'APPROVED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Approve
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
<span>Approve</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Reject
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
<span>Reject</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Pending
<Icon name="ri:time-line" class="h-3.5 w-3.5" />
<span>Pending</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'WITHDRAWN'
? 'border-night-400 bg-night-500/50 text-night-300 border'
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
)}
data-action="order-id-status"
data-value="WITHDRAWN"
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
<span>Withdrawn</span>
</button>
</div>
</div>
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.verifier) return null
action === 'suspicious' ||
action === 'requires-admin-review' ||
action === 'kyc-requested' ||
action === 'funds-blocked'
action === 'funds-blocked' ||
action === 'toggle-rating-active'
? value === 'true'
: value,
})
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.verifier) return null
if (action === 'status') {
window.location.reload()
} else if (action === 'suspicious') {
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
btn.classList.toggle('bg-yellow-500/20')
btn.classList.toggle('text-yellow-400')
btn.classList.toggle('border-yellow-500/30')
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.verifier) return null
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'requires-admin-review') {
btn.textContent = value === 'true' ? 'No Review' : 'Review'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
btn.classList.toggle('bg-purple-500/20')
btn.classList.toggle('text-purple-400')
btn.classList.toggle('border-purple-500/30')
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.verifier) return null
// Refresh to show updated order ID status
window.location.reload()
} else if (action === 'kyc-requested') {
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.verifier) return null
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'funds-blocked') {
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'toggle-rating-active') {
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
btn.classList.toggle('bg-blue-500/20')
btn.classList.toggle('text-blue-400')
btn.classList.toggle('border-blue-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
}
} else {
console.error('Error moderating comment:', error)

View File

@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="Tags" name="tags">
<InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400">

View File

@@ -202,7 +202,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
Most Upvotes
</a>
{
user && (user.admin || user.verifier) && (
user && (user.admin || user.moderator) && (
<a
href={getSortUrl('status')}
class={cn([

View File

@@ -47,12 +47,9 @@ const averageUserRatingFromQuery =
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
if (averageUserRatingFromProps !== undefined) {
if (
averageUserRatingFromQuery !== averageUserRatingFromProps ||
(averageUserRatingFromQuery !== null &&
averageUserRatingFromProps !== null &&
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
) {
const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
if (a !== b) {
console.error(
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
)

View File

@@ -0,0 +1,51 @@
---
---
<script>
import { SOURCE_CODE_URL } from 'astro:env/client'
const logoStyle = `
padding: 0 119.5px;
display: block;
line-height: 64px;
background-size: auto 64px;
background-repeat: no-repeat;
background-position: 50% 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
`
setTimeout(
console.log.bind(
console,
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻‍♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
logoStyle,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
`,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
background: repeating-linear-gradient(
90deg,
#d97706 0%,
#f59e0b 20%,
#f97316 40%,
#ea580c 60%,
#f97316 80%,
#f59e0b 100%
) -100%/ 200%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
`,
`
font-size: 1rem;
font-family: system-ui, -apple-system, sans-serif;
`
)
)
</script>

View File

@@ -0,0 +1,79 @@
---
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { prisma } from '../lib/prisma'
const user = Astro.locals.user
const hasUnreadNotifications = await Astro.locals.banners.try(
'Error getting unread notification count',
async () =>
user
? !!(await prisma.notification.findFirst({
where: { userId: user.id, read: false },
select: { id: true },
}))
: false,
false
)
function addBadgeIfUnread(href: string) {
if (hasUnreadNotifications) return href.replace('.svg', '-badge.svg')
return href
}
---
{
DEPLOYMENT_MODE === 'production' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
{
DEPLOYMENT_MODE === 'development' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-dev.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-dev-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
{
DEPLOYMENT_MODE === 'staging' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-stage.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-stage-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
<script>
document.addEventListener('sse:new-notification', () => {
const links = document.querySelectorAll('link[rel="icon"]')
links.forEach((link) => {
const href = link.getAttribute('href')
if (href && href.includes('favicon') && !href.endsWith('-badge.svg')) {
const newHref = href.replace('.svg', '-badge.svg')
link.setAttribute('href', newHref)
}
})
})
</script>

View File

@@ -1,6 +1,7 @@
---
import { Icon } from 'astro-icon/components'
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { SOURCE_CODE_URL } from 'astro:env/client'
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { cn } from '../lib/cn'
@@ -27,6 +28,18 @@ const links = [
icon: 'i2p',
external: true,
},
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{
href: '/feeds',
label: 'RSS',
icon: 'ri:rss-line',
external: false,
},
{
href: '/about',
label: 'About',
@@ -43,7 +56,10 @@ const links = [
const { class: className, ...htmlProps } = Astro.props
---
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
<footer
class={cn('xs:gap-x-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-4', className)}
{...htmlProps}
>
{
links.map(
({ href, label, icon, external }) =>
@@ -52,9 +68,9 @@ const { class: className, ...htmlProps } = Astro.props
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
class="text-day-500 xs:gap-1 flex items-center gap-0.5 text-sm transition-colors hover:text-gray-200 hover:underline"
>
<Icon name={icon} class="h-4 w-4" />
<Icon name={icon} class="xs:opacity-100 h-4 w-4 opacity-40" />
{label}
</a>
)

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

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

View File

@@ -123,6 +123,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
transition:name="header-admin-link"
text="Admin Dashboard"
position="left"
aria-label="Admin Dashboard"
>
<Icon name="ri:home-gear-line" class="size-10" />
</Tooltip>
@@ -160,6 +161,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
<a
href={makeUnimpersonateUrl(Astro.url)}
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"
transition:name="header-unimpersonate-link"
aria-label="Unimpersonate"

View File

@@ -27,6 +27,8 @@ const count =
user && (
<a
href="/notifications"
data-notification-count-link
data-current-count={count}
class={cn(
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
className
@@ -35,11 +37,32 @@ const count =
{...htmlProps}
>
<Icon name="material-symbols:notifications-outline" class="size-5" />
{count > 0 && (
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
{count > 99 ? '★' : count.toLocaleString()}
</span>
)}
<span
data-notification-count-badge
class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500 empty:hidden"
>
{count > 0 ? (count > 99 ? '★' : count.toLocaleString()) : ''}
</span>
</a>
)
}
<script>
document.addEventListener('sse:new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
const currentCount = Number(link.getAttribute('data-current-count') || 0)
const newCount = currentCount + 1
link.querySelectorAll<HTMLElement>('[data-notification-count-badge]').forEach((badge) => {
badge.textContent = newCount > 0 ? (newCount > 99 ? '★' : newCount.toLocaleString()) : ''
})
link.setAttribute(
'aria-label',
`Go to notifications${newCount > 0 ? ` (${newCount.toLocaleString()} unread)` : ''}`
)
link.setAttribute('data-current-count', String(newCount))
})
})
</script>

View File

@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)}
>
<input
transition:persist={option.noTransitionPersist ? undefined : true}
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name}
value={option.value}

View File

@@ -0,0 +1,61 @@
---
import { cn } from '../lib/cn'
import type InputWrapper from './InputWrapper.astro'
import type { AstroChildren } from '../lib/astro'
import type { ComponentProps } from 'astro/types'
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
disabled?: boolean
id?: string
} & (
| {
label: string
children?: undefined
}
| {
label?: undefined
children: AstroChildren
}
)
const { disabled, name, required, error, id, label } = Astro.props
const hasError = !!error && error.length > 0
---
{}
<div>
<label
class={cn(
'inline-flex cursor-pointer items-center gap-2',
hasError && 'text-red-300',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<input
transition:persist
type="checkbox"
id={id}
name={name}
required={required}
disabled={disabled}
class={cn(disabled && 'opacity-50')}
/>
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
</label>
{
hasError &&
(typeof error === 'string' ? (
<p class="text-sm text-red-500">{error}</p>
) : (
<ul class="text-sm text-red-500">
{error.map((e) => (
<li>{e}</li>
))}
</ul>
))
}
</div>

View File

@@ -12,13 +12,15 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
options: {
label: string
value: string
icon?: string
icon?: string[] | string
iconClassName?: string[] | string
}[]
disabled?: boolean
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 hasError = !!wrapperProps.error && wrapperProps.error.length > 0
@@ -26,23 +28,38 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
<InputWrapper inputId={inputId} {...wrapperProps}>
<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) => (
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
<input
transition:persist
type="checkbox"
name={wrapperProps.name}
value={option.value}
checked={selectedValues.includes(option.value)}
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
disabled={disabled}
/>
{option.icon && <Icon name={option.icon} class="size-4" />}
<span class="text-sm leading-none">{option.label}</span>
</label>
))
options.map((option) => {
const icons = option.icon ? (Array.isArray(option.icon) ? option.icon : [option.icon]) : []
const iconClassName = option.iconClassName
? Array.isArray(option.iconClassName)
? option.iconClassName
: Array.from({ length: icons.length }, () => option.iconClassName)
: []
return (
<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">
<input
transition:persist
type="checkbox"
name={wrapperProps.name}
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>

View File

@@ -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 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 && (
<label

View File

@@ -2,16 +2,24 @@
import { cn } from '../lib/cn'
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
import Button from './Button.astro'
import InputFile from './InputFile.astro'
import Tooltip from './Tooltip.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
square?: boolean
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>
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
'[&: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>
<script>

View File

@@ -14,10 +14,11 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' |
value: string
disabled?: boolean
}[]
selectProps?: Omit<HTMLAttributes<'select'>, 'name'>
selectProps?: Omit<HTMLAttributes<'select'>, 'name' | 'value'>
selectedValue?: string[] | string
}
const { options, selectProps, ...wrapperProps } = Astro.props
const { options, selectProps, selectedValue, ...wrapperProps } = Astro.props
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
@@ -39,7 +40,15 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
>
{
options.map((option) => (
<option value={option.value} disabled={option.disabled}>
<option
value={option.value}
disabled={option.disabled}
selected={
Array.isArray(selectedValue)
? selectedValue.includes(option.value)
: selectedValue === option.value
}
>
{option.label}
</option>
))

Some files were not shown because too many files have changed in this diff Show More