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>[]
);
```

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ dump*.sql
*.bak *.bak
migrate.py migrate.py
sync-all.sh sync-all.sh
.DS_Store

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,9 @@
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public" DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379" REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com" SOURCE_CODE_URL="https://github.com"
DATABASE_UI_URL="http://localhost:5555"
SITE_URL="https://localhost:4321" SITE_URL="https://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
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 ENV PORT=4321
EXPOSE 4321 EXPOSE 4321
# Add entrypoint script and make it executable # Add knm-migrate command script and make it executable
COPY docker-entrypoint.sh /usr/local/bin/ COPY migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/knm-migrate
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "./dist/server/entry.mjs"] CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -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` 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, url: true,
optional: false, optional: false,
}), }),
I2P_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
ONION_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
REDIS_URL: envField.string({ REDIS_URL: envField.string({
context: 'server', context: 'server',
@@ -119,11 +131,11 @@ export default defineConfig({
min: 1, min: 1,
default: 'admin', default: 'admin',
}), }),
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({ DEV_MODERATOR_USER_SECRET_TOKEN: envField.string({
context: 'server', context: 'server',
access: 'secret', access: 'secret',
min: 1, min: 1,
default: 'verifier', default: 'moderator',
}), }),
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({ DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
context: 'server', context: 'server',
@@ -158,6 +170,25 @@ export default defineConfig({
url: true, url: true,
optional: false, 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 fi
done done
# Start the application echo "Migrations completed."
echo "Starting the application..."
exec "$@"

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

View File

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

View File

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

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, EventType,
type User, type User,
ServiceUserRole, ServiceUserRole,
AnnouncementType,
} from '@prisma/client' } from '@prisma/client'
import { uniqBy } from 'lodash-es' import { uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
@@ -84,7 +85,7 @@ async function createAccount(preGeneratedToken?: string) {
verifiedLink, verifiedLink,
verified: !!verifiedLink, verified: !!verifiedLink,
admin: faker.datatype.boolean({ probability: 0.1 }), admin: faker.datatype.boolean({ probability: 0.1 }),
verifier: faker.datatype.boolean({ probability: 0.1 }), moderator: faker.datatype.boolean({ probability: 0.1 }),
}, },
include: { include: {
serviceAffiliations: true, serviceAffiliations: true,
@@ -844,40 +845,45 @@ const generateFakeComment = (userId: number, serviceId: number, parentId?: numbe
const generateFakeServiceContactMethod = (serviceId: number) => { const generateFakeServiceContactMethod = (serviceId: number) => {
const types = [ const types = [
{ {
label: 'Email',
value: `mailto:${faker.internet.email()}`, value: `mailto:${faker.internet.email()}`,
iconId: 'ri:mail-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Phone',
value: `tel:${faker.phone.number({ style: 'international' })}`, value: `tel:${faker.phone.number({ style: 'international' })}`,
iconId: 'ri:phone-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'WhatsApp',
value: `https://wa.me/${faker.phone.number({ style: 'international' })}`, value: `https://wa.me/${faker.phone.number({ style: 'international' })}`,
iconId: 'ri:whatsapp-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Telegram',
value: `https://t.me/${faker.internet.username()}`, value: `https://t.me/${faker.internet.username()}`,
iconId: 'ri:telegram-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Website', value: `https://x.com/${faker.internet.username()}`,
},
{
value: `https://matrix.to/#/@${faker.internet.username()}:${faker.internet.domainName()}`,
},
{
value: `https://instagram.com/${faker.internet.username()}`,
},
{
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
},
{
label: faker.lorem.word({ length: 2 }),
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
},
{
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
},
{
value: faker.internet.url(), value: faker.internet.url(),
iconId: 'ri:global-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'LinkedIn', label: faker.lorem.word({ length: 2 }),
value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`, value: faker.internet.url(),
iconId: 'ri:linkedin-box-line', },
info: faker.lorem.sentence(), {
value: `https://linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
}, },
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[] ] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
@@ -893,19 +899,19 @@ const specialUsersData = {
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN', envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
defaultToken: 'admin', defaultToken: 'admin',
admin: true, admin: true,
verifier: true, moderator: true,
verified: true, verified: true,
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
}, },
verifier: { moderator: {
name: 'verifier_dev', name: 'moderator_dev',
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN', envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
defaultToken: 'verifier', defaultToken: 'moderator',
admin: false, admin: false,
verifier: true, moderator: true,
verified: true, verified: true,
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
@@ -917,7 +923,7 @@ const specialUsersData = {
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN', envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
defaultToken: 'verified', defaultToken: 'verified',
admin: false, admin: false,
verifier: false, moderator: false,
verified: true, verified: true,
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
@@ -927,7 +933,7 @@ const specialUsersData = {
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN', envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
defaultToken: 'normal', defaultToken: 'normal',
admin: false, admin: false,
verifier: false, moderator: false,
verified: false, verified: false,
}, },
spam: { spam: {
@@ -935,7 +941,7 @@ const specialUsersData = {
envToken: 'DEV_SPAM_USER_SECRET_TOKEN', envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
defaultToken: 'spam', defaultToken: 'spam',
admin: false, admin: false,
verifier: false, moderator: false,
verified: false, verified: false,
totalKarma: -100, totalKarma: -100,
spammer: true, spammer: true,
@@ -981,6 +987,22 @@ const generateFakeInternalNote = (userId: number, addedByUserId?: number) =>
addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined, addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined,
}) satisfies Prisma.InternalUserNoteCreateInput }) 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() { async function runFaker() {
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
@@ -1004,6 +1026,7 @@ async function runFaker() {
await tx.category.deleteMany() await tx.category.deleteMany()
await tx.internalUserNote.deleteMany() await tx.internalUserNote.deleteMany()
await tx.user.deleteMany() await tx.user.deleteMany()
await tx.announcement.deleteMany()
console.info('✅ Existing data cleaned up') console.info('✅ Existing data cleaned up')
} catch (error) { } catch (error) {
console.error('❌ Error cleaning up data:', error) console.error('❌ Error cleaning up data:', error)
@@ -1283,7 +1306,7 @@ async function runFaker() {
tx.internalUserNote.create({ tx.internalUserNote.create({
data: generateFakeInternalNote( data: generateFakeInternalNote(
user.id, 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({ tx.internalUserNote.create({
data: generateFakeInternalNote( data: generateFakeInternalNote(
user.id, 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 timeout: 1000 * 60 * 10, // 10 minutes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import InputHoneypotTrap from './InputHoneypotTrap.astro'
import InputRating from './InputRating.astro' import InputRating from './InputRating.astro'
import InputText from './InputText.astro' import InputText from './InputText.astro'
import InputWrapper from './InputWrapper.astro' import InputWrapper from './InputWrapper.astro'
import UserBadge from './UserBadge.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
@@ -67,7 +68,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="text-day-400 flex items-center gap-2 text-xs peer-checked/use-form-secret-token:hidden"> <div class="text-day-400 flex items-center gap-2 text-xs peer-checked/use-form-secret-token:hidden">
<Icon name="ri:user-line" class="size-3.5" /> <Icon name="ri:user-line" class="size-3.5" />
<span> <span>
Commenting as: <span class="font-title font-medium text-green-400">{user.name}</span> Commenting as: <UserBadge user={user} size="sm" class="text-green-400" />
</span> </span>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
---
import { cn } from '../lib/cn'
import type { AstroChildren } from '../lib/astro'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'section'> & {
title: string
subtitle?: string
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
children: AstroChildren
}
const { title, subtitle, class: className, heading = 'h2', ...props } = Astro.props
const HeadingTag = heading
---
<section class={cn('mt-24 space-y-2 first:mt-0', className)} {...props}>
<HeadingTag class="font-title text-center text-3xl leading-none font-bold">{title}</HeadingTag>
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
<slot />
</section>

View File

@@ -0,0 +1,24 @@
---
import { cn } from '../lib/cn'
import type { AstroChildren } from '../lib/astro'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'section'> & {
title: string
subtitle?: string
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
children: AstroChildren
}
const { title, subtitle, class: className, heading = 'h3', ...props } = Astro.props
const HeadingTag = heading
---
<section class={cn('mt-6 space-y-2 first:mt-0', className)} {...props}>
<HeadingTag class="font-title text-day-400 text-center text-lg font-medium">{title}</HeadingTag>
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
<slot />
</section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,11 @@ type Props = {
verificationSummary: true verificationSummary: true
listedAt: true listedAt: true
createdAt: true createdAt: true
verificationSteps: {
select: {
status: true
}
}
} }
}> }>
} }
@@ -45,7 +50,7 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
)} )}
</p> </p>
{!!service.verificationSummary && ( {!!service.verificationSummary && (
<div class="mt-2 whitespace-pre-wrap">{service.verificationSummary}</div> <div class="mt-2 wrap-anywhere whitespace-pre-wrap" set:text={service.verificationSummary} />
)} )}
</div> </div>
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? ( ) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
@@ -67,3 +72,18 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
</div> </div>
) : null ) : null
} }
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.verificationSteps.some((step) => step.status === 'FAILED') && (
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/>
<span>
This service has failed one or more verification steps. Review the verification details carefully.
</span>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,4 +14,7 @@ export const splashTexts: string[] = [
'Ditch the gatekeepers.', 'Ditch the gatekeepers.',
'Own your identity.', 'Own your identity.',
'Financial privacy matters.', '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: description:
'Thoroughly tested and verified by the team. But things might change, this is not a guarantee.', 'Thoroughly tested and verified by the team. But things might change, this is not a guarantee.',
privacyPoints: 0, privacyPoints: 0,
trustPoints: 5, trustPoints: 10,
classNames: { classNames: {
icon: 'text-[#40e6c2]', icon: 'text-[#40e6c2]',
badgeBig: 'bg-green-800/50 text-green-100', 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 BaseHead from '../components/BaseHead.astro'
import Footer from '../components/Footer.astro' import Footer from '../components/Footer.astro'
import Header from '../components/Header.astro' import Header from '../components/Header.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { prisma } from '../lib/prisma'
import type { AstroChildren } from '../lib/astro' import type { AstroChildren } from '../lib/astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
@@ -42,6 +44,31 @@ const {
const actualErrors = [...errors, ...Astro.locals.banners.errors] const actualErrors = [...errors, ...Astro.locals.banners.errors]
const actualSuccess = [...success, ...Astro.locals.banners.successes] 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"> <html lang="en" transition:name="root" transition:animate="none">
@@ -51,6 +78,7 @@ const actualSuccess = [...success, ...Astro.locals.banners.successes]
<BaseHead {...baseHeadProps} /> <BaseHead {...baseHeadProps} />
</head> </head>
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}> <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 <Header
classNames={{ classNames={{
nav: cn( nav: cn(

View File

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

View File

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

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

View File

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

View File

@@ -39,16 +39,19 @@ const {
</p> </p>
{ {
(DEPLOYMENT_MODE !== 'production' || Astro.locals.user?.admin) && ( (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"> <div
{error instanceof Error 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"
? error.message set:text={
: error === undefined error instanceof Error
? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ? error.message
message || 'undefined' : error === undefined
: typeof error === 'object' ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
? JSON.stringify(error, null, 2) message || 'undefined'
: String(error as unknown)} : typeof error === 'object'
</div> ? 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. 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: 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 ## 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) - [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 ## Disclaimer
This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project. This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project.

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"> <h1 class="font-title mt-8 text-3xl font-semibold tracking-wide text-white sm:mt-12 sm:text-5xl">
Access denied Access denied
</h1> </h1>
<p class="mt-8 text-lg leading-7 text-balance whitespace-pre-wrap text-red-400"> <p
{reason} class="mt-8 text-lg leading-7 text-balance wrap-anywhere whitespace-pre-wrap text-red-400"
</p> set:text={reason}
/>
<div class="mt-12 flex flex-wrap items-center justify-center"> <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"> <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 <MiniLayout
pageTitle={`Edit Profile - ${user.name}`} pageTitle={`Edit Profile - ${user.displayName ?? user.name}`}
description="Edit your user profile" description="Edit your user profile"
ogImage={{ template: 'generic', title: 'Edit Profile', icon: 'ri:user-settings-line' }} ogImage={{ template: 'generic', title: 'Edit Profile', icon: 'ri:user-settings-line' }}
layoutHeader={{ layoutHeader={{
@@ -48,7 +48,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="displayName" name="displayName"
error={inputErrors.displayName} error={inputErrors.displayName}
inputProps={{ inputProps={{
value: user.displayName ?? '', value: user.displayName,
maxlength: 100, maxlength: 100,
disabled: !user.karmaUnlocks.displayName, disabled: !user.karmaUnlocks.displayName,
}} }}
@@ -62,7 +62,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="link" name="link"
error={inputErrors.link} error={inputErrors.link}
inputProps={{ inputProps={{
value: user.link ?? '', value: user.link,
type: 'url', type: 'url',
placeholder: 'https://example.com', placeholder: 'https://example.com',
disabled: !user.karmaUnlocks.websiteLink, disabled: !user.karmaUnlocks.websiteLink,

View File

@@ -8,6 +8,7 @@ import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro' import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions' import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks' import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project' import { SUPPORT_EMAIL } from '../../constants/project'
@@ -43,7 +44,7 @@ const user = await Astro.locals.banners.try('user', async () => {
spammer: true, spammer: true,
verified: true, verified: true,
admin: true, admin: true,
verifier: true, moderator: true,
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
@@ -65,6 +66,7 @@ const user = await Astro.locals.banners.try('user', async () => {
select: { select: {
name: true, name: true,
displayName: true, displayName: true,
picture: true,
}, },
}, },
comment: { comment: {
@@ -158,11 +160,11 @@ if (!user) return Astro.rewrite('/404')
--- ---
<BaseLayout <BaseLayout
pageTitle={`${user.name} - Account`} pageTitle={`${user.displayName ?? user.name} - Account`}
description="Manage your user profile" description="Manage your user profile"
ogImage={{ ogImage={{
template: 'generic', template: 'generic',
title: `${user.name} | Account`, title: `${user.displayName ?? user.name} | Account`,
description: 'Manage your user profile', description: 'Manage your user profile',
icon: 'ri:user-3-line', 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"> <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"> <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 user.picture ? (
src={user.picture} <MyPicture
alt="" src={user.picture}
class="ring-day-500/30 size-16 rounded-full ring-2" alt=""
width={64} class="ring-day-500/30 xs:size-14 size-12 rounded-full ring-2 sm:size-16"
height={64} 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 class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
</div> <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> <div class="grow">
{user.displayName && <p class="text-day-200">{user.displayName}</p>} <h1 class="font-title text-lg font-bold tracking-wider text-white">
<div class="mt-1 flex gap-2"> {user.displayName ?? user.name}
</h1>
{user.displayName && <p class="text-day-200 font-title">{user.name}</p>}
{ {
user.admin && ( (user.admin || user.verified || user.moderator) && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400"> <div class="mt-1 flex gap-2">
admin {user.admin && (
</span> <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 && ( {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"> <span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified verified
</span> </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">
user.verifier && ( moderator
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400"> </span>
verifier )}
</span> </div>
) )
} }
</div> </div>
</div> </div>
<nav class="ml-auto flex items-center gap-2"> <nav class="flex flex-wrap items-center justify-center gap-2">
<Tooltip <Tooltip
as="a" as="a"
href={`/u/${user.name}`} 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"> <span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
Verifier Moderator
</span> </span>
) )
} }
{ {
!user.admin && !user.verified && !user.verifier && ( !user.admin && !user.verified && !user.moderator && (
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs"> <span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
Standard User Standard User
</span> </span>
@@ -429,7 +433,7 @@ if (!user) return Astro.rewrite('/404')
<li> <li>
<Button <Button
as="a" 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" label="Request verification"
size="sm" size="sm"
/> />
@@ -448,7 +452,7 @@ if (!user) return Astro.rewrite('/404')
</h2> </h2>
<Button <Button
as="a" 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" label="Request"
size="md" 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="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3"> <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>
{ <div class="mt-3 hidden space-y-3 peer-checked:block md:block">
sortBy( {
karmaUnlocks.filter((unlock) => unlock.karma >= 0), sortBy(
'karma' karmaUnlocks.filter((unlock) => unlock.karma >= 0),
).map((unlock) => ( 'karma'
<div ).map((unlock) => (
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 <div
class={cn( class={cn(
'flex items-center justify-between rounded-md border p-3', 'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id] 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' : 'border-night-500 bg-night-800'
)} )}
> >
<div class="flex items-center"> <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" /> <Icon name={unlock.icon} class="size-5" />
</span> </span>
<div> <div>
<p <p
class={cn( class={cn(
'font-medium', 'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400' user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)} )}
> >
{unlock.name} {unlock.name}
@@ -614,24 +579,85 @@ if (!user) return Astro.rewrite('/404')
</div> </div>
<div> <div>
{user.karmaUnlocks[unlock.id] ? ( {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"> <span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active <Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span> </span>
) : ( ) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs"> <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> </span>
)} )}
</div> </div>
</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"> <div class="space-y-3">
<Icon name="ri:information-line" class="inline-block size-4" /> <input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties. <label
</p> 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>
</div> </div>
</section> </section>
@@ -858,7 +884,10 @@ if (!user) return Astro.rewrite('/404')
} }
</section> </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"> <header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <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" /> <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" /> <Icon name={actionInfo.icon} class="size-4" />
{actionInfo.label} {actionInfo.label}
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && ( {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
<a <>
href={`/u/${transaction.grantedBy.name}`} <span class="text-day-500">from</span>
class="text-day-500 ml-1 hover:underline" <UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
> </>
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
by {transaction.grantedBy.displayName || transaction.grantedBy.name}
</a>
)} )}
</span> </span>
</td> </td>
@@ -912,7 +938,12 @@ if (!user) return Astro.rewrite('/404')
{transaction.points} {transaction.points}
</td> </td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap"> <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> </td>
</tr> </tr>
) )

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
--- ---
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import BadgeSmall from '../../components/BadgeSmall.astro'
import CommentModeration from '../../components/CommentModeration.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 { import {
commentStatusFilters, commentStatusFilters,
commentStatusFiltersZodEnum, commentStatusFiltersZodEnum,
@@ -15,7 +20,7 @@ import { prisma } from '../../lib/prisma'
import { urlWithParams } from '../../lib/urls' import { urlWithParams } from '../../lib/urls'
const user = Astro.locals.user const user = Astro.locals.user
if (!user || (!user.admin && !user.verifier)) { if (!user || (!user.admin && !user.moderator)) {
return Astro.rewrite('/404') return Astro.rewrite('/404')
} }
@@ -36,11 +41,18 @@ const [comments = [], totalComments = 0] = await Astro.locals.banners.try(
prisma.comment.findManyAndCount({ prisma.comment.findManyAndCount({
where: statusFilter.whereClause, where: statusFilter.whereClause,
include: { include: {
author: true, author: {
select: {
name: true,
displayName: true,
picture: true,
},
},
service: { service: {
select: { select: {
name: true, name: true,
slug: true, slug: true,
imageUrl: true,
}, },
}, },
parent: { parent: {
@@ -70,12 +82,13 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
<a <a
href={urlWithParams(Astro.url, { status: filter.value })} href={urlWithParams(Astro.url, { status: filter.value })}
class={cn([ 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 params.status === filter.value
? filter.styles.filter ? filter.classNames.filter
: 'border-zinc-700 transition-colors hover:border-green-500/50', : 'border-zinc-700 transition-colors hover:border-green-500/50',
])} ])}
> >
<Icon name={filter.icon} class="size-4 shrink-0" />
{filter.label} {filter.label}
</a> </a>
)) ))
@@ -98,22 +111,7 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
> >
<div class="mb-4 flex flex-wrap items-center gap-2"> <div class="mb-4 flex flex-wrap items-center gap-2">
{/* Author Info */} {/* Author Info */}
<span class="font-title text-sm">{comment.author.name}</span> <UserBadge user={comment.author} size="md" />
{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>
)}
{/* Service Link */} {/* Service Link */}
<span class="text-xs text-zinc-500">•</span> <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()}`} href={`/service/${comment.service.slug}#comment-${comment.id.toString()}`}
class="text-sm text-blue-400 transition-colors hover:text-blue-300" 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} {comment.service.name}
</a> </a>
{/* Date */} {/* Date */}
<span class="text-xs text-zinc-500">•</span> <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 */} {/* 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> </div>
{/* Parent Comment Context */} {/* Parent Comment Context */}
{comment.parent && ( {comment.parent && (
<div class="mb-4 border-l-2 border-zinc-700 pl-4"> <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 class="text-sm text-zinc-400">{comment.parent.content}</div>
</div> </div>
)} )}

View File

@@ -1,7 +1,9 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { DATABASE_UI_URL } from 'astro:env/server'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
@@ -9,7 +11,9 @@ type AdminLink = {
icon: ComponentProps<typeof Icon>['name'] icon: ComponentProps<typeof Icon>['name']
title: string title: string
href: string href: string
description: string classNames: {
base?: string
}
} }
const adminLinks: AdminLink[] = [ const adminLinks: AdminLink[] = [
@@ -17,66 +21,98 @@ const adminLinks: AdminLink[] = [
icon: 'ri:box-3-line', icon: 'ri:box-3-line',
title: 'Services', title: 'Services',
href: '/admin/services', href: '/admin/services',
description: 'Manage your available services', classNames: {
}, base: 'text-green-300',
{ },
icon: 'ri:file-list-3-line',
title: 'Attributes',
href: '/admin/attributes',
description: 'Configure service attributes',
}, },
{ {
icon: 'ri:user-3-line', icon: 'ri:user-3-line',
title: 'Users', title: 'Users',
href: '/admin/users', href: '/admin/users',
description: 'Manage user accounts', classNames: {
base: 'text-red-300',
},
}, },
{ {
icon: 'ri:chat-settings-line', icon: 'ri:chat-4-line',
title: 'Comments', title: 'Comments',
href: '/admin/comments', href: '/admin/comments',
description: 'Moderate user comments', classNames: {
base: 'text-yellow-300',
},
}, },
{ {
icon: 'ri:lightbulb-line', icon: 'ri:lightbulb-line',
title: 'Service suggestions', title: 'Suggestions',
href: '/admin/service-suggestions', href: '/admin/service-suggestions',
description: 'Review and manage service suggestions', classNames: {
base: 'text-purple-300',
},
},
{
icon: 'ri:price-tag-3-line',
title: 'Attributes',
href: '/admin/attributes',
classNames: {
base: 'text-blue-300',
},
}, },
{ {
icon: 'ri:megaphone-line', icon: 'ri:megaphone-line',
title: 'Announcements', title: 'Announcements',
href: '/admin/announcements', href: '/admin/announcements',
description: 'Manage site announcements', classNames: {
base: 'text-pink-300',
},
},
{
icon: 'ri:rocket-2-line',
title: 'Releases',
href: '/admin/releases',
classNames: {
base: 'text-orange-300',
},
},
{
icon: 'ri:database-2-line',
title: 'Database',
href: DATABASE_UI_URL,
classNames: {
base: 'text-gray-300',
},
}, },
] ]
--- ---
<BaseLayout pageTitle="Admin Dashboard" widthClassName="max-w-screen-xl"> <BaseLayout pageTitle="Admin Dashboard" widthClassName="max-w-screen-xl">
<h1 class="font-title mb-8 text-3xl font-bold text-zinc-100"> <h1 class="font-title text-day-100 mb-8 text-3xl font-bold">
<Icon name="ri:home-gear-line" class="me-1 inline-block size-10 align-[-0.35em]" /> <Icon name="ri:home-gear-line" class="me-1 inline-block size-10 align-[-0.35em]" />
Admin Dashboard Admin Dashboard
</h1> </h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <nav>
{ <ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
adminLinks.map((link) => ( {
<a adminLinks.map((link) => (
href={link.href} <li
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" class={cn(
> 'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
<div class="mb-4 flex items-center gap-3"> link.classNames.base
<Icon )}
name={link.icon} >
class="h-6 w-6 text-zinc-400 transition-colors group-hover:text-green-400" <a
/> href={link.href}
<h2 class="font-title text-xl font-semibold text-zinc-100 transition-colors group-hover:text-green-400"> 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"
{link.title} >
</h2> <Icon
</div> name={link.icon}
<p class="text-sm text-zinc-400">{link.description}</p> class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
</a> />
)) <span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
} </a>
</div> </li>
))
}
</ol>
</nav>
</BaseLayout> </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 { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro' import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro' import ServiceCard from '../../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus' import UserBadge from '../../../components/UserBadge.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn' import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers' import { parseIntWithFallback } from '../../../lib/numbers'
@@ -37,6 +42,8 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
select: { select: {
id: true, id: true,
name: true, name: true,
displayName: true,
picture: true,
}, },
}, },
service: { service: {
@@ -66,6 +73,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
user: { user: {
select: { select: {
id: true, id: true,
displayName: true,
name: true, name: true,
picture: true, picture: true,
}, },
@@ -92,13 +100,15 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
> >
<div class="mb-4 flex items-center gap-4"> <div class="mb-4 flex items-center gap-4">
<a <Button
as="a"
href="/admin/service-suggestions" href="/admin/service-suggestions"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" color="success"
> variant="faded"
<Icon name="ri:arrow-left-s-line" class="mr-1 size-4" /> size="md"
Back icon="ri:arrow-left-s-line"
</a> label="Back"
/>
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1> <h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
</div> </div>
@@ -126,11 +136,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
</span> </span>
<span class="font-title text-gray-400">Submitted by:</span> <span class="font-title text-gray-400">Submitted by:</span>
<span class="text-gray-300"> <UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
<a href={`/admin/users?name=${serviceSuggestion.user.name}`} class="hover:text-green-500">
{serviceSuggestion.user.name}
</a>
</span>
<span class="font-title text-gray-400">Submitted at:</span> <span class="font-title text-gray-400">Submitted at:</span>
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span> <span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
@@ -148,9 +154,10 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
serviceSuggestion.notes && ( serviceSuggestion.notes && (
<div class="mb-4"> <div class="mb-4">
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3> <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"> <div
{serviceSuggestion.notes} class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm wrap-anywhere whitespace-pre-wrap text-gray-300"
</div> set:text={serviceSuggestion.notes}
/>
</div> </div>
) )
} }
@@ -169,17 +176,23 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
name="status" name="status"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50" class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50"
> >
<option value="PENDING" selected={serviceSuggestion.status === 'PENDING'}> Pending </option> {
<option value="APPROVED" selected={serviceSuggestion.status === 'APPROVED'}> Approve </option> serviceSuggestionStatuses.map((status) => (
<option value="REJECTED" selected={serviceSuggestion.status === 'REJECTED'}> Reject </option> <option value={status.value} selected={serviceSuggestion.status === status.value}>
<option value="WITHDRAWN" selected={serviceSuggestion.status === 'WITHDRAWN'}> Withdrawn </option> {status.label}
</option>
))
}
</select> </select>
<button <Button
as="button"
type="submit" type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" color="success"
> variant="faded"
<Icon name="ri:save-line" class="mr-1 size-4" /> Update size="md"
</button> icon="ri:save-line"
label="Update"
/>
</form> </form>
</div> </div>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -169,7 +169,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
'[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden' '[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden'
)} )}
hx-get={Astro.url.pathname} 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-target="#events-list-container"
hx-select="#events-list-container" hx-select="#events-list-container"
hx-swap="outerHTML" hx-swap="outerHTML"

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