Compare commits

...

21 Commits

Author SHA1 Message Date
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
107 changed files with 4204 additions and 3221 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

@@ -29,7 +29,7 @@ npm run db-fill-clean
Now open the [.env](web/.env) file and fill in the missing values.
> Default users are created with tokens: `admin`, `verifier`, `verified`, `normal` (configurable via env vars)
> Default users are created with tokens: `admin`, `moderator`, `verified`, `normal` (configurable via env vars)
### Running the project

View File

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

View File

@@ -1,4 +1,9 @@
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com"
SITE_URL="https://localhost:4321"
DATABASE_UI_URL="http://localhost:5555"
SITE_URL="https://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
RELEASE_NUMBER=123
RELEASE_DATE="2025-05-23T19:00:00.000Z"

View File

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

View File

@@ -26,4 +26,4 @@ All commands are run from the root of the project, from a terminal:
> **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-fill` and `db-fill-clean` create default users with tokens: `admin`, `verifier`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)

View File

@@ -74,6 +74,18 @@ export default defineConfig({
url: true,
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({
context: 'server',
@@ -119,11 +131,11 @@ export default defineConfig({
min: 1,
default: 'admin',
}),
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({
DEV_MODERATOR_USER_SECRET_TOKEN: envField.string({
context: 'server',
access: 'secret',
min: 1,
default: 'verifier',
default: 'moderator',
}),
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
context: 'server',
@@ -158,6 +170,25 @@ export default defineConfig({
url: true,
optional: false,
}),
DATABASE_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: false,
}),
RELEASE_NUMBER: envField.number({
context: 'server',
access: 'public',
int: true,
optional: true,
}),
RELEASE_DATE: envField.string({
context: 'server',
access: 'public',
optional: true,
}),
},
},
})

View File

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

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

@@ -120,8 +120,8 @@ enum AccountStatusChange {
ADMIN_FALSE
VERIFIED_TRUE
VERIFIED_FALSE
VERIFIER_TRUE
VERIFIER_FALSE
MODERATOR_TRUE
MODERATOR_FALSE
SPAMMER_TRUE
SPAMMER_FALSE
}
@@ -135,6 +135,7 @@ enum NotificationType {
SUGGESTION_MESSAGE
SUGGESTION_STATUS_CHANGE
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
KARMA_CHANGE
/// Marked as spammer, promoted to admin, etc.
ACCOUNT_STATUS_CHANGE
EVENT_CREATED
@@ -207,6 +208,8 @@ model Notification {
aboutCommentStatusChange CommentStatusChange?
aboutServiceVerificationStatusChange ServiceVerificationStatusChange?
aboutSuggestionStatusChange ServiceSuggestionStatusChange?
aboutKarmaTransaction KarmaTransaction? @relation(fields: [aboutKarmaTransactionId], references: [id])
aboutKarmaTransactionId Int?
@@index([userId])
@@index([read])
@@ -229,6 +232,7 @@ model NotificationPreferences {
enableOnMyCommentStatusChange Boolean @default(true)
enableAutowatchMyComments Boolean @default(true)
enableNotifyPendingRepliesOnWatch Boolean @default(false)
karmaNotificationThreshold Int @default(10)
onEventCreatedForServices Service[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices")
@@ -393,12 +397,15 @@ model Service {
}
model ServiceContactMethod {
id Int @id @default(autoincrement())
label String
id Int @id @default(autoincrement())
/// Only include it if you want to override the formatted value.
label String?
/// Including the protocol (e.g. "mailto:", "tel:", "https://")
value String
iconId String
info String
value String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade)
serviceId Int
}
@@ -457,7 +464,7 @@ model User {
spammer Boolean @default(false)
verified Boolean @default(false)
admin Boolean @default(false)
verifier Boolean @default(false)
moderator Boolean @default(false)
verifiedLink String?
secretTokenHash String @unique
/// Computed via trigger. Do not update through prisma.
@@ -522,6 +529,7 @@ model KarmaTransaction {
createdAt DateTime @default(now())
grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull)
grantedByUserId Int?
Notification Notification[]
@@index([createdAt])
@@index([userId])
@@ -613,9 +621,10 @@ model ServiceUser {
model Announcement {
id Int @id @default(autoincrement())
title String
content String
type AnnouncementType
link String?
linkText String?
startDate DateTime
endDate DateTime?
isActive Boolean @default(true)

View File

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

View File

@@ -0,0 +1,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();

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

View File

@@ -14,6 +14,7 @@ import {
EventType,
type User,
ServiceUserRole,
AnnouncementType,
} from '@prisma/client'
import { uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
@@ -84,7 +85,7 @@ async function createAccount(preGeneratedToken?: string) {
verifiedLink,
verified: !!verifiedLink,
admin: faker.datatype.boolean({ probability: 0.1 }),
verifier: faker.datatype.boolean({ probability: 0.1 }),
moderator: faker.datatype.boolean({ probability: 0.1 }),
},
include: {
serviceAffiliations: true,
@@ -844,40 +845,45 @@ const generateFakeComment = (userId: number, serviceId: number, parentId?: numbe
const generateFakeServiceContactMethod = (serviceId: number) => {
const types = [
{
label: 'Email',
value: `mailto:${faker.internet.email()}`,
iconId: 'ri:mail-line',
info: faker.lorem.sentence(),
},
{
label: 'Phone',
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' })}`,
iconId: 'ri:whatsapp-line',
info: faker.lorem.sentence(),
},
{
label: 'Telegram',
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(),
iconId: 'ri:global-line',
info: faker.lorem.sentence(),
},
{
label: 'LinkedIn',
value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
iconId: 'ri:linkedin-box-line',
info: faker.lorem.sentence(),
label: faker.lorem.word({ length: 2 }),
value: faker.internet.url(),
},
{
value: `https://linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
},
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
@@ -893,19 +899,19 @@ const specialUsersData = {
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
defaultToken: 'admin',
admin: true,
verifier: true,
moderator: true,
verified: true,
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
},
verifier: {
name: 'verifier_dev',
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
defaultToken: 'verifier',
moderator: {
name: 'moderator_dev',
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
defaultToken: 'moderator',
admin: false,
verifier: true,
moderator: true,
verified: true,
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
@@ -917,7 +923,7 @@ const specialUsersData = {
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
defaultToken: 'verified',
admin: false,
verifier: false,
moderator: false,
verified: true,
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
@@ -927,7 +933,7 @@ const specialUsersData = {
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
defaultToken: 'normal',
admin: false,
verifier: false,
moderator: false,
verified: false,
},
spam: {
@@ -935,7 +941,7 @@ const specialUsersData = {
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
defaultToken: 'spam',
admin: false,
verifier: false,
moderator: false,
verified: false,
totalKarma: -100,
spammer: true,
@@ -981,6 +987,22 @@ const generateFakeInternalNote = (userId: number, addedByUserId?: number) =>
addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined,
}) satisfies Prisma.InternalUserNoteCreateInput
const generateFakeAnnouncement = () => {
const type = faker.helpers.arrayElement(Object.values(AnnouncementType))
const startDate = faker.date.past()
const endDate = faker.helpers.maybe(() => faker.date.future(), { probability: 0.3 })
return {
content: faker.lorem.sentence(),
type,
link: faker.internet.url(),
linkText: faker.lorem.word({ length: 2 }),
startDate,
endDate,
isActive: true,
} as const satisfies Prisma.AnnouncementCreateInput
}
async function runFaker() {
await prisma.$transaction(
async (tx) => {
@@ -1004,6 +1026,7 @@ async function runFaker() {
await tx.category.deleteMany()
await tx.internalUserNote.deleteMany()
await tx.user.deleteMany()
await tx.announcement.deleteMany()
console.info('✅ Existing data cleaned up')
} catch (error) {
console.error('❌ Error cleaning up data:', error)
@@ -1283,7 +1306,7 @@ async function runFaker() {
tx.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
),
})
)
@@ -1300,13 +1323,18 @@ async function runFaker() {
tx.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
),
})
)
)
})
)
// ---- Create announcement ----
await tx.announcement.create({
data: generateFakeAnnouncement(),
})
},
{
timeout: 1000 * 60 * 10, // 10 minutes

View File

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

View File

@@ -1,4 +1,4 @@
import { type Prisma, type PrismaClient, type AnnouncementType } from '@prisma/client'
import { type Prisma, type PrismaClient } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
@@ -9,9 +9,10 @@ const prisma = prismaInstance as PrismaClient
const selectAnnouncementReturnFields = {
id: true,
title: true,
content: true,
type: true,
link: true,
linkText: true,
startDate: true,
endDate: true,
isActive: true,
@@ -24,12 +25,18 @@ export const adminAnnouncementActions = {
accept: 'form',
permissions: 'admin',
input: z.object({
title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
content: z
.string()
.min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'),
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(),
endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true),
@@ -37,8 +44,13 @@ export const adminAnnouncementActions = {
handler: async (input) => {
const announcement = await prisma.announcement.create({
data: {
...input,
endDate: input.endDate || null,
content: input.content,
type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
},
select: selectAnnouncementReturnFields,
})
@@ -52,12 +64,18 @@ export const adminAnnouncementActions = {
permissions: 'admin',
input: z.object({
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
.string()
.min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'),
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(),
endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true),
@@ -82,8 +100,13 @@ export const adminAnnouncementActions = {
const updatedAnnouncement = await prisma.announcement.update({
where: { id: announcement.id },
data: {
...input,
endDate: input.endDate || null,
content: input.content,
type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
},
select: selectAnnouncementReturnFields,
})

View File

@@ -14,7 +14,7 @@ import {
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number(),
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
@@ -56,15 +56,6 @@ const addSlugIfMissing = <
}),
})
const contactMethodSchema = z.object({
id: z.number().optional(),
label: z.string().min(1).max(50),
value: z.string().min(1).max(200),
iconId: z.string().min(1).max(50),
info: z.string().max(200).optional().default(''),
serviceId: z.number(),
})
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
@@ -195,10 +186,18 @@ export const adminServiceActions = {
createContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: contactMethodSchema.omit({ id: true }),
input: z.object({
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.create({
data: input,
data: {
label: input.label,
value: input.value,
serviceId: input.serviceId,
},
})
return { contactMethod }
},
@@ -207,12 +206,20 @@ export const adminServiceActions = {
updateContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: contactMethodSchema,
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 { id, ...data } = input
const contactMethod = await prisma.serviceContactMethod.update({
where: { id },
data,
where: { id: input.id },
data: {
label: input.label,
value: input.value,
serviceId: input.serviceId,
},
})
return { contactMethod }
},
@@ -222,7 +229,7 @@ export const adminServiceActions = {
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number(),
id: z.number().int().positive(),
}),
handler: async (input) => {
await prisma.serviceContactMethod.delete({

View File

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

View File

@@ -14,9 +14,9 @@ import { timeTrapSecretKey } from '../lib/timeTrapSecret'
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_VERIFIED_USER = 5
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
export const commentActions = {
vote: defineProtectedAction({
@@ -331,7 +331,7 @@ export const commentActions = {
}),
moderate: defineProtectedAction({
permissions: ['admin', 'verifier'],
permissions: ['admin', 'moderator'],
input: z.object({
commentId: z.number(),
userId: z.number(),

View File

@@ -31,6 +31,7 @@ export const notificationActions = {
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
enableAutowatchMyComments: z.coerce.boolean().optional(),
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).optional(),
}),
handler: async (input, context) => {
await prisma.notificationPreferences.upsert({
@@ -39,12 +40,14 @@ export const notificationActions = {
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
enableAutowatchMyComments: input.enableAutowatchMyComments,
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
karmaNotificationThreshold: input.karmaNotificationThreshold,
},
create: {
userId: context.locals.user.id,
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
enableAutowatchMyComments: input.enableAutowatchMyComments,
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
karmaNotificationThreshold: input.karmaNotificationThreshold,
},
})
},

View File

@@ -1,57 +1,92 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { getAnnouncementTypeInfo } from '../constants/announcementTypes'
import { cn } from '../lib/cn'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = {
announcements:
| Prisma.AnnouncementGetPayload<{
select: {
id: true
title: true
content: true
type: true
startDate: true
endDate: true
isActive: true
}
}>[]
| null
| undefined
type Props = HTMLAttributes<'div'> & {
announcement: Prisma.AnnouncementGetPayload<{
select: {
id: true
content: true
type: true
link: true
linkText: true
startDate: true
endDate: true
isActive: true
}
}>
}
const { announcements } = Astro.props
const { announcement, class: className, ...props } = Astro.props
const typeInfo = getAnnouncementTypeInfo(announcement.type)
const Tag = announcement.link ? 'a' : 'div'
---
{
!!announcements && announcements.length > 0 && (
<div class="mb-4 flex flex-col items-center space-y-1">
{announcements.map((announcement) => {
const typeInfo = getAnnouncementTypeInfo(announcement.type)
return (
<div
class={cn(
'mx-auto flex w-auto max-w-full flex-row items-center rounded border px-3 py-2',
typeInfo.classNames.container
)}
>
<Icon name={typeInfo.icon} class={cn('mr-2 size-4 flex-shrink-0', typeInfo.classNames.title)} />
<div class="flex min-w-0 flex-col">
<span class={cn('truncate text-sm leading-tight font-bold', typeInfo.classNames.title)}>
{announcement.title}
</span>
<span class={cn('truncate text-xs leading-snug [&_a]:underline', typeInfo.classNames.content)}>
<Markdown content={announcement.content} />
</span>
</div>
</div>
)
})}
<Tag
href={announcement.link}
target={announcement.link ? '_blank' : undefined}
rel="noopener noreferrer"
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',
className
)}
{...props}
>
<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"
>
<div
class={cn(
'aspect-[577/310] w-[36.0625rem] bg-gradient-to-r from-green-500 to-green-700 opacity-20',
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
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,15 @@
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import { cn } from '../lib/cn'
import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & {
as: Tag
label?: string
label: string
icon?: string
endIcon?: string
classNames?: {
@@ -19,6 +21,7 @@ type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
dataAstroReload?: boolean
children?: never
disabled?: boolean
inlineIcon?: boolean
}
>
@@ -26,7 +29,7 @@ export type ButtonProps<Tag extends 'a' | 'button' | 'label' = 'button'> = Props
const button = tv({
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',
label: 'text-left whitespace-nowrap',
endIcon: 'size-4 shrink-0',
@@ -51,28 +54,24 @@ const button = tv({
label: 'font-bold tracking-wider uppercase',
},
},
iconOnly: {
true: {
base: 'p-0',
label: 'sr-only',
},
},
color: {
black: {
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
},
white: {
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
},
gray: {
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
},
success: {
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
},
error: {
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
},
warning: {
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
},
info: {
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
},
black: '',
white: '',
gray: '',
success: '',
danger: '',
warning: '',
info: '',
},
variant: {
solid: '',
faded: '',
},
shadow: {
true: {
@@ -86,6 +85,107 @@ const button = tv({
},
},
compoundVariants: [
// Color variants - solid
{
color: 'black',
variant: 'solid',
class: {
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
},
},
{
color: 'white',
variant: 'solid',
class: {
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
},
},
{
color: 'gray',
variant: 'solid',
class: {
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
},
},
{
color: 'success',
variant: 'solid',
class: {
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
},
},
{
color: 'danger',
variant: 'solid',
class: {
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
},
},
{
color: 'warning',
variant: 'solid',
class: {
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
},
},
{
color: 'info',
variant: 'solid',
class: {
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
},
},
// Color variants - faded
{
color: 'black',
variant: 'faded',
class: {
base: 'bg2night-800/15 hover:bg-night-700/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
},
},
{
color: 'white',
variant: 'faded',
class: {
base: 'b2-day-100/15 hover:bg-day-200/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
},
},
{
color: 'gray',
variant: 'faded',
class: {
base: 'b2-day-400/15 hover:bg-day-500/30 text-day-300 hover:text-day-100 border-current/30 focus-visible:ring-white/50',
},
},
{
color: 'success',
variant: 'faded',
class: {
base: 'border-current/20 bg-green-500/15 text-green-300 hover:bg-green-500/30 hover:text-green-100',
},
},
{
color: 'danger',
variant: 'faded',
class: {
base: 'border-current/20 bg-red-500/15 text-red-300 hover:bg-red-500/30 hover:text-red-100',
},
},
{
color: 'warning',
variant: 'faded',
class: {
base: 'border-current/20 bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/30 hover:text-yellow-100',
},
},
{
color: 'info',
variant: 'faded',
class: {
base: 'border-current/20 bg-blue-500/15 text-blue-300 hover:bg-blue-500/30 hover:text-blue-100',
},
},
// Shadow variants
{
color: 'black',
shadow: true,
@@ -107,7 +207,7 @@ const button = tv({
class: 'shadow-green-500/30',
},
{
color: 'error',
color: 'danger',
shadow: true,
class: 'shadow-red-500/30',
},
@@ -121,12 +221,30 @@ const button = tv({
shadow: true,
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: {
size: 'md',
color: 'black',
variant: 'solid',
shadow: false,
disabled: false,
iconOnly: false,
},
})
@@ -137,12 +255,15 @@ const {
endIcon,
size,
color,
variant,
shadow,
class: className,
classNames,
role,
dataAstroReload,
disabled,
inlineIcon,
iconOnly,
...htmlProps
} = Astro.props
@@ -151,24 +272,31 @@ const {
icon: iconSlot,
label: labelSlot,
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
---
<ActualTag
class={base({ class: className })}
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ??
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
aria-disabled={disabled}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps}
>
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} />}
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
<span class={labelSlot({ class: classNames?.label })}>{label}</span>
{
!!endIcon && (
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })}>
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>
{endIcon}
</Icon>
)

View File

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

View File

@@ -2,7 +2,7 @@
import { cn } from '../lib/cn'
import { formatDateShort } from '../lib/timeAgo'
import MyPicture from './MyPicture.astro'
import UserBadge from './UserBadge.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
@@ -15,6 +15,7 @@ export type ChatMessage = {
select: {
id: true
name: true
displayName: true
picture: true
}
}>
@@ -71,31 +72,19 @@ const { messages, userId, class: className, ...htmlProps } = Astro.props
)}
>
{!isCurrentUser && !isNextFromSameUser && (
<p 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>
<UserBadge user={message.user} size="sm" class="text-day-500 mb-0.5 text-xs" />
)}
<p
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 ? 'rounded-br-xs' : 'rounded-bl-xs',
isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tr-xs',
!isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tl-xs'
)}
id={`message-${message.id.toString()}`}
>
{message.content}
</p>
set:text={message.content}
/>
{(!isPrevFromSameUser || !isPrevSameDate) && (
<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 { actions } from 'astro:actions'
import { commentStatusById } from '../constants/commentStatus'
import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { getServiceUserRoleInfo } from '../constants/serviceUserRoles'
import { cn } from '../lib/cn'
@@ -18,9 +19,9 @@ import { formatDateShort } from '../lib/timeAgo'
import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro'
import CommentReply from './CommentReply.astro'
import MyPicture from './MyPicture.astro'
import TimeFormatted from './TimeFormatted.astro'
import Tooltip from './Tooltip.astro'
import UserBadge from './UserBadge.astro'
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 isAuthor = user?.id === comment.author.id
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
const isAdminOrModerator = !!user && (user.admin || user.moderator)
const isAuthorOrPrivileged = isAuthor || isAdminOrModerator
// Check if user is new (less than 1 week old)
const isNewUser =
@@ -74,7 +75,7 @@ const isRatingActive =
!comment.suspicious &&
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
// Skip rendering if comment is not approved/verified and user is not the author or admin/moderator
const shouldShow =
comment.status === 'APPROVED' ||
comment.status === 'VERIFIED' ||
@@ -156,33 +157,17 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
</label>
<span class="flex items-center gap-1">
{
comment.author.picture && (
<MyPicture
src={comment.author.picture}
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>
<UserBadge
user={comment.author}
size="md"
class={cn('text-day-300', isAuthor && 'font-medium text-green-500')}
/>
{
(comment.author.verified || comment.author.admin || comment.author.verifier) && (
(comment.author.verified || comment.author.admin || comment.author.moderator) && (
<Tooltip
text={`${
comment.author.admin || comment.author.verifier
comment.author.admin || comment.author.moderator
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
@@ -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
icon="ri:graduation-cap-fill"
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 })}`}>
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
</Tooltip>
)
}
{
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.moderator && (
<BadgeSmall
icon={karmaUnlocksById.highKarmaBadge.icon}
color="lime"
@@ -307,20 +292,35 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{
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') &&
(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 && (
<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
/>
)
}
@@ -371,15 +371,16 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.communityNote && (
<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">
<span class="font-medium text-zinc-400">Added context:</span>
<span class="text-zinc-300">{comment.communityNote}</span>
<span class="prose prose-sm prose-invert prose-strong:text-zinc-300/90 text-xs text-zinc-300">
<Markdown content={`**Added context:** ${comment.communityNote}`} />
</span>
</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="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>
@@ -390,7 +391,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
}
{
user && (user.admin || user.verifier) && comment.privateContext && (
user && (user.admin || user.moderator) && comment.privateContext && (
<div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
<span class="font-medium text-blue-400">Private context:</span>

View File

@@ -28,8 +28,8 @@ const { comment, class: className, ...divProps } = Astro.props
const user = Astro.locals.user
// Only render for admin/verifier users
if (!user || !user.admin || !user.verifier) return null
// Only render for admin/moderator users
if (!user || !user.admin || !user.moderator) return null
---
<div {...divProps} class={cn('text-xs', className)}>
@@ -110,16 +110,18 @@ if (!user || !user.admin || !user.verifier) return null
<button
class={cn(
'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'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)}
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-user-id={user.id}
>
{comment.status === 'PENDING' ? 'Approve' : 'Pending'}
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</button>
<button

View File

@@ -11,6 +11,7 @@ import InputHoneypotTrap from './InputHoneypotTrap.astro'
import InputRating from './InputRating.astro'
import InputText from './InputText.astro'
import InputWrapper from './InputWrapper.astro'
import UserBadge from './UserBadge.astro'
import type { Prisma } from '@prisma/client'
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">
<Icon name="ri:user-line" class="size-3.5" />
<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>
</div>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
---
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'
@@ -11,10 +11,22 @@ type Props = HTMLAttributes<'footer'>
const links = [
{
href: SOURCE_CODE_URL,
label: 'Source Code',
label: 'Code',
icon: 'ri:git-repository-line',
external: true,
},
{
href: ONION_ADDRESS,
label: 'Tor',
icon: 'onion',
external: true,
},
{
href: I2P_ADDRESS,
label: 'I2P',
icon: 'i2p',
external: true,
},
{
href: '/about',
label: 'About',
@@ -40,7 +52,7 @@ const { class: className, ...htmlProps } = Astro.props
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
>
<Icon name={icon} class="h-4 w-4" />
{label}

View File

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

View File

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

View File

@@ -25,8 +25,19 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
<InputWrapper inputId={inputId} {...wrapperProps}>
{
!!removeCheckbox && (
<label class="flex cursor-pointer items-center gap-2 py-1 pl-1 text-sm leading-none">
<input transition:persist type="checkbox" name={removeCheckbox.name} data-remove-checkbox />
<label
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'}
</label>
)

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ type Props = HTMLAttributes<'div'> & {
error?: string[] | string
icon?: string
inputId?: string
hideLabel?: boolean
}
const {
@@ -30,6 +31,7 @@ const {
icon,
class: className,
inputId,
hideLabel,
...htmlProps
} = Astro.props
@@ -37,17 +39,20 @@ const hasError = !!error && error.length > 0
---
<fieldset class={cn('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')}>
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
<label for={inputId}>{label}</label>{required && '*'}
</legend>
{
!!descriptionLabel && (
<span class="text-day-400 flex-1 basis-24 text-xs text-pretty">{descriptionLabel}</span>
)
}
</div>
{
!hideLabel && (
<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')}>
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
<label for={inputId}>{label}</label>
{required && '*'}
</legend>
{!!descriptionLabel && (
<span class="text-day-400 flex-1 basis-24 text-xs text-pretty">{descriptionLabel}</span>
)}
</div>
)
}
<slot />

View File

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

View File

@@ -9,10 +9,11 @@ type Props = HTMLAttributes<'div'> & {
value: HTMLAttributes<'input'>['value']
label: string
}[]
inputProps?: Omit<HTMLAttributes<'input'>, 'checked' | 'class' | 'name' | 'type' | 'value'>
selectedValue?: string | null
}
const { name, options, selectedValue, class: className, ...rest } = Astro.props
const { name, options, selectedValue, inputProps, class: className, ...rest } = Astro.props
---
<div
@@ -31,6 +32,7 @@ const { name, options, selectedValue, class: className, ...rest } = Astro.props
value={option.value}
checked={selectedValue === option.value}
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">
{option.label}

View File

@@ -88,12 +88,26 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
{name}{
statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-my-2 shrink-0">
<Icon
is:inline={inlineIcons}
name={statusIcon.icon}
class={cn('inline-block size-6 shrink-0 rounded-lg p-1', statusIcon.classNames.icon)}
/>
<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>
)
}

View File

@@ -34,17 +34,23 @@ const {
<form
method="GET"
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-select={`#${searchResultsId}`}
hx-push-url="true"
hx-indicator="#search-indicator"
hx-swap="outerHTML"
data-services-filters-form
data-default-verification-filter={options.verification
.filter((verification) => verification.default)
.map((verification) => verification.slug)}
{...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',
className
)}
>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-title text-xl text-green-500">FILTERS</h2>
@@ -64,6 +70,7 @@ const {
name="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"
data-trigger-on-change
>
{
options.sort.map((option) => (
@@ -101,14 +108,16 @@ const {
{
options.categories?.map((category) => (
<li data-show-always={category.showAlways ? '' : undefined}>
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white">
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input
type="checkbox"
class="peer text-green-500"
name="categories"
value={category.slug}
checked={category.checked}
data-trigger-on-change
/>
<Icon name={category.icon} class="size-4" />
<span class="peer-checked:font-bold">
{category.name}
<span class="text-day-500 font-normal">{category._count.services}</span>
@@ -121,13 +130,7 @@ const {
{
options.categories.filter((category) => category.showAlways).length < options.categories.length && (
<>
<input
type="checkbox"
id="show-more-categories"
class="peer sr-only"
hx-preserve
data-show-more-input
/>
<input type="checkbox" id="show-more-categories" class="peer sr-only" hx-preserve />
<label
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"
@@ -158,6 +161,7 @@ const {
name="verification"
value={verification.slug}
checked={filters.verification.includes(verification.value)}
data-trigger-on-change
/>
<Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} />
<span class="peer-checked:font-bold">{verification.labelShort}</span>
@@ -176,6 +180,9 @@ const {
options={options.modeOptions}
selectedValue={filters['currency-mode']}
class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/>
</div>
<div>
@@ -188,6 +195,7 @@ const {
name="currencies"
value={currency.slug}
checked={filters.currencies?.some((id) => id === currency.id)}
data-trigger-on-change
/>
<Icon name={currency.icon} class="size-4" />
<span class="peer-checked:font-bold">{currency.name}</span>
@@ -210,6 +218,7 @@ const {
name="networks"
value={network.slug}
checked={filters.networks?.some((slug) => slug === network.slug)}
data-trigger-on-change
/>
<Icon name={network.icon} class="size-4" />
<span class="peer-checked:font-bold">{network.name}</span>
@@ -233,6 +242,7 @@ const {
id="max-kyc"
value={filters['max-kyc'] ?? 4}
class="w-full accent-green-500"
data-trigger-on-change
/>
</div>
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
@@ -261,6 +271,7 @@ const {
id="user-rating"
value={filters['user-rating']}
class="w-full accent-green-500"
data-trigger-on-change
/>
</div>
<div class="text-day-400 mt-1 flex justify-between px-2 text-xs">
@@ -289,12 +300,22 @@ const {
options={options.modeOptions}
selectedValue={filters['attribute-mode']}
class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/>
</div>
{
options.attributesByCategory.map(({ category, attributes }) => (
options.attributesByCategory.map(({ categoryInfo, attributes }) => (
<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">
{attributes.map((attribute) => {
@@ -318,6 +339,7 @@ const {
value=""
checked={!attribute.value}
aria-label="Ignore"
data-trigger-on-change
/>
<input
type="radio"
@@ -327,6 +349,7 @@ const {
class="peer/yes sr-only"
checked={attribute.value === 'yes'}
aria-label="Include"
data-trigger-on-change
/>
<input
type="radio"
@@ -336,6 +359,7 @@ const {
class="peer/no sr-only"
checked={attribute.value === 'no'}
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" />
@@ -356,11 +380,9 @@ const {
</label>
<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"
>
<span class="bg-night-400 border-night-600 block h-full w-px border-y-2" />
</span>
/>
<label
for={noId}
@@ -383,8 +405,8 @@ const {
aria-hidden="true"
>
<Icon
name={attribute.info.icon}
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.info.classNames.icon)}
name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
aria-hidden="true"
/>
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -398,8 +420,8 @@ const {
aria-hidden="true"
>
<Icon
name={attribute.info.icon}
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.info.classNames.icon)}
name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
aria-hidden="true"
/>
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -417,19 +439,18 @@ const {
<>
<input
type="checkbox"
id={`show-more-attributes-${category}`}
id={`show-more-attributes-${categoryInfo.slug}`}
class="peer sr-only"
hx-preserve
data-show-more-input
/>
<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"
>
+ Show more
</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"
>
- Show less
@@ -455,6 +476,7 @@ const {
id="min-score"
value={filters['min-score']}
class="w-full accent-green-500"
data-trigger-on-change
/>
</div>
<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 { uniq } from 'lodash-es'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn'
import { pluralize } from '../lib/pluralize'
import { createPageUrl } from '../lib/urls'
import { createPageUrl, urlWithParams } from '../lib/urls'
import Button from './Button.astro'
import ServiceCard from './ServiceCard.astro'
@@ -19,7 +21,9 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number
sortSeed?: string
filters: ServicesFiltersObject
hadToIncludeCommunityContributed: boolean
includeScams: boolean
countCommunityOnly: number | null
inlineIcons?: boolean
}
const {
@@ -31,89 +35,191 @@ const {
sortSeed,
class: className,
filters,
hadToIncludeCommunityContributed,
includeScams,
countCommunityOnly,
inlineIcons,
...divProps
} = Astro.props
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
const hasCommunityContributed =
const hasScams =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('COMMUNITY_CONTRIBUTED') || hadToIncludeCommunityContributed
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
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 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 class="mb-6 flex items-center justify-between">
<span class="text-day-500 text-sm">
<div class="flex items-center justify-between">
<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()}
{pluralize('result', total)}
<span
<Icon
name="ri:loader-4-line"
id="search-indicator"
class="htmx-request:opacity-100 text-white opacity-0 transition-opacity duration-500"
>
<Icon name="ri:loader-4-line" class="inline-block size-4 animate-spin" />
Loading...
</span>
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}
/>
{
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>
<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>
{
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">
<Icon name="ri:alert-fill" class="-mr-1 inline-block size-4 text-red-500" />
<Icon name="ri:question-line" class="mr-2 inline-block size-4 text-yellow-500" />
Showing SCAM and unverified community-contributed services.
{hadToIncludeCommunityContributed && 'Because there were no other results.'}
<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="inline-block size-4 shrink-0 align-[-0.2em] text-red-500"
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>
)
}
{
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">
<Icon name="ri:alert-fill" class="mr-2 inline-block size-4 text-red-500" />
Showing SCAM services!
<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 shrink-0 align-[-0.2em] text-red-500"
is:inline={inlineIcons}
/>
Results {hasSomeScam ? 'include' : 'may include'} SCAM services
</div>
)
}
{
!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">
<Icon name="ri:question-line" class="mr-2 inline-block size-4" />
{hadToIncludeCommunityContributed
? 'Showing unverified community-contributed services, because there were no other results. Some might be scams.'
: 'Showing unverified community-contributed services, some might be scams.'}
<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-1 inline-block size-4 shrink-0 align-[-0.2em] text-yellow-400"
is:inline={inlineIcons}
/>
Results {hasSomeCommunityContributed ? 'include' : 'may include'} unverified community-contributed
services, some might be scams.
</div>
)
}
{
!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">
<Icon name="ri:emotion-sad-line" class="mb-4 size-16 text-green-500/50" />
<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" is:inline={inlineIcons} />
<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>
<a
href={Astro.url.pathname}
class={cn(
'bg-night-800 font-title mt-4 rounded-md px-4 py-2 text-sm tracking-wider text-white uppercase',
hasDefaultFilters && 'hidden'
<div class="mt-4 flex justify-center gap-2">
{!hasDefaultFilters && (
<Button
as="a"
href={Astro.url.pathname}
label="Clear filters"
icon="ri:close-line"
inlineIcon={inlineIcons}
/>
)}
>
Clear filters
</a>
{countCommunityOnly && (
<Button
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 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) => (
<ServiceCard
inlineIcons
inlineIcons={inlineIcons}
service={service}
data-hx-search-results-card
{...(i === services.length - 1 && currentPage < totalPages
@@ -131,11 +237,29 @@ const totalPages = Math.ceil(total / pageSize) || 1
<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">
<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...
</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>

View File

@@ -36,7 +36,7 @@ const {
class={cn(
'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',
'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
{
'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
)}
>
{text}
</span>
set:text={text}
/>
)
}
</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
listedAt: true
createdAt: true
verificationSteps: {
select: {
status: true
}
}
}
}>
}
@@ -45,7 +50,7 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
)}
</p>
{!!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>
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
@@ -67,3 +72,18 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
</div>
) : 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',
},
{
value: 'VERIFIER_TRUE',
label: 'Verifier role granted',
notificationTitle: 'Verifier role granted',
value: 'MODERATOR_TRUE',
label: 'Moderator role granted',
notificationTitle: 'Moderator role granted',
},
{
value: 'VERIFIER_FALSE',
label: 'Verifier role revoked',
notificationTitle: 'Verifier role revoked',
value: 'MODERATOR_FALSE',
label: 'Moderator role revoked',
notificationTitle: 'Moderator role revoked',
},
{
value: 'SPAMMER_TRUE',

View File

@@ -9,25 +9,29 @@ type AnnouncementTypeInfo<T extends string | null | undefined = string> = {
icon: string
classNames: {
container: string
title: string
bg: string
content: string
icon: string
badge: string
}
}
export const {
dataArray: announcementTypes,
dataObject: announcementTypesById,
getFn: getAnnouncementTypeInfo,
zodEnumById: zodAnnouncementTypesById,
} = makeHelpersForOptions(
'value',
(value): AnnouncementTypeInfo<typeof value> => ({
value,
label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
icon: 'ri:information-line',
icon: 'ri:question-line',
classNames: {
container: 'bg-blue-900/40 border-blue-500/30',
title: 'text-blue-400',
content: 'text-blue-300',
container: 'bg-cyan-950',
bg: 'from-cyan-400 to-cyan-700',
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',
icon: 'ri:information-line',
classNames: {
container: 'bg-blue-900/40 border-blue-500/30',
title: 'text-blue-400',
content: 'text-blue-300',
container: 'bg-cyan-950',
bg: 'from-cyan-400 to-cyan-700',
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',
label: 'Warning',
icon: 'ri:alert-line',
icon: 'ri:alert-fill',
classNames: {
container: 'bg-yellow-900/40 border-yellow-500/30',
title: 'text-yellow-400',
content: 'text-yellow-300',
container: 'bg-yellow-950',
bg: 'from-yellow-400 to-yellow-700',
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',
label: 'Alert',
icon: 'ri:error-warning-line',
icon: 'ri:spam-fill',
classNames: {
container: 'bg-red-900/40 border-red-500/30',
title: 'text-red-400',
content: 'text-red-300',
container: 'bg-red-950',
bg: 'from-red-400 to-red-700',
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>[]

View File

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

View File

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

View File

@@ -0,0 +1,123 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
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: (value: string) => string | null
icon: 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:shield-fill',
matcher: /(.*)/,
formatter: (value) => value,
}),
[
{
type: 'email',
label: 'Email',
matcher: /mailto:(.+)/,
formatter: (value) => value,
icon: 'ri:mail-line',
},
{
type: 'telephone',
label: 'Telephone',
matcher: /tel:(.+)/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
icon: 'ri:phone-line',
},
{
type: 'whatsapp',
label: 'WhatsApp',
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
icon: 'ri:whatsapp-line',
},
{
type: 'telegram',
label: 'Telegram',
matcher: /https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: (value) => `t.me/${value}`,
icon: 'ri:telegram-line',
},
{
type: 'linkedin',
label: 'LinkedIn',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: (value) => `in/${value}`,
icon: 'ri:linkedin-box-line',
},
{
type: 'x',
label: 'X',
matcher: /https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: (value) => `@${value}`,
icon: 'ri:twitter-x-line',
},
{
type: 'instagram',
label: 'Instagram',
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: (value) => `@${value}`,
icon: 'ri:instagram-line',
},
{
type: 'matrix',
label: 'Matrix',
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: (value) => value,
icon: 'ri:hashtag',
},
{
type: 'bitcointalk',
label: 'BitcoinTalk',
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line',
},
// 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,
icon: 'ri:global-line',
},
] as const satisfies ContactMethodInfo[]
)
export function formatContactMethod(url: string) {
for (const contactMethod of contactMethods) {
const captureGroup = url.match(contactMethod.matcher)?.[1]
if (!captureGroup) continue
const formattedValue = contactMethod.formatter(captureGroup)
if (!formattedValue) continue
return {
...contactMethod,
formattedValue,
} as const
}
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
}

View File

@@ -1,7 +1,9 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { EventType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type EventTypeInfo<T extends string | null | undefined = string> = {
id: T
@@ -12,6 +14,7 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
dot: string
}
icon: string
color: ComponentProps<typeof BadgeSmall>['color']
}
export const {
@@ -32,6 +35,7 @@ export const {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
},
icon: 'ri:question-fill',
color: 'gray',
}),
[
{
@@ -43,6 +47,7 @@ export const {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
},
icon: 'ri:error-warning-fill',
color: 'yellow',
},
{
id: 'WARNING_SOLVED',
@@ -53,6 +58,7 @@ export const {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
},
icon: 'ri:check-fill',
color: 'green',
},
{
id: 'ALERT',
@@ -63,6 +69,7 @@ export const {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
},
icon: 'ri:alert-fill',
color: 'red',
},
{
id: 'ALERT_SOLVED',
@@ -73,6 +80,7 @@ export const {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
},
icon: 'ri:check-fill',
color: 'green',
},
{
id: 'INFO',
@@ -83,6 +91,7 @@ export const {
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
},
icon: 'ri:information-fill',
color: 'sky',
},
{
id: 'NORMAL',
@@ -93,6 +102,7 @@ export const {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
},
icon: 'ri:notification-fill',
color: 'green',
},
{
id: 'UPDATE',
@@ -103,6 +113,7 @@ export const {
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
},
icon: 'ri:pencil-fill',
color: 'sky',
},
] as const satisfies EventTypeInfo<EventType>[]
)

View File

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

View File

@@ -46,11 +46,11 @@ export const {
icon: 'ri:lightbulb-line',
},
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// {
// id: 'KARMA_UNLOCK',
// label: 'Karma unlock',
// icon: 'ri:award-line',
// },
{
id: 'KARMA_CHANGE',
label: 'Karma recieved',
icon: 'ri:award-line',
},
{
id: 'ACCOUNT_STATUS_CHANGE',
label: 'Change in account status',

View File

@@ -14,4 +14,7 @@ export const splashTexts: string[] = [
'Ditch the gatekeepers.',
'Own your identity.',
'Financial privacy matters.',
'Surveillance is the enemy of the soul.',
'Privacy is freedom.',
'Privacy is the freedom to try things out.',
]

View File

@@ -70,7 +70,7 @@ export const {
description:
'Thoroughly tested and verified by the team. But things might change, this is not a guarantee.',
privacyPoints: 0,
trustPoints: 5,
trustPoints: 10,
classNames: {
icon: 'text-[#40e6c2]',
badgeBig: 'bg-green-800/50 text-green-100',

View File

@@ -0,0 +1,53 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { VerificationStepStatus } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
value: T
label: string
icon: string
color: ComponentProps<typeof BadgeSmall>['color']
}
export const {
dataArray: verificationStepStatuses,
dataObject: verificationStepStatusesByValue,
getFn: getVerificationStepStatusInfo,
} = makeHelpersForOptions(
'value',
(value): VerificationStepStatusInfo<typeof value> => ({
value,
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line',
color: 'gray',
}),
[
{
value: 'PASSED',
label: 'Passed',
icon: 'ri:verified-badge-fill',
color: 'green',
},
{
value: 'IN_PROGRESS',
label: 'In Progress',
icon: 'ri:loader-line',
color: 'yellow',
},
{
value: 'FAILED',
label: 'Failed',
icon: 'ri:alert-line',
color: 'red',
},
{
value: 'PENDING',
label: 'Pending',
icon: 'ri:time-line',
color: 'sky',
},
] as const satisfies VerificationStepStatusInfo<VerificationStepStatus>[]
)

View File

@@ -1,8 +1,10 @@
---
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import BaseHead from '../components/BaseHead.astro'
import Footer from '../components/Footer.astro'
import Header from '../components/Header.astro'
import { cn } from '../lib/cn'
import { prisma } from '../lib/prisma'
import type { AstroChildren } from '../lib/astro'
import type { ComponentProps } from 'astro/types'
@@ -42,6 +44,31 @@ const {
const actualErrors = [...errors, ...Astro.locals.banners.errors]
const actualSuccess = [...success, ...Astro.locals.banners.successes]
const currentDate = new Date()
const announcement = await Astro.locals.banners.try(
'Unable to load announcements.',
() =>
prisma.announcement.findFirst({
where: {
isActive: true,
startDate: { lte: currentDate },
OR: [{ endDate: null }, { endDate: { gt: currentDate } }],
},
select: {
id: true,
content: true,
type: true,
link: true,
linkText: true,
startDate: true,
endDate: true,
isActive: true,
},
orderBy: [{ type: 'desc' }, { createdAt: 'desc' }],
}),
null
)
---
<html lang="en" transition:name="root" transition:animate="none">
@@ -51,6 +78,7 @@ const actualSuccess = [...success, ...Astro.locals.banners.successes]
<BaseHead {...baseHeadProps} />
</head>
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}>
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
<Header
classNames={{
nav: cn(

View File

@@ -8,7 +8,7 @@ import BaseLayout from './BaseLayout.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
layoutHeader: { icon: string; title: string; subtitle: string }
layoutHeader: { icon: string; title: string; subtitle?: string }
}
const { layoutHeader, ...baseLayoutProps } = Astro.props
@@ -28,10 +28,19 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
</div>
<h1 class="font-title text-day-200 mt-1 text-center text-3xl font-semibold">
<h1
class={cn(
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
!layoutHeader.subtitle && 'xs:mb-8 mb-6'
)}
>
{layoutHeader.title}
</h1>
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
{
!!layoutHeader.subtitle && (
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
)
}
<slot />
</div>

View File

@@ -31,7 +31,7 @@ const commentReplyQuery = {
name: true,
verified: true,
admin: true,
verifier: true,
moderator: true,
createdAt: true,
displayName: true,
picture: true,

View File

@@ -1,60 +0,0 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js'
type Formatter = {
id: string
matcher: RegExp
formatter: (value: string) => string | null
}
const formatters = [
{
id: 'email',
matcher: /mailto:(.*)/,
formatter: (value) => value,
},
{
id: 'telephone',
matcher: /tel:(.*)/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
},
{
id: 'whatsapp',
matcher: /https?:\/\/wa\.me\/(.*)\/?/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
},
{
id: 'telegram',
matcher: /https?:\/\/t\.me\/(.*)\/?/,
formatter: (value) => `t.me/${value}`,
},
{
id: 'linkedin',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/,
formatter: (value) => `in/${value}`,
},
{
id: 'website',
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: (value) => value,
},
] as const satisfies Formatter[]
export function formatContactMethod(url: string) {
for (const formatter of formatters) {
const captureGroup = url.match(formatter.matcher)?.[1]
if (!captureGroup) continue
const formattedValue = formatter.formatter(captureGroup)
if (!formattedValue) continue
return {
type: formatter.id,
formattedValue,
} as const
}
return null
}

View File

@@ -9,7 +9,7 @@ import {
import type { MaybePromise } from 'astro/actions/runtime/utils.js'
import type { z } from 'astro/zod'
type SpecialUserPermission = 'admin' | 'verified' | 'verifier'
type SpecialUserPermission = 'admin' | 'moderator' | 'verified'
type Permission = SpecialUserPermission | 'guest' | 'not-spammer' | 'user'
type ActionAPIContextWithUser = ActionAPIContext & {
@@ -87,8 +87,8 @@ export function defineProtectedAction<
}
if (
(permissions === 'verifier' || (Array.isArray(permissions) && permissions.includes('verifier'))) &&
!context.locals.user.verifier
(permissions === 'moderator' || (Array.isArray(permissions) && permissions.includes('moderator'))) &&
!context.locals.user.moderator
) {
if (context.locals.user.spammer) {
throw new ActionError({
@@ -98,7 +98,7 @@ export function defineProtectedAction<
}
throw new ActionError({
code: 'FORBIDDEN',
message: 'Verifier privileges required.',
message: 'Moderator privileges required.',
})
}

View File

@@ -1,6 +1,7 @@
import { accountStatusChangesById } from '../constants/accountStatusChange'
import { commentStatusChangesById } from '../constants/commentStatusChange'
import { eventTypesById } from '../constants/eventTypes'
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
@@ -16,6 +17,12 @@ export function makeNotificationTitle(
aboutCommentStatusChange: true
aboutServiceVerificationStatusChange: true
aboutSuggestionStatusChange: true
aboutKarmaTransaction: {
select: {
points: true
action: true
}
}
aboutComment: {
select: {
author: { select: { id: true } }
@@ -137,6 +144,13 @@ export function makeNotificationTitle(
// case 'KARMA_UNLOCK': {
// return 'New karma level unlocked'
// }
case 'KARMA_CHANGE': {
if (!notification.aboutKarmaTransaction) return 'Your karma has changed'
const { points, action } = notification.aboutKarmaTransaction
const sign = points > 0 ? '+' : ''
const karmaInfo = getKarmaTransactionActionInfo(action)
return `${sign}${points.toLocaleString()} karma for ${karmaInfo.label}`
}
case 'ACCOUNT_STATUS_CHANGE': {
if (!notification.aboutAccountStatusChange) return 'Your account status has been updated'
const accountStatusChange = accountStatusChangesById[notification.aboutAccountStatusChange]
@@ -165,6 +179,11 @@ export function makeNotificationContent(
notification: Prisma.NotificationGetPayload<{
select: {
type: true
aboutKarmaTransaction: {
select: {
description: true
}
}
aboutComment: {
select: {
content: true
@@ -187,6 +206,10 @@ export function makeNotificationContent(
switch (notification.type) {
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK':
case 'KARMA_CHANGE': {
if (!notification.aboutKarmaTransaction) return null
return notification.aboutKarmaTransaction.description
}
case 'SUGGESTION_STATUS_CHANGE':
case 'ACCOUNT_STATUS_CHANGE':
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
@@ -280,6 +303,9 @@ export function makeNotificationLink(
// case 'KARMA_UNLOCK': {
// return `${origin}/account#karma-unlocks`
// }
case 'KARMA_CHANGE': {
return `${origin}/account#karma-transactions`
}
case 'ACCOUNT_STATUS_CHANGE': {
return `${origin}/account#account-status`
}

8
web/src/lib/tailwind.ts Normal file
View File

@@ -0,0 +1,8 @@
import { parseIntWithFallback } from './numbers'
const TW_SIZING_TO_PX_RATIO = 4
export function getSizePxFromTailwindClasses(className: string, fallbackPxSize: number) {
const twSizing = /(?: |^|\n)(?:(?:size-(\d+))|(?:w-(\d+))|(?:h-(\d+)))(?: |$|\n)/.exec(className)?.[1]
return parseIntWithFallback(twSizing, fallbackPxSize / TW_SIZING_TO_PX_RATIO) * TW_SIZING_TO_PX_RATIO
}

View File

@@ -29,8 +29,8 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
defaultToken: 'admin',
},
{
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
defaultToken: 'verifier',
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
defaultToken: 'moderator',
},
{
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',

View File

@@ -39,16 +39,19 @@ const {
</p>
{
(DEPLOYMENT_MODE !== 'production' || Astro.locals.user?.admin) && (
<div class="bg-night-800 mt-4 block max-h-96 min-h-32 w-full max-w-4xl overflow-auto rounded-lg p-4 text-left text-sm break-words whitespace-pre-wrap">
{error instanceof Error
? error.message
: error === undefined
? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
message || 'undefined'
: typeof error === 'object'
? JSON.stringify(error, null, 2)
: String(error as unknown)}
</div>
<div
class="bg-night-800 mt-4 block max-h-96 min-h-32 w-full max-w-4xl overflow-auto rounded-lg p-4 text-left text-sm wrap-anywhere whitespace-pre-wrap"
set:text={
error instanceof Error
? error.message
: error === undefined
? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
message || 'undefined'
: typeof error === 'object'
? JSON.stringify(error, null, 2)
: String(error as unknown)
}
/>
)
}

View File

@@ -190,7 +190,7 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## Support the project
## Support
If you like this project, you can **support** it through these methods:
@@ -202,13 +202,10 @@ If you like this project, you can **support** it through these methods:
## Contact
You can contact via direct chat or via email.
You can contact via direct chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
- If you use ProtonMail or Tutanota, you can have E2E encrypted communications with us directly. We also offer a [PGP Key](/pgp). Otherwise, we recommend reaching out via SimpleX chat for encrypted communications.
## Disclaimer
This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project.

View File

@@ -34,9 +34,10 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
<h1 class="font-title mt-8 text-3xl font-semibold tracking-wide text-white sm:mt-12 sm:text-5xl">
Access denied
</h1>
<p class="mt-8 text-lg leading-7 text-balance whitespace-pre-wrap text-red-400">
{reason}
</p>
<p
class="mt-8 text-lg leading-7 text-balance wrap-anywhere whitespace-pre-wrap text-red-400"
set:text={reason}
/>
<div class="mt-12 flex flex-wrap items-center justify-center">
<a href="/" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white">

View File

@@ -22,7 +22,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
---
<MiniLayout
pageTitle={`Edit Profile - ${user.name}`}
pageTitle={`Edit Profile - ${user.displayName ?? user.name}`}
description="Edit your user profile"
ogImage={{ template: 'generic', title: 'Edit Profile', icon: 'ri:user-settings-line' }}
layoutHeader={{
@@ -48,7 +48,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="displayName"
error={inputErrors.displayName}
inputProps={{
value: user.displayName ?? '',
value: user.displayName,
maxlength: 100,
disabled: !user.karmaUnlocks.displayName,
}}
@@ -62,7 +62,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="link"
error={inputErrors.link}
inputProps={{
value: user.link ?? '',
value: user.link,
type: 'url',
placeholder: 'https://example.com',
disabled: !user.karmaUnlocks.websiteLink,

View File

@@ -8,6 +8,7 @@ import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project'
@@ -43,7 +44,7 @@ const user = await Astro.locals.banners.try('user', async () => {
spammer: true,
verified: true,
admin: true,
verifier: true,
moderator: true,
verifiedLink: true,
totalKarma: true,
createdAt: true,
@@ -65,6 +66,7 @@ const user = await Astro.locals.banners.try('user', async () => {
select: {
name: true,
displayName: true,
picture: true,
},
},
comment: {
@@ -158,11 +160,11 @@ if (!user) return Astro.rewrite('/404')
---
<BaseLayout
pageTitle={`${user.name} - Account`}
pageTitle={`${user.displayName ?? user.name} - Account`}
description="Manage your user profile"
ogImage={{
template: 'generic',
title: `${user.name} | Account`,
title: `${user.displayName ?? user.name} | Account`,
description: 'Manage your user profile',
icon: 'ri:user-3-line',
}}
@@ -181,50 +183,52 @@ if (!user) return Astro.rewrite('/404')
]}
>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center gap-4">
{
user.picture ? (
<MyPicture
src={user.picture}
alt=""
class="ring-day-500/30 size-16 rounded-full ring-2"
width={64}
height={64}
/>
) : (
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" />
</div>
)
}
<div>
<h1 class="font-title text-lg font-bold tracking-wider text-white">{user.name}</h1>
{user.displayName && <p class="text-day-200">{user.displayName}</p>}
<div class="mt-1 flex gap-2">
<header class="flex flex-wrap items-center justify-center gap-4">
<div class="flex grow flex-wrap items-center justify-center gap-4">
{
user.picture ? (
<MyPicture
src={user.picture}
alt=""
class="ring-day-500/30 xs:size-14 size-12 rounded-full ring-2 sm:size-16"
width={64}
height={64}
/>
) : (
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" />
</div>
)
}
<div class="grow">
<h1 class="font-title text-lg font-bold tracking-wider text-white">
{user.displayName ?? user.name}
</h1>
{user.displayName && <p class="text-day-200 font-title">{user.name}</p>}
{
user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
(user.admin || user.verified || user.moderator) && (
<div class="mt-1 flex gap-2">
{user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)}
{user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)}
{user.moderator && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
moderator
</span>
)}
</div>
)
}
</div>
</div>
<nav class="ml-auto flex items-center gap-2">
<nav class="flex flex-wrap items-center justify-center gap-2">
<Tooltip
as="a"
href={`/u/${user.name}`}
@@ -352,14 +356,14 @@ if (!user) return Astro.rewrite('/404')
)
}
{
user.verifier && (
user.moderator && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
Verifier
Moderator
</span>
)
}
{
!user.admin && !user.verified && !user.verifier && (
!user.admin && !user.verified && !user.moderator && (
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
Standard User
</span>
@@ -429,7 +433,7 @@ if (!user) return Astro.rewrite('/404')
<li>
<Button
as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=User verification request - ${user.name}&body=I would like to be verified as related to https://www.example.com`}
href={`mailto:${SUPPORT_EMAIL}?subject=User verification request - ${user.displayName ?? user.name}&body=I would like to be verified as related to https://www.example.com`}
label="Request verification"
size="sm"
/>
@@ -448,7 +452,7 @@ if (!user) return Astro.rewrite('/404')
</h2>
<Button
as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=Service Affiliation Verification Request - ${user.name}&body=I would like to be verified as related to the services ACME as Admin and XYZ as Team Member. Here is the proof...`}
href={`mailto:${SUPPORT_EMAIL}?subject=Service Affiliation Verification Request - ${user.displayName ?? user.name}&body=I would like to be verified as related to the services ACME as Admin and XYZ as Team Member. Here is the proof...`}
label="Request"
size="md"
/>
@@ -534,77 +538,38 @@ if (!user) return Astro.rewrite('/404')
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3">
<h3 class="font-title border-day-700/20 text-day-200 border-b pb-2 text-sm">Positive unlocks</h3>
<input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
<label
for="positive-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
'karma'
).map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn('font-medium', user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400')}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
</div>
<div class="space-y-3">
<h3 class="font-title border-b border-red-500/20 pb-2 text-sm text-red-400">Negative unlocks</h3>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
'karma'
).map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)}
>
{unlock.name}
@@ -614,24 +579,85 @@ if (!user) return Astro.rewrite('/404')
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
}
</div>
</div>
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties.
</p>
<div class="space-y-3">
<input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
<label
for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
</span>
)}
</div>
</div>
))
}
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties.
</p>
</div>
</div>
</div>
</section>
@@ -858,7 +884,10 @@ if (!user) return Astro.rewrite('/404')
}
</section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<section
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
id="karma-transactions"
>
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
@@ -891,13 +920,10 @@ if (!user) return Astro.rewrite('/404')
<Icon name={actionInfo.icon} class="size-4" />
{actionInfo.label}
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
<a
href={`/u/${transaction.grantedBy.name}`}
class="text-day-500 ml-1 hover:underline"
>
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
by {transaction.grantedBy.displayName || transaction.grantedBy.name}
</a>
<>
<span class="text-day-500">from</span>
<UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
</>
)}
</span>
</td>
@@ -912,7 +938,12 @@ if (!user) return Astro.rewrite('/404')
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
{new Date(transaction.createdAt).toLocaleDateString()}
<TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)

View File

@@ -6,13 +6,17 @@ import { z } from 'astro:schema'
import { adminAnnouncementActions } from '../../../actions/admin/announcement'
import Button from '../../../components/Button.astro'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputSelect from '../../../components/InputSelect.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
import {
announcementTypes,
getAnnouncementTypeInfo,
zodAnnouncementTypesById,
} from '../../../constants/announcementTypes'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma'
@@ -26,7 +30,7 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
.default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
type: z.enum(['INFO', 'WARNING', 'ALERT']).optional(),
type: zodAnnouncementTypesById.optional(),
status: z.enum(['active', 'inactive']).optional(),
},
Astro
@@ -41,10 +45,7 @@ const prismaOrderBy = {
const whereClause: Prisma.AnnouncementWhereInput = {}
if (filters.search) {
whereClause.OR = [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ content: { contains: filters.search, mode: 'insensitive' } },
]
whereClause.OR = [{ content: { contains: filters.search, mode: 'insensitive' } }]
}
if (filters.type) {
@@ -72,32 +73,19 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
return `/admin/announcements?${searchParams.toString()}`
}
// Get type badge class based on announcement type
const getTypeBadgeClass = (type: AnnouncementType) => {
switch (type) {
case 'INFO':
return 'bg-blue-900/30 text-blue-400'
case 'WARNING':
return 'bg-yellow-900/30 text-yellow-400'
case 'ALERT':
return 'bg-red-900/30 text-red-400'
default:
return 'bg-zinc-900/30 text-zinc-400'
}
}
// Current date for form min values
const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm
// Default new announcement
const newAnnouncement = {
title: '',
content: '',
type: 'INFO' as const,
link: null,
linkText: null,
startDate: currentDate,
endDate: '',
isActive: true,
}
isActive: true as boolean,
} satisfies Prisma.AnnouncementCreateInput
// Get action results
const createResult = Astro.getActionResult(adminAnnouncementActions.create)
@@ -155,9 +143,9 @@ if (toggleResult?.error) {
}
---
<BaseLayout pageTitle="Announcement Management" widthClassName="max-w-screen-xl">
<BaseLayout pageTitle="Announcements" widthClassName="max-w-screen-xl">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-title text-2xl font-bold text-white">Announcement Management</h1>
<h1 class="font-title text-2xl font-bold text-white">Announcements</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{announcements.length} announcements</span>
</div>
@@ -184,9 +172,13 @@ if (toggleResult?.error) {
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="" selected={!filters.type}>All Types</option>
<option value="INFO" selected={filters.type === 'INFO'}>Info</option>
<option value="WARNING" selected={filters.type === 'WARNING'}>Warning</option>
<option value="ALERT" selected={filters.type === 'ALERT'}>Alert</option>
{
announcementTypes.map((type) => (
<option value={type.value} selected={filters.type === type.value}>
{type.label}
</option>
))
}
</select>
</div>
<div>
@@ -201,12 +193,17 @@ if (toggleResult?.error) {
<option value="active" selected={filters.status === 'active'}>Active</option>
<option value="inactive" selected={filters.status === 'inactive'}>Inactive</option>
</select>
<button
<Button
as="button"
type="submit"
class="inline-flex items-center rounded-r-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="rounded-l-none"
/>
</div>
</div>
</form>
@@ -214,13 +211,16 @@ if (toggleResult?.error) {
<!-- Create New Announcement Form -->
<div class="mb-6">
<button
<Button
as="button"
id="toggle-new-announcement-form"
class="mb-4 inline-flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:add-line" class="mr-1 h-4 w-4" />
Create New Announcement
</button>
color="success"
variant="solid"
size="md"
icon="ri:add-line"
label="Create"
class="mb-4"
/>
<div
id="new-announcement-form"
@@ -229,20 +229,8 @@ if (toggleResult?.error) {
<h2 class="font-title mb-4 text-lg font-semibold text-blue-400">Create New Announcement</h2>
<form method="POST" action={actions.admin.announcement.create} class="grid gap-4 md:grid-cols-2">
<div class="space-y-3 md:col-span-2">
<InputText
label="Title*"
name="title"
error={createInputErrors.title}
inputProps={{
required: true,
maxlength: 255,
placeholder: 'Announcement Title',
value: newAnnouncement.title,
}}
/>
<InputTextArea
label="Content*"
label="Content"
name="content"
error={createInputErrors.content}
value={newAnnouncement.content}
@@ -256,23 +244,41 @@ if (toggleResult?.error) {
</div>
<div class="space-y-3">
<InputSelect
label="Type*"
name="type"
error={createInputErrors.type}
options={[
{ label: 'Info', value: 'INFO' },
{ label: 'Warning', value: 'WARNING' },
{ label: 'Alert', value: 'ALERT' },
]}
selectProps={{
required: true,
value: newAnnouncement.type,
<InputText
label="Link"
name="link"
error={createInputErrors.link}
inputProps={{
type: 'url',
placeholder: 'https://example.com',
}}
/>
<InputText
label="Link Text "
name="linkText"
error={createInputErrors.linkText}
inputProps={{
placeholder: 'Link Text',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
label="Type"
name="type"
options={announcementTypes.map((type) => ({
label: type.label,
value: type.value,
icon: type.icon,
}))}
cardSize="sm"
required
selectedValue={newAnnouncement.type}
/>
<InputText
label="Start Date & Time*"
label="Start Date & Time"
name="startDate"
error={createInputErrors.startDate}
inputProps={{
@@ -283,7 +289,7 @@ if (toggleResult?.error) {
/>
<InputText
label="End Date & Time (Optional)"
label="End Date & Time"
name="endDate"
error={createInputErrors.endDate}
inputProps={{
@@ -307,14 +313,17 @@ if (toggleResult?.error) {
/>
<div class="pt-4">
<InputSubmitButton label="Create Announcement" icon="ri:save-line" />
<button
<InputSubmitButton label="Create Announcement" icon="ri:save-line" hideCancel />
<Button
as="button"
type="button"
id="cancel-create"
class="ml-2 inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Cancel
</button>
color="gray"
variant="faded"
size="md"
label="Cancel"
class="ml-2"
/>
</div>
</div>
</form>
@@ -341,19 +350,24 @@ if (toggleResult?.error) {
<form method="POST" action={actions.admin.announcement.delete} id="delete-form">
<input type="hidden" name="id" id="delete-id" />
<div class="flex justify-end gap-2">
<button
<Button
as="button"
type="button"
class="close-modal inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Cancel
</button>
<button
class="close-modal"
color="gray"
variant="faded"
size="md"
label="Cancel"
/>
<Button
as="button"
type="submit"
class="inline-flex items-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:delete-bin-line" class="mr-1 h-4 w-4" />
Delete
</button>
color="danger"
variant="solid"
size="md"
icon="ri:delete-bin-line"
label="Delete"
/>
</div>
</form>
</div>
@@ -455,198 +469,222 @@ if (toggleResult?.error) {
)
}
{
announcements.map((announcement) => (
<>
<tr class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm">
<div class="font-medium text-zinc-200">{announcement.title}</div>
<div class="mt-1 line-clamp-1 text-xs text-zinc-400">{announcement.content}</div>
</td>
<td class="px-4 py-3 text-left text-sm">
<span
class={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-medium ${getTypeBadgeClass(announcement.type)}`}
>
{announcement.type}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
<TimeFormatted date={announcement.startDate} hourPrecision={false} prefix={false} />
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
{announcement.endDate ? (
<TimeFormatted date={announcement.endDate} hourPrecision={false} prefix={false} />
) : (
<span class="text-zinc-500">—</span>
)}
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${announcement.isActive ? 'bg-green-900/30 text-green-400' : 'bg-zinc-700/50 text-zinc-400'}`}
>
{announcement.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-400">
<TimeFormatted date={announcement.createdAt} hourPrecision hoursShort prefix={false} />
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="button"
type="button"
data-id={announcement.id}
class="edit-button inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
announcements.map((announcement) => {
const announcementTypeInfo = getAnnouncementTypeInfo(announcement.type)
return (
<>
<tr class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm">
<div class="line-clamp-2 text-zinc-400">{announcement.content}</div>
</td>
<td class="px-4 py-3 text-left text-sm">
<span
class={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-medium ${announcementTypeInfo.classNames.badge}`}
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
<form
method="POST"
action={actions.admin.announcement.toggleActive}
class="inline-block"
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
<Icon name={announcementTypeInfo.icon} class="me-1 size-3" />
{announcementTypeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
<TimeFormatted date={announcement.startDate} hourPrecision={false} prefix={false} />
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
{announcement.endDate ? (
<TimeFormatted date={announcement.endDate} hourPrecision={false} prefix={false} />
) : (
<span class="text-zinc-500">—</span>
)}
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${announcement.isActive ? 'bg-green-900/30 text-green-400' : 'bg-zinc-700/50 text-zinc-400'}`}
>
<input type="hidden" name="id" value={announcement.id} />
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
<button
type="submit"
class={`rounded-md border px-1 py-1 text-xs transition-colors ${
announcement.isActive
? 'border-yellow-500/50 bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'border-green-500/50 bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
>
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
<Icon
name={announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'}
class="size-4"
/>
</Tooltip>
</button>
</form>
<form
method="POST"
action={actions.admin.announcement.delete}
class="inline-block"
data-confirm="Are you sure you want to delete this announcement?"
>
<input type="hidden" name="id" value={announcement.id} />
<button
type="submit"
class="rounded-md border border-red-500/50 bg-red-500/20 px-1 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/30"
>
<Tooltip text="Delete">
<Icon name="ri:delete-bin-line" class="size-4" />
</Tooltip>
</button>
</form>
</div>
</td>
</tr>
<tr id={`edit-announcement-form-${announcement.id}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-3 font-semibold text-blue-300">
Edit: {announcement.title}
</h3>
<form
method="POST"
action={actions.admin.announcement.update}
class="grid gap-4 md:grid-cols-2"
>
<input type="hidden" name="id" value={announcement.id} />
<div class="space-y-3 md:col-span-2">
<InputText
label="Title*"
name="title"
inputProps={{
id: `edit-title-${announcement.id}`,
required: true,
maxlength: 255,
value: announcement.title,
}}
/>
<InputTextArea
label="Content*"
name="content"
value={announcement.content}
inputProps={{
id: `edit-content-${announcement.id}`,
required: true,
maxlength: 1000,
rows: 3,
}}
/>
</div>
<div class="space-y-3">
<InputSelect
label="Type*"
name="type"
options={[
{ label: 'Info', value: 'INFO' },
{ label: 'Warning', value: 'WARNING' },
{ label: 'Alert', value: 'ALERT' },
]}
selectProps={{
id: `edit-type-${announcement.id}`,
required: true,
value: announcement.type,
}}
/>
<InputText
label="Start Date & Time*"
name="startDate"
inputProps={{
id: `edit-startDate-${announcement.id}`,
type: 'datetime-local',
required: true,
value: new Date(announcement.startDate).toISOString().slice(0, 16),
}}
/>
<InputText
label="End Date & Time (Optional)"
name="endDate"
inputProps={{
id: `edit-endDate-${announcement.id}`,
type: 'datetime-local',
value: announcement.endDate
? new Date(announcement.endDate).toISOString().slice(0, 16)
: '',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4">
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel={true} />
{announcement.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-400">
<TimeFormatted
date={announcement.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip text="Edit">
<Button
as="button"
type="button"
label="Cancel"
color="gray"
data-id={announcement.id}
class="edit-button"
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
class="ml-2"
/>
</Tooltip>
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
<form
method="POST"
action={actions.admin.announcement.toggleActive}
class="inline-block"
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
>
<input type="hidden" name="id" value={announcement.id} />
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
<Button
as="button"
type="submit"
color={announcement.isActive ? 'warning' : 'success'}
variant="faded"
size="sm"
iconOnly
icon={announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'}
label={announcement.isActive ? 'Deactivate' : 'Activate'}
/>
</form>
</Tooltip>
<Tooltip text="Delete">
<form
method="POST"
action={actions.admin.announcement.delete}
class="inline-block"
data-confirm="Are you sure you want to delete this announcement?"
>
<input type="hidden" name="id" value={announcement.id} />
<Button
as="button"
type="submit"
color="danger"
variant="faded"
size="sm"
iconOnly
icon="ri:delete-bin-line"
label="Delete"
/>
</form>
</Tooltip>
</div>
</td>
</tr>
<tr id={`edit-announcement-form-${announcement.id}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-3 font-semibold text-blue-300">
Edit: {announcement.content}
</h3>
<form
method="POST"
action={actions.admin.announcement.update}
class="grid gap-4 md:grid-cols-2"
>
<input type="hidden" name="id" value={announcement.id} />
<div class="space-y-3 md:col-span-2">
<InputTextArea
label="Content"
name="content"
value={announcement.content}
inputProps={{
required: true,
maxlength: 1000,
rows: 3,
}}
/>
</div>
</div>
</form>
</td>
</tr>
</>
))
<div class="space-y-3">
<InputText
label="Link"
name="link"
inputProps={{
type: 'url',
placeholder: 'https://example.com',
value: announcement.link,
}}
/>
<InputText
label="Link Text"
name="linkText"
inputProps={{
placeholder: 'Link Text',
value: announcement.linkText,
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
label="Type"
name="type"
options={announcementTypes.map((type) => ({
label: type.label,
value: type.value,
icon: type.icon,
}))}
cardSize="sm"
required
selectedValue={announcement.type}
/>
<InputText
label="Start Date & Time"
name="startDate"
inputProps={{
type: 'datetime-local',
required: true,
value: new Date(announcement.startDate).toISOString().slice(0, 16),
}}
/>
<InputText
label="End Date & Time"
name="endDate"
inputProps={{
type: 'datetime-local',
value: announcement.endDate
? new Date(announcement.endDate).toISOString().slice(0, 16)
: '',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4">
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel />
<Button
as="button"
type="button"
label="Cancel"
color="gray"
variant="faded"
size="md"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
class="ml-2"
/>
</div>
</div>
</form>
</td>
</tr>
</>
)
})
}
</tbody>
</table>

View File

@@ -5,7 +5,9 @@ import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import Button from '../../components/Button.astro'
import SortArrowIcon from '../../components/SortArrowIcon.astro'
import Tooltip from '../../components/Tooltip.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import BaseLayout from '../../layouts/BaseLayout.astro'
@@ -136,14 +138,15 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<h1 class="font-title text-2xl font-bold text-white">Attribute Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{attributeCount} attributes</span>
<button
type="button"
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none"
<Button
as="button"
color="success"
variant="solid"
size="md"
icon="ri:add-line"
label="New Attribute"
onclick="document.getElementById('create-attribute-form').classList.toggle('hidden')"
>
<Icon name="ri:add-line" class="size-4" />
<span>New Attribute</span>
</button>
/>
</div>
</div>
@@ -180,7 +183,8 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
required
rows="3"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none"
></textarea>
set:text=""
/>
{
createInputErrors.description && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.description.join(', ')}</p>
@@ -285,12 +289,15 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div>
</div>
<button
<Button
as="button"
type="submit"
class="w-full rounded-md bg-green-600 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Create Attribute
</button>
color="success"
variant="solid"
size="md"
label="Create Attribute"
class="w-full"
/>
</form>
</div>
@@ -342,12 +349,17 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
))
}
</select>
<button
<Button
as="button"
type="submit"
class="inline-flex items-center rounded-r-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="rounded-l-none"
/>
</div>
</div>
</form>
@@ -518,25 +530,34 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-blue-500/50 bg-blue-500/20 p-1.5 text-blue-400 transition-colors hover:bg-blue-500/30 focus:outline-none"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
title="Edit attribute"
>
<Icon name="ri:edit-line" class="size-3.5" />
</button>
<form method="POST" action={actions.admin.attribute.delete} class="inline-block">
<input type="hidden" name="id" value={attribute.id} />
<button
type="submit"
class="inline-flex items-center justify-center rounded-md border border-red-500/50 bg-red-500/20 p-1.5 text-red-400 transition-colors hover:bg-red-500/30 focus:outline-none"
onclick="return confirm('Are you sure you want to delete this attribute?')"
title="Delete attribute"
>
<Icon name="ri:delete-bin-line" class="size-3.5" />
</button>
</form>
<Tooltip text="Edit">
<Button
as="button"
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
/>
</Tooltip>
<Tooltip text="Delete">
<form method="POST" action={actions.admin.attribute.delete} class="inline-block">
<input type="hidden" name="id" value={attribute.id} />
<Button
as="button"
type="submit"
color="danger"
variant="faded"
size="sm"
iconOnly
icon="ri:delete-bin-line"
label="Delete"
onclick="return confirm('Are you sure you want to delete this attribute?')"
/>
</form>
</Tooltip>
</div>
</td>
</tr>
@@ -593,9 +614,8 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
required
rows="3"
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{attribute.description}
</textarea>
set:text={attribute.description}
/>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
@@ -673,19 +693,23 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
class="rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:outline-none"
<Button
as="button"
color="gray"
variant="faded"
size="sm"
label="Cancel"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
>
Cancel
</button>
<button
/>
<Button
as="button"
type="submit"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Save Changes
</button>
color="info"
variant="solid"
size="sm"
icon="ri:save-line"
label="Save"
/>
</div>
</form>
</td>

View File

@@ -1,7 +1,12 @@
---
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import BadgeSmall from '../../components/BadgeSmall.astro'
import CommentModeration from '../../components/CommentModeration.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import UserBadge from '../../components/UserBadge.astro'
import {
commentStatusFilters,
commentStatusFiltersZodEnum,
@@ -15,7 +20,7 @@ import { prisma } from '../../lib/prisma'
import { urlWithParams } from '../../lib/urls'
const user = Astro.locals.user
if (!user || (!user.admin && !user.verifier)) {
if (!user || (!user.admin && !user.moderator)) {
return Astro.rewrite('/404')
}
@@ -36,11 +41,18 @@ const [comments = [], totalComments = 0] = await Astro.locals.banners.try(
prisma.comment.findManyAndCount({
where: statusFilter.whereClause,
include: {
author: true,
author: {
select: {
name: true,
displayName: true,
picture: true,
},
},
service: {
select: {
name: true,
slug: true,
imageUrl: true,
},
},
parent: {
@@ -70,12 +82,13 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
<a
href={urlWithParams(Astro.url, { status: filter.value })}
class={cn([
'font-title rounded-md border px-3 py-1 text-sm',
'font-title flex items-center gap-2 rounded-md border px-3 py-1 text-sm',
params.status === filter.value
? filter.styles.filter
? filter.classNames.filter
: 'border-zinc-700 transition-colors hover:border-green-500/50',
])}
>
<Icon name={filter.icon} class="size-4 shrink-0" />
{filter.label}
</a>
))
@@ -98,22 +111,7 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
>
<div class="mb-4 flex flex-wrap items-center gap-2">
{/* Author Info */}
<span class="font-title text-sm">{comment.author.name}</span>
{comment.author.admin && (
<span class="rounded-sm bg-yellow-500/20 px-2 py-0.5 text-[12px] font-medium text-yellow-500">
admin
</span>
)}
{comment.author.verified && !comment.author.admin && (
<span class="rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500">
verified
</span>
)}
{comment.author.verifier && !comment.author.admin && (
<span class="rounded-sm bg-green-500/20 px-2 py-0.5 text-[12px] font-medium text-green-500">
verifier
</span>
)}
<UserBadge user={comment.author} size="md" />
{/* Service Link */}
<span class="text-xs text-zinc-500">•</span>
@@ -121,21 +119,55 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
href={`/service/${comment.service.slug}#comment-${comment.id.toString()}`}
class="text-sm text-blue-400 transition-colors hover:text-blue-300"
>
{!!comment.service.imageUrl && (
<MyPicture
src={comment.service.imageUrl}
height={16}
width={16}
class="inline-block size-4 rounded-full align-[-0.2em]"
alt=""
/>
)}
{comment.service.name}
</a>
{/* Date */}
<span class="text-xs text-zinc-500">•</span>
<span class="text-sm text-zinc-400">{new Date(comment.createdAt).toLocaleString()}</span>
<TimeFormatted
date={comment.createdAt}
hourPrecision
caseType="sentence"
class="text-sm text-zinc-400"
/>
<span class="text-xs text-zinc-500">•</span>
{/* Status Badges */}
<span class={comment.statusFilterInfo.styles.badge}>{comment.statusFilterInfo.label}</span>
<BadgeSmall
color={comment.statusFilterInfo.color}
text={comment.statusFilterInfo.label}
icon={comment.statusFilterInfo.icon}
inlineIcon
/>
<span class="text-xs text-zinc-500">•</span>
{/* Link to Comment */}
<a
href={`/service/${comment.service.slug}?showPending=true#comment-${comment.id.toString()}`}
class="text-whit/50 flex items-center gap-1 text-sm transition-colors hover:underline"
>
<Icon name="ri:link" class="size-4" />
Open
</a>
</div>
{/* Parent Comment Context */}
{comment.parent && (
<div class="mb-4 border-l-2 border-zinc-700 pl-4">
<div class="mb-1 text-sm text-zinc-500">Replying to {comment.parent.author.name}:</div>
<div class="mb-1 text-sm opacity-50">
Replying to <UserBadge user={comment.parent.author} size="md" />
</div>
<div class="text-sm text-zinc-400">{comment.parent.content}</div>
</div>
)}

View File

@@ -1,7 +1,9 @@
---
import { Icon } from 'astro-icon/components'
import { DATABASE_UI_URL } from 'astro:env/server'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import type { ComponentProps } from 'astro/types'
@@ -9,7 +11,9 @@ type AdminLink = {
icon: ComponentProps<typeof Icon>['name']
title: string
href: string
description: string
classNames: {
base?: string
}
}
const adminLinks: AdminLink[] = [
@@ -17,66 +21,98 @@ const adminLinks: AdminLink[] = [
icon: 'ri:box-3-line',
title: 'Services',
href: '/admin/services',
description: 'Manage your available services',
},
{
icon: 'ri:file-list-3-line',
title: 'Attributes',
href: '/admin/attributes',
description: 'Configure service attributes',
classNames: {
base: 'text-green-300',
},
},
{
icon: 'ri:user-3-line',
title: 'Users',
href: '/admin/users',
description: 'Manage user accounts',
classNames: {
base: 'text-red-300',
},
},
{
icon: 'ri:chat-settings-line',
icon: 'ri:chat-4-line',
title: 'Comments',
href: '/admin/comments',
description: 'Moderate user comments',
classNames: {
base: 'text-yellow-300',
},
},
{
icon: 'ri:lightbulb-line',
title: 'Service suggestions',
title: 'Suggestions',
href: '/admin/service-suggestions',
description: 'Review and manage service suggestions',
classNames: {
base: 'text-purple-300',
},
},
{
icon: 'ri:price-tag-3-line',
title: 'Attributes',
href: '/admin/attributes',
classNames: {
base: 'text-blue-300',
},
},
{
icon: 'ri:megaphone-line',
title: 'Announcements',
href: '/admin/announcements',
description: 'Manage site announcements',
classNames: {
base: 'text-pink-300',
},
},
{
icon: 'ri:rocket-2-line',
title: 'Releases',
href: '/admin/releases',
classNames: {
base: 'text-orange-300',
},
},
{
icon: 'ri:database-2-line',
title: 'Database',
href: DATABASE_UI_URL,
classNames: {
base: 'text-gray-300',
},
},
]
---
<BaseLayout pageTitle="Admin Dashboard" widthClassName="max-w-screen-xl">
<h1 class="font-title mb-8 text-3xl font-bold text-zinc-100">
<h1 class="font-title text-day-100 mb-8 text-3xl font-bold">
<Icon name="ri:home-gear-line" class="me-1 inline-block size-10 align-[-0.35em]" />
Admin Dashboard
</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{
adminLinks.map((link) => (
<a
href={link.href}
class="group block rounded-lg border border-zinc-800 bg-gradient-to-br from-zinc-900/90 to-zinc-900/50 p-6 shadow-lg backdrop-blur-xs transition-all duration-300 hover:-translate-y-0.5 hover:from-zinc-800/90 hover:to-zinc-800/50 hover:shadow-xl hover:shadow-zinc-900/20"
>
<div class="mb-4 flex items-center gap-3">
<Icon
name={link.icon}
class="h-6 w-6 text-zinc-400 transition-colors group-hover:text-green-400"
/>
<h2 class="font-title text-xl font-semibold text-zinc-100 transition-colors group-hover:text-green-400">
{link.title}
</h2>
</div>
<p class="text-sm text-zinc-400">{link.description}</p>
</a>
))
}
</div>
<nav>
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
{
adminLinks.map((link) => (
<li
class={cn(
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
link.classNames.base
)}
>
<a
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
>
<Icon
name={link.icon}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
/>
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
}
</ol>
</nav>
</BaseLayout>

View File

@@ -0,0 +1,44 @@
---
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
import TimeFormatted from '../../components/TimeFormatted.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
const releaseDate =
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
---
<MiniLayout
pageTitle="Releases"
description="Manage releases"
layoutHeader={{
icon: 'ri:rocket-2-line',
title: 'Releases',
subtitle: 'Current release',
}}
className={{
main: 'flex flex-col items-center justify-center text-center',
}}
>
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
</p>
<time class="text-day-400 mt-4 block text-center text-xl" datetime={releaseDate?.toISOString()}>
{
releaseDate?.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}) ?? 'Unknown release date'
}
</time>
{
!!releaseDate && (
<p class="text-day-500 mt-2">
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
</p>
)
}
</MiniLayout>

View File

@@ -2,9 +2,14 @@
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus'
import UserBadge from '../../../components/UserBadge.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
@@ -37,6 +42,8 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
select: {
id: true,
name: true,
displayName: true,
picture: true,
},
},
service: {
@@ -66,6 +73,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
user: {
select: {
id: true,
displayName: true,
name: true,
picture: true,
},
@@ -92,13 +100,15 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
widthClassName="max-w-screen-md"
>
<div class="mb-4 flex items-center gap-4">
<a
<Button
as="a"
href="/admin/service-suggestions"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:arrow-left-s-line" class="mr-1 size-4" />
Back
</a>
color="success"
variant="faded"
size="md"
icon="ri:arrow-left-s-line"
label="Back"
/>
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
</div>
@@ -126,11 +136,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
</span>
<span class="font-title text-gray-400">Submitted by:</span>
<span class="text-gray-300">
<a href={`/admin/users?name=${serviceSuggestion.user.name}`} class="hover:text-green-500">
{serviceSuggestion.user.name}
</a>
</span>
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
<span class="font-title text-gray-400">Submitted at:</span>
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
@@ -148,9 +154,10 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
serviceSuggestion.notes && (
<div class="mb-4">
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
<div class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm whitespace-pre-wrap text-gray-300">
{serviceSuggestion.notes}
</div>
<div
class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm wrap-anywhere whitespace-pre-wrap text-gray-300"
set:text={serviceSuggestion.notes}
/>
</div>
)
}
@@ -169,17 +176,23 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
name="status"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50"
>
<option value="PENDING" selected={serviceSuggestion.status === 'PENDING'}> Pending </option>
<option value="APPROVED" selected={serviceSuggestion.status === 'APPROVED'}> Approve </option>
<option value="REJECTED" selected={serviceSuggestion.status === 'REJECTED'}> Reject </option>
<option value="WITHDRAWN" selected={serviceSuggestion.status === 'WITHDRAWN'}> Withdrawn </option>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={serviceSuggestion.status === status.value}>
{status.label}
</option>
))
}
</select>
<button
<Button
as="button"
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:save-line" class="mr-1 size-4" /> Update
</button>
color="success"
variant="faded"
size="md"
icon="ri:save-line"
label="Update"
/>
</form>
</div>

View File

@@ -2,10 +2,13 @@
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import { orderBy } from 'lodash-es'
import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
import UserBadge from '../../../components/UserBadge.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
@@ -67,8 +70,9 @@ let suggestions = await prisma.serviceSuggestion.findMany({
createdAt: true,
user: {
select: {
id: true,
displayName: true,
name: true,
picture: true,
},
},
service: {
@@ -120,21 +124,13 @@ let suggestionsWithDetails = suggestions.map((s) => ({
}))
if (sortBy === 'service') {
suggestionsWithDetails = lodashOrderBy(
suggestionsWithDetails,
[(s) => s.service.name.toLowerCase()],
[sortOrder]
)
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder])
} else if (sortBy === 'status') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
} else if (sortBy === 'user') {
suggestionsWithDetails = lodashOrderBy(
suggestionsWithDetails,
[(s) => s.user.name.toLowerCase()],
[sortOrder]
)
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder])
} else if (sortBy === 'messageCount') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
}
const suggestionCount = suggestionsWithDetails.length
@@ -189,12 +185,16 @@ const makeSortUrl = (slug: string) => {
</select>
</div>
<div class="flex items-end">
<button
<Button
as="button"
type="submit"
class="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
/>
</div>
</form>
</div>
@@ -293,9 +293,7 @@ const makeSortUrl = (slug: string) => {
</div>
</td>
<td class="px-4 py-3">
<a href={`/admin/users?name=${suggestion.user.name}`} class="hover:text-green-500">
{suggestion.user.name}
</a>
<UserBadge user={suggestion.user} size="md" />
</td>
<td class="px-4 py-3">
<form method="POST" action={actions.admin.serviceSuggestions.update}>
@@ -325,13 +323,18 @@ const makeSortUrl = (slug: string) => {
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-1">
<a
href={`/admin/service-suggestions/${suggestion.id}`}
class="inline-flex items-center justify-center rounded-full border border-green-500/40 bg-green-500/10 p-1.5 text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
title="View"
>
<Icon name="ri:external-link-line" class="size-4" />
</a>
<Tooltip text="Manage">
<Button
as="a"
href={`/admin/service-suggestions/${suggestion.id}`}
color="success"
variant="faded"
size="sm"
iconOnly
icon="ri:arrow-right-line"
label="Manage"
/>
</Tooltip>
</div>
</td>
</tr>

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,10 @@ import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/clie
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import Button from '../../../components/Button.astro'
import MyPicture from '../../../components/MyPicture.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import Tooltip from '../../../components/Tooltip.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
@@ -171,13 +173,15 @@ const truncate = (text: string, length: number) => {
<h1 class="font-title text-2xl font-bold text-white">Service Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{totalServicesCount} services</span>
<a
<Button
as="a"
href="/admin/services/new"
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none"
>
<Icon name="ri:add-line" class="size-4" />
<span>New Service</span>
</a>
color="success"
variant="solid"
size="md"
icon="ri:add-line"
label="New Service"
/>
</div>
</div>
@@ -250,12 +254,17 @@ const truncate = (text: string, length: number) => {
))
}
</select>
<button
<Button
as="button"
type="submit"
class="ml-2 inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="ml-2"
/>
</div>
</div>
</form>
@@ -437,20 +446,30 @@ const truncate = (text: string, length: number) => {
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end space-x-2">
<a
href={`/service/${service.slug}`}
target="_blank"
class="inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-2 py-1 text-xs text-zinc-300 transition-colors hover:bg-zinc-700"
title="View on site"
>
<Icon name="ri:external-link-line" class="size-3.5" />
</a>
<a
href={`/admin/services/${service.slug}/edit`}
class="inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
>
Edit
</a>
<Tooltip text="View">
<Button
as="a"
href={`/service/${service.slug}`}
color="success"
variant="faded"
size="sm"
iconOnly
icon="ri:global-line"
label="View"
/>
</Tooltip>
<Tooltip text="Edit">
<Button
as="a"
href={`/admin/services/${service.slug}/edit`}
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
/>
</Tooltip>
</div>
</td>
</tr>

View File

@@ -64,7 +64,8 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
id="description"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
></textarea>
set:text=""
/>
{
inputErrors.description && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p>
@@ -80,7 +81,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="serviceUrls"
id="serviceUrls"
rows={3}
placeholder="https://example1.com https://example2.com"></textarea>
placeholder="https://example1.com https://example2.com"
set:text=""
/>
{
inputErrors.serviceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
@@ -96,7 +99,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="tosUrls"
id="tosUrls"
rows={3}
placeholder="https://example1.com/tos https://example2.com/tos"></textarea>
placeholder="https://example1.com/tos https://example2.com/tos"
set:text=""
/>
{
inputErrors.tosUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p>
@@ -112,7 +117,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="onionUrls"
id="onionUrls"
rows={3}
placeholder="http://example.onion"></textarea>
placeholder="http://example.onion"
set:text=""
/>
{
inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
@@ -266,7 +273,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationSummary"
id="verificationSummary"
rows={3}></textarea>
rows={3}
set:text=""
/>
{
inputErrors.verificationSummary && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p>
@@ -283,7 +292,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationProofMd"
id="verificationProofMd"
rows={10}></textarea>
rows={10}
set:text=""
/>
{
inputErrors.verificationProofMd && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p>

View File

@@ -4,6 +4,7 @@ import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import FormSection from '../../../components/FormSection.astro'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputImageFile from '../../../components/InputImageFile.astro'
import InputSelect from '../../../components/InputSelect.astro'
@@ -52,7 +53,7 @@ const [user, allServices] = await Astro.locals.banners.tryMany([
link: true,
admin: true,
verified: true,
verifier: true,
moderator: true,
spammer: true,
verifiedLink: true,
internalNotes: {
@@ -116,7 +117,7 @@ if (!user) return Astro.rewrite('/404')
---
<BaseLayout
pageTitle={`User: ${user.name}`}
pageTitle={`${user.displayName ?? user.name} - User`}
widthClassName="max-w-screen-lg"
className={{ main: 'space-y-24' }}
>
@@ -141,7 +142,7 @@ if (!user) return Astro.rewrite('/404')
<div class="mb-4 flex flex-wrap justify-center gap-2">
{user.admin && <BadgeSmall color="green" text="Admin" icon="ri:shield-star-fill" />}
{user.verified && <BadgeSmall color="cyan" text="Verified" icon="ri:verified-badge-fill" />}
{user.verifier && <BadgeSmall color="blue" text="Moderator" icon="ri:graduation-cap-fill" />}
{user.moderator && <BadgeSmall color="blue" text="Moderator" icon="ri:graduation-cap-fill" />}
{user.spammer && <BadgeSmall color="red" text="Spammer" icon="ri:alert-fill" />}
</div>
@@ -171,90 +172,88 @@ if (!user) return Astro.rewrite('/404')
</div>
</div>
<form
method="POST"
action={actions.admin.user.update}
enctype="multipart/form-data"
class="space-y-2"
data-astro-reload
>
<h2 class="font-title text-center text-3xl leading-none font-bold">Edit profile</h2>
<FormSection title="Edit profile">
<form
method="POST"
action={actions.admin.user.update}
enctype="multipart/form-data"
class="space-y-2"
data-astro-reload
>
<input type="hidden" name="id" value={user.id} />
<input type="hidden" name="id" value={user.id} />
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<InputText
label="Name"
name="name"
error={updateInputErrors.name}
inputProps={{ value: user.name, required: true }}
/>
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<InputText
label="Name"
name="name"
error={updateInputErrors.name}
inputProps={{ value: user.name, required: true }}
<InputText
label="Display Name"
name="displayName"
error={updateInputErrors.displayName}
inputProps={{ value: user.displayName ?? '', maxlength: 50 }}
/>
<InputText
label="Link"
name="link"
error={updateInputErrors.link}
inputProps={{ value: user.link ?? '', type: 'url' }}
/>
<InputText
label="Verified Link"
name="verifiedLink"
error={updateInputErrors.verifiedLink}
inputProps={{ value: user.verifiedLink, type: 'url' }}
/>
</div>
<InputImageFile
label="Profile Picture Upload"
name="pictureFile"
value={user.picture}
error={updateInputErrors.pictureFile}
square
description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF. Max size: 5MB."
/>
<InputText
label="Display Name"
name="displayName"
error={updateInputErrors.displayName}
inputProps={{ value: user.displayName ?? '', maxlength: 50 }}
<InputCardGroup
name="type"
label="Type"
options={[
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' },
{ label: 'Moderator', value: 'moderator', icon: 'ri:graduation-cap-fill' },
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
{
label: 'Verified',
value: 'verified',
icon: 'ri:verified-badge-fill',
disabled: true,
noTransitionPersist: true,
},
]}
selectedValue={[
user.admin ? 'admin' : null,
user.verified ? 'verified' : null,
user.moderator ? 'moderator' : null,
user.spammer ? 'spammer' : null,
].filter((v) => v !== null)}
required
cardSize="sm"
iconSize="sm"
multiple
error={updateInputErrors.type}
/>
<InputText
label="Link"
name="link"
error={updateInputErrors.link}
inputProps={{ value: user.link ?? '', type: 'url' }}
/>
<InputText
label="Verified Link"
name="verifiedLink"
error={updateInputErrors.verifiedLink}
inputProps={{ value: user.verifiedLink, type: 'url' }}
/>
</div>
<InputImageFile
label="Profile Picture Upload"
name="pictureFile"
value={user.picture}
error={updateInputErrors.pictureFile}
square
description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF. Max size: 5MB."
/>
<InputCardGroup
name="type"
label="Type"
options={[
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' },
{ label: 'Moderator', value: 'verifier', icon: 'ri:graduation-cap-fill' },
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
{
label: 'Verified',
value: 'verified',
icon: 'ri:verified-badge-fill',
disabled: true,
noTransitionPersist: true,
},
]}
selectedValue={[
user.admin ? 'admin' : null,
user.verified ? 'verified' : null,
user.verifier ? 'verifier' : null,
user.spammer ? 'spammer' : null,
].filter((v) => v !== null)}
required
cardSize="sm"
iconSize="sm"
multiple
error={updateInputErrors.type}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
<section class="space-y-2">
<h2 class="font-title text-center text-3xl leading-none font-bold">Internal Notes</h2>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</FormSection>
<FormSection title="Internal Notes">
{
user.internalNotes.length === 0 ? (
<p class="text-day-300 text-center">No internal notes yet.</p>
@@ -294,7 +293,7 @@ if (!user) return Astro.rewrite('/404')
<form
method="POST"
action={actions.admin.user.internalNotes.delete}
class="contents"
class="space-y-2"
data-astro-reload
>
<input type="hidden" name="noteId" value={note.id} />
@@ -306,7 +305,7 @@ if (!user) return Astro.rewrite('/404')
</div>
<div data-note-content>
<p class="text-day-200 whitespace-pre-wrap">{note.content}</p>
<p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
</div>
<form
@@ -347,11 +346,9 @@ if (!user) return Astro.rewrite('/404')
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form>
</section>
<section class="space-y-2">
<h2 class="font-title text-center text-3xl leading-none font-bold">Service Affiliations</h2>
</FormSection>
<FormSection title="Service Affiliations">
{
user.serviceAffiliations.length === 0 ? (
<p class="text-day-200 text-center">No service affiliations yet.</p>
@@ -380,7 +377,7 @@ if (!user) return Astro.rewrite('/404')
method="POST"
action={actions.admin.user.serviceAffiliations.remove}
data-astro-reload
class="contents"
class="space-y-2"
>
<input type="hidden" name="id" value={affiliation.id} />
<button type="submit" class="text-day-300 transition-colors hover:text-red-400">
@@ -429,29 +426,29 @@ if (!user) return Astro.rewrite('/404')
<InputSubmitButton label="Add Affiliation" icon="ri:link" hideCancel />
</form>
</section>
</FormSection>
<form method="POST" action={actions.admin.user.karmaTransactions.add} data-astro-reload class="space-y-2">
<h2 class="font-title text-center text-3xl leading-none font-bold">Grant/Remove Karma</h2>
<FormSection title="Grant/Remove Karma">
<form method="POST" action={actions.admin.user.karmaTransactions.add} data-astro-reload class="space-y-2">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="userId" value={user.id} />
<InputText
label="Points"
name="points"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ type: 'number', required: true }}
/>
<InputText
label="Points"
name="points"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ type: 'number', required: true }}
/>
<InputTextArea
label="Description"
name="description"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ required: true }}
/>
<InputTextArea
label="Description"
name="description"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ required: true }}
/>
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
</form>
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
</form>
</FormSection>
</BaseLayout>
<script>

View File

@@ -3,14 +3,17 @@ import { Icon } from 'astro-icon/components'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
import UserBadge from '../../../components/UserBadge.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { pluralize } from '../../../lib/pluralize'
import { prisma } from '../../../lib/prisma'
import { formatDateShort } from '../../../lib/timeAgo'
import { urlWithParams } from '../../../lib/urls'
import type { Prisma } from '@prisma/client'
@@ -19,7 +22,7 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
role: z.enum(['user', 'admin', 'verifier', 'verified', 'spammer']).optional(),
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
},
Astro
)
@@ -44,7 +47,7 @@ if (filters.role) {
switch (filters.role) {
case 'user': {
whereClause.admin = false
whereClause.verifier = false
whereClause.moderator = false
whereClause.verified = false
whereClause.spammer = false
break
@@ -53,8 +56,8 @@ if (filters.role) {
whereClause.admin = true
break
}
case 'verifier': {
whereClause.verifier = true
case 'moderator': {
whereClause.moderator = true
break
}
case 'verified': {
@@ -74,9 +77,11 @@ const dbUsers = await prisma.user.findMany({
select: {
id: true,
name: true,
displayName: true,
picture: true,
verified: true,
admin: true,
verifier: true,
moderator: true,
spammer: true,
totalKarma: true,
createdAt: true,
@@ -103,14 +108,11 @@ const users =
? lodashOrderBy(dbUsers, [(u) => (u.admin ? 'admin' : 'user')], [filters['sort-order']])
: dbUsers
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/users?${searchParams.toString()}`
const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
return urlWithParams(Astro.url, {
'sort-by': sortBy,
'sort-order': filters['sort-by'] === sortBy && filters['sort-order'] === 'asc' ? 'desc' : 'asc',
})
}
---
@@ -146,16 +148,21 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<option value="" selected={!filters.role}>All Users</option>
<option value="user" selected={filters.role === 'user'}>Regular Users</option>
<option value="admin" selected={filters.role === 'admin'}>Admins</option>
<option value="verifier" selected={filters.role === 'verifier'}>Verifiers</option>
<option value="moderator" selected={filters.role === 'moderator'}>Moderators</option>
<option value="verified" selected={filters.role === 'verified'}>Verified Users</option>
<option value="spammer" selected={filters.role === 'spammer'}>Spammers</option>
</select>
<button
<Button
as="button"
type="submit"
class="inline-flex items-center rounded-r-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
color="info"
variant="solid"
size="md"
iconOnly
icon="ri:search-2-line"
label="Search"
class="rounded-l-none"
/>
</div>
</div>
</form>
@@ -241,10 +248,10 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
class={`group hover:bg-zinc-700/30 ${user.spammer ? 'bg-red-900/10' : ''}`}
>
<td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div>{user.name}</div>
<UserBadge user={user} size="md" class="flex text-white" />
{user.internalNotes.length > 0 && (
<Tooltip
class="text-2xs mt-1 text-yellow-400"
class="text-2xs font-light text-yellow-200/40"
position="right"
text={user.internalNotes
.map(
@@ -257,7 +264,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
)
.join('\n\n')}
>
<Icon name="ri:sticky-note-line" class="mr-1 inline-block size-3" />
<Icon name="ri:sticky-note-line" class="mr-0.5 inline-block size-3" />
{user.internalNotes.length} internal {pluralize('note', user.internalNotes.length)}
</Tooltip>
)}
@@ -276,10 +283,10 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
Verified
</span>
)}
{user.verifier && (
{user.moderator && (
<span class="inline-flex items-center gap-1 rounded-md bg-blue-900/30 px-2 py-0.5 text-xs font-medium text-blue-400">
<Icon name="ri:shield-check-fill" class="size-3.5" />
Verifier
Moderator
</span>
)}
</div>
@@ -319,22 +326,43 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
data-astro-prefetch="tap"
class="inline-flex items-center rounded-md border border-orange-500/50 bg-orange-500/20 px-1 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/30"
text="Impersonate"
>
<Icon name="ri:spy-line" class="size-4" />
<Tooltip text="Impersonate">
<Button
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
data-astro-prefetch="tap"
color="warning"
variant="faded"
size="sm"
iconOnly
icon="ri:spy-line"
label="Impersonate"
disabled={user.admin}
/>
</Tooltip>
<Tooltip
as="a"
href={`/admin/users/${user.name}`}
class="inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
>
<Icon name="ri:edit-line" class="size-4" />
<Tooltip text="Edit">
<Button
as="a"
href={`/admin/users/${user.name}`}
color="info"
variant="faded"
size="sm"
iconOnly
icon="ri:edit-line"
label="Edit"
/>
</Tooltip>
<Tooltip text="Public profile">
<Button
as="a"
href={`/u/${user.name}`}
color="success"
variant="faded"
size="sm"
iconOnly
icon="ri:global-line"
label="Public profile"
/>
</Tooltip>
</div>
</td>

View File

@@ -169,7 +169,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
'[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden'
)}
hx-get={Astro.url.pathname}
hx-trigger="input from:input, keyup[key=='Enter'], change from:select"
hx-trigger="input from:find input, keyup[key=='Enter'], change from:find select"
hx-target="#events-list-container"
hx-select="#events-list-container"
hx-swap="outerHTML"

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