Compare commits
21 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9a2f428a | ||
|
|
970622d061 | ||
|
|
4806a7fd4e | ||
|
|
85605de8aa | ||
|
|
7a22629c55 | ||
|
|
8deb9acb93 | ||
|
|
61a5448ff5 | ||
|
|
cdfdcfc122 | ||
|
|
f4525e3d32 | ||
|
|
ecc8f67fc4 | ||
|
|
72c238a4dc | ||
|
|
d79bedf219 | ||
|
|
2362d2cc73 | ||
|
|
a69c0aeed4 | ||
|
|
ed86f863e3 | ||
|
|
845aa1185c | ||
|
|
17b3642f7e | ||
|
|
d64268d396 | ||
|
|
9c289753dd | ||
|
|
8bdbe8ea36 | ||
|
|
af7ebe813b |
20
.cursor/rules/astro-actions-api.mdc
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: web/src/actions,web/src/pages
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
- In the astro actions return, generaly don't return anythig unless the caller doesn't needs it. Specially don't `return { success: true }`, or similar. If needed, just return an object with the newly created/edited objects (Like: `return { newService: service }` or don't return anything if not needed).
|
||||||
|
- When importing actions, use `import { actions } from 'astro:actions'`. Example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { actions } from 'astro:actions'; /* CORRECT */
|
||||||
|
import { server } from '~/actions'; /* WRONG!!!! DON'T DO THIS */
|
||||||
|
import { adminAttributeActions } from '~/actions/admin/attribute.ts'; /* WRONG!!!! DON'T DO THIS */
|
||||||
|
|
||||||
|
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||||
|
```
|
||||||
|
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||||
|
- When throwing errors in Astro actions use ActionError.
|
||||||
|
- Always use Astro actions instead of with API routes and instead of `if (Astro.request.method === "POST")`.
|
||||||
|
- Generally call the actions using html forms. But if you need to, you can call them from the server-side code with Astro.callAction(), or [callActionWithUrlParams.ts](mdc:web/src/lib/callActionWithUrlParams.ts).
|
||||||
|
- The admin actions go into a separate folder.
|
||||||
26
.cursor/rules/client-side-javascript.mdc
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: web/src/pages,web/src/components
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
- Avoid sending JavaScript to the client. The JS send should always be optional.
|
||||||
|
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||||
|
- To avoid using JavaScript, you can use HTML and CSS features such as hidden checkboxes, deltails tag, etc.
|
||||||
|
- The admin pages can use client-side JavaScript.
|
||||||
|
- When adding clientside JS do it with HTMX.
|
||||||
|
- When adding HTMX, the layout component BaseLayout [BaseLayout.astro](mdc:web/src/layouts/BaseLayout.astro) [BaseHead.astro](mdc:web/src/components/BaseHead.astro) accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||||
|
- When adding client scripts remember to use the event `astro:page-load`, `querySelectorAll<Type>` and add an explanation comment, like so:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<script>
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
// Optional script for __________. //
|
||||||
|
// Desctiption goes here... //
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
document.querySelectorAll<HTMLDivElement>('[data-my-div]').forEach((myDiv) => {
|
||||||
|
// Do something
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
8
.cursor/rules/code-style.mdc
Normal file
@@ -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.
|
||||||
54
.cursor/rules/database.mdc
Normal 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.
|
||||||
98
.cursor/rules/design-patterns.mdc
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
- The main libraries used are: Astro, TypeScript, Tailwind 4, HTMX, Prisma, npm, zod, lodash-es, date-fns, ts-toolbelt. Full list in: [package.json](mdc:web/package.json)
|
||||||
|
- When creating constants or enums, use the `makeHelpersForOptions` function [makeHelpersForOptions.ts](mdc:web/src/lib/makeHelpersForOptions.ts) like in this example. Save the file in the `web/src/constants` folder (like [attributeTypes.ts](mdc:web/src/constants/attributeTypes.ts)). Note that it's not necessary to use all the options or export all the variables that the example has, just the ones you need.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions';
|
||||||
|
import { transformCase } from '../lib/strings';
|
||||||
|
|
||||||
|
import type { AttributeType } from '@prisma/client';
|
||||||
|
|
||||||
|
type AttributeTypeInfo<T extends string | null | undefined = string> = {
|
||||||
|
value: T;
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
order: number;
|
||||||
|
classNames: {
|
||||||
|
text: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const {
|
||||||
|
dataArray: attributeTypes,
|
||||||
|
dataObject: attributeTypesById,
|
||||||
|
getFn: getAttributeTypeInfo,
|
||||||
|
getFnSlug: getAttributeTypeInfoBySlug,
|
||||||
|
zodEnumBySlug: attributeTypesZodEnumBySlug,
|
||||||
|
zodEnumById: attributeTypesZodEnumById,
|
||||||
|
keyToSlug: attributeTypeIdToSlug,
|
||||||
|
slugToKey: attributeTypeSlugToId,
|
||||||
|
} = makeHelpersForOptions(
|
||||||
|
'value',
|
||||||
|
(value): AttributeTypeInfo<typeof value> => ({
|
||||||
|
value,
|
||||||
|
slug: value ? value.toLowerCase() : '',
|
||||||
|
label: value
|
||||||
|
? transformCase(value.replace('_', ' '), 'title')
|
||||||
|
: String(value),
|
||||||
|
icon: 'ri:question-line',
|
||||||
|
order: Infinity,
|
||||||
|
classNames: {
|
||||||
|
text: 'text-current/60',
|
||||||
|
icon: 'text-current/60',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: 'BAD',
|
||||||
|
slug: 'bad',
|
||||||
|
label: 'Bad',
|
||||||
|
icon: 'ri:close-line',
|
||||||
|
order: 1,
|
||||||
|
classNames: {
|
||||||
|
text: 'text-red-200',
|
||||||
|
icon: 'text-red-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'WARNING',
|
||||||
|
slug: 'warning',
|
||||||
|
label: 'Warning',
|
||||||
|
icon: 'ri:alert-line',
|
||||||
|
order: 2,
|
||||||
|
classNames: {
|
||||||
|
text: 'text-yellow-200',
|
||||||
|
icon: 'text-yellow-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'GOOD',
|
||||||
|
slug: 'good',
|
||||||
|
label: 'Good',
|
||||||
|
icon: 'ri:check-line',
|
||||||
|
order: 3,
|
||||||
|
classNames: {
|
||||||
|
text: 'text-green-200',
|
||||||
|
icon: 'text-green-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INFO',
|
||||||
|
slug: 'info',
|
||||||
|
label: 'Info',
|
||||||
|
icon: 'ri:information-line',
|
||||||
|
order: 4,
|
||||||
|
classNames: {
|
||||||
|
text: 'text-blue-200',
|
||||||
|
icon: 'text-blue-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const satisfies AttributeTypeInfo<AttributeType>[]
|
||||||
|
);
|
||||||
|
```
|
||||||
161
.cursor/rules/pages-and-components.mdc
Normal file
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: /web/src/pages,/web/src/components,/web/src/constants
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
- We use Tailwind 4 (the latest version), make sure to not use outdated classes from Tailwind 3.
|
||||||
|
- Checkout the custom tailwind theme [global.css](mdc:web/src/styles/global.css).
|
||||||
|
- When adding conditional styles or merging tailwind classes, use the `cn` function. Never use template strings. [cn.ts](mdc:web/src/lib/cn.ts)
|
||||||
|
- For the grayscale colors, try to use the custom color `day` for the light/foreground colors (50-500) and `night` for the dark/bakground (500-950).
|
||||||
|
- Generally avoid using opacity modifiers (In `text-red-500/50` the `/50`), but it's fine to also use it.
|
||||||
312
.cursorrules
@@ -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
@@ -14,3 +14,4 @@ dump*.sql
|
|||||||
*.bak
|
*.bak
|
||||||
migrate.py
|
migrate.py
|
||||||
sync-all.sh
|
sync-all.sh
|
||||||
|
.DS_Store
|
||||||
|
|||||||
4
.platform/hooks/predeploy/01_dump_database.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
pwd
|
||||||
|
just dump-db
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 "$@"
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Announcement" ADD COLUMN "link" TEXT;
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Announcement" ADD COLUMN "linkText" TEXT;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
29
web/prisma/triggers/11_notifications_karma.sql
Normal 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();
|
||||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 566 B |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 566 B |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 692 B |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 566 B |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
24
web/src/components/FormSection.astro
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<'section'> & {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
children: AstroChildren
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, subtitle, class: className, heading = 'h2', ...props } = Astro.props
|
||||||
|
|
||||||
|
const HeadingTag = heading
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class={cn('mt-24 space-y-2 first:mt-0', className)} {...props}>
|
||||||
|
<HeadingTag class="font-title text-center text-3xl leading-none font-bold">{title}</HeadingTag>
|
||||||
|
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
24
web/src/components/FormSubSection.astro
Normal file
@@ -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>
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
87
web/src/components/UserBadge.astro
Normal 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>
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>[]
|
||||||
|
|||||||
@@ -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>[]
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
123
web/src/constants/contactMethods.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>[]
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
53
web/src/constants/verificationStepStatus.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
|
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||||
|
import type { VerificationStepStatus } from '@prisma/client'
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
|
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
color: ComponentProps<typeof BadgeSmall>['color']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
dataArray: verificationStepStatuses,
|
||||||
|
dataObject: verificationStepStatusesByValue,
|
||||||
|
getFn: getVerificationStepStatusInfo,
|
||||||
|
} = makeHelpersForOptions(
|
||||||
|
'value',
|
||||||
|
(value): VerificationStepStatusInfo<typeof value> => ({
|
||||||
|
value,
|
||||||
|
label: value ? transformCase(value, 'title') : String(value),
|
||||||
|
icon: 'ri:question-line',
|
||||||
|
color: 'gray',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: 'PASSED',
|
||||||
|
label: 'Passed',
|
||||||
|
icon: 'ri:verified-badge-fill',
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'IN_PROGRESS',
|
||||||
|
label: 'In Progress',
|
||||||
|
icon: 'ri:loader-line',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FAILED',
|
||||||
|
label: 'Failed',
|
||||||
|
icon: 'ri:alert-line',
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'PENDING',
|
||||||
|
label: 'Pending',
|
||||||
|
icon: 'ri:time-line',
|
||||||
|
color: 'sky',
|
||||||
|
},
|
||||||
|
] as const satisfies VerificationStepStatusInfo<VerificationStepStatus>[]
|
||||||
|
)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
44
web/src/pages/admin/releases.astro
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
|
||||||
|
|
||||||
|
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||||
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
|
||||||
|
const releaseDate =
|
||||||
|
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
|
||||||
|
---
|
||||||
|
|
||||||
|
<MiniLayout
|
||||||
|
pageTitle="Releases"
|
||||||
|
description="Manage releases"
|
||||||
|
layoutHeader={{
|
||||||
|
icon: 'ri:rocket-2-line',
|
||||||
|
title: 'Releases',
|
||||||
|
subtitle: 'Current release',
|
||||||
|
}}
|
||||||
|
className={{
|
||||||
|
main: 'flex flex-col items-center justify-center text-center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
|
||||||
|
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
|
||||||
|
</p>
|
||||||
|
<time class="text-day-400 mt-4 block text-center text-xl" datetime={releaseDate?.toISOString()}>
|
||||||
|
{
|
||||||
|
releaseDate?.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}) ?? 'Unknown release date'
|
||||||
|
}
|
||||||
|
</time>
|
||||||
|
{
|
||||||
|
!!releaseDate && (
|
||||||
|
<p class="text-day-500 mt-2">
|
||||||
|
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</MiniLayout>
|
||||||
@@ -2,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||