Compare commits

...

46 Commits

Author SHA1 Message Date
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
pluja
cdfdcfc122 Release 2025-05-23-R3WZ 2025-05-23 11:52:16 +00:00
pluja
f4525e3d32 Release 2025-05-22-SwZ1 2025-05-22 23:07:55 +00:00
pluja
ecc8f67fc4 Release 2025-05-22-XDxe 2025-05-22 22:58:18 +00:00
pluja
72c238a4dc Release 2025-05-22-16vM 2025-05-22 22:38:41 +00:00
pluja
d79bedf219 Release 2025-05-22-5X5Q 2025-05-22 19:43:20 +00:00
pluja
2362d2cc73 Release 2025-05-22-Uvv4 2025-05-22 19:19:07 +00:00
pluja
a69c0aeed4 Release 2025-05-22-GmO6 2025-05-22 11:10:18 +00:00
pluja
ed86f863e3 Release 2025-05-21-MXjT 2025-05-21 14:31:33 +00:00
pluja
845aa1185c Release 2025-05-21-AQ5C 2025-05-21 07:03:39 +00:00
pluja
17b3642f7e Update favicon 2025-05-20 11:27:55 +00:00
pluja
d64268d396 fix logout issue 2025-05-20 11:12:55 +00:00
pluja
9c289753dd fix generate 2025-05-20 11:00:28 +00:00
pluja
8bdbe8ea36 small updates 2025-05-20 10:29:03 +00:00
pluja
af7ebe813b announcements style 2025-05-20 10:20:09 +00:00
170 changed files with 8796 additions and 4229 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>[]
);
```

0
.env.example Normal file
View File

3
.gitignore vendored
View File

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

View File

@@ -0,0 +1,4 @@
#!/bin/bash
pwd
just dump-db

View File

@@ -23,13 +23,12 @@ cd web
nvm install nvm install
npm i npm i
cp -n .env.example .env cp -n .env.example .env
npm run db-push npm run db-reset
npm run db-fill-clean
``` ```
Now open the [.env](web/.env) file and fill in the missing values. Now open the [.env](web/.env) file and fill in the missing values.
> Default users are created with tokens: `admin`, `verifier`, `verified`, `normal` (configurable via env vars) > Default users are created with tokens: `admin`, `moderator`, `verified`, `normal` (configurable via env vars)
### Running the project ### Running the project

View File

@@ -62,6 +62,8 @@ services:
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot} POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public" DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
REDIS_URL: "redis://redis:6379" REDIS_URL: "redis://redis:6379"
env_file:
- .env
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
@@ -70,7 +72,7 @@ services:
expose: expose:
- 4321 - 4321
healthcheck: healthcheck:
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321"] test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

@@ -51,42 +51,6 @@ import-db file="":
fi fi
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 ===" echo "=== STEP 1: PREPARING DATABASE ==="
# Drop all connections to the 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 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

@@ -1,4 +1,13 @@
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public" DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379" REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com" SOURCE_CODE_URL="https://github.com"
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

@@ -19,9 +19,8 @@ ENV HOST=0.0.0.0
ENV PORT=4321 ENV PORT=4321
EXPOSE 4321 EXPOSE 4321
# Add entrypoint script and make it executable # Add knm-migrate command script and make it executable
COPY docker-entrypoint.sh /usr/local/bin/ COPY migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/knm-migrate
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "./dist/server/entry.mjs"] CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -6,24 +6,23 @@
All commands are run from the root of the project, from a terminal: All commands are run from the root of the project, from a terminal:
| Command | Action | | Command | Action |
| :------------------------ | :------------------------------------------------------------------- | | :------------------------ | :------------------------------------------------------------------ |
| `nvm install` | Installs and uses the correct version of node | | `nvm install` | Installs and uses the correct version of node |
| `npm install` | Installs dependencies | | `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` | | `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` | | `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying | | `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI | | `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run db-admin` | Runs Prisma Studio (database admin) | | `npm run db-admin` | Runs Prisma Studio (database admin) |
| `npm run db-gen` | Generates the Prisma client without running migrations | | `npm run db-gen` | Generates the Prisma client without running migrations |
| `npm run db-push` | Updates the database schema with latest changes (development mode). | | `npm run db-push` | Updates the database schema with latest changes (development mode). |
| `npm run db-fill` | Fills the database with fake data (development mode) | | `npm run db-seed` | Seeds the database with fake data (development mode) |
| `npm run db-fill-clean` | Cleans existing data and fills with new fake data (development mode) | | `npm run format` | Formats the code with Prettier |
| `npm run format` | Formats the code with Prettier | | `npm run lint` | Lints the code with ESLint |
| `npm run lint` | Lints the code with ESLint | | `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
> **Note**: `db-fill` and `db-fill-clean` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-fill -- --services=5` will add 5 fake services. > **Note**: `db-seed` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-seed -- --services=5` will add 5 fake services.
> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `verifier`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars) > **Note**: `db-seed` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)

View File

@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { defineConfig, envField } from 'astro/config' import { defineConfig, envField } from 'astro/config'
import icon from 'astro-icon' import icon from 'astro-icon'
import { loadEnv } from 'vite'
// @ts-expect-error process.env actually exists import { postgresListener } from './src/lib/postgresListenerIntegration'
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '') import { getServerEnvVariable } from './src/lib/serverEnvVariables'
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
const SITE_URL = getServerEnvVariable('SITE_URL')
export default defineConfig({ export default defineConfig({
site: SITE_URL, site: SITE_URL,
@@ -22,6 +22,7 @@ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
integrations: [ integrations: [
postgresListener(),
icon(), icon(),
mdx(), mdx(),
sitemap({ sitemap({
@@ -74,6 +75,18 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
I2P_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
ONION_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
REDIS_URL: envField.string({ REDIS_URL: envField.string({
context: 'server', context: 'server',
@@ -119,11 +132,11 @@ export default defineConfig({
min: 1, min: 1,
default: 'admin', default: 'admin',
}), }),
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({ DEV_MODERATOR_USER_SECRET_TOKEN: envField.string({
context: 'server', context: 'server',
access: 'secret', access: 'secret',
min: 1, min: 1,
default: 'verifier', default: 'moderator',
}), }),
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({ DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
context: 'server', context: 'server',
@@ -158,6 +171,52 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
DATABASE_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: false,
}),
LOGS_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: true,
}),
RELEASE_NUMBER: envField.number({
context: 'server',
access: 'public',
int: true,
optional: true,
}),
RELEASE_DATE: envField.string({
context: 'server',
access: 'public',
optional: true,
}),
// 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

@@ -16,6 +16,4 @@ for trigger_file in prisma/triggers/*.sql; do
fi fi
done done
# Start the application echo "Migrations completed."
echo "Starting the application..."
exec "$@"

224
web/package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@prisma/client": "6.8.2", "@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7", "@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.7.13", "astro": "5.7.13",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
@@ -32,6 +33,7 @@
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.0.1", "redis": "5.0.1",
@@ -44,6 +46,7 @@
"tailwindcss": "4.1.7", "tailwindcss": "4.1.7",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7" "zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
@@ -60,6 +63,7 @@
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.4",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.32.1",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
@@ -3150,12 +3154,10 @@
} }
}, },
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.6.1", "version": "8.15.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
"integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==", "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"pg-protocol": "*", "pg-protocol": "*",
@@ -3215,6 +3217,16 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
@@ -3797,6 +3809,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4063,6 +4084,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types-flow": { "node_modules/ast-types-flow": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4822,6 +4855,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/boolbase": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -4916,6 +4955,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bundle-name": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -6157,6 +6202,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8181,6 +8235,15 @@
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==", "integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
@@ -8203,6 +8266,19 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -8935,6 +9011,27 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -10556,6 +10653,12 @@
"mini-svg-data-uri": "cli.js" "mini-svg-data-uri": "cli.js"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -11310,32 +11413,75 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.0",
"pg-pool": "^3.10.0",
"pg-protocol": "^1.10.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 8.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.5"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz",
"integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz",
"integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==",
"license": "MIT"
},
"node_modules/pg-int8": { "node_modules/pg-int8": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4.0.0" "node": ">=4.0.0"
} }
}, },
"node_modules/pg-protocol": { "node_modules/pg-pool": {
"version": "1.8.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz",
"integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", "integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==",
"license": "MIT", "license": "MIT",
"optional": true, "peerDependencies": {
"peer": true "pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz",
"integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==",
"license": "MIT"
}, },
"node_modules/pg-types": { "node_modules/pg-types": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"pg-int8": "1.0.1", "pg-int8": "1.0.1",
"postgres-array": "~2.0.0", "postgres-array": "~2.0.0",
@@ -11347,6 +11493,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11456,8 +11611,6 @@
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@@ -11467,8 +11620,6 @@
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -11478,8 +11629,6 @@
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -11489,8 +11638,6 @@
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"xtend": "^4.0.0" "xtend": "^4.0.0"
}, },
@@ -12696,7 +12843,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass-formatter": { "node_modules/sass-formatter": {
@@ -13149,6 +13295,15 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -14675,6 +14830,25 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-streams-polyfill": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -14916,8 +15090,6 @@
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.4" "node": ">=0.4"
} }

View File

@@ -12,13 +12,15 @@
"db-push": "prisma migrate dev", "db-push": "prisma migrate dev",
"db-triggers": "just import-triggers", "db-triggers": "just import-triggers",
"db-update": "prisma migrate dev && just import-triggers", "db-update": "prisma migrate dev && just import-triggers",
"db-reset": "prisma migrate reset && prisma migrate dev && just import-triggers && tsx scripts/faker.ts", "db-reset": "prisma migrate reset -f && prisma migrate dev",
"db-fill": "tsx scripts/faker.ts", "db-seed": "prisma db seed",
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint .", "lint": "eslint .",
"lint-fix": "eslint . --fix && prettier --write ." "lint-fix": "eslint . --fix && prettier --write ."
}, },
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@astrojs/check": "0.9.4", "@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14", "@astrojs/db": "0.14.14",
@@ -31,6 +33,7 @@
"@prisma/client": "6.8.2", "@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7", "@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.7.13", "astro": "5.7.13",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
@@ -44,6 +47,7 @@
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.0.1", "redis": "5.0.1",
@@ -56,6 +60,7 @@
"tailwindcss": "4.1.7", "tailwindcss": "4.1.7",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7" "zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
@@ -72,6 +77,7 @@
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.4",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.32.1",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Announcement" ADD COLUMN "link" TEXT;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `title` on the `Announcement` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Announcement" DROP COLUMN "title";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Announcement" ADD COLUMN "linkText" TEXT;

View File

@@ -0,0 +1,11 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'KARMA_CHANGE';
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "aboutKarmaTransactionId" INTEGER;
-- AlterTable
ALTER TABLE "NotificationPreferences" ADD COLUMN "karmaNotificationThreshold" INTEGER NOT NULL DEFAULT 10;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_aboutKarmaTransactionId_fkey" FOREIGN KEY ("aboutKarmaTransactionId") REFERENCES "KarmaTransaction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `iconId` on the `ServiceContactMethod` table. All the data in the column will be lost.
- You are about to drop the column `info` on the `ServiceContactMethod` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "ServiceContactMethod" DROP COLUMN "iconId",
DROP COLUMN "info",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN "label" DROP NOT NULL;

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

@@ -87,6 +87,7 @@ enum ServiceVisibility {
PUBLIC PUBLIC
UNLISTED UNLISTED
HIDDEN HIDDEN
ARCHIVED
} }
enum Currency { enum Currency {
@@ -120,8 +121,8 @@ enum AccountStatusChange {
ADMIN_FALSE ADMIN_FALSE
VERIFIED_TRUE VERIFIED_TRUE
VERIFIED_FALSE VERIFIED_FALSE
VERIFIER_TRUE MODERATOR_TRUE
VERIFIER_FALSE MODERATOR_FALSE
SPAMMER_TRUE SPAMMER_TRUE
SPAMMER_FALSE SPAMMER_FALSE
} }
@@ -135,6 +136,7 @@ enum NotificationType {
SUGGESTION_MESSAGE SUGGESTION_MESSAGE
SUGGESTION_STATUS_CHANGE SUGGESTION_STATUS_CHANGE
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code. // KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
KARMA_CHANGE
/// Marked as spammer, promoted to admin, etc. /// Marked as spammer, promoted to admin, etc.
ACCOUNT_STATUS_CHANGE ACCOUNT_STATUS_CHANGE
EVENT_CREATED EVENT_CREATED
@@ -207,6 +209,8 @@ model Notification {
aboutCommentStatusChange CommentStatusChange? aboutCommentStatusChange CommentStatusChange?
aboutServiceVerificationStatusChange ServiceVerificationStatusChange? aboutServiceVerificationStatusChange ServiceVerificationStatusChange?
aboutSuggestionStatusChange ServiceSuggestionStatusChange? aboutSuggestionStatusChange ServiceSuggestionStatusChange?
aboutKarmaTransaction KarmaTransaction? @relation(fields: [aboutKarmaTransactionId], references: [id])
aboutKarmaTransactionId Int?
@@index([userId]) @@index([userId])
@@index([read]) @@index([read])
@@ -229,6 +233,7 @@ model NotificationPreferences {
enableOnMyCommentStatusChange Boolean @default(true) enableOnMyCommentStatusChange Boolean @default(true)
enableAutowatchMyComments Boolean @default(true) enableAutowatchMyComments Boolean @default(true)
enableNotifyPendingRepliesOnWatch Boolean @default(false) enableNotifyPendingRepliesOnWatch Boolean @default(false)
karmaNotificationThreshold Int @default(10)
onEventCreatedForServices Service[] @relation("onEventCreatedForServices") onEventCreatedForServices Service[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices") onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices")
@@ -292,6 +297,11 @@ enum ServiceSuggestionType {
EDIT_SERVICE EDIT_SERVICE
} }
enum KycLevelClarification {
NONE
DEPENDS_ON_PARTNERS
}
model ServiceSuggestion { model ServiceSuggestion {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type ServiceSuggestionType type ServiceSuggestionType
@@ -331,9 +341,11 @@ model Service {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
slug String @unique slug String @unique
previousSlugs String[] @default([])
description String description String
categories Category[] @relation("ServiceToCategory") categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4) kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE)
overallScore Int @default(0) overallScore Int @default(0)
privacyScore Int @default(0) privacyScore Int @default(0)
trustScore Int @default(0) trustScore Int @default(0)
@@ -372,6 +384,7 @@ model Service {
attributes ServiceAttribute[] attributes ServiceAttribute[]
verificationSteps VerificationStep[] verificationSteps VerificationStep[]
suggestions ServiceSuggestion[] suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices") onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices") onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
@@ -390,15 +403,19 @@ model Service {
@@index([createdAt]) @@index([createdAt])
@@index([updatedAt]) @@index([updatedAt])
@@index([slug]) @@index([slug])
@@index([previousSlugs])
} }
model ServiceContactMethod { model ServiceContactMethod {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
label String /// Only include it if you want to override the formatted value.
label String?
/// Including the protocol (e.g. "mailto:", "tel:", "https://") /// Including the protocol (e.g. "mailto:", "tel:", "https://")
value String value String
iconId String
info String createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade) services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade)
serviceId Int serviceId Int
} }
@@ -434,6 +451,7 @@ model Attribute {
model InternalUserNote { model InternalUserNote {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
/// Markdown
content String content String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@ -448,6 +466,23 @@ model InternalUserNote {
@@index([createdAt]) @@index([createdAt])
} }
model InternalServiceNote {
id Int @id @default(autoincrement())
/// Markdown
content String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
service Service @relation("ServiceRecievedNotes", fields: [serviceId], references: [id], onDelete: Cascade)
serviceId Int
addedByUser User? @relation("UserAddedServiceNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
addedByUserId Int?
@@index([serviceId])
@@index([addedByUserId])
@@index([createdAt])
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
@@ -457,7 +492,7 @@ model User {
spammer Boolean @default(false) spammer Boolean @default(false)
verified Boolean @default(false) verified Boolean @default(false)
admin Boolean @default(false) admin Boolean @default(false)
verifier Boolean @default(false) moderator Boolean @default(false)
verifiedLink String? verifiedLink String?
secretTokenHash String @unique secretTokenHash String @unique
/// Computed via trigger. Do not update through prisma. /// Computed via trigger. Do not update through prisma.
@@ -474,10 +509,12 @@ model User {
suggestionMessages ServiceSuggestionMessage[] suggestionMessages ServiceSuggestionMessage[]
internalNotes InternalUserNote[] @relation("UserRecievedNotes") internalNotes InternalUserNote[] @relation("UserRecievedNotes")
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes") addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
verificationRequests ServiceVerificationRequest[] verificationRequests ServiceVerificationRequest[]
notifications Notification[] @relation("NotificationOwner") notifications Notification[] @relation("NotificationOwner")
notificationPreferences NotificationPreferences? notificationPreferences NotificationPreferences?
serviceAffiliations ServiceUser[] @relation("UserServices") serviceAffiliations ServiceUser[] @relation("UserServices")
pushSubscriptions PushSubscription[]
@@index([createdAt]) @@index([createdAt])
@@index([totalKarma]) @@index([totalKarma])
@@ -522,6 +559,7 @@ model KarmaTransaction {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull) grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull)
grantedByUserId Int? grantedByUserId Int?
Notification Notification[]
@@index([createdAt]) @@index([createdAt])
@@index([userId]) @@index([userId])
@@ -613,9 +651,10 @@ model ServiceUser {
model Announcement { model Announcement {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String
content String content String
type AnnouncementType type AnnouncementType
link String?
linkText String?
startDate DateTime startDate DateTime
endDate DateTime? endDate DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -624,3 +663,21 @@ model Announcement {
@@index([isActive, startDate, endDate]) @@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,21 +1,26 @@
import crypto from 'crypto' import crypto from 'crypto'
import { execSync } from 'node:child_process'
import { parseArgs } from 'node:util'
import { faker } from '@faker-js/faker' import { faker } from '@faker-js/faker'
import { import {
AnnouncementType,
AttributeCategory, AttributeCategory,
AttributeType, AttributeType,
CommentStatus, CommentStatus,
Currency, Currency,
EventType,
PrismaClient, PrismaClient,
ServiceSuggestionStatus, ServiceSuggestionStatus,
ServiceSuggestionType, ServiceUserRole,
VerificationStatus, VerificationStatus,
type Prisma, type Prisma,
EventType,
type User, type User,
ServiceUserRole, type ServiceVisibility,
ServiceSuggestionType,
KycLevelClarification,
} from '@prisma/client' } from '@prisma/client'
import { uniqBy } from 'lodash-es' import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
import { kycLevels } from '../src/constants/kycLevels' import { kycLevels } from '../src/constants/kycLevels'
@@ -84,7 +89,7 @@ async function createAccount(preGeneratedToken?: string) {
verifiedLink, verifiedLink,
verified: !!verifiedLink, verified: !!verifiedLink,
admin: faker.datatype.boolean({ probability: 0.1 }), admin: faker.datatype.boolean({ probability: 0.1 }),
verifier: faker.datatype.boolean({ probability: 0.1 }), moderator: faker.datatype.boolean({ probability: 0.1 }),
}, },
include: { include: {
serviceAffiliations: true, serviceAffiliations: true,
@@ -94,20 +99,6 @@ async function createAccount(preGeneratedToken?: string) {
return { token, user } return { token, user }
} }
// Parse command line arguments
const args = process.argv.slice(2)
const shouldCleanup = args.includes('--cleanup') || args.includes('-c')
const onlyCleanup = args.includes('--only-cleanup') || args.includes('-oc')
// Parse number of services from --services or -s flag
const servicesArg = args.find((arg) => arg.startsWith('--services=') || arg.startsWith('-s='))
const numServices = parseIntWithFallback(servicesArg?.split('=')[1], 100) // Default to 100 if not specified
if (isNaN(numServices) || numServices < 1) {
console.error('❌ Invalid number of services specified. Must be a positive number.')
process.exit(1)
}
const prisma = new PrismaClient() const prisma = new PrismaClient()
const generateFakeAttribute = () => { const generateFakeAttribute = () => {
@@ -610,18 +601,35 @@ const generateFakeEvent = (serviceId: number) => {
} }
const generateFakeService = (users: User[]) => { const generateFakeService = (users: User[]) => {
const status = faker.helpers.arrayElement(Object.values(VerificationStatus)) const status = faker.helpers.weightedArrayElement<VerificationStatus>([
{ weight: 20, value: 'VERIFICATION_SUCCESS' },
{ weight: 30, value: 'APPROVED' },
{ weight: 40, value: 'COMMUNITY_CONTRIBUTED' },
{ weight: 10, value: 'VERIFICATION_FAILED' },
])
const name = faker.helpers.arrayElement(serviceNames) const name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
return { return {
name, name,
slug, slug,
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
description: faker.helpers.arrayElement(serviceDescriptions), description: faker.helpers.arrayElement(serviceDescriptions),
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)), 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, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
]),
verificationStatus: status, verificationStatus: status,
verificationSummary: verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -635,25 +643,37 @@ const generateFakeService = (users: User[]) => {
}, },
verificationProofMd: verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
referral: `?ref=${faker.string.alphanumeric(6)}`, referral: faker.helpers.arrayElement([
`?ref=${faker.string.alphanumeric(6)}`,
`/ref/${faker.string.alphanumeric(6)}`,
]),
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }), acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()), serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()), tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
onionUrls: Array.from( onionUrls: faker.helpers.multiple(
{ length: faker.number.int({ min: 0, max: 2 }) }, () => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion` { count: { min: 0, max: 2 } }
), ),
i2pUrls: Array.from( i2pUrls: faker.helpers.multiple(
{ length: faker.number.int({ min: 0, max: 2 }) }, () => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p` { count: { min: 0, max: 2 } }
), ),
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`, imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(), listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null, verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples), tosReview: faker.helpers.arrayElement(tosReviewExamples),
tosReviewAt: faker.date.past(), tosReviewAt: faker.date.past(),
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined, userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(), userSentimentAt: faker.date.recent(),
internalNotes: faker.helpers.maybe(
() => ({
create: {
content: faker.lorem.paragraph(),
addedByUserId: faker.helpers.arrayElement(users.filter((user) => user.admin)).id,
},
}),
{ probability: 0.33 }
),
} as const satisfies Prisma.ServiceCreateInput } as const satisfies Prisma.ServiceCreateInput
} }
@@ -844,40 +864,45 @@ const generateFakeComment = (userId: number, serviceId: number, parentId?: numbe
const generateFakeServiceContactMethod = (serviceId: number) => { const generateFakeServiceContactMethod = (serviceId: number) => {
const types = [ const types = [
{ {
label: 'Email',
value: `mailto:${faker.internet.email()}`, value: `mailto:${faker.internet.email()}`,
iconId: 'ri:mail-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Phone',
value: `tel:${faker.phone.number({ style: 'international' })}`, value: `tel:${faker.phone.number({ style: 'international' })}`,
iconId: 'ri:phone-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'WhatsApp',
value: `https://wa.me/${faker.phone.number({ style: 'international' })}`, value: `https://wa.me/${faker.phone.number({ style: 'international' })}`,
iconId: 'ri:whatsapp-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Telegram',
value: `https://t.me/${faker.internet.username()}`, value: `https://t.me/${faker.internet.username()}`,
iconId: 'ri:telegram-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Website', value: `https://x.com/${faker.internet.username()}`,
},
{
value: `https://matrix.to/#/@${faker.internet.username()}:${faker.internet.domainName()}`,
},
{
value: `https://instagram.com/${faker.internet.username()}`,
},
{
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
},
{
label: faker.lorem.word({ length: 2 }),
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
},
{
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
},
{
value: faker.internet.url(), value: faker.internet.url(),
iconId: 'ri:global-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'LinkedIn', label: faker.lorem.word({ length: 2 }),
value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`, value: faker.internet.url(),
iconId: 'ri:linkedin-box-line', },
info: faker.lorem.sentence(), {
value: `https://linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
}, },
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[] ] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
@@ -893,31 +918,31 @@ const specialUsersData = {
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN', envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
defaultToken: 'admin', defaultToken: 'admin',
admin: true, admin: true,
verifier: true, moderator: true,
verified: true, verified: true,
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
verifier: { moderator: {
name: 'verifier_dev', name: 'moderator_dev',
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN', envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
defaultToken: 'verifier', defaultToken: 'moderator',
admin: false, admin: false,
verifier: true, moderator: true,
verified: true, verified: true,
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
verified: { verified: {
name: 'verified_dev', name: 'verified_dev',
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN', envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
defaultToken: 'verified', defaultToken: 'verified',
admin: false, admin: false,
verifier: false, moderator: false,
verified: true, verified: true,
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
@@ -927,7 +952,7 @@ const specialUsersData = {
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN', envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
defaultToken: 'normal', defaultToken: 'normal',
admin: false, admin: false,
verifier: false, moderator: false,
verified: false, verified: false,
}, },
spam: { spam: {
@@ -935,7 +960,7 @@ const specialUsersData = {
envToken: 'DEV_SPAM_USER_SECRET_TOKEN', envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
defaultToken: 'spam', defaultToken: 'spam',
admin: false, admin: false,
verifier: false, moderator: false,
verified: false, verified: false,
totalKarma: -100, totalKarma: -100,
spammer: true, spammer: true,
@@ -981,353 +1006,394 @@ const generateFakeInternalNote = (userId: number, addedByUserId?: number) =>
addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined, addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined,
}) satisfies Prisma.InternalUserNoteCreateInput }) satisfies Prisma.InternalUserNoteCreateInput
async function runFaker() { const generateFakeAnnouncement = () => {
await prisma.$transaction( const type = faker.helpers.arrayElement(Object.values(AnnouncementType))
async (tx) => { const startDate = faker.date.past()
// ---- Clean up existing data if requested ---- const endDate = faker.helpers.maybe(() => faker.date.future(), { probability: 0.3 })
if (shouldCleanup || onlyCleanup) {
console.info('🧹 Cleaning up existing data...')
try { return {
await tx.commentVote.deleteMany() content: faker.lorem.sentence(),
await tx.karmaTransaction.deleteMany() type,
await tx.comment.deleteMany() link: faker.internet.url(),
await tx.serviceAttribute.deleteMany() linkText: faker.lorem.word({ length: 2 }),
await tx.serviceContactMethod.deleteMany() startDate,
await tx.event.deleteMany() endDate,
await tx.verificationStep.deleteMany() isActive: true,
await tx.serviceSuggestionMessage.deleteMany() } as const satisfies Prisma.AnnouncementCreateInput
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()
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])
),
})
)
)
})
)
},
{
timeout: 1000 * 60 * 10, // 10 minutes
}
)
} }
async function main() { async function cleanup() {
try { console.info('🧹 Cleaning up existing data...')
await runFaker()
console.info('✅ Fake data generated successfully') try {
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) { } catch (error) {
console.error('❌ Error generating fake data:', error) console.error('❌ Error cleaning up data:', error)
process.exit(1) throw error
} finally {
await prisma.$disconnect()
} }
} }
main().catch((error: unknown) => { function importTriggers() {
console.error('❌ Fatal error:', error) console.info('🔄 Importing SQL triggers...')
process.exit(1) try {
}) execSync('just import-triggers', { stdio: 'inherit' })
console.info('✅ Triggers imported')
} catch (error) {
console.error('❌ Error importing triggers:', error)
throw error
}
}
async function main() {
const { values: options } = parseArgs({
options: {
services: { type: 'string', short: 's', default: '100' },
cleanup: { type: 'boolean', short: 'c', default: true },
'only-cleanup': { type: 'boolean', short: 'o', default: false },
},
})
const numServices = parseIntWithFallback(options.services, 100)
if (isNaN(numServices) || numServices < 1) {
console.error('❌ Invalid number of services specified. Must be a positive number.')
process.exit(1)
}
importTriggers()
if (options.cleanup || options['only-cleanup']) {
await cleanup()
if (options['only-cleanup']) return
}
// ---- Get or create categories ----
const categories = await Promise.all(
categoriesToCreate.map(async (cat) => {
const existing = await prisma.category.findUnique({
where: { name: cat.name },
})
if (existing) return existing
return await prisma.category.create({
data: cat,
})
})
)
// ---- Create users ----
const specialUsersUntyped = Object.fromEntries(
await Promise.all(
Object.entries(specialUsersData).map(async ([key, userData]) => {
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
const secretTokenHash = hashUserSecretToken(secretToken)
const { envToken, defaultToken, ...userCreateData } = userData
const user = await prisma.user.create({
data: {
notificationPreferences: { create: {} },
...userCreateData,
secretTokenHash,
},
})
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
return [key, user] as const
})
)
)
const specialUsers = specialUsersUntyped as {
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
}
let users = await Promise.all(
Array.from({ length: 10 }, async () => {
const { user } = await createAccount()
return user
})
)
// ---- Create attributes ----
const attributes = await Promise.all(
Array.from({ length: 16 }, async () => {
return await prisma.attribute.create({
data: generateFakeAttribute(),
})
})
)
// ---- Create services ----
const services = await Promise.all(
Array.from({ length: numServices }, async () => {
const serviceData = generateFakeService([...users, ...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

@@ -25,12 +25,12 @@ BEGIN
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change); VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
END IF; END IF;
-- Check for verifier status change -- Check for moderator status change
IF OLD.verifier IS DISTINCT FROM NEW.verifier THEN IF OLD.moderator IS DISTINCT FROM NEW.moderator THEN
IF NEW.verifier = true THEN IF NEW.moderator = true THEN
status_change := 'VERIFIER_TRUE'; status_change := 'MODERATOR_TRUE';
ELSE ELSE
status_change := 'VERIFIER_FALSE'; status_change := 'MODERATOR_FALSE';
END IF; END IF;
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange") INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change); VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
@@ -57,6 +57,6 @@ DROP TRIGGER IF EXISTS user_status_change_notifications_trigger ON "User";
-- Create the trigger to fire after updates on specific status columns -- Create the trigger to fire after updates on specific status columns
CREATE TRIGGER user_status_change_notifications_trigger CREATE TRIGGER user_status_change_notifications_trigger
AFTER UPDATE OF admin, verified, verifier, spammer ON "User" AFTER UPDATE OF admin, verified, moderator, spammer ON "User"
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION trigger_user_status_change_notifications(); EXECUTE FUNCTION trigger_user_status_change_notifications();

View File

@@ -0,0 +1,29 @@
CREATE OR REPLACE FUNCTION trigger_karma_notifications()
RETURNS TRIGGER AS $$
BEGIN
-- Only create notification if the user has enabled karma notifications
-- and the karma change exceeds their threshold
INSERT INTO "Notification" ("userId", "type", "aboutKarmaTransactionId")
SELECT NEW."userId", 'KARMA_CHANGE', NEW.id
FROM "NotificationPreferences" np
WHERE np."userId" = NEW."userId"
AND ABS(NEW.points) >= COALESCE(np."karmaNotificationThreshold", 10)
AND NOT EXISTS (
SELECT 1 FROM "Notification" n
WHERE n."userId" = NEW."userId"
AND n."type" = 'KARMA_CHANGE'
AND n."aboutKarmaTransactionId" = NEW.id
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the trigger if it exists to ensure a clean setup
DROP TRIGGER IF EXISTS karma_notifications_trigger ON "KarmaTransaction";
-- Create the trigger to fire after inserts
CREATE TRIGGER karma_notifications_trigger
AFTER INSERT ON "KarmaTransaction"
FOR EACH ROW
EXECUTE FUNCTION trigger_karma_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();

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 692 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

83
web/public/sw.js Normal file
View File

@@ -0,0 +1,83 @@
// @ts-check
/// <reference lib="webworker" />
/** @type {ServiceWorkerGlobalScope} */
// @ts-expect-error
const typedSelf = self
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
typedSelf.addEventListener('install', (event) => {
console.log('Service Worker installing')
typedSelf.skipWaiting()
})
typedSelf.addEventListener('activate', (event) => {
console.log('Service Worker activating')
event.waitUntil(typedSelf.clients.claim())
})
typedSelf.addEventListener('push', (event) => {
console.log('Push event received:', event)
if (!event.data) {
console.log('Push event but no data')
return
}
let notificationData
try {
notificationData = event.data.json()
} catch (error) {
console.error('Error parsing push data:', error)
notificationData = {
title: 'New Notification',
options: {
body: event.data.text() || 'You have a new notification',
},
}
}
const { title, options } = notificationData
const notificationOptions = {
body: options.body || '',
icon: options.icon || '/favicon.svg',
badge: options.badge || '/favicon.svg',
data: options.data || {},
requireInteraction: false,
silent: false,
...options,
}
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
})
typedSelf.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event)
event.notification.close()
const url = event.notification.data?.url || '/'
event.waitUntil(
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
// If a window is already open, focus it
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus()
}
}
// Otherwise, open a new window
if (typedSelf.clients.openWindow) {
return typedSelf.clients.openWindow(url)
}
})
)
})
typedSelf.addEventListener('notificationclose', (event) => {
console.log('Notification closed:', event)
})

View File

@@ -151,15 +151,10 @@ export const accountActions = {
permissions: 'user', permissions: 'user',
input: z.object({ input: z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
displayName: z.string().max(100, 'Display name must be 100 characters or less').optional().nullable(), displayName: z.string().max(100, 'Display name must be 100 characters or less').nullable(),
link: z link: z.string().url('Must be a valid URL').max(255, 'URL must be 255 characters or less').nullable(),
.string()
.url('Must be a valid URL')
.max(255, 'URL must be 255 characters or less')
.optional()
.nullable(),
pictureFile: imageFileSchema, pictureFile: imageFileSchema,
removePicture: z.coerce.boolean().default(false), removePicture: z.coerce.boolean(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
if (input.id !== context.locals.user.id) { if (input.id !== context.locals.user.id) {
@@ -170,7 +165,7 @@ export const accountActions = {
} }
if ( if (
input.displayName !== undefined && input.displayName !== null &&
input.displayName !== context.locals.user.displayName && input.displayName !== context.locals.user.displayName &&
!context.locals.user.karmaUnlocks.displayName !context.locals.user.karmaUnlocks.displayName
) { ) {
@@ -181,7 +176,7 @@ export const accountActions = {
} }
if ( if (
input.link !== undefined && input.link !== null &&
input.link !== context.locals.user.link && input.link !== context.locals.user.link &&
!context.locals.user.karmaUnlocks.websiteLink !context.locals.user.karmaUnlocks.websiteLink
) { ) {
@@ -198,6 +193,13 @@ export const accountActions = {
}) })
} }
if (input.removePicture && !context.locals.user.karmaUnlocks.profilePicture) {
throw new ActionError({
code: 'FORBIDDEN',
message: makeKarmaUnlockMessage(karmaUnlocksById.profilePicture),
})
}
const pictureUrl = const pictureUrl =
input.pictureFile && input.pictureFile.size > 0 input.pictureFile && input.pictureFile.size > 0
? await saveFileLocally( ? await saveFileLocally(
@@ -210,9 +212,13 @@ export const accountActions = {
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { id: context.locals.user.id }, where: { id: context.locals.user.id },
data: { data: {
displayName: input.displayName ?? null, displayName: context.locals.user.karmaUnlocks.displayName ? (input.displayName ?? null) : undefined,
link: input.link ?? null, link: context.locals.user.karmaUnlocks.websiteLink ? (input.link ?? null) : undefined,
picture: input.removePicture ? null : (pictureUrl ?? undefined), picture: context.locals.user.karmaUnlocks.profilePicture
? input.removePicture
? null
: (pictureUrl ?? undefined)
: undefined,
}, },
}) })

View File

@@ -1,17 +1,16 @@
import { type Prisma, type PrismaClient, type AnnouncementType } from '@prisma/client' import { type Prisma } from '@prisma/client'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { z } from 'zod' import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma as prismaInstance } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
const selectAnnouncementReturnFields = { const selectAnnouncementReturnFields = {
id: true, id: true,
title: true,
content: true, content: true,
type: true, type: true,
link: true,
linkText: true,
startDate: true, startDate: true,
endDate: true, endDate: true,
isActive: true, isActive: true,
@@ -24,12 +23,18 @@ export const adminAnnouncementActions = {
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
content: z content: z
.string() .string()
.min(1, 'Content is required') .min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'), .max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']), type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(), endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true), isActive: z.coerce.boolean().default(true),
@@ -37,8 +42,13 @@ export const adminAnnouncementActions = {
handler: async (input) => { handler: async (input) => {
const announcement = await prisma.announcement.create({ const announcement = await prisma.announcement.create({
data: { data: {
...input, content: input.content,
endDate: input.endDate || null, type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
}, },
select: selectAnnouncementReturnFields, select: selectAnnouncementReturnFields,
}) })
@@ -52,12 +62,18 @@ export const adminAnnouncementActions = {
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
content: z content: z
.string() .string()
.min(1, 'Content is required') .min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'), .max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']), type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(), endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true), isActive: z.coerce.boolean().default(true),
@@ -82,8 +98,13 @@ export const adminAnnouncementActions = {
const updatedAnnouncement = await prisma.announcement.update({ const updatedAnnouncement = await prisma.announcement.update({
where: { id: announcement.id }, where: { id: announcement.id },
data: { data: {
...input, content: input.content,
endDate: input.endDate || null, type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
}, },
select: selectAnnouncementReturnFields, select: selectAnnouncementReturnFields,
}) })

View File

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

View File

@@ -0,0 +1,80 @@
import { z } from 'astro/zod'
import { sumBy } from 'lodash-es'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { sendPushNotification } from '../../lib/webPush'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
webPush: {
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
title: z.string().min(1).nullable(),
body: z.string().nullable(),
url: z.string().url().optional(),
}),
handler: async (input) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { user: { name: { in: input.userNames } } },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
userAgent: true,
user: {
select: {
id: true,
name: true,
},
},
},
})
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
{
title: input.title ?? 'Test Notification',
body: input.body ?? 'This is a test push notification from KYCNot.me',
url: input.url ?? '/',
}
)
// If subscription is invalid, remove it from database
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({
where: { id: subscription.id },
})
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
}
return result.success
})
)
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
const now = new Date()
return {
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
totalSubscriptions: subscriptions.length,
successCount,
failureCount,
sentAt: now,
}
},
}),
},
}

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 { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
import slugify from 'slugify' import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage' import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { separateServiceUrlsByType } from '../../lib/urls'
import { import {
imageFileSchema, imageFileSchema,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired, stringListOfUrlsSchemaRequired,
zodCohercedNumber, zodCohercedNumber,
zodContactMethod,
} from '../../lib/zodUtils' } from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number(),
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 = < const addSlugIfMissing = <
T extends { T extends {
slug?: string | null | undefined slug?: string | null | undefined
@@ -56,21 +34,61 @@ const addSlugIfMissing = <
}), }),
}) })
const contactMethodSchema = z.object({ const serviceSchemaBase = z.object({
id: z.number().optional(), id: z.number().int().positive(),
label: z.string().min(1).max(50), slug: z
value: z.string().min(1).max(200), .string()
iconId: z.string().min(1).max(50), .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
info: z.string().max(200).optional().default(''), .optional(),
serviceId: z.number(), 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 = { export const adminServiceActions = {
create: defineProtectedAction({ create: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing), input: createServiceInputSchema,
handler: async (input) => { handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
const existing = await prisma.service.findUnique({ const existing = await prisma.service.findUnique({
where: { where: {
slug: input.slug, slug: input.slug,
@@ -84,12 +102,34 @@ export const adminServiceActions = {
}) })
} }
const { imageFile, ...serviceData } = input const imageUrl = input.imageFile
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined ? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({ const service = await prisma.service.create({
data: { data: {
...serviceData, name: input.name,
description: input.description,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
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: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),
}, },
@@ -101,6 +141,14 @@ export const adminServiceActions = {
})), })),
}, },
imageUrl, imageUrl,
internalNotes: input.internalNote
? {
create: {
content: input.internalNote,
addedByUserId: context.locals.user.id,
},
}
: undefined,
}, },
select: { select: {
id: true, id: true,
@@ -115,34 +163,40 @@ export const adminServiceActions = {
update: defineProtectedAction({ update: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: serviceSchemaBase.transform(addSlugIfMissing), input: updateServiceInputSchema,
handler: async (input) => { handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
const { id, categories, attributes, imageFile, ...data } = input const anotherServiceWithNewSlug = await prisma.service.findUnique({
const existing = await prisma.service.findUnique({
where: { where: {
slug: input.slug, slug: input.slug,
NOT: { id }, NOT: { id: input.id },
}, },
}) })
if (existing) { if (anotherServiceWithNewSlug) {
throw new ActionError({ throw new ActionError({
code: 'CONFLICT', code: 'CONFLICT',
message: 'A service with this slug already exists', message: 'A service with this slug already exists',
}) })
} }
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
// Get existing attributes and categories to compute differences
const existingService = await prisma.service.findUnique({ const existingService = await prisma.service.findUnique({
where: { id }, where: { id: input.id },
include: { select: {
categories: true, slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
attributes: { attributes: {
include: { select: {
attribute: true, attributeId: true,
attribute: {
select: {
id: true,
},
},
}, },
}, },
}, },
@@ -155,80 +209,259 @@ export const adminServiceActions = {
}) })
} }
// Find categories to connect and disconnect
const existingCategoryIds = existingService.categories.map((c) => c.id) const existingCategoryIds = existingService.categories.map((c) => c.id)
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId)) const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId)) const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId))
// Find attributes to connect and disconnect
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId) const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId)) const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId)) const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const 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({ const service = await prisma.service.update({
where: { id }, where: { id: input.id },
data: { data: {
...data, name: input.name,
description: input.description,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
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, imageUrl,
categories: { categories: {
connect: categoriesToAdd.map((id) => ({ id })), connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })), disconnect: categoriesToRemove.map((id) => ({ id })),
}, },
attributes: { attributes: {
// Connect new attributes
create: attributesToAdd.map((attributeId) => ({ create: attributesToAdd.map((attributeId) => ({
attribute: { attribute: {
connect: { id: attributeId }, connect: { id: attributeId },
}, },
})), })),
// Delete specific attributes that are no longer needed
deleteMany: attributesToRemove.map((attributeId) => ({ deleteMany: attributesToRemove.map((attributeId) => ({
attributeId, attributeId,
})), })),
}, },
}, },
}) })
return { service } return { service }
}, },
}), }),
createContactMethod: defineProtectedAction({ contactMethod: {
accept: 'form', add: defineProtectedAction({
permissions: 'admin', accept: 'form',
input: contactMethodSchema.omit({ id: true }), permissions: 'admin',
handler: async (input) => { input: z.object({
const contactMethod = await prisma.serviceContactMethod.create({ label: z.string().min(1).max(50).nullable(),
data: input, value: zodContactMethod,
}) serviceId: z.number().int().positive(),
return { contactMethod } }),
}, handler: async (input) => {
}), const contactMethod = await prisma.serviceContactMethod.create({
data: {
updateContactMethod: defineProtectedAction({ label: input.label,
accept: 'form', value: input.value,
permissions: 'admin', serviceId: input.serviceId,
input: contactMethodSchema, },
handler: async (input) => { })
const { id, ...data } = input return { contactMethod }
const contactMethod = await prisma.serviceContactMethod.update({ },
where: { id },
data,
})
return { contactMethod }
},
}),
deleteContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number(),
}), }),
handler: async (input) => {
await prisma.serviceContactMethod.delete({ update: defineProtectedAction({
where: { id: input.id }, accept: 'form',
}) permissions: 'admin',
return { success: true } input: z.object({
}, id: z.number().int().positive(),
}), label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
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 }
},
}),
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 }
},
}),
},
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) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},
} }

View File

@@ -16,7 +16,7 @@ const selectUserReturnFields = {
picture: true, picture: true,
admin: true, admin: true,
verified: true, verified: true,
verifier: true, moderator: true,
verifiedLink: true, verifiedLink: true,
secretTokenHash: true, secretTokenHash: true,
totalKarma: true, totalKarma: true,
@@ -55,7 +55,7 @@ export const adminUserActions = {
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing .default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null), .transform((val) => val || null),
pictureFile: z.instanceof(File).optional(), pictureFile: z.instanceof(File).optional(),
type: z.array(z.enum(['admin', 'verifier', 'spammer'])), type: z.array(z.enum(['admin', 'moderator', 'spammer'])),
verifiedLink: z verifiedLink: z
.string() .string()
.url('Invalid URL') .url('Invalid URL')
@@ -101,7 +101,7 @@ export const adminUserActions = {
verified: !!valuesToUpdate.verifiedLink, verified: !!valuesToUpdate.verifiedLink,
picture: pictureUrl, picture: pictureUrl,
admin: type.includes('admin'), admin: type.includes('admin'),
verifier: type.includes('verifier'), moderator: type.includes('moderator'),
spammer: type.includes('spammer'), spammer: type.includes('spammer'),
}, },
select: selectUserReturnFields, select: selectUserReturnFields,

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

@@ -14,9 +14,9 @@ import { timeTrapSecretKey } from '../lib/timeTrapSecret'
import type { CommentStatus, Prisma } from '@prisma/client' import type { CommentStatus, Prisma } from '@prisma/client'
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 5 const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
const MAX_COMMENTS_PER_WINDOW = 1 const MAX_COMMENTS_PER_WINDOW = 1
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 5 const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
export const commentActions = { export const commentActions = {
vote: defineProtectedAction({ vote: defineProtectedAction({
@@ -331,7 +331,7 @@ export const commentActions = {
}), }),
moderate: defineProtectedAction({ moderate: defineProtectedAction({
permissions: ['admin', 'verifier'], permissions: ['admin', 'moderator'],
input: z.object({ input: z.object({
commentId: z.number(), commentId: z.number(),
userId: z.number(), userId: z.number(),

View File

@@ -1,5 +1,6 @@
import { accountActions } from './account' import { accountActions } from './account'
import { adminActions } from './admin' import { adminActions } from './admin'
import { apiActions } from './api'
import { commentActions } from './comment' import { commentActions } from './comment'
import { notificationActions } from './notifications' import { notificationActions } from './notifications'
import { serviceActions } from './service' import { serviceActions } from './service'
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
export const server = { export const server = {
account: accountActions, account: accountActions,
admin: adminActions, admin: adminActions,
api: apiActions,
comment: commentActions, comment: commentActions,
notification: notificationActions, notification: notificationActions,
service: serviceActions, 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: { preferences: {
update: defineProtectedAction({ update: defineProtectedAction({
accept: 'form', accept: 'form',
@@ -31,6 +88,7 @@ export const notificationActions = {
enableOnMyCommentStatusChange: z.coerce.boolean().optional(), enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
enableAutowatchMyComments: z.coerce.boolean().optional(), enableAutowatchMyComments: z.coerce.boolean().optional(),
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(), enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
await prisma.notificationPreferences.upsert({ await prisma.notificationPreferences.upsert({
@@ -39,12 +97,14 @@ export const notificationActions = {
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange, enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
enableAutowatchMyComments: input.enableAutowatchMyComments, enableAutowatchMyComments: input.enableAutowatchMyComments,
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch, enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
karmaNotificationThreshold: input.karmaNotificationThreshold,
}, },
create: { create: {
userId: context.locals.user.id, userId: context.locals.user.id,
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange, enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
enableAutowatchMyComments: input.enableAutowatchMyComments, enableAutowatchMyComments: input.enableAutowatchMyComments,
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch, enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
karmaNotificationThreshold: input.karmaNotificationThreshold,
}, },
}) })
}, },

View File

@@ -1,10 +1,4 @@
import { import { Currency, KycLevelClarification } from '@prisma/client'
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns' import { formatDistanceStrict } from 'date-fns'
@@ -12,11 +6,13 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation' import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { separateServiceUrlsByType } from '../lib/urls'
import { import {
imageFileSchemaRequired, imageFileSchemaRequired,
stringListOfUrlsSchema, stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired, stringListOfUrlsSchemaRequired,
zodCohercedNumber, zodCohercedNumber,
} from '../lib/zodUtils' } from '../lib/zodUtils'
@@ -33,11 +29,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000 export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => { 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: { where: {
name: { id: {
contains: input.name, in: matches.map(({ id }) => id),
mode: 'insensitive',
}, },
}, },
select: { select: {
@@ -47,8 +44,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true, description: true,
}, },
}) })
return possibleDuplicates
} }
const serializeExtraNotes = <T extends Record<string, unknown>>( const serializeExtraNotes = <T extends Record<string, unknown>>(
@@ -122,9 +117,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({ const serviceSuggestion = await prisma.serviceSuggestion.create({
data: { data: {
type: ServiceSuggestionType.EDIT_SERVICE, type: 'EDIT_SERVICE',
notes: combinedNotes, notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },
@@ -161,10 +156,11 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' } { message: 'Slug must be unique, try a different one' }
), ),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH), description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema, contactMethods: stringListOfContactMethodsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)), kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
kycLevelClarification: z.nativeEnum(KycLevelClarification),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), categories: z.array(z.coerce.number().int().positive()).min(1),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1), acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
@@ -210,6 +206,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name) const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => { const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = { const serviceSelect = {
id: true, id: true,
@@ -221,18 +223,20 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
imageUrl, imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED, verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
listedAt: new Date(), listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED, serviceVisibility: 'UNLISTED',
categories: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),
}, },
@@ -241,6 +245,11 @@ export const serviceSuggestionActions = {
attributeId: id, attributeId: id,
})), })),
}, },
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
}, },
select: serviceSelect, select: serviceSelect,
}) })
@@ -248,8 +257,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({ const serviceSuggestion = await tx.serviceSuggestion.create({
data: { data: {
notes: input.notes, notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE, type: 'CREATE_SERVICE',
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },

View File

@@ -1,57 +1,92 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { getAnnouncementTypeInfo } from '../constants/announcementTypes' import { getAnnouncementTypeInfo } from '../constants/announcementTypes'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = { type Props = HTMLAttributes<'div'> & {
announcements: announcement: Prisma.AnnouncementGetPayload<{
| Prisma.AnnouncementGetPayload<{ select: {
select: { id: true
id: true content: true
title: true type: true
content: true link: true
type: true linkText: true
startDate: true startDate: true
endDate: true endDate: true
isActive: true isActive: true
} }
}>[] }>
| null
| undefined
} }
const { announcements } = Astro.props const { announcement, class: className, ...props } = Astro.props
const typeInfo = getAnnouncementTypeInfo(announcement.type)
const Tag = announcement.link ? 'a' : 'div'
--- ---
{ <Tag
!!announcements && announcements.length > 0 && ( href={announcement.link}
<div class="mb-4 flex flex-col items-center space-y-1"> target={announcement.link ? '_blank' : undefined}
{announcements.map((announcement) => { rel="noopener noreferrer"
const typeInfo = getAnnouncementTypeInfo(announcement.type) class={cn(
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
return ( className
<div )}
class={cn( {...props}
'mx-auto flex w-auto max-w-full flex-row items-center rounded border px-3 py-2', >
typeInfo.classNames.container <div
)} aria-hidden="true"
> class="pointer-events-none absolute top-1/2 left-[max(-7rem,calc(50%-52rem))] -z-10 -translate-y-1/2 transform-gpu blur-2xl"
<Icon name={typeInfo.icon} class={cn('mr-2 size-4 flex-shrink-0', typeInfo.classNames.title)} /> >
<div class="flex min-w-0 flex-col"> <div
<span class={cn('truncate text-sm leading-tight font-bold', typeInfo.classNames.title)}> class={cn(
{announcement.title} 'aspect-[577/310] w-[36.0625rem] bg-gradient-to-r from-green-500 to-green-700 opacity-20',
</span> typeInfo.classNames.bg
<span class={cn('truncate text-xs leading-snug [&_a]:underline', typeInfo.classNames.content)}> )}
<Markdown content={announcement.content} /> style="clip-path:polygon(74.8% 41.9%, 97.2% 73.2%, 100% 34.9%, 92.5% 0.4%, 87.5% 0%, 75% 28.6%, 58.5% 54.6%, 50.1% 56.8%, 46.9% 44%, 48.3% 17.4%, 24.7% 53.9%, 0% 27.9%, 11.9% 74.2%, 24.9% 54.1%, 68.6% 100%, 74.8% 41.9%)"
</span> >
</div>
</div>
)
})}
</div> </div>
) </div>
} <div
aria-hidden="true"
class="pointer-events-none absolute top-1/2 left-[max(45rem,calc(50%+8rem))] -z-10 -translate-y-1/2 transform-gpu blur-2xl"
>
<div
class={cn(
'aspect-[577/310] w-[36.0625rem] bg-gradient-to-r from-green-500 to-green-700 opacity-30',
typeInfo.classNames.bg
)}
style="clip-path:polygon(74.8% 41.9%, 97.2% 73.2%, 100% 34.9%, 92.5% 0.4%, 87.5% 0%, 75% 28.6%, 58.5% 54.6%, 50.1% 56.8%, 46.9% 44%, 48.3% 17.4%, 24.7% 53.9%, 0% 27.9%, 11.9% 74.2%, 24.9% 54.1%, 68.6% 100%, 74.8% 41.9%)"
>
</div>
</div>
<div class={cn('flex items-center justify-between gap-x-3 md:justify-center', typeInfo.classNames.icon)}>
<Icon name={typeInfo.icon} class={cn('size-5 flex-shrink-0')} />
<span
class={cn(
'font-title animate-text-gradient line-clamp-3 bg-[linear-gradient(90deg,var(--gradient-edge,#FFEBF9)_0%,var(--gradient-center,#8a56cc)_50%,var(--gradient-edge,#FFEBF9)_100%)] bg-size-[200%] bg-clip-text text-sm leading-tight text-pretty text-transparent [&_a]:underline',
typeInfo.classNames.content
)}
>
{announcement.content}
</span>
</div>
<div
class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]"
>
<span class="2xs:inline-block hidden">
{announcement.linkText}
</span>
<Icon
name="ri:arrow-right-line"
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</div>
</Tag>

View File

@@ -2,13 +2,14 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants' import { tv, type VariantProps } from 'tailwind-variants'
import type { AstroChildren } from '../lib/astro'
import type { Polymorphic } from 'astro/types' import type { Polymorphic } from 'astro/types'
const badge = tv({ const badge = tv({
slots: { slots: {
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium', 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', 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: { variants: {
color: { 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> & { VariantProps<typeof badge> & {
as: Tag as: Tag
icon?: string icon?: string
text: string endIcon?: string
text?: Text
inlineIcon?: boolean inlineIcon?: boolean
classNames?: { classNames?: {
icon?: string icon?: string
text?: string text?: string
endIcon?: string
} }
children?: Text extends string ? never : AstroChildren
} }
> >
const { const {
as: Tag = 'div', as: Tag = 'div',
icon: iconName, icon: iconName,
endIcon: endIconName,
text: textContent, text: textContent,
inlineIcon, inlineIcon,
classNames, 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} /> <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> </Tag>

View File

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

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,13 +2,15 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants' import { tv, type VariantProps } from 'tailwind-variants'
import { cn } from '../lib/cn'
import type { HTMLAttributes, Polymorphic } from 'astro/types' import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic< type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> & Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & { VariantProps<typeof button> & {
as: Tag as: Tag
label?: string label: string
icon?: string icon?: string
endIcon?: string endIcon?: string
classNames?: { classNames?: {
@@ -19,6 +21,7 @@ type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
dataAstroReload?: boolean dataAstroReload?: boolean
children?: never children?: never
disabled?: boolean disabled?: boolean
inlineIcon?: boolean
} }
> >
@@ -26,7 +29,7 @@ export type ButtonProps<Tag extends 'a' | 'button' | 'label' = 'button'> = Props
const button = tv({ const button = tv({
slots: { slots: {
base: 'inline-flex items-center justify-center gap-2 rounded-lg border transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-hidden', base: 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-hidden',
icon: 'size-4 shrink-0', icon: 'size-4 shrink-0',
label: 'text-left whitespace-nowrap', label: 'text-left whitespace-nowrap',
endIcon: 'size-4 shrink-0', endIcon: 'size-4 shrink-0',
@@ -51,28 +54,24 @@ const button = tv({
label: 'font-bold tracking-wider uppercase', label: 'font-bold tracking-wider uppercase',
}, },
}, },
iconOnly: {
true: {
base: 'p-0',
label: 'sr-only',
},
},
color: { color: {
black: { black: '',
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white', white: '',
}, gray: '',
white: { success: '',
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500', danger: '',
}, warning: '',
gray: { info: '',
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white', },
}, variant: {
success: { solid: '',
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600', faded: '',
},
error: {
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
},
warning: {
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
},
info: {
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
},
}, },
shadow: { shadow: {
true: { true: {
@@ -86,6 +85,107 @@ const button = tv({
}, },
}, },
compoundVariants: [ compoundVariants: [
// Color variants - solid
{
color: 'black',
variant: 'solid',
class: {
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
},
},
{
color: 'white',
variant: 'solid',
class: {
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
},
},
{
color: 'gray',
variant: 'solid',
class: {
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
},
},
{
color: 'success',
variant: 'solid',
class: {
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
},
},
{
color: 'danger',
variant: 'solid',
class: {
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
},
},
{
color: 'warning',
variant: 'solid',
class: {
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
},
},
{
color: 'info',
variant: 'solid',
class: {
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
},
},
// Color variants - faded
{
color: 'black',
variant: 'faded',
class: {
base: 'bg2night-800/15 hover:bg-night-700/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
},
},
{
color: 'white',
variant: 'faded',
class: {
base: 'b2-day-100/15 hover:bg-day-200/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
},
},
{
color: 'gray',
variant: 'faded',
class: {
base: 'b2-day-400/15 hover:bg-day-500/30 text-day-300 hover:text-day-100 border-current/30 focus-visible:ring-white/50',
},
},
{
color: 'success',
variant: 'faded',
class: {
base: 'border-current/20 bg-green-500/15 text-green-300 hover:bg-green-500/30 hover:text-green-100',
},
},
{
color: 'danger',
variant: 'faded',
class: {
base: 'border-current/20 bg-red-500/15 text-red-300 hover:bg-red-500/30 hover:text-red-100',
},
},
{
color: 'warning',
variant: 'faded',
class: {
base: 'border-current/20 bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/30 hover:text-yellow-100',
},
},
{
color: 'info',
variant: 'faded',
class: {
base: 'border-current/20 bg-blue-500/15 text-blue-300 hover:bg-blue-500/30 hover:text-blue-100',
},
},
// Shadow variants
{ {
color: 'black', color: 'black',
shadow: true, shadow: true,
@@ -107,7 +207,7 @@ const button = tv({
class: 'shadow-green-500/30', class: 'shadow-green-500/30',
}, },
{ {
color: 'error', color: 'danger',
shadow: true, shadow: true,
class: 'shadow-red-500/30', class: 'shadow-red-500/30',
}, },
@@ -121,28 +221,49 @@ const button = tv({
shadow: true, shadow: true,
class: 'shadow-blue-500/30', class: 'shadow-blue-500/30',
}, },
// Icon only variants
{
iconOnly: true,
size: 'sm',
class: 'w-8',
},
{
iconOnly: true,
size: 'md',
class: 'w-9',
},
{
iconOnly: true,
size: 'lg',
class: 'w-10',
},
], ],
defaultVariants: { defaultVariants: {
size: 'md', size: 'md',
color: 'black', color: 'black',
variant: 'solid',
shadow: false, shadow: false,
disabled: false, disabled: false,
iconOnly: false,
}, },
}) })
const { const {
as: Tag = 'button' as 'a' | 'button' | 'label', as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
label, label,
icon, icon,
endIcon, endIcon,
size, size,
color, color,
variant,
shadow, shadow,
class: className, class: className,
classNames, classNames,
role, role,
dataAstroReload, dataAstroReload,
disabled, disabled,
inlineIcon,
iconOnly,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
@@ -151,24 +272,30 @@ const {
icon: iconSlot, icon: iconSlot,
label: labelSlot, label: labelSlot,
endIcon: endIconSlot, endIcon: endIconSlot,
} = button({ size, color, shadow, disabled }) } = button({
size,
color,
variant,
shadow,
disabled,
iconOnly: iconOnly ?? (!label && !(!!icon && !!endIcon)),
})
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
--- ---
<ActualTag <ActualTag
class={base({ class: className })} class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ?? role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
aria-disabled={disabled} aria-disabled={disabled}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }} {...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps} {...htmlProps}
> >
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} />} {!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>} <span class={labelSlot({ class: classNames?.label })}>{label}</span>
{ {
!!endIcon && ( !!endIcon && (
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })}> <Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>
{endIcon} {endIcon}
</Icon> </Icon>
) )

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions' import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions'
import { Image } from 'astro:assets'
import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha' import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'

View File

@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
/> />
<Tooltip text="Send"> <Tooltip text="Send">
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" /> <Button
type="submit"
icon="ri:send-plane-fill"
size="lg"
color="success"
class="h-16"
label="Send"
iconOnly
/>
</Tooltip> </Tooltip>
</form> </form>
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>} {!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}

View File

@@ -2,7 +2,7 @@
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { formatDateShort } from '../lib/timeAgo' import { formatDateShort } from '../lib/timeAgo'
import MyPicture from './MyPicture.astro' import UserBadge from './UserBadge.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
@@ -15,6 +15,7 @@ export type ChatMessage = {
select: { select: {
id: true id: true
name: true name: true
displayName: true
picture: true picture: true
} }
}> }>
@@ -71,31 +72,19 @@ const { messages, userId, class: className, ...htmlProps } = Astro.props
)} )}
> >
{!isCurrentUser && !isNextFromSameUser && ( {!isCurrentUser && !isNextFromSameUser && (
<p class="text-day-500 mb-0.5 text-xs"> <UserBadge user={message.user} size="sm" class="text-day-500 mb-0.5 text-xs" />
{!!message.user.picture && (
<MyPicture
src={message.user.picture}
height={16}
width={16}
class="inline-block rounded-full align-[-0.33em]"
alt=""
/>
)}
{message.user.name}
</p>
)} )}
<p <p
class={cn( class={cn(
'rounded-xl p-3 text-sm whitespace-pre-wrap', 'rounded-xl p-3 text-sm wrap-anywhere whitespace-pre-wrap',
isCurrentUser ? 'bg-blue-900 text-white' : 'bg-night-500 text-day-300', isCurrentUser ? 'bg-blue-900 text-white' : 'bg-night-500 text-day-300',
isCurrentUser ? 'rounded-br-xs' : 'rounded-bl-xs', isCurrentUser ? 'rounded-br-xs' : 'rounded-bl-xs',
isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tr-xs', isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tr-xs',
!isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tl-xs' !isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tl-xs'
)} )}
id={`message-${message.id.toString()}`} id={`message-${message.id.toString()}`}
> set:text={message.content}
{message.content} />
</p>
{(!isPrevFromSameUser || !isPrevSameDate) && ( {(!isPrevFromSameUser || !isPrevSameDate) && (
<p class="text-day-500 mt-0.5 mb-2 text-xs">{message.formattedCreatedAt}</p> <p class="text-day-500 mt-0.5 mb-2 text-xs">{message.formattedCreatedAt}</p>
)} )}

View File

@@ -4,6 +4,7 @@ import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { commentStatusById } from '../constants/commentStatus'
import { karmaUnlocksById } from '../constants/karmaUnlocks' import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { getServiceUserRoleInfo } from '../constants/serviceUserRoles' import { getServiceUserRoleInfo } from '../constants/serviceUserRoles'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -18,9 +19,9 @@ import { formatDateShort } from '../lib/timeAgo'
import BadgeSmall from './BadgeSmall.astro' import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro' import CommentModeration from './CommentModeration.astro'
import CommentReply from './CommentReply.astro' import CommentReply from './CommentReply.astro'
import MyPicture from './MyPicture.astro'
import TimeFormatted from './TimeFormatted.astro' import TimeFormatted from './TimeFormatted.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import UserBadge from './UserBadge.astro'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
@@ -60,8 +61,8 @@ const isHighlighted = comment.id === highlightedCommentId
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
const isAuthor = user?.id === comment.author.id const isAuthor = user?.id === comment.author.id
const isAdminOrVerifier = !!user && (user.admin || user.verifier) const isAdminOrModerator = !!user && (user.admin || user.moderator)
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier const isAuthorOrPrivileged = isAuthor || isAdminOrModerator
// Check if user is new (less than 1 week old) // Check if user is new (less than 1 week old)
const isNewUser = const isNewUser =
@@ -74,7 +75,7 @@ const isRatingActive =
!comment.suspicious && !comment.suspicious &&
(comment.status === 'APPROVED' || comment.status === 'VERIFIED') (comment.status === 'APPROVED' || comment.status === 'VERIFIED')
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier // Skip rendering if comment is not approved/verified and user is not the author or admin/moderator
const shouldShow = const shouldShow =
comment.status === 'APPROVED' || comment.status === 'APPROVED' ||
comment.status === 'VERIFIED' || comment.status === 'VERIFIED' ||
@@ -101,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps} {...htmlProps}
id={`comment-${comment.id.toString()}`} id={`comment-${comment.id.toString()}`}
class={cn([ 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', 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) && comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]', 'bg-[#182a1f]',
@@ -149,40 +150,24 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
checked={comment.suspicious} checked={comment.suspicious}
/> />
<div class="comment-header flex items-center gap-2 text-sm"> <div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300"> <label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span> <span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span> <span class="sr-only">Toggle comment visibility</span>
</label> </label>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
{ <UserBadge
comment.author.picture && ( user={comment.author}
<MyPicture size="md"
src={comment.author.picture} class={cn('text-day-300', isAuthor && 'font-medium text-green-500')}
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`} />
class="size-6 rounded-full bg-zinc-700 object-cover"
height={24}
width={24}
/>
)
}
<a
href={`/u/${comment.author.name}`}
class={cn([
'font-title text-day-300 font-medium hover:underline focus-visible:underline',
isAuthor && 'font-medium text-green-500',
])}
>
{comment.author.displayName ?? comment.author.name}
</a>
{ {
(comment.author.verified || comment.author.admin || comment.author.verifier) && ( (comment.author.verified || comment.author.admin || comment.author.moderator) && (
<Tooltip <Tooltip
text={`${ text={`${
comment.author.admin || comment.author.verifier comment.author.admin || comment.author.moderator
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}` ? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: '' : ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`} }${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
@@ -201,7 +186,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
) )
} }
{ {
comment.author.verifier && !comment.author.admin && ( comment.author.moderator && !comment.author.admin && (
<BadgeSmall <BadgeSmall
icon="ri:graduation-cap-fill" icon="ri:graduation-cap-fill"
color="teal" color="teal"
@@ -213,14 +198,14 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
} }
{ {
isNewUser && !comment.author.admin && !comment.author.verifier && ( isNewUser && !comment.author.admin && !comment.author.moderator && (
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}> <Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon /> <BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
</Tooltip> </Tooltip>
) )
} }
{ {
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && ( authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.moderator && (
<BadgeSmall <BadgeSmall
icon={karmaUnlocksById.highKarmaBadge.icon} icon={karmaUnlocksById.highKarmaBadge.icon}
color="lime" color="lime"
@@ -258,13 +243,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.author.serviceAffiliations.map((affiliation) => { comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role) const roleInfo = getServiceUserRoleInfo(affiliation.role)
return ( return (
<BadgeSmall <BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
icon={roleInfo.icon} {roleInfo.label} at
color={roleInfo.color} <a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
text={`${roleInfo.label} at ${affiliation.service.name}`} </BadgeSmall>
variant="faded"
inlineIcon
/>
) )
}) })
} }
@@ -285,12 +267,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />} {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 && ( comment.rating !== null && !comment.parentId && (
<Tooltip <Tooltip
@@ -307,20 +283,48 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{ {
comment.status === 'VERIFIED' && ( comment.status === 'VERIFIED' && (
<BadgeSmall icon="ri:check-double-fill" color="green" text="Verified" inlineIcon /> <BadgeSmall
icon={commentStatusById.VERIFIED.icon}
color={commentStatusById.VERIFIED.color}
text={commentStatusById.VERIFIED.label}
inlineIcon
/>
) )
} }
{ {
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') && (comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') &&
(showPending || isHighlightParent || isAuthorOrPrivileged) && ( (showPending || isHighlightParent || isAuthorOrPrivileged) && (
<BadgeSmall icon="ri:time-fill" color="yellow" text="Unmoderated" inlineIcon /> <BadgeSmall
icon={commentStatusById.PENDING.icon}
color={commentStatusById.PENDING.color}
text={commentStatusById.PENDING.label}
inlineIcon
/>
) )
} }
{ {
comment.status === 'REJECTED' && isAuthorOrPrivileged && ( comment.status === 'REJECTED' && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Rejected" inlineIcon /> <BadgeSmall
icon={commentStatusById.REJECTED.icon}
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"
/>
) )
} }
@@ -371,15 +375,16 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.communityNote && ( comment.communityNote && (
<div class="mt-2 peer-checked/collapse:hidden"> <div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs"> <div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs">
<span class="font-medium text-zinc-400">Added context:</span> <span class="prose prose-sm prose-invert prose-strong:text-zinc-300/90 text-xs text-zinc-300">
<span class="text-zinc-300">{comment.communityNote}</span> <Markdown content={`**Added context:** ${comment.communityNote}`} />
</span>
</div> </div>
</div> </div>
) )
} }
{ {
user && (user.admin || user.verifier) && comment.internalNote && ( user && (user.admin || user.moderator) && comment.internalNote && (
<div class="mt-2 peer-checked/collapse:hidden"> <div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs"> <div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
<span class="font-medium text-red-400">Internal note:</span> <span class="font-medium text-red-400">Internal note:</span>
@@ -390,7 +395,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
} }
{ {
user && (user.admin || user.verifier) && comment.privateContext && ( user && (user.admin || user.moderator) && comment.privateContext && (
<div class="mt-2 peer-checked/collapse:hidden"> <div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs"> <div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
<span class="font-medium text-blue-400">Private context:</span> <span class="font-medium text-blue-400">Private context:</span>

View File

@@ -28,8 +28,8 @@ const { comment, class: className, ...divProps } = Astro.props
const user = Astro.locals.user const user = Astro.locals.user
// Only render for admin/verifier users // Only render for admin/moderator users
if (!user || !user.admin || !user.verifier) return null if (!user || !user.admin || !user.moderator) return null
--- ---
<div {...divProps} class={cn('text-xs', className)}> <div {...divProps} class={cn('text-xs', className)}>
@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.verifier) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'} {comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button> </button>
<button <button
@@ -110,16 +110,18 @@ if (!user || !user.admin || !user.verifier) return null
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING' comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400' ? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400' : 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)} )}
data-action="status" data-action="status"
data-value={comment.status === 'PENDING' ? 'APPROVED' : 'PENDING'} data-value={comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'APPROVED'
: 'PENDING'}
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.status === 'PENDING' ? 'Approve' : 'Pending'} {comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</button> </button>
<button <button

View File

@@ -11,6 +11,7 @@ import InputHoneypotTrap from './InputHoneypotTrap.astro'
import InputRating from './InputRating.astro' import InputRating from './InputRating.astro'
import InputText from './InputText.astro' import InputText from './InputText.astro'
import InputWrapper from './InputWrapper.astro' import InputWrapper from './InputWrapper.astro'
import UserBadge from './UserBadge.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
@@ -67,7 +68,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="text-day-400 flex items-center gap-2 text-xs peer-checked/use-form-secret-token:hidden"> <div class="text-day-400 flex items-center gap-2 text-xs peer-checked/use-form-secret-token:hidden">
<Icon name="ri:user-line" class="size-3.5" /> <Icon name="ri:user-line" class="size-3.5" />
<span> <span>
Commenting as: <span class="font-title font-medium text-green-400">{user.name}</span> Commenting as: <UserBadge user={user} size="sm" class="text-green-400" />
</span> </span>
</div> </div>
@@ -91,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" /> <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"> <label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" /> <input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs 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 Most Upvotes
</a> </a>
{ {
user && (user.admin || user.verifier) && ( user && (user.admin || user.moderator) && (
<a <a
href={getSortUrl('status')} href={getSortUrl('status')}
class={cn([ class={cn([

View File

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

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import QRCode from 'qrcode' import * as QRCode from 'qrcode'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -26,7 +26,7 @@ function getAddressURI(address: string, cryptoName: string) {
} }
const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName), { const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName), {
width: 128, width: 256,
margin: 1, margin: 1,
color: { color: {
dark: '#ffffff', dark: '#ffffff',
@@ -41,13 +41,18 @@ const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName),
<Icon name={cryptoIcon} class="size-6 text-white" /> <Icon name={cryptoIcon} class="size-6 text-white" />
<span class="font-title text-base font-semibold text-white">{cryptoName}</span> <span class="font-title text-base font-semibold text-white">{cryptoName}</span>
</div> </div>
<p class="px-7 font-mono text-base leading-snug tracking-wide break-all text-white"> <p
<span class="cursor-pointer px-7 font-mono text-base leading-snug tracking-wide break-all text-white select-all"
class="cursor-pointer select-all" >
set:html={address.length > 12 {
? `<span class="font-bold mr-0.5 text-green-500">${address.substring(0, 6)}</span>${address.substring(6, address.length - 6)}<span class="font-bold ml-0.5 text-green-500">${address.substring(address.length - 6)}</span>` address.length > 12
: `<span class="font-bold">${address}</span>`} ? [
/> <span class="mr-0.5 font-bold text-green-500">{address.substring(0, 6)}</span>,
address.substring(6, address.length - 6),
<span class="ml-0.5 font-bold text-green-500">{address.substring(address.length - 6)}</span>,
]
: address
}
</p> </p>
</div> </div>
<img <img
@@ -55,6 +60,6 @@ const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName),
alt={`${cryptoName} QR code`} alt={`${cryptoName} QR code`}
width="128" width="128"
height="128" height="128"
class="mr-4 hidden size-36 rounded sm:block" class="mr-4 hidden size-32 rounded sm:block"
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { SOURCE_CODE_URL } from 'astro:env/server' import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -11,10 +11,28 @@ type Props = HTMLAttributes<'footer'>
const links = [ const links = [
{ {
href: SOURCE_CODE_URL, href: SOURCE_CODE_URL,
label: 'Source Code', label: 'Code',
icon: 'ri:git-repository-line', icon: 'ri:git-repository-line',
external: true, external: true,
}, },
{
href: ONION_ADDRESS,
label: 'Tor',
icon: 'onion',
external: true,
},
{
href: I2P_ADDRESS,
label: 'I2P',
icon: 'i2p',
external: true,
},
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{ {
href: '/about', href: '/about',
label: 'About', label: 'About',
@@ -40,7 +58,7 @@ const { class: className, ...htmlProps } = Astro.props
href={href} href={href}
target={external ? '_blank' : undefined} target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined} rel={external ? 'noopener noreferrer' : undefined}
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700" class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
> >
<Icon name={icon} class="h-4 w-4" /> <Icon name={icon} class="h-4 w-4" />
{label} {label}

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

@@ -12,6 +12,7 @@ import HeaderNotificationIndicator from './HeaderNotificationIndicator.astro'
import HeaderSplashTextScript from './HeaderSplashTextScript.astro' import HeaderSplashTextScript from './HeaderSplashTextScript.astro'
import Logo from './Logo.astro' import Logo from './Logo.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import UserBadge from './UserBadge.astro'
const user = Astro.locals.user const user = Astro.locals.user
const actualUser = Astro.locals.actualUser const actualUser = Astro.locals.actualUser
@@ -35,6 +36,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
'border-red-900 bg-red-500/60': !!actualUser, 'border-red-900 bg-red-500/60': !!actualUser,
} }
)} )}
transition:name="header-container"
> >
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}> <nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center"> <div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
@@ -117,7 +119,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
<Tooltip <Tooltip
as="a" as="a"
href="/admin" href="/admin"
class="text-red-500 transition-colors hover:text-red-400" class="flex h-full items-center text-red-500 transition-colors hover:text-red-400"
transition:name="header-admin-link" transition:name="header-admin-link"
text="Admin Dashboard" text="Admin Dashboard"
position="left" position="left"
@@ -130,9 +132,12 @@ const splashText = showSplashText ? sample(splashTexts) : null
user ? ( user ? (
<> <>
{actualUser && ( {actualUser && (
<span class="text-sm text-white/40 hover:text-white" transition:name="header-actual-user-name"> <UserBadge
({actualUser.name}) user={actualUser}
</span> size="sm"
class="text-white/40 hover:text-white"
transition:name="header-actual-user-name"
/>
)} )}
<HeaderNotificationIndicator <HeaderNotificationIndicator
@@ -140,17 +145,22 @@ const splashText = showSplashText ? sample(splashTexts) : null
transition:name="header-notification-indicator" transition:name="header-notification-indicator"
/> />
<a <UserBadge
href="/account" href="/account"
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-zinc-400 transition-colors last:-mr-1 hover:text-zinc-300" user={user}
size="md"
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 h-full px-1 text-zinc-400 transition-colors last:-mr-1 hover:text-zinc-300"
classNames={{
image: 'max-2xs:hidden',
}}
transition:name="header-user-link" transition:name="header-user-link"
> />
{user.name}
</a>
{actualUser ? ( {actualUser ? (
<a <a
href={makeUnimpersonateUrl(Astro.url)} href={makeUnimpersonateUrl(Astro.url)}
data-astro-reload data-astro-reload
data-astro-prefetch="tap"
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200" class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
transition:name="header-unimpersonate-link" transition:name="header-unimpersonate-link"
aria-label="Unimpersonate" aria-label="Unimpersonate"
@@ -158,17 +168,15 @@ const splashText = showSplashText ? sample(splashTexts) : null
<Icon name="ri:user-shared-2-line" class="size-4" /> <Icon name="ri:user-shared-2-line" class="size-4" />
</a> </a>
) : ( ) : (
DEPLOYMENT_MODE !== 'production' && ( <a
<a href="/account/logout"
href="/account/logout" data-astro-prefetch="tap"
data-astro-prefetch="tap" class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200" transition:name="header-logout-link"
transition:name="header-logout-link" aria-label="Logout"
aria-label="Logout" >
> <Icon name="ri:logout-box-r-line" class="size-4" />
<Icon name="ri:logout-box-r-line" class="size-4" /> </a>
</a>
)
)} )}
</> </>
) : ( ) : (

View File

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

View File

@@ -12,13 +12,15 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
options: { options: {
label: string label: string
value: string value: string
icon?: string icon?: string[] | string
iconClassName?: string[] | string
}[] }[]
disabled?: boolean disabled?: boolean
selectedValues?: string[] selectedValues?: string[]
size?: 'lg' | 'md'
} }
const { options, disabled, selectedValues = [], ...wrapperProps } = Astro.props const { options, disabled, selectedValues = [], size = 'md', ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`) const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
@@ -26,23 +28,38 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
<InputWrapper inputId={inputId} {...wrapperProps}> <InputWrapper inputId={inputId} {...wrapperProps}>
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}> <div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
<div class="h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5"> <div
class={cn('h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5', {
'h-96': size === 'lg',
'h-48': size === 'md',
})}
>
{ {
options.map((option) => ( options.map((option) => {
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1"> const icons = option.icon ? (Array.isArray(option.icon) ? option.icon : [option.icon]) : []
<input const iconClassName = option.iconClassName
transition:persist ? Array.isArray(option.iconClassName)
type="checkbox" ? option.iconClassName
name={wrapperProps.name} : Array.from({ length: icons.length }, () => option.iconClassName)
value={option.value} : []
checked={selectedValues.includes(option.value)} return (
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)} <label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1 has-checked:bg-green-800/20 has-checked:hover:bg-green-800/30">
disabled={disabled} <input
/> transition:persist
{option.icon && <Icon name={option.icon} class="size-4" />} type="checkbox"
<span class="text-sm leading-none">{option.label}</span> name={wrapperProps.name}
</label> value={option.value}
)) checked={selectedValues.includes(option.value)}
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
disabled={disabled}
/>
{icons.map((icon, index) => (
<Icon name={icon} class={cn('size-4 shrink-0', iconClassName[index])} />
))}
<span class="truncate text-sm leading-none">{option.label}</span>
</label>
)
})
} }
</div> </div>
</div> </div>

View File

@@ -16,17 +16,35 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
} }
} }
const { accept, disabled, multiple, removeCheckbox, ...wrapperProps } = Astro.props const { accept, disabled, multiple, removeCheckbox, classNames, ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`) const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
--- ---
<InputWrapper inputId={inputId} {...wrapperProps}> <InputWrapper
inputId={inputId}
classNames={{
...classNames,
description: cn(classNames?.description, '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'),
}}
{...wrapperProps}
>
{ {
!!removeCheckbox && ( !!removeCheckbox && (
<label class="flex cursor-pointer items-center gap-2 py-1 pl-1 text-sm leading-none"> <label
<input transition:persist type="checkbox" name={removeCheckbox.name} data-remove-checkbox /> class={cn(
'flex cursor-pointer items-center gap-2 py-1 pl-1 text-sm leading-none',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<input
transition:persist
type="checkbox"
name={removeCheckbox.name}
data-remove-checkbox
disabled={disabled}
/>
{removeCheckbox.label || 'Remove'} {removeCheckbox.label || 'Remove'}
</label> </label>
) )

View File

@@ -2,16 +2,24 @@
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils' import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
import Button from './Button.astro'
import InputFile from './InputFile.astro' import InputFile from './InputFile.astro'
import Tooltip from './Tooltip.astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & { type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
square?: boolean square?: boolean
value?: string | null value?: string | null
downloadButton?: boolean
} }
const { class: className, square, value, ...inputFileProps } = Astro.props const { class: className, square, value, downloadButton, ...inputFileProps } = Astro.props
function makeDownloadFilename(value: string) {
const url = new URL(value, Astro.url.origin)
return url.pathname.split('/').pop() ?? 'service-image'
}
--- ---
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image> <div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden' '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
)} )}
/> />
{
downloadButton && value && (
<Tooltip
text="Download"
classNames={{
tooltip: 'min-2xs:[&:is(:has([data-remove-checkbox]:checked)_~_*_*)]:hidden',
}}
>
<Button
as="a"
href={value}
download={makeDownloadFilename(value)}
icon="ri:download-line"
size="sm"
label="Download"
class={cn(
'bg-night-600 border-night-400 text-day-200 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:h-24 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:px-0 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:w-8 shrink-0 rounded-md border'
)}
classNames={{
label: '2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:hidden block ',
}}
/>
</Tooltip>
)
}
</div> </div>
<script> <script>

View File

@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
import Button from './Button.astro' import Button from './Button.astro'
import type { HTMLAttributes } from 'astro/types' import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & { type Props = HTMLAttributes<'div'> & {
hideCancel?: boolean hideCancel?: boolean
icon?: string icon?: string
label?: string label?: string
disabled?: boolean
color?: ComponentProps<typeof Button>['color']
} }
const { const {
hideCancel = false, hideCancel = false,
icon = 'ri:send-plane-2-line', icon = 'ri:send-plane-2-line',
label = 'Submit', label = 'Submit',
disabled = false,
class: className, class: className,
color = 'success',
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
--- ---
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}> <div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />} {!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" /> <Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
</div> </div>

View File

@@ -10,7 +10,9 @@ import InputWrapper from './InputWrapper.astro'
import type { ComponentProps, HTMLAttributes } from 'astro/types' import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & { type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
inputProps?: Omit<HTMLAttributes<'input'>, 'name'> inputProps?: Omit<HTMLAttributes<'input'>, 'name'> & {
'transition:persist'?: boolean
}
inputIcon?: string inputIcon?: string
inputIconClass?: string inputIconClass?: string
} }
@@ -26,7 +28,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
inputIcon ? ( inputIcon ? (
<div class="relative"> <div class="relative">
<input <input
transition:persist transition:persist={inputProps?.['transition:persist'] === false ? undefined : true}
{...omit(inputProps, ['class', 'id', 'name'])} {...omit(inputProps, ['class', 'id', 'name'])}
id={inputId} id={inputId}
class={cn( class={cn(

View File

@@ -10,7 +10,7 @@ import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & { type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
inputProps?: Omit<HTMLAttributes<'textarea'>, 'name'> inputProps?: Omit<HTMLAttributes<'textarea'>, 'name'>
value?: string value?: string | null | undefined
} }
const { inputProps, value, ...wrapperProps } = Astro.props const { inputProps, value, ...wrapperProps } = Astro.props
@@ -31,6 +31,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
hasError && baseInputClassNames.error, hasError && baseInputClassNames.error,
!!inputProps?.disabled && baseInputClassNames.disabled !!inputProps?.disabled && baseInputClassNames.disabled
)} )}
name={wrapperProps.name}>{value}</textarea name={wrapperProps.name}
> set:text={value}
/>
</InputWrapper> </InputWrapper>

View File

@@ -18,6 +18,10 @@ type Props = HTMLAttributes<'div'> & {
error?: string[] | string error?: string[] | string
icon?: string icon?: string
inputId?: string inputId?: string
hideLabel?: boolean
classNames?: {
description?: string
}
} }
const { const {
@@ -30,24 +34,29 @@ const {
icon, icon,
class: className, class: className,
inputId, inputId,
hideLabel,
classNames,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
const hasError = !!error && error.length > 0 const hasError = !!error && error.length > 0
--- ---
<fieldset class={cn('space-y-1', className)} {...htmlProps}> <fieldset class={cn('min-w-0 space-y-1', className)} {...htmlProps}>
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}> {
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}> !hideLabel && (
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />} <div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
<label for={inputId}>{label}</label>{required && '*'} <legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
</legend> {icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
{ <label for={inputId}>{label}</label>
!!descriptionLabel && ( {required && '*'}
<span class="text-day-400 flex-1 basis-24 text-xs text-pretty">{descriptionLabel}</span> </legend>
) {!!descriptionLabel && (
} <span class="text-day-400 flex-1 basis-24 text-xs text-pretty">{descriptionLabel}</span>
</div> )}
</div>
)
}
<slot /> <slot />
@@ -66,7 +75,12 @@ const hasError = !!error && error.length > 0
{ {
!!description && ( !!description && (
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty"> <div
class={cn(
'prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty',
classNames?.description
)}
>
<Markdown content={description} /> <Markdown content={description} />
</div> </div>
) )

View File

@@ -4,6 +4,7 @@ import type { ComponentProps } from 'react'
import { Picture } from 'astro:assets' import { Picture } from 'astro:assets'
import defaultServiceImage from '../assets/fallback-service-image.jpg' import defaultServiceImage from '../assets/fallback-service-image.jpg'
import { cn } from '../lib/cn'
const fallbackImages = { const fallbackImages = {
service: defaultServiceImage, service: defaultServiceImage,
@@ -20,6 +21,7 @@ const {
fallback = undefined as keyof typeof fallbackImages | undefined, fallback = undefined as keyof typeof fallbackImages | undefined,
height, height,
width, width,
pictureAttributes,
...props ...props
} = Astro.props } = Astro.props
@@ -36,6 +38,10 @@ const fallbackImage = fallback ? fallbackImages[fallback] : undefined
formats={formats} formats={formats}
height={height ? Number(height) * 2 : undefined} height={height ? Number(height) * 2 : undefined}
width={width ? Number(width) * 2 : undefined} width={width ? Number(width) * 2 : undefined}
pictureAttributes={{
...pictureAttributes,
class: cn('shrink-0', pictureAttributes?.class),
}}
{...(props as any)} {...(props as any)}
/> />
) )

View File

@@ -9,10 +9,11 @@ type Props = HTMLAttributes<'div'> & {
value: HTMLAttributes<'input'>['value'] value: HTMLAttributes<'input'>['value']
label: string label: string
}[] }[]
inputProps?: Omit<HTMLAttributes<'input'>, 'checked' | 'class' | 'name' | 'type' | 'value'>
selectedValue?: string | null selectedValue?: string | null
} }
const { name, options, selectedValue, class: className, ...rest } = Astro.props const { name, options, selectedValue, inputProps, class: className, ...rest } = Astro.props
--- ---
<div <div
@@ -31,6 +32,7 @@ const { name, options, selectedValue, class: className, ...rest } = Astro.props
value={option.value} value={option.value}
checked={selectedValue === option.value} checked={selectedValue === option.value}
class="peer sr-only" class="peer sr-only"
{...inputProps}
/> />
<span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500"> <span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500">
{option.label} {option.label}

View File

@@ -0,0 +1,374 @@
---
import { Icon } from 'astro-icon/components'
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
dismissable?: boolean
hideIfEnabled?: boolean
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: {
endpoint: true
userAgent: true
}
}>[]
}
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
// TODO: Feature flag, enabled only for admins
if (!Astro.locals.user?.admin) {
return null
}
---
<div
data-push-notification-banner
data-dismissed={undefined /* Updated by client script */}
data-supports-push-notifications={undefined /* Updated by client script */}
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
data-is-enabled={undefined /* Updated by client script */}
class={cn(
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
'data-dismissed:hidden',
hideIfEnabled && 'data-is-enabled:hidden',
'not-data-supports-push-notifications:hidden',
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
className
)}
{...props}
>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
<div
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
</div>
<div class="flex items-center gap-x-3">
<div class="rounded-md bg-blue-800/30 p-2">
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
</div>
<div>
<h3 class="font-medium text-blue-100">
<span data-show-if-enabled>Push notifications enabled</span>
<span data-show-if-disabled>Turn on push notifications?</span>
</h3>
<p class="text-sm text-blue-200/80">
<span data-show-if-enabled>Turn notifications off for this device?</span>
<span data-show-if-disabled>Get notifications on this device.</span>
</p>
</div>
</div>
<div class="flex items-center gap-2">
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
<Button
as="span"
label="Yes, notify me"
color="white"
data-push-action="subscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-disabled
/>
<Button
as="span"
label="Stop notifications"
color="white"
variant="faded"
data-push-action="unsubscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-enabled
/>
</div>
</div>
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification banner dismissal. //
/////////////////////////////////////////////////////////////
import { typedLocalStorage } from '../lib/localstorage'
document.addEventListener('astro:page-load', () => {
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
if (
pushNotificationsBannerDismissedAt &&
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
) {
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
pushNotificationsBannerDismissedAt = undefined
}
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
if (!skipButton) return
if (pushNotificationsBannerDismissedAt) {
banner.dataset.dismissed = ''
}
skipButton.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
banner.dataset.dismissed = ''
const now = new Date()
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
pushNotificationsBannerDismissedAt = now
})
})
})
</script>
<script>
/////////////////////////////////////////////////////////
// Script to style when notifications enabled. //
////////////////////////////////////////////////////////
type ServerSubscription = {
endpoint: string
userAgent: string | null
}
/** Parse push subscriptions from string */
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
try {
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
const subscriptions = JSON.parse(subscriptionsAsString)
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
throw new Error('Push subscriptions must be an array of objects')
}
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
throw new Error('Push subscriptions must be an array of objects with endpoint property')
}
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
throw new Error('Push subscriptions must be an array of objects with userAgent property')
}
return subscriptions as ServerSubscription[]
} catch (error) {
console.error('Failed to parse push subscriptions:', error)
return []
}
}
/** Check if current device has an active push subscription */
async function getCurrentPushSubscription() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
return await registration.pushManager.getSubscription()
} catch (error) {
console.error('Error getting current push subscription:', error)
return null
}
}
/** Check if current subscription matches any server subscription */
function isCurrentDeviceSubscribed(
currentSubscription: PushSubscription | null,
serverSubscriptions: ServerSubscription[]
) {
if (!currentSubscription || serverSubscriptions.length === 0) return false
const currentEndpoint = currentSubscription.endpoint
const currentUserAgent = navigator.userAgent
return serverSubscriptions.some(
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
}
document.addEventListener('astro:page-load', async () => {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
const currentSubscription = await getCurrentPushSubscription()
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
if (isSubscribed) banner.dataset.isEnabled = ''
})
})
</script>
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification subscription. //
/////////////////////////////////////////////////////////////
import type { actions } from 'astro:actions'
import type { ActionInput } from '../lib/astroActions'
/** Utility function to convert VAPID key */
function urlB64ToUint8Array(base64String: string) {
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
const base64 = cleaned + padding
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
/** Check for browser support */
function checkSupport() {
const isSecure =
window.isSecureContext ||
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1'
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered:', registration)
const readyRegistration = await navigator.serviceWorker.ready
console.log('Service Worker is active and ready:', readyRegistration)
return readyRegistration
} catch (error) {
console.error('Service Worker registration failed:', error)
throw error
}
}
async function subscribeToPush(vapidPublicKey: string) {
try {
if (!checkSupport()) return
// Request notification permission
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
alert('Push notifications permission denied')
return
}
const registration = await registerServiceWorker()
// Subscribe to push manager
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
})
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
// Send subscription to server
const response = await fetch('/internal-api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
userAgent: navigator.userAgent,
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push subscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push subscription failed:', error)
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
}
}
async function unsubscribeFromPush() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
console.log('No service worker registration found')
return
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
console.log('No push subscription found')
return
}
// Unsubscribe from browser
await subscription.unsubscribe()
// Remove from server
const response = await fetch('/internal-api/notifications/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push unsubscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push unsubscription failed:', error)
alert('Failed to unsubscribe from push notifications')
}
}
document.addEventListener('astro:page-load', () => {
const supportsPushNotifications = checkSupport()
if (supportsPushNotifications) {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
element.dataset.supportsPushNotifications = ''
})
}
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
const vapidPublicKey = button.dataset.vapidPublicKey
if (!vapidPublicKey) {
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
return
}
button.addEventListener('click', async (event) => {
event.preventDefault()
event.stopPropagation()
const action = button.dataset.pushAction
if (action === 'subscribe') {
await subscribeToPush(vapidPublicKey)
} else if (action === 'unsubscribe') {
await unsubscribeFromPush()
}
})
})
})
</script>

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { currencies } from '../constants/currencies' import { currencies } from '../constants/currencies'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
kycLevel: true kycLevel: true
imageUrl: true imageUrl: true
verificationStatus: true verificationStatus: true
serviceVisibility: true
acceptedCurrencies: true acceptedCurrencies: true
categories: { categories: {
select: { select: {
@@ -43,11 +45,11 @@ const {
slug, slug,
description, description,
overallScore, overallScore,
kycLevel, kycLevel,
imageUrl, imageUrl,
categories, categories,
verificationStatus, verificationStatus,
serviceVisibility,
acceptedCurrencies, acceptedCurrencies,
}, },
class: className, class: className,
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
href={Element === 'a' ? `/service/${slug}` : undefined} href={Element === 'a' ? `/service/${slug}` : undefined}
{...aProps} {...aProps}
class={cn( class={cn(
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]', 'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
className className
)} )}
> >
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
src={imageUrl} src={imageUrl}
fallback="service" fallback="service"
alt={name || 'Service logo'} alt={name || 'Service logo'}
class="size-12 shrink-0 rounded-sm object-contain text-white" class={cn(
'size-12 shrink-0 rounded-sm object-contain text-white',
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
)}
width={48} width={48}
height={48} height={48}
/> />
@@ -88,11 +96,42 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white"> <h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
{name}{ {name}{
statusIcon && ( statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-my-2 shrink-0"> <Tooltip
text={statusIcon.label}
position="right"
class="-my-2 shrink-0 whitespace-nowrap"
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
>
{[
<Icon
is:inline={inlineIcons}
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
statusIcon.classNames.icon
)}
/>,
verificationStatus === 'VERIFICATION_FAILED' && (
<span class="text-sm font-bold text-red-500">SCAM</span>
),
]}
</Tooltip>
)
}{
serviceVisibility === 'ARCHIVED' && (
<Tooltip
text={serviceVisibilitiesById.ARCHIVED.label}
position="right"
class="-my-2 shrink-0 whitespace-nowrap"
>
<Icon <Icon
is:inline={inlineIcons} is:inline={inlineIcons}
name={statusIcon.icon} name={serviceVisibilitiesById.ARCHIVED.icon}
class={cn('inline-block size-6 shrink-0 rounded-lg p-1', statusIcon.classNames.icon)} class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
serviceVisibilitiesById.ARCHIVED.iconClass
)}
/> />
</Tooltip> </Tooltip>
) )

View File

@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
} }
} }
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
if (bitcointalkMatch) {
return {
type: 'clearnet' as const,
url: urlWithReferral,
textBits: [
{
style: 'normal',
text: 'BitcoinTalk ',
},
{
style: 'irrelevant',
text: 'thread',
},
],
icon: networksBySlug.clearnet.icon,
}
}
return { return {
type: 'clearnet' as const, type: 'clearnet' as const,
url: urlWithReferral, url: urlWithReferral,

View File

@@ -34,17 +34,24 @@ const {
<form <form
method="GET" method="GET"
hx-get={Astro.url.pathname} hx-get={Astro.url.pathname}
hx-trigger={`input delay:500ms from:input[type='text'], keyup[key=='Enter'], change from:input:not([data-show-more-input], #${showFiltersId}), change from:select`} hx-trigger={// NOTE: I need to do the [data-trigger-on-change] hack, because HTMX doesnt suport the :not() selector, and I need to exclude the Show more buttons, and not trigger for inputs outside the form
"input delay:500ms from:([data-services-filters-form] input[type='text']), keyup[key=='Enter'], change from:([data-services-filters-form] [data-trigger-on-change])"}
hx-target={`#${searchResultsId}`} hx-target={`#${searchResultsId}`}
hx-select={`#${searchResultsId}`} hx-select={`#${searchResultsId}`}
hx-push-url="true" hx-push-url="true"
hx-indicator="#search-indicator" hx-indicator="#search-indicator"
hx-swap="outerHTML"
data-services-filters-form data-services-filters-form
data-default-verification-filter={options.verification data-default-verification-filter={options.verification
.filter((verification) => verification.default) .filter((verification) => verification.default)
.map((verification) => verification.slug)} .map((verification) => verification.slug)}
{...formProps} {...formProps}
class={cn('', className)} class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
className
)}
> >
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="font-title text-xl text-green-500">FILTERS</h2> <h2 class="font-title text-xl text-green-500">FILTERS</h2>
@@ -64,6 +71,7 @@ const {
name="sort" name="sort"
id="sort" id="sort"
class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden" class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
data-trigger-on-change
> >
{ {
options.sort.map((option) => ( options.sort.map((option) => (
@@ -73,16 +81,20 @@ const {
)) ))
} }
</select> </select>
<p class="text-day-500 mt-1.5 text-center text-sm"> <p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" /> <Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
Ties randomly sorted Ties randomly sorted
</p> </p>
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
Sorted by match first
</p>
</fieldset> </fieldset>
<!-- Text Search --> <!-- Text Search -->
<fieldset class="mb-6"> <fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500"> <legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label> <label for="q">Name</label>
</legend> </legend>
<input <input
type="text" type="text"
@@ -101,14 +113,16 @@ const {
{ {
options.categories?.map((category) => ( options.categories?.map((category) => (
<li data-show-always={category.showAlways ? '' : undefined}> <li data-show-always={category.showAlways ? '' : undefined}>
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white"> <label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input <input
type="checkbox" type="checkbox"
class="peer text-green-500" class="peer text-green-500"
name="categories" name="categories"
value={category.slug} value={category.slug}
checked={category.checked} checked={category.checked}
data-trigger-on-change
/> />
<Icon name={category.icon} class="size-4" />
<span class="peer-checked:font-bold"> <span class="peer-checked:font-bold">
{category.name} {category.name}
<span class="text-day-500 font-normal">{category._count.services}</span> <span class="text-day-500 font-normal">{category._count.services}</span>
@@ -121,13 +135,7 @@ const {
{ {
options.categories.filter((category) => category.showAlways).length < options.categories.length && ( options.categories.filter((category) => category.showAlways).length < options.categories.length && (
<> <>
<input <input type="checkbox" id="show-more-categories" class="peer sr-only" hx-preserve />
type="checkbox"
id="show-more-categories"
class="peer sr-only"
hx-preserve
data-show-more-input
/>
<label <label
for="show-more-categories" for="show-more-categories"
class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2" class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
@@ -158,6 +166,7 @@ const {
name="verification" name="verification"
value={verification.slug} value={verification.slug}
checked={filters.verification.includes(verification.value)} checked={filters.verification.includes(verification.value)}
data-trigger-on-change
/> />
<Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} /> <Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} />
<span class="peer-checked:font-bold">{verification.labelShort}</span> <span class="peer-checked:font-bold">{verification.labelShort}</span>
@@ -176,6 +185,9 @@ const {
options={options.modeOptions} options={options.modeOptions}
selectedValue={filters['currency-mode']} selectedValue={filters['currency-mode']}
class="-my-2" class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/> />
</div> </div>
<div> <div>
@@ -188,6 +200,7 @@ const {
name="currencies" name="currencies"
value={currency.slug} value={currency.slug}
checked={filters.currencies?.some((id) => id === currency.id)} checked={filters.currencies?.some((id) => id === currency.id)}
data-trigger-on-change
/> />
<Icon name={currency.icon} class="size-4" /> <Icon name={currency.icon} class="size-4" />
<span class="peer-checked:font-bold">{currency.name}</span> <span class="peer-checked:font-bold">{currency.name}</span>
@@ -210,6 +223,7 @@ const {
name="networks" name="networks"
value={network.slug} value={network.slug}
checked={filters.networks?.some((slug) => slug === network.slug)} checked={filters.networks?.some((slug) => slug === network.slug)}
data-trigger-on-change
/> />
<Icon name={network.icon} class="size-4" /> <Icon name={network.icon} class="size-4" />
<span class="peer-checked:font-bold">{network.name}</span> <span class="peer-checked:font-bold">{network.name}</span>
@@ -233,6 +247,7 @@ const {
id="max-kyc" id="max-kyc"
value={filters['max-kyc'] ?? 4} value={filters['max-kyc'] ?? 4}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs"> <div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
@@ -261,6 +276,7 @@ const {
id="user-rating" id="user-rating"
value={filters['user-rating']} value={filters['user-rating']}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-2 text-xs"> <div class="text-day-400 mt-1 flex justify-between px-2 text-xs">
@@ -289,12 +305,22 @@ const {
options={options.modeOptions} options={options.modeOptions}
selectedValue={filters['attribute-mode']} selectedValue={filters['attribute-mode']}
class="-my-2" class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/> />
</div> </div>
{ {
options.attributesByCategory.map(({ category, attributes }) => ( options.attributesByCategory.map(({ categoryInfo, attributes }) => (
<fieldset class="min-w-0"> <fieldset class="min-w-0">
<legend class="font-title mb-0.5 text-xs tracking-wide text-white">{category}</legend> <legend class="font-title mb-0.5 inline-flex items-center gap-1 text-[0.8125rem] tracking-wide text-white uppercase">
<Icon
name={categoryInfo.icon}
class={cn('size-4 shrink-0 opacity-80', categoryInfo.classNames.icon)}
aria-hidden="true"
/>
{categoryInfo.label}
</legend>
<ul class="[:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden"> <ul class="[:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden">
{attributes.map((attribute) => { {attributes.map((attribute) => {
@@ -318,6 +344,7 @@ const {
value="" value=""
checked={!attribute.value} checked={!attribute.value}
aria-label="Ignore" aria-label="Ignore"
data-trigger-on-change
/> />
<input <input
type="radio" type="radio"
@@ -327,6 +354,7 @@ const {
class="peer/yes sr-only" class="peer/yes sr-only"
checked={attribute.value === 'yes'} checked={attribute.value === 'yes'}
aria-label="Include" aria-label="Include"
data-trigger-on-change
/> />
<input <input
type="radio" type="radio"
@@ -336,6 +364,7 @@ const {
class="peer/no sr-only" class="peer/no sr-only"
checked={attribute.value === 'no'} checked={attribute.value === 'no'}
aria-label="Exclude" aria-label="Exclude"
data-trigger-on-change
/> />
<div class="pointer-events-none absolute inset-y-0 -left-[2px] hidden w-[calc(var(--spacing)*4.5*2+1px)] rounded-md border-2 border-blue-500 peer-focus-visible/empty:block peer-focus-visible/no:block peer-focus-visible/yes:block" /> <div class="pointer-events-none absolute inset-y-0 -left-[2px] hidden w-[calc(var(--spacing)*4.5*2+1px)] rounded-md border-2 border-blue-500 peer-focus-visible/empty:block peer-focus-visible/no:block peer-focus-visible/yes:block" />
@@ -356,11 +385,9 @@ const {
</label> </label>
<span <span
class="bg-night-400 border-night-500 pointer-events-none block h-4 w-px border-y peer-checked/no:w-[0.5px] peer-checked/yes:w-[0.5px]" class="bg-night-400 border-night-500 before:bg-night-400 before:border-night-600 pointer-events-none block h-4 w-px border-y peer-checked/no:w-[0.5px] peer-checked/yes:w-[0.5px] before:h-full before:w-px before:border-y-2"
aria-hidden="true" aria-hidden="true"
> />
<span class="bg-night-400 border-night-600 block h-full w-px border-y-2" />
</span>
<label <label
for={noId} for={noId}
@@ -383,8 +410,8 @@ const {
aria-hidden="true" aria-hidden="true"
> >
<Icon <Icon
name={attribute.info.icon} name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.info.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -398,8 +425,8 @@ const {
aria-hidden="true" aria-hidden="true"
> >
<Icon <Icon
name={attribute.info.icon} name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.info.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -417,19 +444,18 @@ const {
<> <>
<input <input
type="checkbox" type="checkbox"
id={`show-more-attributes-${category}`} id={`show-more-attributes-${categoryInfo.slug}`}
class="peer sr-only" class="peer sr-only"
hx-preserve hx-preserve
data-show-more-input
/> />
<label <label
for={`show-more-attributes-${category}`} for={`show-more-attributes-${categoryInfo.slug}`}
class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2" class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
+ Show more + Show more
</label> </label>
<label <label
for={`show-more-attributes-${category}`} for={`show-more-attributes-${categoryInfo.slug}`}
class="peer-focus-visible:ring-offset-night-700 mt-2 hidden cursor-pointer rounded-sm text-sm text-green-500 peer-checked:block peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2" class="peer-focus-visible:ring-offset-night-700 mt-2 hidden cursor-pointer rounded-sm text-sm text-green-500 peer-checked:block peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
- Show less - Show less
@@ -455,6 +481,7 @@ const {
id="min-score" id="min-score"
value={filters['min-score']} value={filters['min-score']}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="-mx-1.5 mt-2 flex justify-between px-1"> <div class="-mx-1.5 mt-2 flex justify-between px-1">

View File

@@ -1,9 +1,11 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { uniq } from 'lodash-es'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { pluralize } from '../lib/pluralize' import { pluralize } from '../lib/pluralize'
import { createPageUrl } from '../lib/urls' import { createPageUrl, urlWithParams } from '../lib/urls'
import Button from './Button.astro' import Button from './Button.astro'
import ServiceCard from './ServiceCard.astro' import ServiceCard from './ServiceCard.astro'
@@ -19,7 +21,8 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number pageSize: number
sortSeed?: string sortSeed?: string
filters: ServicesFiltersObject filters: ServicesFiltersObject
hadToIncludeCommunityContributed: boolean countCommunityOnly: number | null
inlineIcons?: boolean
} }
const { const {
@@ -31,89 +34,188 @@ const {
sortSeed, sortSeed,
class: className, class: className,
filters, filters,
hadToIncludeCommunityContributed, countCommunityOnly,
inlineIcons,
...divProps ...divProps
} = Astro.props } = Astro.props
const hasScams = filters.verification.includes('VERIFICATION_FAILED') const hasScams = filters.verification.includes('VERIFICATION_FAILED')
const hasCommunityContributed = const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('COMMUNITY_CONTRIBUTED') || hadToIncludeCommunityContributed const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
const hasSomeCommunityContributed = !!services?.some((service) =>
service.verificationStatus.includes('COMMUNITY_CONTRIBUTED')
)
const totalPages = Math.ceil(total / pageSize) || 1 const totalPages = Math.ceil(total / pageSize) || 1
const urlIfIncludingCommunity = urlWithParams(Astro.url, {
verification: uniq([
...filters.verification.map((v) => verificationStatusesByValue[v].slug),
verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug,
]),
})
--- ---
<div {...divProps} class={cn('flex-1', className)}> <div {...divProps} class={cn('flex-1', className)}>
<div class="mb-6 flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-day-500 text-sm"> <span class="text-day-500 xs:gap-x-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm sm:gap-x-6">
{total.toLocaleString()} {total.toLocaleString()}
{pluralize('result', total)} {pluralize('result', total)}
<span <Icon
name="ri:loader-4-line"
id="search-indicator" id="search-indicator"
class="htmx-request:opacity-100 text-white opacity-0 transition-opacity duration-500" class="htmx-request:opacity-100 xs:-mx-1.5 -mx-1 inline-block size-4 animate-spin text-white opacity-0 transition-opacity duration-500 sm:-mx-3"
> is:inline={inlineIcons}
<Icon name="ri:loader-4-line" class="inline-block size-4 animate-spin" /> />
Loading...
</span> {
!!countCommunityOnly && (
<>
<Button
as="a"
href={urlIfIncludingCommunity}
label={`Include +${countCommunityOnly.toLocaleString()} community contributed`}
size="sm"
class="hidden lg:inline-flex"
icon="ri:search-line"
inlineIcon={inlineIcons}
/>
<Button
as="a"
href={urlIfIncludingCommunity}
label={`Include +${countCommunityOnly.toLocaleString()}`}
size="sm"
class="hidden sm:inline-flex lg:hidden"
icon="ri:search-line"
endIcon="ri:question-line"
classNames={{
endIcon: 'text-yellow-200/50',
}}
inlineIcon={inlineIcons}
/>
<a
href={urlIfIncludingCommunity}
class="border-night-500 bg-night-800 flex items-center gap-1 rounded-md border px-2 py-0.5 text-sm sm:hidden"
>
<Icon
name="ri:search-line"
class="mr-0.5 inline-block size-3.5 shrink-0 align-[-0.15em]"
is:inline={inlineIcons}
/>
Include
{countCommunityOnly.toLocaleString()}
<Icon
name="ri:question-line"
class="inline-block size-3.5 shrink-0 align-[-0.15em] text-yellow-200/50"
is:inline={inlineIcons}
/>
</a>
</>
)
}
</span> </span>
<Button as="a" href="/service-suggestion/new" label="Add service" icon="ri:add-line" /> <Button
as="a"
href="/service-suggestion/new"
label="Add service"
icon="ri:add-line"
inlineIcon={inlineIcons}
class="max-xs:w-9 max-xs:px-0"
classNames={{
label: 'max-xs:hidden',
}}
/>
</div> </div>
{ {
hasScams && hasCommunityContributed && ( hasScams && hasCommunityContributed && (
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500"> <div class="font-title mt-2 rounded-lg bg-red-900/50 px-3 py-2 text-sm text-pretty text-red-400">
<Icon name="ri:alert-fill" class="-mr-1 inline-block size-4 text-red-500" /> <Icon
<Icon name="ri:question-line" class="mr-2 inline-block size-4 text-yellow-500" /> name="ri:alert-fill"
Showing SCAM and unverified community-contributed services. class="inline-block size-4 shrink-0 align-[-0.2em] text-red-500"
{hadToIncludeCommunityContributed && 'Because there were no other results.'} is:inline={inlineIcons}
/>
<Icon
name="ri:question-line"
class="mr-1 inline-block size-4 shrink-0 align-[-0.2em] text-yellow-400"
is:inline={inlineIcons}
/>
Results {hasSomeScam || hasSomeCommunityContributed ? 'include' : 'may include'} SCAMs or
community-contributed services.
</div> </div>
) )
} }
{ {
hasScams && !hasCommunityContributed && ( hasScams && !hasCommunityContributed && (
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500"> <div class="font-title mt-2 rounded-lg bg-red-900/50 px-3 py-2 text-sm text-pretty text-red-400">
<Icon name="ri:alert-fill" class="mr-2 inline-block size-4 text-red-500" /> <Icon
Showing SCAM services! name="ri:alert-fill"
class="mr-1 inline-block size-4 shrink-0 align-[-0.2em] text-red-500"
is:inline={inlineIcons}
/>
Results {hasSomeScam ? 'include' : 'may include'} SCAM services
</div> </div>
) )
} }
{ {
!hasScams && hasCommunityContributed && ( !hasScams && hasCommunityContributed && (
<div class="font-title mb-6 rounded-lg border border-yellow-500/30 bg-yellow-950 p-4 text-sm text-yellow-500"> <div class="font-title mt-2 rounded-lg bg-yellow-600/30 px-3 py-2 text-sm text-pretty text-yellow-200">
<Icon name="ri:question-line" class="mr-2 inline-block size-4" /> <Icon
name="ri:question-line"
{hadToIncludeCommunityContributed class="mr-1 inline-block size-4 shrink-0 align-[-0.2em] text-yellow-400"
? 'Showing unverified community-contributed services, because there were no other results. Some might be scams.' is:inline={inlineIcons}
: 'Showing unverified community-contributed services, some might be scams.'} />
Results {hasSomeCommunityContributed ? 'include' : 'may include'} unverified community-contributed
services, some might be scams.
</div> </div>
) )
} }
{ {
!services || services.length === 0 ? ( !services || services.length === 0 ? (
<div class="sticky top-20 flex flex-col items-center justify-center rounded-lg border border-green-500/30 bg-black/40 p-12 text-center"> <div class="sticky top-20 mt-6 flex flex-col items-center justify-center py-12 text-center">
<Icon name="ri:emotion-sad-line" class="mb-4 size-16 text-green-500/50" /> <Icon name="ri:emotion-sad-line" class="mb-4 size-16 text-green-500/50" is:inline={inlineIcons} />
<h3 class="font-title mb-3 text-xl text-green-500">No services found</h3> <h3 class="font-title mb-3 text-xl text-green-500">No services found</h3>
<p class="text-day-400">Try adjusting your filters to find more services</p> <p class="text-day-400">Try adjusting your filters to find more services</p>
<a <div class="mt-4 flex justify-center gap-2">
href={Astro.url.pathname} {!hasDefaultFilters && (
class={cn( <Button
'bg-night-800 font-title mt-4 rounded-md px-4 py-2 text-sm tracking-wider text-white uppercase', as="a"
hasDefaultFilters && 'hidden' href={Astro.url.pathname}
label="Clear filters"
icon="ri:close-line"
inlineIcon={inlineIcons}
/>
)} )}
> {!!countCommunityOnly && (
Clear filters <Button
</a> as="a"
href={urlIfIncludingCommunity}
label={`Show ${countCommunityOnly.toLocaleString()} community contributed`}
icon="ri:search-line"
inlineIcon={inlineIcons}
/>
)}
<Button
as="a"
href="/service-suggestion/new"
label="Add service"
icon="ri:add-line"
inlineIcon={inlineIcons}
/>
</div>
</div> </div>
) : ( ) : (
<> <>
<div class="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]"> <div class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
{services.map((service, i) => ( {services.map((service, i) => (
<ServiceCard <ServiceCard
inlineIcons inlineIcons={inlineIcons}
service={service} service={service}
data-hx-search-results-card data-hx-search-results-card
{...(i === services.length - 1 && currentPage < totalPages {...(i === services.length - 1 && currentPage < totalPages
@@ -131,11 +233,29 @@ const totalPages = Math.ceil(total / pageSize) || 1
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator"> <div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500"> <div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
<Icon name="ri:loader-4-line" class="size-8 animate-spin text-green-500" /> <Icon
name="ri:loader-4-line"
class="size-8 animate-spin text-green-500"
is:inline={inlineIcons}
/>
Loading more services... Loading more services...
</div> </div>
</div> </div>
</> </>
) )
} }
{
services && services.length > 0 && (
<div class="mt-4 text-center">
<Button
as="a"
href="/service-suggestion/new"
label="Add service"
icon="ri:add-line"
inlineIcon={inlineIcons}
class="mx-auto"
/>
</div>
)
}
</div> </div>

View File

@@ -36,7 +36,7 @@ const {
class={cn( class={cn(
'pointer-events-none hidden select-none group-hover/tooltip:flex', 'pointer-events-none hidden select-none group-hover/tooltip:flex',
'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0', 'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0',
'z-1000 w-max max-w-sm rounded-lg px-3 py-2 font-sans text-sm font-normal tracking-normal text-pretty whitespace-pre-wrap', 'z-1000 w-max max-w-sm rounded-lg px-3 py-2 font-sans text-sm font-normal tracking-normal text-pretty wrap-anywhere whitespace-pre-wrap',
// Position classes // Position classes
{ {
'absolute -top-2 left-1/2 origin-bottom -translate-x-1/2 translate-y-[calc(-100%+0.5rem)] text-center group-hover/tooltip:-translate-y-full starting:group-hover/tooltip:translate-y-[calc(-100%+0.25rem)]': 'absolute -top-2 left-1/2 origin-bottom -translate-x-1/2 translate-y-[calc(-100%+0.5rem)] text-center group-hover/tooltip:-translate-y-full starting:group-hover/tooltip:translate-y-[calc(-100%+0.25rem)]':
@@ -85,9 +85,8 @@ const {
}, },
classNames?.tooltip classNames?.tooltip
)} )}
> set:text={text}
{text} />
</span>
) )
} }
</Component> </Component>

View File

@@ -0,0 +1,87 @@
---
import { tv, type VariantProps } from 'tailwind-variants'
import { getSizePxFromTailwindClasses } from '../lib/tailwind'
import MyPicture from './MyPicture.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
import type { O } from 'ts-toolbelt'
const userBadge = tv({
slots: {
base: 'group/user-badge font-title inline-flex max-w-full items-center gap-1 overflow-hidden font-medium',
image: 'inline-block rounded-full object-cover',
text: 'truncate',
},
variants: {
size: {
sm: {
base: 'gap-1 text-xs',
image: 'size-4',
},
md: {
base: 'gap-2 text-sm',
image: 'size-5',
},
lg: {
base: 'gap-2 text-base',
image: 'size-6',
},
},
noLink: {
true: {
text: 'cursor-default',
},
false: {
base: 'cursor-pointer',
text: 'group-hover/user-badge:underline',
},
},
},
defaultVariants: {
size: 'sm',
noLink: false,
},
})
type Props = O.Optional<HTMLAttributes<'a'>, 'href'> &
VariantProps<typeof userBadge> & {
user: Prisma.UserGetPayload<{
select: {
name: true
displayName: true
picture: true
}
}>
classNames?: {
image?: string
text?: string
}
children?: never
}
const { user, href, class: className, size = 'sm', classNames, noLink = false, ...htmlProps } = Astro.props
const { base, image, text } = userBadge({ size, noLink })
const imageClassName = image({ class: classNames?.image })
const imageSizePx = getSizePxFromTailwindClasses(imageClassName, 16)
const Tag = noLink ? 'span' : 'a'
---
<Tag
href={Tag === 'a' ? (href ?? `/u/${user.name}`) : undefined}
class={base({ class: className })}
{...htmlProps}
>
{
!!user.picture && (
<MyPicture src={user.picture} height={imageSizePx} width={imageSizePx} class={imageClassName} alt="" />
)
}
<span class={text({ class: classNames?.text })}>
{user.displayName ?? user.name}
</span>
</Tag>

View File

@@ -19,6 +19,11 @@ type Props = {
verificationSummary: true verificationSummary: true
listedAt: true listedAt: true
createdAt: true createdAt: true
verificationSteps: {
select: {
status: true
}
}
} }
}> }>
} }
@@ -45,13 +50,21 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
)} )}
</p> </p>
{!!service.verificationSummary && ( {!!service.verificationSummary && (
<div class="mt-2 whitespace-pre-wrap">{service.verificationSummary}</div> <div class="mt-2 wrap-anywhere whitespace-pre-wrap" set:text={service.verificationSummary} />
)} )}
</div> </div>
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? ( ) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200"> <div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
<Icon name="ri:alert-line" class="size-5 text-yellow-400" /> <Icon name="ri:alert-line" class="size-5 text-yellow-400" />
<span>Community-contributed. Information not reviewed.</span> <span>
Community-contributed. Information not reviewed.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</span>
</div> </div>
) : wasRecentlyAdded ? ( ) : wasRecentlyAdded ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400"> <div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
@@ -59,11 +72,40 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} /> <TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with {service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution. caution.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</div> </div>
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? ( ) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200"> <div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
<Icon name="ri:information-line" class="size-5 text-blue-400" /> <Icon name="ri:information-line" class="size-5 text-blue-400" />
<span>Basic checks passed, but not fully verified.</span> <span>
Basic checks passed, but not fully verified.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</span>
</div> </div>
) : null ) : null
} }
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.verificationSteps.some((step) => step.status === 'FAILED') && (
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/>
<span>
This service has failed one or more verification steps. Review the verification details carefully.
</span>
</div>
)
}

View File

@@ -43,14 +43,14 @@ export const {
notificationTitle: 'Your account is no longer verified', notificationTitle: 'Your account is no longer verified',
}, },
{ {
value: 'VERIFIER_TRUE', value: 'MODERATOR_TRUE',
label: 'Verifier role granted', label: 'Moderator role granted',
notificationTitle: 'Verifier role granted', notificationTitle: 'Moderator role granted',
}, },
{ {
value: 'VERIFIER_FALSE', value: 'MODERATOR_FALSE',
label: 'Verifier role revoked', label: 'Moderator role revoked',
notificationTitle: 'Verifier role revoked', notificationTitle: 'Moderator role revoked',
}, },
{ {
value: 'SPAMMER_TRUE', value: 'SPAMMER_TRUE',

View File

@@ -9,25 +9,29 @@ type AnnouncementTypeInfo<T extends string | null | undefined = string> = {
icon: string icon: string
classNames: { classNames: {
container: string container: string
title: string bg: string
content: string content: string
icon: string
badge: string
} }
} }
export const { export const {
dataArray: announcementTypes, dataArray: announcementTypes,
dataObject: announcementTypesById, dataObject: announcementTypesById,
getFn: getAnnouncementTypeInfo, getFn: getAnnouncementTypeInfo,
zodEnumById: zodAnnouncementTypesById,
} = makeHelpersForOptions( } = makeHelpersForOptions(
'value', 'value',
(value): AnnouncementTypeInfo<typeof value> => ({ (value): AnnouncementTypeInfo<typeof value> => ({
value, value,
label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value), label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
icon: 'ri:information-line', icon: 'ri:question-line',
classNames: { classNames: {
container: 'bg-blue-900/40 border-blue-500/30', container: 'bg-cyan-950',
title: 'text-blue-400', bg: 'from-cyan-400 to-cyan-700',
content: 'text-blue-300', content: '[--gradient-edge:var(--color-green-100)] [--gradient-center:var(--color-cyan-400)]',
icon: 'text-cyan-300/80',
badge: 'bg-blue-900/30 text-blue-400',
}, },
}), }),
[ [
@@ -36,29 +40,35 @@ export const {
label: 'Info', label: 'Info',
icon: 'ri:information-line', icon: 'ri:information-line',
classNames: { classNames: {
container: 'bg-blue-900/40 border-blue-500/30', container: 'bg-cyan-950',
title: 'text-blue-400', bg: 'from-cyan-400 to-cyan-700',
content: 'text-blue-300', content: '[--gradient-edge:var(--color-green-100)] [--gradient-center:var(--color-cyan-400)]',
icon: 'text-cyan-300/80',
badge: 'bg-blue-900/30 text-blue-400',
}, },
}, },
{ {
value: 'WARNING', value: 'WARNING',
label: 'Warning', label: 'Warning',
icon: 'ri:alert-line', icon: 'ri:alert-fill',
classNames: { classNames: {
container: 'bg-yellow-900/40 border-yellow-500/30', container: 'bg-yellow-950',
title: 'text-yellow-400', bg: 'from-yellow-400 to-yellow-700',
content: 'text-yellow-300', content: '[--gradient-edge:var(--color-lime-100)] [--gradient-center:var(--color-yellow-400)]',
icon: 'text-yellow-400/80',
badge: 'bg-yellow-900/30 text-yellow-400',
}, },
}, },
{ {
value: 'ALERT', value: 'ALERT',
label: 'Alert', label: 'Alert',
icon: 'ri:error-warning-line', icon: 'ri:spam-fill',
classNames: { classNames: {
container: 'bg-red-900/40 border-red-500/30', container: 'bg-red-950',
title: 'text-red-400', bg: 'from-red-400 to-red-700',
content: 'text-red-300', content: '[--gradient-edge:var(--color-red-100)] [--gradient-center:var(--color-rose-400)]',
icon: 'text-red-400/80',
badge: 'bg-red-900/30 text-red-400',
}, },
}, },
] as const satisfies AnnouncementTypeInfo<AnnouncementType>[] ] as const satisfies AnnouncementTypeInfo<AnnouncementType>[]

View File

@@ -1,12 +1,15 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { CommentStatus } from '@prisma/client' import type { CommentStatus } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type CommentStatusInfo<T extends string | null | undefined = string> = { type CommentStatusInfo<T extends string | null | undefined = string> = {
id: T id: T
icon: string icon: string
label: string label: string
color: ComponentProps<typeof BadgeSmall>['color']
creativeWorkStatus: string | undefined creativeWorkStatus: string | undefined
} }
@@ -20,37 +23,43 @@ export const {
id, id,
icon: 'ri:question-line', icon: 'ri:question-line',
label: id ? transformCase(id, 'title') : String(id), label: id ? transformCase(id, 'title') : String(id),
color: 'gray',
creativeWorkStatus: undefined, creativeWorkStatus: undefined,
}), }),
[ [
{ {
id: 'PENDING', id: 'PENDING',
icon: 'ri:question-line', icon: 'ri:question-line',
label: 'Pending', label: 'Unmoderated',
color: 'yellow',
creativeWorkStatus: 'Deleted', creativeWorkStatus: 'Deleted',
}, },
{ {
id: 'HUMAN_PENDING', id: 'HUMAN_PENDING',
icon: 'ri:question-line', icon: 'ri:question-line',
label: 'Pending 2', label: 'Unmoderated',
color: 'yellow',
creativeWorkStatus: 'Deleted', creativeWorkStatus: 'Deleted',
}, },
{ {
id: 'VERIFIED', id: 'VERIFIED',
icon: 'ri:check-line', icon: 'ri:verified-badge-fill',
label: 'Verified', label: 'Verified',
color: 'blue',
creativeWorkStatus: 'Verified', creativeWorkStatus: 'Verified',
}, },
{ {
id: 'REJECTED', id: 'REJECTED',
icon: 'ri:close-line', icon: 'ri:close-line',
label: 'Rejected', label: 'Rejected',
color: 'red',
creativeWorkStatus: 'Deleted', creativeWorkStatus: 'Deleted',
}, },
{ {
id: 'APPROVED', id: 'APPROVED',
icon: 'ri:check-line', icon: 'ri:check-line',
label: 'Approved', label: 'Approved',
color: 'green',
creativeWorkStatus: 'Active', creativeWorkStatus: 'Active',
}, },
] as const satisfies CommentStatusInfo<CommentStatus>[] ] as const satisfies CommentStatusInfo<CommentStatus>[]

View File

@@ -1,15 +1,20 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import { commentStatusById } from './commentStatus'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type CommentStatusFilterInfo<T extends string | null | undefined = string> = { type CommentStatusFilterInfo<T extends string | null | undefined = string> = {
value: T value: T
label: string label: string
color: ComponentProps<typeof BadgeSmall>['color']
icon: string
whereClause: Prisma.CommentWhereInput whereClause: Prisma.CommentWhereInput
styles: { classNames: {
filter: string filter: string
badge: string
} }
} }
@@ -24,9 +29,10 @@ export const {
value, value,
label: value ? transformCase(value, 'title') : String(value), label: value ? transformCase(value, 'title') : String(value),
whereClause: {}, whereClause: {},
styles: { color: 'gray',
icon: 'ri:question-line',
classNames: {
filter: 'border-zinc-700 transition-colors hover:border-green-500/50', filter: 'border-zinc-700 transition-colors hover:border-green-500/50',
badge: '',
}, },
}), }),
[ [
@@ -34,75 +40,92 @@ export const {
label: 'All', label: 'All',
value: 'all', value: 'all',
whereClause: {}, whereClause: {},
styles: { color: 'gray',
icon: 'ri:question-line',
classNames: {
filter: 'border-green-500 bg-green-500/20 text-green-400', filter: 'border-green-500 bg-green-500/20 text-green-400',
badge: '',
}, },
}, },
{ {
label: 'Pending',
value: 'pending', value: 'pending',
label: 'AI pending',
color: commentStatusById.PENDING.color,
icon: 'ri:robot-2-line',
whereClause: { whereClause: {
OR: [{ status: 'PENDING' }, { status: 'HUMAN_PENDING' }], OR: [{ status: 'PENDING' }, { status: 'HUMAN_PENDING' }],
}, },
styles: { classNames: {
filter: 'border-blue-500 bg-blue-500/20 text-blue-400',
},
},
{
value: 'human-pending',
label: 'Human needed',
color: commentStatusById.HUMAN_PENDING.color,
icon: 'ri:user-search-line',
whereClause: { status: 'HUMAN_PENDING' },
classNames: {
filter: 'border-blue-500 bg-blue-500/20 text-blue-400', filter: 'border-blue-500 bg-blue-500/20 text-blue-400',
badge: 'rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500',
}, },
}, },
{ {
label: 'Rejected',
value: 'rejected', value: 'rejected',
label: commentStatusById.REJECTED.label,
color: commentStatusById.REJECTED.color,
icon: commentStatusById.REJECTED.icon,
whereClause: { whereClause: {
status: 'REJECTED', status: 'REJECTED',
}, },
styles: { classNames: {
filter: 'border-red-500 bg-red-500/20 text-red-400', filter: 'border-red-500 bg-red-500/20 text-red-400',
badge: 'rounded-sm bg-red-500/20 px-2 py-0.5 text-[12px] font-medium text-red-500',
}, },
}, },
{ {
label: 'Suspicious', label: 'Suspicious',
value: 'suspicious', value: 'suspicious',
color: 'red',
icon: 'ri:close-circle-fill',
whereClause: { whereClause: {
suspicious: true, suspicious: true,
}, },
styles: { classNames: {
filter: 'border-red-500 bg-red-500/20 text-red-400', filter: 'border-red-500 bg-red-500/20 text-red-400',
badge: 'rounded-sm bg-red-500/20 px-2 py-0.5 text-[12px] font-medium text-red-500',
}, },
}, },
{ {
label: 'Verified',
value: 'verified', value: 'verified',
label: commentStatusById.VERIFIED.label,
color: commentStatusById.VERIFIED.color,
icon: commentStatusById.VERIFIED.icon,
whereClause: { whereClause: {
status: 'VERIFIED', status: 'VERIFIED',
}, },
styles: { classNames: {
filter: 'border-blue-500 bg-blue-500/20 text-blue-400', filter: 'border-blue-500 bg-blue-500/20 text-blue-400',
badge: 'rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500',
}, },
}, },
{ {
label: 'Approved',
value: 'approved', value: 'approved',
label: commentStatusById.APPROVED.label,
color: commentStatusById.APPROVED.color,
icon: commentStatusById.APPROVED.icon,
whereClause: { whereClause: {
status: 'APPROVED', status: 'APPROVED',
}, },
styles: { classNames: {
filter: 'border-green-500 bg-green-500/20 text-green-400', filter: 'border-green-500 bg-green-500/20 text-green-400',
badge: 'rounded-sm bg-green-500/20 px-2 py-0.5 text-[12px] font-medium text-green-500',
}, },
}, },
{ {
label: 'Needs Review', label: 'Needs Review',
value: 'needs-review', value: 'needs-review',
color: 'yellow',
icon: 'ri:question-line',
whereClause: { whereClause: {
requiresAdminReview: true, requiresAdminReview: true,
}, },
styles: { classNames: {
filter: 'border-yellow-500 bg-yellow-500/20 text-yellow-400', filter: 'border-yellow-500 bg-yellow-500/20 text-yellow-400',
badge: 'rounded-sm bg-yellow-500/20 px-2 py-0.5 text-[12px] font-medium text-yellow-500',
}, },
}, },
] as const satisfies CommentStatusFilterInfo[] ] as const satisfies CommentStatusFilterInfo[]
@@ -123,10 +146,12 @@ export function getCommentStatusFilterValue(
if (comment.suspicious) return 'suspicious' if (comment.suspicious) return 'suspicious'
switch (comment.status) { switch (comment.status) {
case 'PENDING': case 'PENDING': {
case 'HUMAN_PENDING': {
return 'pending' return 'pending'
} }
case 'HUMAN_PENDING': {
return 'human-pending'
}
case 'VERIFIED': { case 'VERIFIED': {
return 'verified' return 'verified'
} }

View File

@@ -0,0 +1,199 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { Assert } from '../lib/assert'
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
type ContactMethodInfo<T extends string | null | undefined = string> = {
type: T
label: string
/** Notice that the first capture group is then used to format the value */
matcher: RegExp
formatter: (match: RegExpMatchArray) => string | null
icon: string
urlType: string
}
export const {
dataArray: contactMethods,
dataObject: contactMethodsById,
/** Use {@link formatContactMethod} instead */
getFn: getContactMethodInfo,
} = makeHelpersForOptions(
'type',
(type): ContactMethodInfo<typeof type> => ({
type,
label: type ? transformCase(type, 'title') : String(type),
icon: 'ri:link',
matcher: /(.*)/,
formatter: ([, value]) => value ?? String(value),
urlType: type ?? 'unknown',
}),
[
{
type: 'email',
label: 'Email',
matcher: /mailto:(.+)/,
formatter: ([, value]) => value ?? 'Email',
icon: 'ri:mail-line',
urlType: 'email',
},
{
type: 'telephone',
label: 'Telephone',
matcher: /tel:(.+)/,
formatter: ([, value]) => {
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
} catch (_error) {
console.error(`Invalid telephone number: ${value ?? 'undefined'}`, _error)
return value ?? 'Telephone'
}
},
icon: 'ri:phone-line',
urlType: 'telephone',
},
{
type: 'whatsapp',
label: 'WhatsApp',
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: ([, value]) => {
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
} catch (_error) {
console.error(`Invalid WhatsApp number: ${value ?? 'undefined'}`, _error)
return value ?? 'WhatsApp'
}
},
icon: 'ri:whatsapp-line',
urlType: 'url',
},
{
type: 'telegram',
label: 'Telegram',
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
icon: 'ri:telegram-line',
urlType: 'url',
},
{
type: 'linkedin',
label: 'LinkedIn',
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
icon: 'ri:linkedin-box-line',
urlType: 'url',
},
{
type: 'x',
label: 'X',
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'X'),
icon: 'ri:twitter-x-line',
urlType: 'url',
},
{
type: 'instagram',
label: 'Instagram',
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
icon: 'ri:instagram-line',
urlType: 'url',
},
{
type: 'matrix',
label: 'Matrix',
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
icon: 'ri:hashtag',
urlType: 'url',
},
{
type: 'bitcointalk',
label: 'BitcoinTalk',
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line',
urlType: 'url',
},
{
type: 'simplex',
label: 'SimpleX Chat',
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
urlType: 'url',
},
{
type: 'nostr',
label: 'Nostr',
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
formatter: () => 'Nostr',
icon: 'nostr',
urlType: 'url',
},
{
// Website must go last because it's a catch-all
type: 'website',
label: 'Website',
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: ([, value]) => value ?? 'Website',
icon: 'ri:global-line',
urlType: 'url',
},
] as const satisfies ContactMethodInfo[]
)
export function formatContactMethod(url: string) {
for (const contactMethod of contactMethods) {
const match = url.match(contactMethod.matcher)
if (!match) continue
const formattedValue = contactMethod.formatter(match)
if (!formattedValue) continue
return {
...contactMethod,
formattedValue,
} as const
}
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
}
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
value: T
labelPlural: string
}
export const {
dataArray: contactMethodUrlTypes,
dataObject: contactMethodUrlTypesById,
getFn: getContactMethodUrlTypeInfo,
} = makeHelpersForOptions(
'value',
(value): ContactMethodUrlTypeInfo<typeof value> => ({
value,
labelPlural: value ? transformCase(value, 'title') : String(value),
}),
[
{
value: 'email',
labelPlural: 'emails',
},
{
value: 'telephone',
labelPlural: 'phone numbers',
},
{
value: 'url',
labelPlural: 'URLs',
},
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
)
type _ExpectUrlTypesToHaveAllValues = Assert<
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
>

View File

@@ -1,7 +1,9 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { EventType } from '@prisma/client' import type { EventType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type EventTypeInfo<T extends string | null | undefined = string> = { type EventTypeInfo<T extends string | null | undefined = string> = {
id: T id: T
@@ -12,6 +14,9 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
dot: string dot: string
} }
icon: string icon: string
color: ComponentProps<typeof BadgeSmall>['color']
isSolved: boolean
showBanner: boolean
} }
export const { export const {
@@ -32,6 +37,9 @@ export const {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50', dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
}, },
icon: 'ri:question-fill', icon: 'ri:question-fill',
color: 'gray',
isSolved: false,
showBanner: false,
}), }),
[ [
{ {
@@ -42,7 +50,10 @@ export const {
classNames: { classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:error-warning-fill', icon: 'ri:alert-fill',
color: 'yellow',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'WARNING_SOLVED', id: 'WARNING_SOLVED',
@@ -50,9 +61,12 @@ export const {
label: 'Warning Solved', label: 'Warning Solved',
description: 'A previously reported warning has been solved', description: 'A previously reported warning has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:alert-fill',
color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'ALERT', id: 'ALERT',
@@ -62,7 +76,10 @@ export const {
classNames: { classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:alert-fill', icon: 'ri:spam-fill',
color: 'red',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'ALERT_SOLVED', id: 'ALERT_SOLVED',
@@ -70,9 +87,12 @@ export const {
label: 'Alert Solved', label: 'Alert Solved',
description: 'A previously reported alert has been solved', description: 'A previously reported alert has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:spam-fill',
color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'INFO', id: 'INFO',
@@ -83,6 +103,9 @@ export const {
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50', dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
}, },
icon: 'ri:information-fill', icon: 'ri:information-fill',
color: 'sky',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'NORMAL', id: 'NORMAL',
@@ -93,6 +116,9 @@ export const {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50', dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
}, },
icon: 'ri:notification-fill', icon: 'ri:notification-fill',
color: 'green',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'UPDATE', id: 'UPDATE',
@@ -103,6 +129,9 @@ export const {
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50', dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
}, },
icon: 'ri:pencil-fill', icon: 'ri:pencil-fill',
color: 'sky',
isSolved: false,
showBanner: false,
}, },
] as const satisfies EventTypeInfo<EventType>[] ] as const satisfies EventTypeInfo<EventType>[]
) )

View File

@@ -78,8 +78,8 @@ export const {
}, },
{ {
value: 'MANUAL_ADJUSTMENT', value: 'MANUAL_ADJUSTMENT',
slug: 'manual-adjustment', slug: 'gift',
label: 'Manual adjustment', label: 'Gift',
icon: 'ri:gift-line', icon: 'ri:gift-line',
}, },
] as const satisfies KarmaTransactionActionInfo<KarmaTransactionAction>[] ] as const satisfies KarmaTransactionActionInfo<KarmaTransactionAction>[]

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