Release 2025-05-19
@@ -1,28 +0,0 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# local only data
|
||||
local_data/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
*.example
|
||||
@@ -1,4 +0,0 @@
|
||||
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
SOURCE_CODE_URL="https://github.com"
|
||||
SITE_URL="https://localhost:4321"
|
||||
31
web/.gitignore
vendored
@@ -1,31 +0,0 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# local only data
|
||||
local_data/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
local_uploads/
|
||||
!local_uploads/.gitkeep
|
||||
uploads/
|
||||
@@ -1 +0,0 @@
|
||||
save-exact=true
|
||||
@@ -1 +0,0 @@
|
||||
23
|
||||
@@ -1,5 +0,0 @@
|
||||
web/public/
|
||||
.git/
|
||||
package-lock.json
|
||||
local_data/
|
||||
.astro/
|
||||
@@ -1,22 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/** @type {import("prettier").Config} */
|
||||
export default {
|
||||
plugins: ['prettier-plugin-astro', 'prettier-plugin-tailwindcss'],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.astro',
|
||||
options: {
|
||||
parser: 'astro',
|
||||
},
|
||||
},
|
||||
],
|
||||
tailwindFunctions: ['cn', 'clsx', 'tv'],
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 110,
|
||||
bracketSpacing: true,
|
||||
endOfLine: 'lf',
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
FROM node:lts AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
COPY .npmrc .npmrc
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG ASTRO_BUILD_MODE=production
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
# Build the application
|
||||
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
# Add entrypoint script and make it executable
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -1,29 +0,0 @@
|
||||
# KYCnot.me website
|
||||
|
||||
[KYCnot.me](https://kycnot.me)
|
||||
|
||||
## Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :------------------------------------------------------------------- |
|
||||
| `nvm install` | Installs and uses the correct version of node |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
| `npm run db-admin` | Runs Prisma Studio (database admin) |
|
||||
| `npm run db-gen` | Generates the Prisma client without running migrations |
|
||||
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
|
||||
| `npm run db-fill` | Fills the database with fake data (development mode) |
|
||||
| `npm run db-fill-clean` | Cleans existing data and fills with new fake data (development mode) |
|
||||
| `npm run format` | Formats the code with Prettier |
|
||||
| `npm run lint` | Lints the code with ESLint |
|
||||
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
|
||||
|
||||
> **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)
|
||||
@@ -1,159 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import mdx from '@astrojs/mdx'
|
||||
import node from '@astrojs/node'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, envField } from 'astro/config'
|
||||
import icon from 'astro-icon'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
// @ts-expect-error process.env actually exists
|
||||
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
|
||||
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
vite: {
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
integrations: [
|
||||
icon(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
filter: (page) => {
|
||||
const url = new URL(page)
|
||||
return !url.pathname.startsWith('/admin') && !url.pathname.startsWith('/account/impersonate')
|
||||
},
|
||||
}),
|
||||
],
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
output: 'server',
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
},
|
||||
server: {
|
||||
open: false,
|
||||
allowedHosts: [new URL(SITE_URL).hostname],
|
||||
},
|
||||
redirects: {
|
||||
// #region Redirects from old website
|
||||
'/pending': '/?verification=verified&verification=approved&verification=community',
|
||||
'/changelog': '/events',
|
||||
'/request': '/service-suggestion/new',
|
||||
'/service/[...slug]/summary': '/service/[...slug]/#scores',
|
||||
'/service/[...slug]/proof': '/service/[...slug]/#verification',
|
||||
'/attribute/[...slug]': '/attributes',
|
||||
'/attr/[...slug]': '/attributes',
|
||||
// #endregion
|
||||
},
|
||||
env: {
|
||||
schema: {
|
||||
// Database (server-only, secret)
|
||||
DATABASE_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
url: true,
|
||||
startsWith: 'postgresql://',
|
||||
default: 'postgresql://kycnot:kycnot@database:5432/kycnot?schema=public',
|
||||
}),
|
||||
// Public URLs (can be accessed from both server and client)
|
||||
SOURCE_CODE_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'public',
|
||||
url: true,
|
||||
optional: false,
|
||||
}),
|
||||
|
||||
REDIS_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
url: true,
|
||||
startsWith: 'redis://',
|
||||
default: 'redis://redis:6379',
|
||||
}),
|
||||
REDIS_USER_SESSION_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 60 * 24, // 24 hours in seconds
|
||||
}),
|
||||
REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 60 * 24, // 24 hours in seconds
|
||||
}),
|
||||
REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 5, // 5 minutes in seconds
|
||||
}),
|
||||
|
||||
REDIS_ACTIONS_SESSION_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 5, // 5 minutes in seconds
|
||||
}),
|
||||
|
||||
// Development tokens
|
||||
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: 'admin',
|
||||
}),
|
||||
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: 'verifier',
|
||||
}),
|
||||
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: 'verified',
|
||||
}),
|
||||
DEV_NORMAL_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: 'normal',
|
||||
}),
|
||||
DEV_SPAM_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: 'spam',
|
||||
}),
|
||||
|
||||
// Upload directory configuration
|
||||
UPLOAD_DIR: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: './local_uploads',
|
||||
}),
|
||||
|
||||
SITE_URL: envField.string({
|
||||
context: 'client',
|
||||
access: 'public',
|
||||
url: true,
|
||||
optional: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Apply migrations
|
||||
echo "Applying database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Apply triggers
|
||||
echo "Applying database triggers..."
|
||||
for trigger_file in prisma/triggers/*.sql; do
|
||||
if [ -f "$trigger_file" ]; then
|
||||
echo "Applying trigger: $trigger_file"
|
||||
npx prisma db execute --file "$trigger_file" --schema=./prisma/schema.prisma
|
||||
else
|
||||
echo "No trigger files found in prisma/triggers/ or $trigger_file is not a file."
|
||||
fi
|
||||
done
|
||||
|
||||
# Start the application
|
||||
echo "Starting the application..."
|
||||
exec "$@"
|
||||
@@ -1,147 +0,0 @@
|
||||
// @ts-check
|
||||
import pluginJs from '@eslint/js'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import { configs as eslintAstroPluginConfig } from 'eslint-plugin-astro'
|
||||
import importPlugin from 'eslint-plugin-import'
|
||||
import globals from 'globals'
|
||||
import { without } from 'lodash-es'
|
||||
import tseslint, { configs as tseslintConfigs } from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'.astro/**',
|
||||
'dist/**',
|
||||
'coverage/**',
|
||||
'build/**',
|
||||
'public/**',
|
||||
'.prettierrc.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,ts,mjs,cjs,tsx,jsx,astro}'],
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: 'tsconfig.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
tseslintConfigs.strictTypeChecked,
|
||||
tseslintConfigs.stylisticTypeChecked,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
importPlugin.flatConfigs.recommended,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
importPlugin.flatConfigs.typescript,
|
||||
eslintAstroPluginConfig['flat/recommended'],
|
||||
eslintAstroPluginConfig['flat/jsx-a11y-strict'],
|
||||
[
|
||||
// These rules don't work with Astro and produce false positives
|
||||
{
|
||||
files: ['**/*.astro'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
||||
'@typescript-eslint/no-deprecated': 'warn',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/sort-type-constituents': 'error',
|
||||
'import/order': [
|
||||
'warn',
|
||||
{
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: 'react',
|
||||
group: 'external',
|
||||
position: 'before',
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
'newlines-between': 'always',
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'import/first': 'error',
|
||||
'import/newline-after-import': 'error',
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-unresolved': ['error', { ignore: ['^astro:'] }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
|
||||
'import/namespace': 'off',
|
||||
'object-shorthand': ['warn', 'always', { avoidExplicitReturnArrows: false }],
|
||||
'no-useless-rename': 'warn',
|
||||
curly: ['error', 'multi-line'],
|
||||
'@stylistic/quotes': [
|
||||
'error',
|
||||
'single',
|
||||
{
|
||||
avoidEscape: true,
|
||||
allowTemplateLiterals: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
)
|
||||
14561
web/package-lock.json
generated
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"name": "kycnot.me",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build --remote",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"db-admin": "prisma studio --browser=none",
|
||||
"db-gen": "prisma generate",
|
||||
"db-push": "prisma migrate dev",
|
||||
"db-triggers": "just import-triggers",
|
||||
"db-update": "prisma migrate dev && just import-triggers",
|
||||
"db-reset": "prisma migrate reset && prisma migrate dev && just import-triggers && tsx scripts/faker.ts",
|
||||
"db-fill": "tsx scripts/faker.ts",
|
||||
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix && prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/db": "0.14.14",
|
||||
"@astrojs/mdx": "4.2.6",
|
||||
"@astrojs/node": "9.2.1",
|
||||
"@astrojs/sitemap": "3.4.0",
|
||||
"@fontsource-variable/space-grotesk": "5.2.7",
|
||||
"@fontsource/inter": "5.2.5",
|
||||
"@prisma/client": "6.8.2",
|
||||
"@tailwindcss/vite": "4.1.7",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.7.13",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.0",
|
||||
"clsx": "2.1.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.8",
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.0.1",
|
||||
"schema-dts": "1.1.5",
|
||||
"seedrandom": "3.0.5",
|
||||
"slugify": "1.6.6",
|
||||
"tailwind-merge": "3.3.0",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.7",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"zod-form-data": "2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.27.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@iconify-json/material-symbols": "1.2.21",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
"@stylistic/eslint-plugin": "4.2.0",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-import-resolver-typescript": "4.3.5",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"prisma": "6.8.2",
|
||||
"prisma-json-types-generator": "3.4.1",
|
||||
"tailwind-htmx": "0.1.2",
|
||||
"ts-essentials": "10.0.4",
|
||||
"ts-toolbelt": "9.6.0",
|
||||
"tsx": "4.19.4",
|
||||
"typescript-eslint": "8.32.1"
|
||||
}
|
||||
}
|
||||
@@ -1,798 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CommentStatus" AS ENUM ('PENDING', 'HUMAN_PENDING', 'APPROVED', 'VERIFIED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrderIdStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VerificationStatus" AS ENUM ('COMMUNITY_CONTRIBUTED', 'APPROVED', 'VERIFICATION_SUCCESS', 'VERIFICATION_FAILED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceInfoBanner" AS ENUM ('NONE', 'NO_LONGER_OPERATIONAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'HIDDEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Currency" AS ENUM ('MONERO', 'BITCOIN', 'LIGHTNING', 'FIAT', 'CASH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EventType" AS ENUM ('WARNING', 'WARNING_SOLVED', 'ALERT', 'ALERT_SOLVED', 'INFO', 'NORMAL', 'UPDATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceUserRole" AS ENUM ('OWNER', 'ADMIN', 'MODERATOR', 'SUPPORT', 'TEAM_MEMBER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountStatusChange" AS ENUM ('ADMIN_TRUE', 'ADMIN_FALSE', 'VERIFIED_TRUE', 'VERIFIED_FALSE', 'VERIFIER_TRUE', 'VERIFIER_FALSE', 'SPAMMER_TRUE', 'SPAMMER_FALSE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationType" AS ENUM ('COMMENT_STATUS_CHANGE', 'REPLY_COMMENT_CREATED', 'COMMUNITY_NOTE_ADDED', 'ROOT_COMMENT_CREATED', 'SUGGESTION_MESSAGE', 'SUGGESTION_STATUS_CHANGE', 'ACCOUNT_STATUS_CHANGE', 'EVENT_CREATED', 'SERVICE_VERIFICATION_STATUS_CHANGE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CommentStatusChange" AS ENUM ('MARKED_AS_SPAM', 'UNMARKED_AS_SPAM', 'MARKED_FOR_ADMIN_REVIEW', 'UNMARKED_FOR_ADMIN_REVIEW', 'STATUS_CHANGED_TO_APPROVED', 'STATUS_CHANGED_TO_VERIFIED', 'STATUS_CHANGED_TO_REJECTED', 'STATUS_CHANGED_TO_PENDING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceVerificationStatusChange" AS ENUM ('STATUS_CHANGED_TO_COMMUNITY_CONTRIBUTED', 'STATUS_CHANGED_TO_APPROVED', 'STATUS_CHANGED_TO_VERIFICATION_SUCCESS', 'STATUS_CHANGED_TO_VERIFICATION_FAILED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceSuggestionStatusChange" AS ENUM ('STATUS_CHANGED_TO_PENDING', 'STATUS_CHANGED_TO_APPROVED', 'STATUS_CHANGED_TO_REJECTED', 'STATUS_CHANGED_TO_WITHDRAWN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceSuggestionStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceSuggestionType" AS ENUM ('CREATE_SERVICE', 'EDIT_SERVICE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AttributeCategory" AS ENUM ('PRIVACY', 'TRUST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AttributeType" AS ENUM ('GOOD', 'BAD', 'WARNING', 'INFO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VerificationStepStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'PASSED', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"upvotes" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" "CommentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"suspicious" BOOLEAN NOT NULL DEFAULT false,
|
||||
"requiresAdminReview" BOOLEAN NOT NULL DEFAULT false,
|
||||
"communityNote" TEXT,
|
||||
"verificationNote" TEXT,
|
||||
"internalNote" TEXT,
|
||||
"privateContext" TEXT,
|
||||
"orderId" VARCHAR(100),
|
||||
"orderIdStatus" "OrderIdStatus" DEFAULT 'PENDING',
|
||||
"kycRequested" BOOLEAN NOT NULL DEFAULT false,
|
||||
"fundsBlocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"content" TEXT NOT NULL,
|
||||
"rating" SMALLINT,
|
||||
"ratingActive" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"authorId" INTEGER NOT NULL,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"parentId" INTEGER,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Notification" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"type" "NotificationType" NOT NULL,
|
||||
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"aboutCommentId" INTEGER,
|
||||
"aboutEventId" INTEGER,
|
||||
"aboutServiceId" INTEGER,
|
||||
"aboutServiceSuggestionId" INTEGER,
|
||||
"aboutServiceSuggestionMessageId" INTEGER,
|
||||
"aboutAccountStatusChange" "AccountStatusChange",
|
||||
"aboutCommentStatusChange" "CommentStatusChange",
|
||||
"aboutServiceVerificationStatusChange" "ServiceVerificationStatusChange",
|
||||
"aboutSuggestionStatusChange" "ServiceSuggestionStatusChange",
|
||||
|
||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NotificationPreferences" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"enableOnMyCommentStatusChange" BOOLEAN NOT NULL DEFAULT true,
|
||||
"enableAutowatchMyComments" BOOLEAN NOT NULL DEFAULT true,
|
||||
"enableNotifyPendingRepliesOnWatch" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "NotificationPreferences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NotificationPreferenceOnServiceVerificationChangeFilterFilter" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"verificationStatus" "VerificationStepStatus" NOT NULL,
|
||||
"notificationPreferencesId" INTEGER NOT NULL,
|
||||
"currencies" "Currency"[],
|
||||
"scores" INTEGER[],
|
||||
|
||||
CONSTRAINT "NotificationPreferenceOnServiceVerificationChangeFilterFil_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Event" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"source" TEXT,
|
||||
"type" "EventType" NOT NULL,
|
||||
"visible" BOOLEAN NOT NULL DEFAULT true,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceSuggestion" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"type" "ServiceSuggestionType" NOT NULL,
|
||||
"status" "ServiceSuggestionStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ServiceSuggestion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceSuggestionMessage" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"suggestionId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ServiceSuggestionMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Service" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"kycLevel" INTEGER NOT NULL DEFAULT 4,
|
||||
"overallScore" INTEGER NOT NULL DEFAULT 0,
|
||||
"privacyScore" INTEGER NOT NULL DEFAULT 0,
|
||||
"trustScore" INTEGER NOT NULL DEFAULT 0,
|
||||
"isRecentlyListed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"averageUserRating" DOUBLE PRECISION,
|
||||
"serviceVisibility" "ServiceVisibility" NOT NULL DEFAULT 'PUBLIC',
|
||||
"serviceInfoBanner" "ServiceInfoBanner" NOT NULL DEFAULT 'NONE',
|
||||
"serviceInfoBannerNotes" TEXT,
|
||||
"verificationStatus" "VerificationStatus" NOT NULL DEFAULT 'COMMUNITY_CONTRIBUTED',
|
||||
"verificationSummary" TEXT,
|
||||
"verificationProofMd" TEXT,
|
||||
"verifiedAt" TIMESTAMP(3),
|
||||
"userSentiment" JSONB,
|
||||
"userSentimentAt" TIMESTAMP(3),
|
||||
"referral" TEXT,
|
||||
"acceptedCurrencies" "Currency"[] DEFAULT ARRAY[]::"Currency"[],
|
||||
"serviceUrls" TEXT[],
|
||||
"tosUrls" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"onionUrls" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"i2pUrls" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"imageUrl" TEXT,
|
||||
"tosReview" JSONB,
|
||||
"tosReviewAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"listedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Service_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceContactMethod" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"iconId" TEXT NOT NULL,
|
||||
"info" TEXT NOT NULL,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ServiceContactMethod_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Attribute" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"privacyPoints" INTEGER NOT NULL DEFAULT 0,
|
||||
"trustPoints" INTEGER NOT NULL DEFAULT 0,
|
||||
"category" "AttributeCategory" NOT NULL,
|
||||
"type" "AttributeType" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Attribute_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InternalUserNote" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"addedByUserId" INTEGER,
|
||||
|
||||
CONSTRAINT "InternalUserNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"link" TEXT,
|
||||
"picture" TEXT,
|
||||
"spammer" BOOLEAN NOT NULL DEFAULT false,
|
||||
"verified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"admin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"verifier" BOOLEAN NOT NULL DEFAULT false,
|
||||
"verifiedLink" TEXT,
|
||||
"secretTokenHash" TEXT NOT NULL,
|
||||
"totalKarma" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommentVote" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"downvote" BOOLEAN NOT NULL DEFAULT false,
|
||||
"commentId" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CommentVote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceAttribute" (
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"attributeId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ServiceAttribute_pkey" PRIMARY KEY ("serviceId","attributeId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KarmaTransaction" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"points" INTEGER NOT NULL DEFAULT 0,
|
||||
"commentId" INTEGER,
|
||||
"suggestionId" INTEGER,
|
||||
"description" TEXT NOT NULL,
|
||||
"processed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "KarmaTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationStep" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"status" "VerificationStepStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"evidenceMd" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "VerificationStep_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"icon" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceVerificationRequest" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ServiceVerificationRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceScoreRecalculationJob" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ServiceScoreRecalculationJob_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceUser" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"role" "ServiceUserRole" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ServiceUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_watchedComments" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "_watchedComments_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_onEventCreatedForServices" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "_onEventCreatedForServices_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_onRootCommentCreatedForServices" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "_onRootCommentCreatedForServices_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_onVerificationChangeForServices" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "_onVerificationChangeForServices_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AttributeToNotificationPreferenceOnServiceVerificationChangeFi" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "_AttributeToNotificationPreferenceOnServiceVerification_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ServiceToCategory" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "_ServiceToCategory_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_CategoryToNotificationPreferenceOnServiceVerificationChangeFil" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "_CategoryToNotificationPreferenceOnServiceVerificationC_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_status_idx" ON "Comment"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_createdAt_idx" ON "Comment"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_serviceId_idx" ON "Comment"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_authorId_idx" ON "Comment"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_upvotes_idx" ON "Comment"("upvotes");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_rating_idx" ON "Comment"("rating");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_ratingActive_idx" ON "Comment"("ratingActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Comment_serviceId_orderId_key" ON "Comment"("serviceId", "orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_read_idx" ON "Notification"("read");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_userId_read_createdAt_idx" ON "Notification"("userId", "read", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_userId_type_aboutCommentId_idx" ON "Notification"("userId", "type", "aboutCommentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_notification_suggestion_message" ON "Notification"("userId", "type", "aboutServiceSuggestionMessageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_notification_suggestion_status" ON "Notification"("userId", "type", "aboutServiceSuggestionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_notification_account_status" ON "Notification"("userId", "type", "aboutAccountStatusChange");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NotificationPreferences_userId_key" ON "NotificationPreferences"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NotificationPreferenceOnServiceVerificationChangeFilterFilt_key" ON "NotificationPreferenceOnServiceVerificationChangeFilterFilter"("verificationStatus", "notificationPreferencesId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_visible_idx" ON "Event"("visible");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_startedAt_idx" ON "Event"("startedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_createdAt_idx" ON "Event"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_endedAt_idx" ON "Event"("endedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_type_idx" ON "Event"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_serviceId_idx" ON "Event"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceSuggestion_userId_idx" ON "ServiceSuggestion"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceSuggestion_serviceId_idx" ON "ServiceSuggestion"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceSuggestionMessage_userId_idx" ON "ServiceSuggestionMessage"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceSuggestionMessage_suggestionId_idx" ON "ServiceSuggestionMessage"("suggestionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceSuggestionMessage_createdAt_idx" ON "ServiceSuggestionMessage"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Service_slug_key" ON "Service"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_listedAt_idx" ON "Service"("listedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_overallScore_idx" ON "Service"("overallScore");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_privacyScore_idx" ON "Service"("privacyScore");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_trustScore_idx" ON "Service"("trustScore");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_averageUserRating_idx" ON "Service"("averageUserRating");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_name_idx" ON "Service"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_verificationStatus_idx" ON "Service"("verificationStatus");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_kycLevel_idx" ON "Service"("kycLevel");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_createdAt_idx" ON "Service"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_updatedAt_idx" ON "Service"("updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_slug_idx" ON "Service"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Attribute_slug_key" ON "Attribute"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalUserNote_userId_idx" ON "InternalUserNote"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalUserNote_addedByUserId_idx" ON "InternalUserNote"("addedByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalUserNote_createdAt_idx" ON "InternalUserNote"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_name_key" ON "User"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_secretTokenHash_key" ON "User"("secretTokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_createdAt_idx" ON "User"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_totalKarma_idx" ON "User"("totalKarma");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommentVote_commentId_idx" ON "CommentVote"("commentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommentVote_userId_idx" ON "CommentVote"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommentVote_commentId_userId_key" ON "CommentVote"("commentId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KarmaTransaction_createdAt_idx" ON "KarmaTransaction"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KarmaTransaction_userId_idx" ON "KarmaTransaction"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KarmaTransaction_processed_idx" ON "KarmaTransaction"("processed");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KarmaTransaction_suggestionId_idx" ON "KarmaTransaction"("suggestionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KarmaTransaction_commentId_idx" ON "KarmaTransaction"("commentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VerificationStep_serviceId_idx" ON "VerificationStep"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VerificationStep_status_idx" ON "VerificationStep"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VerificationStep_createdAt_idx" ON "VerificationStep"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Category_name_idx" ON "Category"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Category_slug_idx" ON "Category"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceVerificationRequest_serviceId_idx" ON "ServiceVerificationRequest"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceVerificationRequest_userId_idx" ON "ServiceVerificationRequest"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceVerificationRequest_createdAt_idx" ON "ServiceVerificationRequest"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServiceVerificationRequest_serviceId_userId_key" ON "ServiceVerificationRequest"("serviceId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServiceScoreRecalculationJob_serviceId_key" ON "ServiceScoreRecalculationJob"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceScoreRecalculationJob_processedAt_idx" ON "ServiceScoreRecalculationJob"("processedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceScoreRecalculationJob_createdAt_idx" ON "ServiceScoreRecalculationJob"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceUser_userId_idx" ON "ServiceUser"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceUser_serviceId_idx" ON "ServiceUser"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceUser_role_idx" ON "ServiceUser"("role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServiceUser_userId_serviceId_key" ON "ServiceUser"("userId", "serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_watchedComments_B_index" ON "_watchedComments"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_onEventCreatedForServices_B_index" ON "_onEventCreatedForServices"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_onRootCommentCreatedForServices_B_index" ON "_onRootCommentCreatedForServices"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_onVerificationChangeForServices_B_index" ON "_onVerificationChangeForServices"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AttributeToNotificationPreferenceOnServiceVerification_B_index" ON "_AttributeToNotificationPreferenceOnServiceVerificationChangeFi"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ServiceToCategory_B_index" ON "_ServiceToCategory"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_CategoryToNotificationPreferenceOnServiceVerificationC_B_index" ON "_CategoryToNotificationPreferenceOnServiceVerificationChangeFil"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_aboutCommentId_fkey" FOREIGN KEY ("aboutCommentId") REFERENCES "Comment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_aboutEventId_fkey" FOREIGN KEY ("aboutEventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_aboutServiceId_fkey" FOREIGN KEY ("aboutServiceId") REFERENCES "Service"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_aboutServiceSuggestionId_fkey" FOREIGN KEY ("aboutServiceSuggestionId") REFERENCES "ServiceSuggestion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_aboutServiceSuggestionMessageId_fkey" FOREIGN KEY ("aboutServiceSuggestionMessageId") REFERENCES "ServiceSuggestionMessage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationPreferences" ADD CONSTRAINT "NotificationPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationPreferenceOnServiceVerificationChangeFilterFilter" ADD CONSTRAINT "NotificationPreferenceOnServiceVerificationChangeFilterFil_fkey" FOREIGN KEY ("notificationPreferencesId") REFERENCES "NotificationPreferences"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceSuggestion" ADD CONSTRAINT "ServiceSuggestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceSuggestion" ADD CONSTRAINT "ServiceSuggestion_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceSuggestionMessage" ADD CONSTRAINT "ServiceSuggestionMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceSuggestionMessage" ADD CONSTRAINT "ServiceSuggestionMessage_suggestionId_fkey" FOREIGN KEY ("suggestionId") REFERENCES "ServiceSuggestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceContactMethod" ADD CONSTRAINT "ServiceContactMethod_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalUserNote" ADD CONSTRAINT "InternalUserNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalUserNote" ADD CONSTRAINT "InternalUserNote_addedByUserId_fkey" FOREIGN KEY ("addedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommentVote" ADD CONSTRAINT "CommentVote_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommentVote" ADD CONSTRAINT "CommentVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceAttribute" ADD CONSTRAINT "ServiceAttribute_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceAttribute" ADD CONSTRAINT "ServiceAttribute_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "Attribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KarmaTransaction" ADD CONSTRAINT "KarmaTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KarmaTransaction" ADD CONSTRAINT "KarmaTransaction_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KarmaTransaction" ADD CONSTRAINT "KarmaTransaction_suggestionId_fkey" FOREIGN KEY ("suggestionId") REFERENCES "ServiceSuggestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerificationStep" ADD CONSTRAINT "VerificationStep_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceVerificationRequest" ADD CONSTRAINT "ServiceVerificationRequest_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceVerificationRequest" ADD CONSTRAINT "ServiceVerificationRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceUser" ADD CONSTRAINT "ServiceUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceUser" ADD CONSTRAINT "ServiceUser_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_watchedComments" ADD CONSTRAINT "_watchedComments_A_fkey" FOREIGN KEY ("A") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_watchedComments" ADD CONSTRAINT "_watchedComments_B_fkey" FOREIGN KEY ("B") REFERENCES "NotificationPreferences"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_onEventCreatedForServices" ADD CONSTRAINT "_onEventCreatedForServices_A_fkey" FOREIGN KEY ("A") REFERENCES "NotificationPreferences"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_onEventCreatedForServices" ADD CONSTRAINT "_onEventCreatedForServices_B_fkey" FOREIGN KEY ("B") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_onRootCommentCreatedForServices" ADD CONSTRAINT "_onRootCommentCreatedForServices_A_fkey" FOREIGN KEY ("A") REFERENCES "NotificationPreferences"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_onRootCommentCreatedForServices" ADD CONSTRAINT "_onRootCommentCreatedForServices_B_fkey" FOREIGN KEY ("B") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_onVerificationChangeForServices" ADD CONSTRAINT "_onVerificationChangeForServices_A_fkey" FOREIGN KEY ("A") REFERENCES "NotificationPreferences"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_onVerificationChangeForServices" ADD CONSTRAINT "_onVerificationChangeForServices_B_fkey" FOREIGN KEY ("B") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AttributeToNotificationPreferenceOnServiceVerificationChangeFi" ADD CONSTRAINT "_AttributeToNotificationPreferenceOnServiceVerificationC_A_fkey" FOREIGN KEY ("A") REFERENCES "Attribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AttributeToNotificationPreferenceOnServiceVerificationChangeFi" ADD CONSTRAINT "_AttributeToNotificationPreferenceOnServiceVerificationC_B_fkey" FOREIGN KEY ("B") REFERENCES "NotificationPreferenceOnServiceVerificationChangeFilterFilter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ServiceToCategory" ADD CONSTRAINT "_ServiceToCategory_A_fkey" FOREIGN KEY ("A") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ServiceToCategory" ADD CONSTRAINT "_ServiceToCategory_B_fkey" FOREIGN KEY ("B") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_CategoryToNotificationPreferenceOnServiceVerificationChangeFil" ADD CONSTRAINT "_CategoryToNotificationPreferenceOnServiceVerificationCh_A_fkey" FOREIGN KEY ("A") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_CategoryToNotificationPreferenceOnServiceVerificationChangeFil" ADD CONSTRAINT "_CategoryToNotificationPreferenceOnServiceVerificationCh_B_fkey" FOREIGN KEY ("B") REFERENCES "NotificationPreferenceOnServiceVerificationChangeFilterFilter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "lastLoginAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,590 +0,0 @@
|
||||
// This is your Prisma schema file
|
||||
|
||||
datasource db {
|
||||
provider = "postgres"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
generator json {
|
||||
provider = "prisma-json-types-generator"
|
||||
}
|
||||
|
||||
enum CommentStatus {
|
||||
PENDING
|
||||
HUMAN_PENDING
|
||||
APPROVED
|
||||
VERIFIED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum OrderIdStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
upvotes Int @default(0)
|
||||
status CommentStatus @default(PENDING)
|
||||
suspicious Boolean @default(false)
|
||||
requiresAdminReview Boolean @default(false)
|
||||
communityNote String?
|
||||
verificationNote String?
|
||||
internalNote String?
|
||||
privateContext String?
|
||||
orderId String? @db.VarChar(100)
|
||||
orderIdStatus OrderIdStatus? @default(PENDING)
|
||||
kycRequested Boolean @default(false)
|
||||
fundsBlocked Boolean @default(false)
|
||||
content String
|
||||
rating Int? @db.SmallInt
|
||||
ratingActive Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
authorId Int
|
||||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
parentId Int?
|
||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
replies Comment[] @relation("CommentReplies")
|
||||
karmaTransactions KarmaTransaction[]
|
||||
votes CommentVote[]
|
||||
|
||||
notificationPreferenceswatchedComments NotificationPreferences[] @relation("watchedComments")
|
||||
Notification Notification[]
|
||||
|
||||
@@unique([serviceId, orderId], name: "unique_orderId_per_service")
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@index([serviceId])
|
||||
@@index([authorId])
|
||||
@@index([upvotes])
|
||||
@@index([rating])
|
||||
@@index([ratingActive])
|
||||
}
|
||||
|
||||
enum VerificationStatus {
|
||||
COMMUNITY_CONTRIBUTED
|
||||
// COMMUNITY_VERIFIED
|
||||
APPROVED
|
||||
VERIFICATION_SUCCESS
|
||||
VERIFICATION_FAILED
|
||||
}
|
||||
|
||||
enum ServiceInfoBanner {
|
||||
NONE
|
||||
NO_LONGER_OPERATIONAL
|
||||
}
|
||||
|
||||
enum ServiceVisibility {
|
||||
PUBLIC
|
||||
UNLISTED
|
||||
HIDDEN
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
MONERO
|
||||
BITCOIN
|
||||
LIGHTNING
|
||||
FIAT
|
||||
CASH
|
||||
}
|
||||
|
||||
enum EventType {
|
||||
WARNING
|
||||
WARNING_SOLVED
|
||||
ALERT
|
||||
ALERT_SOLVED
|
||||
INFO
|
||||
NORMAL
|
||||
UPDATE
|
||||
}
|
||||
|
||||
enum ServiceUserRole {
|
||||
OWNER
|
||||
ADMIN
|
||||
MODERATOR
|
||||
SUPPORT
|
||||
TEAM_MEMBER
|
||||
}
|
||||
|
||||
enum AccountStatusChange {
|
||||
ADMIN_TRUE
|
||||
ADMIN_FALSE
|
||||
VERIFIED_TRUE
|
||||
VERIFIED_FALSE
|
||||
VERIFIER_TRUE
|
||||
VERIFIER_FALSE
|
||||
SPAMMER_TRUE
|
||||
SPAMMER_FALSE
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
COMMENT_STATUS_CHANGE
|
||||
REPLY_COMMENT_CREATED
|
||||
COMMUNITY_NOTE_ADDED
|
||||
/// Comment that is not a reply. May include a rating.
|
||||
ROOT_COMMENT_CREATED
|
||||
SUGGESTION_MESSAGE
|
||||
SUGGESTION_STATUS_CHANGE
|
||||
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
/// Marked as spammer, promoted to admin, etc.
|
||||
ACCOUNT_STATUS_CHANGE
|
||||
EVENT_CREATED
|
||||
SERVICE_VERIFICATION_STATUS_CHANGE
|
||||
}
|
||||
|
||||
enum CommentStatusChange {
|
||||
MARKED_AS_SPAM
|
||||
UNMARKED_AS_SPAM
|
||||
MARKED_FOR_ADMIN_REVIEW
|
||||
UNMARKED_FOR_ADMIN_REVIEW
|
||||
STATUS_CHANGED_TO_APPROVED
|
||||
STATUS_CHANGED_TO_VERIFIED
|
||||
STATUS_CHANGED_TO_REJECTED
|
||||
STATUS_CHANGED_TO_PENDING
|
||||
}
|
||||
|
||||
enum ServiceVerificationStatusChange {
|
||||
STATUS_CHANGED_TO_COMMUNITY_CONTRIBUTED
|
||||
STATUS_CHANGED_TO_APPROVED
|
||||
STATUS_CHANGED_TO_VERIFICATION_SUCCESS
|
||||
STATUS_CHANGED_TO_VERIFICATION_FAILED
|
||||
}
|
||||
|
||||
enum ServiceSuggestionStatusChange {
|
||||
STATUS_CHANGED_TO_PENDING
|
||||
STATUS_CHANGED_TO_APPROVED
|
||||
STATUS_CHANGED_TO_REJECTED
|
||||
STATUS_CHANGED_TO_WITHDRAWN
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation("NotificationOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
type NotificationType
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
aboutComment Comment? @relation(fields: [aboutCommentId], references: [id])
|
||||
aboutCommentId Int?
|
||||
aboutEvent Event? @relation(fields: [aboutEventId], references: [id])
|
||||
aboutEventId Int?
|
||||
aboutService Service? @relation(fields: [aboutServiceId], references: [id])
|
||||
aboutServiceId Int?
|
||||
aboutServiceSuggestion ServiceSuggestion? @relation(fields: [aboutServiceSuggestionId], references: [id])
|
||||
aboutServiceSuggestionId Int?
|
||||
aboutServiceSuggestionMessage ServiceSuggestionMessage? @relation(fields: [aboutServiceSuggestionMessageId], references: [id])
|
||||
aboutServiceSuggestionMessageId Int?
|
||||
aboutAccountStatusChange AccountStatusChange?
|
||||
aboutCommentStatusChange CommentStatusChange?
|
||||
aboutServiceVerificationStatusChange ServiceVerificationStatusChange?
|
||||
aboutSuggestionStatusChange ServiceSuggestionStatusChange?
|
||||
|
||||
@@index([userId])
|
||||
@@index([read])
|
||||
@@index([createdAt])
|
||||
@@index([userId, read, createdAt])
|
||||
@@index([userId, type, aboutCommentId])
|
||||
@@index([userId, type, aboutServiceSuggestionMessageId], map: "idx_notification_suggestion_message")
|
||||
@@index([userId, type, aboutServiceSuggestionId], map: "idx_notification_suggestion_status")
|
||||
@@index([userId, type, aboutAccountStatusChange], map: "idx_notification_account_status")
|
||||
}
|
||||
|
||||
model NotificationPreferences {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
enableOnMyCommentStatusChange Boolean @default(true)
|
||||
enableAutowatchMyComments Boolean @default(true)
|
||||
enableNotifyPendingRepliesOnWatch Boolean @default(false)
|
||||
|
||||
onEventCreatedForServices Service[] @relation("onEventCreatedForServices")
|
||||
onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices")
|
||||
onVerificationChangeForServices Service[] @relation("onVerificationChangeForServices")
|
||||
watchedComments Comment[] @relation("watchedComments")
|
||||
|
||||
onServiceVerificationChangeFilter NotificationPreferenceOnServiceVerificationChangeFilterFilter[]
|
||||
}
|
||||
|
||||
model NotificationPreferenceOnServiceVerificationChangeFilterFilter {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
verificationStatus VerificationStepStatus
|
||||
notificationPreferences NotificationPreferences @relation(fields: [notificationPreferencesId], references: [id], onDelete: Cascade)
|
||||
notificationPreferencesId Int
|
||||
|
||||
categories Category[]
|
||||
attributes Attribute[]
|
||||
currencies Currency[]
|
||||
/// 0-10
|
||||
scores Int[]
|
||||
|
||||
@@unique([verificationStatus, notificationPreferencesId])
|
||||
}
|
||||
|
||||
model Event {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
content String
|
||||
source String?
|
||||
type EventType
|
||||
visible Boolean @default(true)
|
||||
startedAt DateTime
|
||||
/// If null, the event is ongoing. If same as startedAt, the event is a one-time event. If startedAt is in the future, the event is upcoming.
|
||||
endedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
Notification Notification[]
|
||||
|
||||
@@index([visible])
|
||||
@@index([startedAt])
|
||||
@@index([createdAt])
|
||||
@@index([endedAt])
|
||||
@@index([type])
|
||||
@@index([serviceId])
|
||||
}
|
||||
|
||||
enum ServiceSuggestionStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
enum ServiceSuggestionType {
|
||||
CREATE_SERVICE
|
||||
EDIT_SERVICE
|
||||
}
|
||||
|
||||
model ServiceSuggestion {
|
||||
id Int @id @default(autoincrement())
|
||||
type ServiceSuggestionType
|
||||
status ServiceSuggestionStatus @default(PENDING)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
userId Int
|
||||
serviceId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
messages ServiceSuggestionMessage[]
|
||||
Notification Notification[]
|
||||
KarmaTransaction KarmaTransaction[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([serviceId])
|
||||
}
|
||||
|
||||
model ServiceSuggestionMessage {
|
||||
id Int @id @default(autoincrement())
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
userId Int
|
||||
suggestionId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
suggestion ServiceSuggestion @relation(fields: [suggestionId], references: [id], onDelete: Cascade)
|
||||
notifications Notification[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([suggestionId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Service {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
slug String @unique
|
||||
description String
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
isRecentlyListed Boolean @default(false)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
averageUserRating Float?
|
||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||
serviceInfoBannerNotes String?
|
||||
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
||||
verificationSummary String?
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
verificationProofMd String?
|
||||
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||
verifiedAt DateTime?
|
||||
/// [UserSentiment]
|
||||
userSentiment Json?
|
||||
userSentimentAt DateTime?
|
||||
referral String?
|
||||
acceptedCurrencies Currency[] @default([])
|
||||
serviceUrls String[]
|
||||
tosUrls String[] @default([])
|
||||
onionUrls String[] @default([])
|
||||
i2pUrls String[] @default([])
|
||||
imageUrl String?
|
||||
/// [TosReview]
|
||||
tosReview Json?
|
||||
tosReviewAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
listedAt DateTime?
|
||||
comments Comment[]
|
||||
events Event[]
|
||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||
attributes ServiceAttribute[]
|
||||
verificationSteps VerificationStep[]
|
||||
suggestions ServiceSuggestion[]
|
||||
|
||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
|
||||
Notification Notification[]
|
||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||
|
||||
@@index([listedAt])
|
||||
@@index([overallScore])
|
||||
@@index([privacyScore])
|
||||
@@index([trustScore])
|
||||
@@index([averageUserRating])
|
||||
@@index([name])
|
||||
@@index([verificationStatus])
|
||||
@@index([kycLevel])
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model ServiceContactMethod {
|
||||
id Int @id @default(autoincrement())
|
||||
label String
|
||||
/// Including the protocol (e.g. "mailto:", "tel:", "https://")
|
||||
value String
|
||||
iconId String
|
||||
info String
|
||||
services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
}
|
||||
|
||||
enum AttributeCategory {
|
||||
PRIVACY
|
||||
TRUST
|
||||
}
|
||||
|
||||
enum AttributeType {
|
||||
GOOD
|
||||
BAD
|
||||
WARNING
|
||||
INFO
|
||||
}
|
||||
|
||||
model Attribute {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
title String
|
||||
/// Markdown
|
||||
description String
|
||||
privacyPoints Int @default(0)
|
||||
trustPoints Int @default(0)
|
||||
category AttributeCategory
|
||||
type AttributeType
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
services ServiceAttribute[]
|
||||
|
||||
notificationPreferencesOnServiceVerificationChange NotificationPreferenceOnServiceVerificationChangeFilterFilter[]
|
||||
}
|
||||
|
||||
model InternalUserNote {
|
||||
id Int @id @default(autoincrement())
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
user User @relation("UserRecievedNotes", fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
addedByUser User? @relation("UserAddedNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
|
||||
addedByUserId Int?
|
||||
|
||||
@@index([userId])
|
||||
@@index([addedByUserId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
displayName String?
|
||||
link String?
|
||||
picture String?
|
||||
spammer Boolean @default(false)
|
||||
verified Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
verifier Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
totalKarma Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastLoginAt DateTime @default(now())
|
||||
comments Comment[]
|
||||
karmaTransactions KarmaTransaction[]
|
||||
commentVotes CommentVote[]
|
||||
suggestions ServiceSuggestion[]
|
||||
suggestionMessages ServiceSuggestionMessage[]
|
||||
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
|
||||
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
notifications Notification[] @relation("NotificationOwner")
|
||||
notificationPreferences NotificationPreferences?
|
||||
serviceAffiliations ServiceUser[] @relation("UserServices")
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([totalKarma])
|
||||
}
|
||||
|
||||
model CommentVote {
|
||||
id Int @id @default(autoincrement())
|
||||
downvote Boolean @default(false) // false = upvote, true = downvote
|
||||
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
commentId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([commentId, userId]) // Ensure one vote per user per comment
|
||||
@@index([commentId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ServiceAttribute {
|
||||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
attribute Attribute @relation(fields: [attributeId], references: [id], onDelete: Cascade)
|
||||
attributeId Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([serviceId, attributeId])
|
||||
}
|
||||
|
||||
model KarmaTransaction {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
action String
|
||||
points Int @default(0)
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
commentId Int?
|
||||
suggestion ServiceSuggestion? @relation(fields: [suggestionId], references: [id], onDelete: Cascade)
|
||||
suggestionId Int?
|
||||
description String
|
||||
processed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([userId])
|
||||
@@index([processed])
|
||||
@@index([suggestionId])
|
||||
@@index([commentId])
|
||||
}
|
||||
|
||||
enum VerificationStepStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
PASSED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model VerificationStep {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String
|
||||
status VerificationStepStatus @default(PENDING)
|
||||
evidenceMd String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
|
||||
@@index([serviceId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
icon String
|
||||
slug String @unique
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
services Service[] @relation("ServiceToCategory")
|
||||
|
||||
notificationPreferencesOnServiceVerificationChange NotificationPreferenceOnServiceVerificationChangeFilterFilter[]
|
||||
|
||||
@@index([name])
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model ServiceVerificationRequest {
|
||||
id Int @id @default(autoincrement())
|
||||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([serviceId, userId])
|
||||
@@index([serviceId])
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ServiceScoreRecalculationJob {
|
||||
id Int @id @default(autoincrement())
|
||||
serviceId Int @unique
|
||||
createdAt DateTime @default(now())
|
||||
processedAt DateTime? @updatedAt
|
||||
|
||||
@@index([processedAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ServiceUser {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation("UserServices", fields: [userId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
service Service @relation("ServiceUsers", fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
role ServiceUserRole
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, serviceId])
|
||||
@@index([userId])
|
||||
@@index([serviceId])
|
||||
@@index([role])
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
-- This script manages user karma based on comment interactions. It handles karma points
|
||||
-- for comment approvals, verifications, spam status changes, and votes (upvotes/downvotes).
|
||||
-- Karma transactions are recorded, and user karma totals are updated accordingly.
|
||||
|
||||
-- Drop existing triggers first
|
||||
DROP TRIGGER IF EXISTS comment_status_change_trigger ON "Comment";
|
||||
DROP TRIGGER IF EXISTS comment_suspicious_change_trigger ON "Comment";
|
||||
DROP TRIGGER IF EXISTS comment_upvote_change_trigger ON "Comment";
|
||||
DROP TRIGGER IF EXISTS comment_vote_change_trigger ON "CommentVote";
|
||||
DROP TRIGGER IF EXISTS suggestion_status_change_trigger ON "ServiceSuggestion";
|
||||
|
||||
-- Drop existing functions
|
||||
DROP FUNCTION IF EXISTS handle_comment_upvote_change();
|
||||
DROP FUNCTION IF EXISTS handle_comment_status_change();
|
||||
DROP FUNCTION IF EXISTS handle_comment_approval();
|
||||
DROP FUNCTION IF EXISTS handle_comment_verification();
|
||||
DROP FUNCTION IF EXISTS handle_comment_spam_status();
|
||||
DROP FUNCTION IF EXISTS handle_comment_vote_change();
|
||||
DROP FUNCTION IF EXISTS insert_karma_transaction();
|
||||
DROP FUNCTION IF EXISTS update_user_karma();
|
||||
DROP FUNCTION IF EXISTS handle_suggestion_status_change();
|
||||
|
||||
-- Helper function to insert karma transaction
|
||||
CREATE OR REPLACE FUNCTION insert_karma_transaction(
|
||||
p_user_id INT,
|
||||
p_points INT,
|
||||
p_action TEXT,
|
||||
p_comment_id INT,
|
||||
p_description TEXT,
|
||||
p_suggestion_id INT DEFAULT NULL
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
INSERT INTO "KarmaTransaction" (
|
||||
"userId", "points", "action", "commentId",
|
||||
"suggestionId",
|
||||
"description", "processed", "createdAt"
|
||||
)
|
||||
VALUES (
|
||||
p_user_id, p_points, p_action, p_comment_id,
|
||||
p_suggestion_id,
|
||||
p_description, true, NOW()
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Helper function to update user karma
|
||||
CREATE OR REPLACE FUNCTION update_user_karma(
|
||||
p_user_id INT,
|
||||
p_karma_change INT
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
UPDATE "User"
|
||||
SET "totalKarma" = "totalKarma" + p_karma_change
|
||||
WHERE id = p_user_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Handle comment approval
|
||||
CREATE OR REPLACE FUNCTION handle_comment_approval(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
1,
|
||||
'comment_approved',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been approved!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 1);
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Handle comment verification
|
||||
CREATE OR REPLACE FUNCTION handle_comment_verification(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
5,
|
||||
'comment_verified',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been verified!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 5);
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Handle spam status changes
|
||||
CREATE OR REPLACE FUNCTION handle_comment_spam_status(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
-- Handle marking as spam
|
||||
IF NEW.suspicious = true AND OLD.suspicious = false THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
-10,
|
||||
'comment_spam',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been marked as spam.',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", -10);
|
||||
-- Handle unmarking as spam
|
||||
ELSIF NEW.suspicious = false AND OLD.suspicious = true THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
10,
|
||||
'comment_spam_reverted',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s is no longer marked as spam.',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 10);
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function for handling vote changes
|
||||
CREATE OR REPLACE FUNCTION handle_comment_vote_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
karma_points INT;
|
||||
vote_action TEXT;
|
||||
vote_description TEXT;
|
||||
comment_author_id INT;
|
||||
service_name TEXT;
|
||||
upvote_change INT := 0; -- Variable to track change in upvotes
|
||||
BEGIN
|
||||
-- Get comment author and service info
|
||||
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
||||
FROM "Comment" c
|
||||
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
||||
|
||||
-- Calculate karma impact based on vote type
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- New vote
|
||||
karma_points := CASE WHEN NEW.downvote THEN -1 ELSE 1 END;
|
||||
vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END;
|
||||
vote_description := format('Your comment #comment-%s in %s received %s',
|
||||
NEW."commentId",
|
||||
service_name,
|
||||
CASE WHEN NEW.downvote THEN 'a downvote' ELSE 'an upvote' END);
|
||||
upvote_change := CASE WHEN NEW.downvote THEN -1 ELSE 1 END; -- -1 for downvote, +1 for upvote
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
-- Removed vote
|
||||
karma_points := CASE WHEN OLD.downvote THEN 1 ELSE -1 END;
|
||||
vote_action := 'comment_vote_removed';
|
||||
vote_description := format('A vote was removed from your comment #comment-%s in %s',
|
||||
OLD."commentId",
|
||||
service_name);
|
||||
upvote_change := CASE WHEN OLD.downvote THEN 1 ELSE -1 END; -- +1 if downvote removed, -1 if upvote removed
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Changed vote (from upvote to downvote or vice versa)
|
||||
karma_points := CASE WHEN NEW.downvote THEN -2 ELSE 2 END;
|
||||
vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END;
|
||||
vote_description := format('Your comment #comment-%s in %s vote changed to %s',
|
||||
NEW."commentId",
|
||||
service_name,
|
||||
CASE WHEN NEW.downvote THEN 'downvote' ELSE 'upvote' END);
|
||||
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
||||
END IF;
|
||||
|
||||
-- Record karma transaction and update user karma
|
||||
PERFORM insert_karma_transaction(
|
||||
comment_author_id,
|
||||
karma_points,
|
||||
vote_action,
|
||||
COALESCE(NEW."commentId", OLD."commentId"),
|
||||
vote_description
|
||||
);
|
||||
|
||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||
|
||||
-- Update comment's upvotes count incrementally
|
||||
UPDATE "Comment"
|
||||
SET upvotes = upvotes + upvote_change
|
||||
WHERE id = COALESCE(NEW."commentId", OLD."commentId");
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Main function for handling status changes
|
||||
CREATE OR REPLACE FUNCTION handle_comment_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM handle_comment_approval(NEW, OLD);
|
||||
PERFORM handle_comment_verification(NEW, OLD);
|
||||
PERFORM handle_comment_spam_status(NEW, OLD);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers
|
||||
CREATE TRIGGER comment_status_change_trigger
|
||||
AFTER UPDATE OF status
|
||||
ON "Comment"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION handle_comment_status_change();
|
||||
|
||||
CREATE TRIGGER comment_suspicious_change_trigger
|
||||
AFTER UPDATE OF suspicious
|
||||
ON "Comment"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION handle_comment_status_change();
|
||||
|
||||
CREATE TRIGGER comment_vote_change_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE
|
||||
ON "CommentVote"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION handle_comment_vote_change();
|
||||
|
||||
-- Function to handle suggestion status changes and award karma
|
||||
CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
service_name TEXT;
|
||||
BEGIN
|
||||
-- Award karma for first approval
|
||||
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
||||
-- and ensure it wasn't already APPROVED.
|
||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||
-- Fetch service name for the description
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."userId",
|
||||
10,
|
||||
'suggestion_approved',
|
||||
NULL, -- p_comment_id (not applicable)
|
||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
||||
NEW.id -- p_suggestion_id
|
||||
);
|
||||
|
||||
-- Update user's total karma
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
END IF;
|
||||
|
||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers
|
||||
CREATE TRIGGER suggestion_status_change_trigger
|
||||
AFTER UPDATE OF status
|
||||
ON "ServiceSuggestion"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION handle_suggestion_status_change();
|
||||
@@ -1,264 +0,0 @@
|
||||
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
||||
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
||||
-- for services when services or their attributes change.
|
||||
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
|
||||
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
||||
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
||||
-- efficient handling of widespread score updates.
|
||||
|
||||
-- Drop existing triggers first
|
||||
DROP TRIGGER IF EXISTS service_score_update_trigger ON "Service";
|
||||
DROP TRIGGER IF EXISTS service_attribute_change_trigger ON "ServiceAttribute";
|
||||
DROP TRIGGER IF EXISTS attribute_change_trigger ON "Attribute";
|
||||
|
||||
-- Drop existing functions
|
||||
DROP FUNCTION IF EXISTS calculate_service_scores();
|
||||
DROP FUNCTION IF EXISTS calculate_privacy_score();
|
||||
DROP FUNCTION IF EXISTS calculate_trust_score();
|
||||
DROP FUNCTION IF EXISTS calculate_overall_score();
|
||||
DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
|
||||
|
||||
-- Calculate privacy score based on service attributes and properties
|
||||
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
privacy_score INT := 50; -- Start from middle value (50)
|
||||
kyc_factor INT;
|
||||
onion_factor INT := 0;
|
||||
i2p_factor INT := 0;
|
||||
monero_factor INT := 0;
|
||||
open_source_factor INT := 0;
|
||||
p2p_factor INT := 0;
|
||||
decentralized_factor INT := 0;
|
||||
attributes_score INT := 0;
|
||||
BEGIN
|
||||
-- Get service data
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "kycLevel" = 0 THEN 25 -- No KYC is best for privacy
|
||||
WHEN "kycLevel" = 1 THEN 10 -- Minimal KYC
|
||||
WHEN "kycLevel" = 2 THEN -5 -- Moderate KYC
|
||||
WHEN "kycLevel" = 3 THEN -15 -- More KYC
|
||||
WHEN "kycLevel" = 4 THEN -25 -- Full mandatory KYC
|
||||
ELSE 0 -- Default to no change
|
||||
END
|
||||
INTO kyc_factor
|
||||
FROM "Service"
|
||||
WHERE "id" = service_id;
|
||||
|
||||
-- Check for onion URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
|
||||
) THEN
|
||||
onion_factor := 5;
|
||||
END IF;
|
||||
|
||||
-- Check for i2p URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
|
||||
) THEN
|
||||
i2p_factor := 5;
|
||||
END IF;
|
||||
|
||||
-- Check for Monero acceptance
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND 'MONERO' = ANY("acceptedCurrencies")
|
||||
) THEN
|
||||
monero_factor := 5;
|
||||
END IF;
|
||||
|
||||
-- Calculate score from privacy attributes - directly use the points
|
||||
SELECT COALESCE(SUM(a."privacyPoints"), 0)
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
||||
|
||||
-- Calculate final privacy score (base 100)
|
||||
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||
|
||||
RETURN privacy_score;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Calculate trust score based on service attributes and verification status
|
||||
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
trust_score INT := 50; -- Start from middle value (50)
|
||||
verification_factor INT;
|
||||
attributes_score INT := 0;
|
||||
BEGIN
|
||||
-- Get verification status factor
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "verificationStatus" = 'VERIFICATION_SUCCESS' THEN 10
|
||||
WHEN "verificationStatus" = 'APPROVED' THEN 5
|
||||
WHEN "verificationStatus" = 'COMMUNITY_CONTRIBUTED' THEN 0
|
||||
WHEN "verificationStatus" = 'VERIFICATION_FAILED' THEN -50
|
||||
ELSE 0
|
||||
END
|
||||
INTO verification_factor
|
||||
FROM "Service"
|
||||
WHERE id = service_id;
|
||||
|
||||
-- Calculate score from trust attributes - directly use the points
|
||||
SELECT COALESCE(SUM(a."trustPoints"), 0)
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a.id
|
||||
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
|
||||
|
||||
-- Apply penalty if service was listed within the last 15 days
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "Service"
|
||||
WHERE id = service_id
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "verificationStatus" = 'APPROVED'
|
||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
||||
) THEN
|
||||
trust_score := trust_score - 10;
|
||||
-- Update the isRecentlyListed flag to true
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = TRUE
|
||||
WHERE id = service_id;
|
||||
ELSE
|
||||
-- Update the isRecentlyListed flag to false
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE
|
||||
WHERE id = service_id;
|
||||
END IF;
|
||||
|
||||
-- Calculate final trust score (base 100)
|
||||
trust_score := trust_score + verification_factor + attributes_score;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||
|
||||
RETURN trust_score;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Calculate overall score based on weighted average of privacy and trust scores
|
||||
CREATE OR REPLACE FUNCTION calculate_overall_score(service_id INT, privacy_score INT, trust_score INT)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
overall_score INT;
|
||||
BEGIN
|
||||
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
|
||||
RETURN GREATEST(0, LEAST(10, overall_score));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Main function to calculate all scores for a service
|
||||
CREATE OR REPLACE FUNCTION calculate_service_scores()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
privacy_score INT;
|
||||
trust_score INT;
|
||||
overall_score INT;
|
||||
service_id INT;
|
||||
BEGIN
|
||||
-- Determine which service ID to use based on the trigger context and table
|
||||
IF TG_TABLE_NAME = 'Service' THEN
|
||||
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
|
||||
service_id := NEW."id";
|
||||
END IF;
|
||||
ELSIF TG_TABLE_NAME = 'ServiceAttribute' THEN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
service_id := OLD."serviceId";
|
||||
ELSE -- INSERT or UPDATE
|
||||
service_id := NEW."serviceId";
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Calculate each score
|
||||
privacy_score := calculate_privacy_score(service_id);
|
||||
trust_score := calculate_trust_score(service_id);
|
||||
overall_score := calculate_overall_score(service_id, privacy_score, trust_score);
|
||||
|
||||
-- Cap score if service is flagged as scam (verificationStatus = 'VERIFICATION_FAILED')
|
||||
IF (SELECT "verificationStatus" FROM "Service" WHERE "id" = service_id) = 'VERIFICATION_FAILED' THEN
|
||||
IF overall_score > 3 THEN
|
||||
overall_score := 3;
|
||||
ELSIF overall_score < 0 THEN
|
||||
overall_score := 0;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Update the service with the new scores
|
||||
UPDATE "Service"
|
||||
SET
|
||||
"privacyScore" = privacy_score,
|
||||
"trustScore" = trust_score,
|
||||
"overallScore" = overall_score
|
||||
WHERE "id" = service_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to recalculate scores when service is created or updated
|
||||
CREATE TRIGGER service_score_update_trigger
|
||||
AFTER INSERT OR UPDATE
|
||||
ON "Service"
|
||||
FOR EACH ROW
|
||||
WHEN (pg_trigger_depth() < 2) -- Prevent recursive triggering
|
||||
EXECUTE FUNCTION calculate_service_scores();
|
||||
|
||||
-- Create trigger to recalculate scores when service attributes change
|
||||
CREATE TRIGGER service_attribute_change_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE
|
||||
ON "ServiceAttribute"
|
||||
FOR EACH ROW
|
||||
WHEN (pg_trigger_depth() < 2) -- Prevent recursive triggering
|
||||
EXECUTE FUNCTION calculate_service_scores();
|
||||
|
||||
-- Function to queue score recalculation for all services with a specific attribute
|
||||
CREATE OR REPLACE FUNCTION queue_service_score_recalculation_for_attribute()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
service_rec RECORD;
|
||||
BEGIN
|
||||
-- Only trigger recalculation if relevant fields changed
|
||||
IF (TG_OP = 'UPDATE' AND (
|
||||
OLD."privacyPoints" != NEW."privacyPoints" OR
|
||||
OLD."trustPoints" != NEW."trustPoints" OR
|
||||
OLD."type" != NEW."type" OR
|
||||
OLD."category" != NEW."category"
|
||||
)) THEN
|
||||
-- Find all services that have this attribute and queue a recalculation job
|
||||
FOR service_rec IN
|
||||
SELECT DISTINCT sa."serviceId"
|
||||
FROM "ServiceAttribute" sa
|
||||
WHERE sa."attributeId" = NEW.id
|
||||
LOOP
|
||||
-- Insert a job into the queue table
|
||||
-- ON CONFLICT clause ensures we don't queue the same service multiple times per transaction
|
||||
INSERT INTO "ServiceScoreRecalculationJob" ("serviceId", "createdAt", "processedAt")
|
||||
VALUES (service_rec."serviceId", NOW(), NULL)
|
||||
ON CONFLICT ("serviceId") DO UPDATE SET "processedAt" = NULL, "createdAt" = NOW();
|
||||
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create constraint trigger to queue score recalculation when attributes are updated
|
||||
DROP TRIGGER IF EXISTS attribute_change_trigger ON "Attribute";
|
||||
CREATE CONSTRAINT TRIGGER attribute_change_trigger
|
||||
AFTER UPDATE
|
||||
ON "Attribute"
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
WHEN (pg_trigger_depth() < 2)
|
||||
EXECUTE FUNCTION queue_service_score_recalculation_for_attribute();
|
||||
@@ -1,57 +0,0 @@
|
||||
-- This script defines a PostgreSQL function and trigger to automatically calculate
|
||||
-- and update the average user rating for services based on associated comments.
|
||||
-- The average rating is recalculated whenever comments are added, updated, or deleted.
|
||||
|
||||
-- Drop existing triggers first
|
||||
DROP TRIGGER IF EXISTS comment_average_rating_trigger ON "Comment";
|
||||
|
||||
-- Drop existing functions
|
||||
DROP FUNCTION IF EXISTS calculate_average_rating();
|
||||
|
||||
-- Calculate average rating based on active comments with approved or verified status
|
||||
CREATE OR REPLACE FUNCTION calculate_average_rating()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
affected_service_id INT;
|
||||
average_user_rating DECIMAL;
|
||||
BEGIN
|
||||
-- Determine which service ID to use based on the trigger context
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
affected_service_id := OLD."serviceId";
|
||||
ELSE -- INSERT or UPDATE
|
||||
affected_service_id := NEW."serviceId";
|
||||
END IF;
|
||||
|
||||
-- Calculate average rating from active comments with approved or verified status
|
||||
-- Excluding suspicious comments and replies (comments with parentId not null)
|
||||
|
||||
SELECT AVG(rating) INTO average_user_rating
|
||||
FROM "Comment"
|
||||
WHERE "serviceId" = affected_service_id
|
||||
AND "parentId" IS NULL
|
||||
AND rating IS NOT NULL
|
||||
AND (status = 'APPROVED' OR status = 'VERIFIED')
|
||||
AND "ratingActive" = true
|
||||
AND suspicious = false;
|
||||
|
||||
-- Update the service with the new average rating
|
||||
UPDATE "Service"
|
||||
SET "averageUserRating" = average_user_rating
|
||||
WHERE "id" = affected_service_id;
|
||||
|
||||
-- Return the appropriate record based on operation
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to recalculate average rating when comments are created, updated, or deleted
|
||||
CREATE TRIGGER comment_average_rating_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE
|
||||
ON "Comment"
|
||||
FOR EACH ROW
|
||||
WHEN (pg_trigger_depth() < 2) -- Prevent recursive triggering
|
||||
EXECUTE FUNCTION calculate_average_rating();
|
||||
@@ -1,48 +0,0 @@
|
||||
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
|
||||
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
|
||||
-- are set or cleared appropriately when a service's verification status is updated.
|
||||
|
||||
CREATE OR REPLACE FUNCTION manage_service_timestamps()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Manage listedAt timestamp
|
||||
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
|
||||
IF OLD."listedAt" IS NULL THEN
|
||||
NEW."listedAt" := NOW();
|
||||
NEW."isRecentlyListed" := TRUE;
|
||||
END IF;
|
||||
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
NEW."listedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
-- Manage verifiedAt timestamp
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
|
||||
NEW."verifiedAt" := NOW();
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
NEW."verifiedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the old trigger first if it exists under the old name
|
||||
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
||||
-- Drop the trigger if it exists under the new name
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_timestamps
|
||||
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
||||
FOR EACH ROW
|
||||
-- Only execute the function if the verificationStatus value has actually changed
|
||||
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
|
||||
EXECUTE FUNCTION manage_service_timestamps();
|
||||
@@ -1,399 +0,0 @@
|
||||
-- Service Events Trigger
|
||||
-- This trigger automatically creates events when services are updated
|
||||
-- to track important changes over time
|
||||
|
||||
CREATE OR REPLACE FUNCTION trigger_service_events()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
change_descriptions TEXT[] := '{}';
|
||||
event_title TEXT;
|
||||
event_content TEXT;
|
||||
change_type TEXT := NULL;
|
||||
event_time TIMESTAMP WITH TIME ZONE := transaction_timestamp();
|
||||
currency_desc TEXT;
|
||||
BEGIN
|
||||
-- Only proceed if this is an UPDATE operation
|
||||
IF TG_OP <> 'UPDATE' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Check for domain/URL changes
|
||||
IF OLD."serviceUrls" IS DISTINCT FROM NEW."serviceUrls" THEN
|
||||
change_descriptions := array_append(change_descriptions,
|
||||
'Service URLs updated from ' || array_to_string(OLD."serviceUrls", ', ') ||
|
||||
' to ' || array_to_string(NEW."serviceUrls", ', ')
|
||||
);
|
||||
change_type := COALESCE(change_type, 'Domain change');
|
||||
END IF;
|
||||
|
||||
-- Check for KYC level changes
|
||||
IF OLD."kycLevel" IS DISTINCT FROM NEW."kycLevel" THEN
|
||||
change_descriptions := array_append(change_descriptions,
|
||||
'KYC level changed from ' || OLD."kycLevel"::TEXT || ' to ' || NEW."kycLevel"::TEXT
|
||||
);
|
||||
change_type := COALESCE(change_type, 'KYC update');
|
||||
END IF;
|
||||
|
||||
-- Check for verification status changes
|
||||
IF OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus" THEN
|
||||
change_descriptions := array_append(change_descriptions,
|
||||
'Verification status changed from ' || OLD."verificationStatus"::TEXT || ' to ' || NEW."verificationStatus"::TEXT
|
||||
);
|
||||
change_type := COALESCE(change_type, 'Verification update');
|
||||
END IF;
|
||||
|
||||
-- Check for description changes
|
||||
IF OLD.description IS DISTINCT FROM NEW.description THEN
|
||||
change_descriptions := array_append(change_descriptions, 'Description was updated');
|
||||
change_type := COALESCE(change_type, 'Description update');
|
||||
END IF;
|
||||
|
||||
-- Check for currency changes
|
||||
IF OLD."acceptedCurrencies" IS DISTINCT FROM NEW."acceptedCurrencies" THEN
|
||||
-- Find currencies added
|
||||
WITH
|
||||
old_currencies AS (SELECT unnest(OLD."acceptedCurrencies") AS currency),
|
||||
new_currencies AS (SELECT unnest(NEW."acceptedCurrencies") AS currency),
|
||||
added_currencies AS (
|
||||
SELECT currency FROM new_currencies
|
||||
EXCEPT
|
||||
SELECT currency FROM old_currencies
|
||||
),
|
||||
removed_currencies AS (
|
||||
SELECT currency FROM old_currencies
|
||||
EXCEPT
|
||||
SELECT currency FROM new_currencies
|
||||
)
|
||||
|
||||
-- Temp variable for currency description
|
||||
SELECT
|
||||
CASE
|
||||
WHEN (SELECT COUNT(*) FROM added_currencies) > 0 AND (SELECT COUNT(*) FROM removed_currencies) > 0 THEN
|
||||
'Currencies updated: added ' || array_to_string(ARRAY(SELECT currency FROM added_currencies), ', ') ||
|
||||
', removed ' || array_to_string(ARRAY(SELECT currency FROM removed_currencies), ', ')
|
||||
WHEN (SELECT COUNT(*) FROM added_currencies) > 0 THEN
|
||||
'Added currencies: ' || array_to_string(ARRAY(SELECT currency FROM added_currencies), ', ')
|
||||
WHEN (SELECT COUNT(*) FROM removed_currencies) > 0 THEN
|
||||
'Removed currencies: ' || array_to_string(ARRAY(SELECT currency FROM removed_currencies), ', ')
|
||||
ELSE
|
||||
'Currencies changed'
|
||||
END
|
||||
INTO currency_desc;
|
||||
|
||||
IF currency_desc IS NOT NULL AND currency_desc <> '' THEN
|
||||
change_descriptions := array_append(change_descriptions, currency_desc);
|
||||
change_type := COALESCE(change_type, 'Currency update');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- If there are changes, create an event
|
||||
IF array_length(change_descriptions, 1) > 0 THEN
|
||||
-- Create a title based on number of changes
|
||||
IF array_length(change_descriptions, 1) = 1 THEN
|
||||
event_title := COALESCE(change_type, 'Service updated'); -- Ensure title is not null
|
||||
ELSE
|
||||
event_title := 'Service updated';
|
||||
END IF;
|
||||
|
||||
-- Create content with all changes
|
||||
event_content := array_to_string(change_descriptions, '. ');
|
||||
|
||||
-- Ensure content is not null or empty
|
||||
IF event_content IS NULL OR event_content = '' THEN
|
||||
event_content := 'Service details changed (content unavailable)';
|
||||
END IF;
|
||||
|
||||
-- Insert the event
|
||||
INSERT INTO "Event" (
|
||||
"title",
|
||||
"content",
|
||||
"type",
|
||||
"visible",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"serviceId"
|
||||
) VALUES (
|
||||
event_title,
|
||||
event_content,
|
||||
'UPDATE',
|
||||
TRUE,
|
||||
event_time,
|
||||
event_time,
|
||||
NEW.id
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger for service updates
|
||||
DROP TRIGGER IF EXISTS service_events_trigger ON "Service";
|
||||
CREATE TRIGGER service_events_trigger
|
||||
AFTER UPDATE OF "serviceUrls", "kycLevel", "verificationStatus", "description", "acceptedCurrencies" ON "Service"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_events();
|
||||
|
||||
-- Additional trigger to monitor changes to ServiceAttribute
|
||||
CREATE OR REPLACE FUNCTION trigger_service_attribute_events()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
attribute_name TEXT;
|
||||
service_name TEXT;
|
||||
event_title TEXT := 'Attribute change'; -- Default title
|
||||
event_content TEXT;
|
||||
event_time TIMESTAMP WITH TIME ZONE := transaction_timestamp();
|
||||
target_service_id INT;
|
||||
service_exists BOOLEAN;
|
||||
service_created_at TIMESTAMP WITH TIME ZONE;
|
||||
is_new_service BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- Determine target service ID and operation type
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
target_service_id := NEW."serviceId";
|
||||
|
||||
-- Check if this is a new service (created within the last minute)
|
||||
-- This helps prevent events when attributes are initially added to a new service
|
||||
SELECT "createdAt" INTO service_created_at FROM "Service" WHERE id = target_service_id;
|
||||
IF service_created_at IS NOT NULL AND (event_time - service_created_at) < INTERVAL '1 minute' THEN
|
||||
is_new_service := TRUE;
|
||||
RETURN NEW; -- Skip event creation for new services
|
||||
END IF;
|
||||
|
||||
SELECT title INTO attribute_name FROM "Attribute" WHERE id = NEW."attributeId";
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = target_service_id;
|
||||
IF attribute_name IS NOT NULL AND service_name IS NOT NULL THEN
|
||||
event_title := 'Attribute added';
|
||||
event_content := 'Attribute "' || attribute_name || '" was added to ' || service_name;
|
||||
ELSE
|
||||
event_content := 'An attribute was added (details unavailable)';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
target_service_id := OLD."serviceId";
|
||||
-- Check if the service still exists before trying to fetch its name or create an event
|
||||
SELECT EXISTS (SELECT 1 FROM "Service" WHERE id = target_service_id) INTO service_exists;
|
||||
IF service_exists THEN
|
||||
SELECT title INTO attribute_name FROM "Attribute" WHERE id = OLD."attributeId";
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = target_service_id;
|
||||
IF attribute_name IS NOT NULL AND service_name IS NOT NULL THEN
|
||||
event_title := 'Attribute removed';
|
||||
event_content := 'Attribute "' || attribute_name || '" was removed from ' || service_name;
|
||||
ELSE
|
||||
-- This case might happen if attribute was deleted concurrently
|
||||
event_content := 'An attribute was removed (details unavailable)';
|
||||
END IF;
|
||||
ELSE
|
||||
-- Service was deleted, don't create an event
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure content is not null/empty and insert
|
||||
IF event_content IS NOT NULL AND event_content <> '' AND target_service_id IS NOT NULL AND NOT is_new_service THEN
|
||||
-- Re-check service existence right before insert just in case of concurrency on INSERT
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
SELECT EXISTS (SELECT 1 FROM "Service" WHERE id = target_service_id) INTO service_exists;
|
||||
END IF;
|
||||
|
||||
IF service_exists THEN
|
||||
INSERT INTO "Event" (
|
||||
"title",
|
||||
"content",
|
||||
"type",
|
||||
"visible",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"serviceId"
|
||||
) VALUES (
|
||||
event_title,
|
||||
event_content,
|
||||
'UPDATE',
|
||||
TRUE,
|
||||
event_time,
|
||||
event_time,
|
||||
target_service_id
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Return appropriate record
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
RETURN NEW;
|
||||
ELSE
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger for service attribute changes
|
||||
DROP TRIGGER IF EXISTS service_attribute_events_trigger ON "ServiceAttribute";
|
||||
CREATE TRIGGER service_attribute_events_trigger
|
||||
AFTER INSERT OR DELETE ON "ServiceAttribute"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_attribute_events();
|
||||
|
||||
-- Additional trigger to monitor changes to service categories
|
||||
CREATE OR REPLACE FUNCTION trigger_service_category_events()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
category_name TEXT;
|
||||
service_name TEXT;
|
||||
event_title TEXT := 'Category change'; -- Default title
|
||||
event_content TEXT;
|
||||
event_time TIMESTAMP WITH TIME ZONE := transaction_timestamp();
|
||||
target_service_id INT;
|
||||
service_exists BOOLEAN;
|
||||
service_created_at TIMESTAMP WITH TIME ZONE;
|
||||
is_new_service BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- Determine target service ID and operation type
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
target_service_id := NEW."A";
|
||||
|
||||
-- Check if this is a new service (created within the last minute)
|
||||
-- This helps prevent events when categories are initially added to a new service
|
||||
SELECT "createdAt" INTO service_created_at FROM "Service" WHERE id = target_service_id;
|
||||
IF service_created_at IS NOT NULL AND (event_time - service_created_at) < INTERVAL '1 minute' THEN
|
||||
is_new_service := TRUE;
|
||||
RETURN NEW; -- Skip event creation for new services
|
||||
END IF;
|
||||
|
||||
SELECT name INTO category_name FROM "Category" WHERE id = NEW."B";
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = target_service_id;
|
||||
IF category_name IS NOT NULL AND service_name IS NOT NULL THEN
|
||||
event_title := 'Category added';
|
||||
event_content := 'Category "' || category_name || '" was added to ' || service_name;
|
||||
ELSE
|
||||
event_content := 'A category was added (details unavailable)';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
target_service_id := OLD."A";
|
||||
-- Check if the service still exists before trying to fetch its name or create an event
|
||||
SELECT EXISTS (SELECT 1 FROM "Service" WHERE id = target_service_id) INTO service_exists;
|
||||
IF service_exists THEN
|
||||
SELECT name INTO category_name FROM "Category" WHERE id = OLD."B";
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = target_service_id;
|
||||
IF category_name IS NOT NULL AND service_name IS NOT NULL THEN
|
||||
event_title := 'Category removed';
|
||||
event_content := 'Category "' || category_name || '" was removed from ' || service_name;
|
||||
ELSE
|
||||
-- This case might happen if category was deleted concurrently
|
||||
event_content := 'A category was removed (details unavailable)';
|
||||
END IF;
|
||||
ELSE
|
||||
-- Service was deleted, don't create an event
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure content is not null/empty and insert
|
||||
IF event_content IS NOT NULL AND event_content <> '' AND target_service_id IS NOT NULL AND NOT is_new_service THEN
|
||||
-- Re-check service existence right before insert just in case of concurrency on INSERT
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
SELECT EXISTS (SELECT 1 FROM "Service" WHERE id = target_service_id) INTO service_exists;
|
||||
END IF;
|
||||
|
||||
IF service_exists THEN
|
||||
INSERT INTO "Event" (
|
||||
"title",
|
||||
"content",
|
||||
"type",
|
||||
"visible",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"serviceId"
|
||||
) VALUES (
|
||||
event_title,
|
||||
event_content,
|
||||
'UPDATE',
|
||||
TRUE,
|
||||
event_time,
|
||||
event_time,
|
||||
target_service_id
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Return appropriate record
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
RETURN NEW;
|
||||
ELSE
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger for service category changes (on the junction table)
|
||||
DROP TRIGGER IF EXISTS service_category_events_trigger ON "_ServiceToCategory";
|
||||
CREATE TRIGGER service_category_events_trigger
|
||||
AFTER INSERT OR DELETE ON "_ServiceToCategory"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_category_events();
|
||||
|
||||
-- Verification Steps Trigger
|
||||
-- This trigger creates events when verification steps are added or status changes
|
||||
CREATE OR REPLACE FUNCTION trigger_verification_step_events()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
service_name TEXT;
|
||||
event_title TEXT;
|
||||
event_content TEXT;
|
||||
event_time TIMESTAMP WITH TIME ZONE := transaction_timestamp();
|
||||
service_exists BOOLEAN;
|
||||
BEGIN
|
||||
-- Check if the service exists
|
||||
SELECT EXISTS (SELECT 1 FROM "Service" WHERE id = NEW."serviceId") INTO service_exists;
|
||||
|
||||
IF NOT service_exists THEN
|
||||
-- Service was deleted or doesn't exist, don't create an event
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Get service name
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||
|
||||
-- Handle different operations
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
event_title := 'Verification step added';
|
||||
event_content := '"' || NEW.title || '" was added';
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN
|
||||
event_title := 'Verification step ' || replace(lower(NEW.status::TEXT), '_', ' ');
|
||||
event_content := '"' || NEW.title || '" status changed from ' ||
|
||||
replace(lower(OLD.status::TEXT), '_', ' ') || ' to ' || replace(lower(NEW.status::TEXT), '_', ' ');
|
||||
ELSE
|
||||
-- No relevant changes, exit
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Insert the event
|
||||
INSERT INTO "Event" (
|
||||
"title",
|
||||
"content",
|
||||
"type",
|
||||
"visible",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"serviceId"
|
||||
) VALUES (
|
||||
event_title,
|
||||
event_content,
|
||||
'UPDATE',
|
||||
TRUE,
|
||||
event_time,
|
||||
event_time,
|
||||
NEW."serviceId"
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for verification step changes
|
||||
DROP TRIGGER IF EXISTS verification_step_events_trigger ON "VerificationStep";
|
||||
CREATE TRIGGER verification_step_events_trigger
|
||||
AFTER INSERT OR UPDATE OF status ON "VerificationStep"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_verification_step_events();
|
||||
@@ -1,227 +0,0 @@
|
||||
-- Function & Trigger for Root Comment Insertions (Approved/Verified)
|
||||
CREATE OR REPLACE FUNCTION notify_root_comment_inserted()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
watcher_count INT;
|
||||
BEGIN
|
||||
RAISE NOTICE '[notify_root_comment_inserted] Trigger fired for comment ID: %', NEW.id;
|
||||
WITH watchers AS (
|
||||
SELECT np."userId", np."enableNotifyPendingRepliesOnWatch"
|
||||
FROM "_onRootCommentCreatedForServices" rc
|
||||
JOIN "NotificationPreferences" np ON rc."A" = np."id"
|
||||
WHERE rc."B" = NEW."serviceId"
|
||||
AND np."userId" <> NEW."authorId"
|
||||
)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutCommentId")
|
||||
SELECT w."userId",
|
||||
'ROOT_COMMENT_CREATED',
|
||||
NEW."id"
|
||||
FROM watchers w
|
||||
WHERE (
|
||||
NEW.status IN ('APPROVED', 'VERIFIED')
|
||||
OR (NEW.status = 'PENDING' AND w."enableNotifyPendingRepliesOnWatch")
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
RAISE NOTICE '[notify_root_comment_inserted] Inserted % notifications for comment ID: %', FOUND, NEW.id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_notify_root_comment_inserted ON "Comment";
|
||||
CREATE TRIGGER trg_notify_root_comment_inserted
|
||||
AFTER INSERT ON "Comment"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW."parentId" IS NULL)
|
||||
EXECUTE FUNCTION notify_root_comment_inserted();
|
||||
|
||||
-- Function & Trigger for Reply Comment Insertions
|
||||
CREATE OR REPLACE FUNCTION notify_reply_comment_inserted()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
WITH watchers AS (
|
||||
SELECT np."userId", np."enableNotifyPendingRepliesOnWatch"
|
||||
FROM "_watchedComments" w
|
||||
JOIN "NotificationPreferences" np ON w."B" = np."id"
|
||||
WHERE w."A" = NEW."parentId"
|
||||
AND np."userId" <> NEW."authorId"
|
||||
)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutCommentId")
|
||||
SELECT w."userId",
|
||||
'REPLY_COMMENT_CREATED',
|
||||
NEW."id"
|
||||
FROM watchers w
|
||||
WHERE (
|
||||
NEW.status IN ('APPROVED', 'VERIFIED')
|
||||
OR (NEW.status = 'PENDING' AND w."enableNotifyPendingRepliesOnWatch")
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_notify_reply_comment_inserted ON "Comment";
|
||||
CREATE TRIGGER trg_notify_reply_comment_inserted
|
||||
AFTER INSERT ON "Comment"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW."parentId" IS NOT NULL)
|
||||
EXECUTE FUNCTION notify_reply_comment_inserted();
|
||||
|
||||
-- Function & Trigger for Reply Approval/Verification
|
||||
CREATE OR REPLACE FUNCTION notify_reply_approved()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
WITH watchers AS (
|
||||
SELECT np."userId"
|
||||
FROM "_watchedComments" w
|
||||
JOIN "NotificationPreferences" np ON w."B" = np."id"
|
||||
WHERE w."A" = NEW."parentId"
|
||||
AND np."userId" <> NEW."authorId"
|
||||
)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutCommentId")
|
||||
SELECT w."userId",
|
||||
'REPLY_COMMENT_CREATED',
|
||||
NEW."id"
|
||||
FROM watchers w
|
||||
ON CONFLICT DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_notify_reply_approved ON "Comment";
|
||||
CREATE TRIGGER trg_notify_reply_approved
|
||||
AFTER UPDATE OF status ON "Comment"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW."parentId" IS NOT NULL AND NEW.status IN ('APPROVED', 'VERIFIED') AND OLD.status NOT IN ('APPROVED', 'VERIFIED'))
|
||||
EXECUTE FUNCTION notify_reply_approved();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_notify_root_approved ON "Comment";
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_root_approved()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
WITH watchers AS (
|
||||
SELECT np."userId"
|
||||
FROM "_onRootCommentCreatedForServices" rc
|
||||
JOIN "NotificationPreferences" np ON rc."A" = np."id"
|
||||
WHERE rc."B" = NEW."serviceId"
|
||||
AND np."userId" <> NEW."authorId"
|
||||
AND NOT np."enableNotifyPendingRepliesOnWatch"
|
||||
)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutCommentId")
|
||||
SELECT w."userId",
|
||||
'ROOT_COMMENT_CREATED',
|
||||
NEW."id"
|
||||
FROM watchers w
|
||||
ON CONFLICT DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_notify_root_approved
|
||||
AFTER UPDATE OF status ON "Comment"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW."parentId" IS NULL AND NEW.status IN ('APPROVED', 'VERIFIED') AND OLD.status NOT IN ('APPROVED', 'VERIFIED'))
|
||||
EXECUTE FUNCTION notify_root_approved();
|
||||
|
||||
-- Function & Trigger for Comment Status Changes (Status, Suspicious, AdminReview)
|
||||
CREATE OR REPLACE FUNCTION notify_comment_status_changed()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_status_change "CommentStatusChange" := NULL;
|
||||
BEGIN
|
||||
-- Determine the status change type
|
||||
IF NEW.status <> OLD.status THEN
|
||||
IF NEW.status = 'APPROVED' THEN v_status_change := 'STATUS_CHANGED_TO_APPROVED';
|
||||
ELSIF NEW.status = 'VERIFIED' THEN v_status_change := 'STATUS_CHANGED_TO_VERIFIED';
|
||||
ELSIF NEW.status = 'REJECTED' THEN v_status_change := 'STATUS_CHANGED_TO_REJECTED';
|
||||
ELSIF (NEW.status = 'PENDING' OR NEW.status = 'HUMAN_PENDING') AND (OLD.status <> 'PENDING' AND OLD.status <> 'HUMAN_PENDING') THEN v_status_change := 'STATUS_CHANGED_TO_PENDING';
|
||||
END IF;
|
||||
ELSIF NEW.suspicious <> OLD.suspicious THEN
|
||||
IF NEW.suspicious = true THEN v_status_change := 'MARKED_AS_SPAM';
|
||||
ELSE v_status_change := 'UNMARKED_AS_SPAM';
|
||||
END IF;
|
||||
ELSIF NEW."requiresAdminReview" <> OLD."requiresAdminReview" THEN
|
||||
IF NEW."requiresAdminReview" = true THEN v_status_change := 'MARKED_FOR_ADMIN_REVIEW';
|
||||
ELSE v_status_change := 'UNMARKED_FOR_ADMIN_REVIEW';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- If a relevant status change occurred, notify watchers of THIS comment
|
||||
IF v_status_change IS NOT NULL THEN
|
||||
WITH watchers AS (
|
||||
-- Get all watchers excluding author
|
||||
SELECT np."userId"
|
||||
FROM "_watchedComments" w
|
||||
JOIN "NotificationPreferences" np ON w."B" = np."id"
|
||||
WHERE w."A" = NEW."id"
|
||||
AND np."userId" <> NEW."authorId"
|
||||
AND np."enableOnMyCommentStatusChange"
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Add author if they have enabled notifications for their own comments
|
||||
SELECT np."userId"
|
||||
FROM "NotificationPreferences" np
|
||||
WHERE np."userId" = NEW."authorId"
|
||||
AND np."enableOnMyCommentStatusChange"
|
||||
)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutCommentId", "aboutCommentStatusChange")
|
||||
SELECT w."userId",
|
||||
'COMMENT_STATUS_CHANGE',
|
||||
NEW."id",
|
||||
v_status_change
|
||||
FROM watchers w
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_notify_comment_status_changed ON "Comment";
|
||||
CREATE TRIGGER trg_notify_comment_status_changed
|
||||
AFTER UPDATE OF status, suspicious, "requiresAdminReview" ON "Comment"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status <> OLD.status OR NEW.suspicious <> OLD.suspicious OR NEW."requiresAdminReview" <> OLD."requiresAdminReview")
|
||||
EXECUTE FUNCTION notify_comment_status_changed();
|
||||
|
||||
-- Function & Trigger for Community Note Added
|
||||
CREATE OR REPLACE FUNCTION notify_community_note_added()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Notify watchers of this specific comment (excluding author)
|
||||
WITH watchers AS (
|
||||
SELECT np."userId"
|
||||
FROM "_watchedComments" w
|
||||
JOIN "NotificationPreferences" np ON w."B" = np."id"
|
||||
WHERE w."A" = NEW."id"
|
||||
AND np."userId" <> NEW."authorId"
|
||||
)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutCommentId")
|
||||
SELECT w."userId",
|
||||
'COMMUNITY_NOTE_ADDED',
|
||||
NEW."id"
|
||||
FROM watchers w
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Always notify the author
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutCommentId")
|
||||
VALUES (NEW."authorId", 'COMMUNITY_NOTE_ADDED', NEW."id")
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_notify_community_note_added ON "Comment";
|
||||
CREATE TRIGGER trg_notify_community_note_added
|
||||
AFTER UPDATE OF "communityNote" ON "Comment"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW."communityNote" IS NOT NULL AND NEW."communityNote" <> '' AND (OLD."communityNote" IS NULL OR OLD."communityNote" = ''))
|
||||
EXECUTE FUNCTION notify_community_note_added();
|
||||
|
||||
-- Remove the old monolithic trigger and function definition if they still exist
|
||||
DROP TRIGGER IF EXISTS comment_notifications_trigger ON "Comment";
|
||||
DROP FUNCTION IF EXISTS trigger_comment_notifications();
|
||||
@@ -1,72 +0,0 @@
|
||||
CREATE OR REPLACE FUNCTION trigger_service_suggestion_notifications()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
suggestion_status_change "ServiceSuggestionStatusChange";
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert
|
||||
-- Notify suggestion author (if not the sender)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
||||
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
||||
FROM "ServiceSuggestion" s
|
||||
WHERE s."id" = NEW."suggestionId"
|
||||
AND s."userId" <> NEW."userId"
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
WHERE n."userId" = s."userId"
|
||||
AND n."type" = 'SUGGESTION_MESSAGE'
|
||||
AND n."aboutServiceSuggestionMessageId" = NEW."id"
|
||||
);
|
||||
|
||||
-- Notify all admins (except the sender), but only if sender is not admin
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
||||
SELECT u."id", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
||||
FROM "User" u
|
||||
WHERE u."admin" = true
|
||||
AND u."id" <> NEW."userId"
|
||||
-- Only notify admins if the message sender is not an admin
|
||||
AND NOT EXISTS (SELECT 1 FROM "User" WHERE "id" = NEW."userId" AND "admin" = true)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
WHERE n."userId" = u."id"
|
||||
AND n."type" = 'SUGGESTION_MESSAGE'
|
||||
AND n."aboutServiceSuggestionMessageId" = NEW."id"
|
||||
);
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN -- Corresponds to ServiceSuggestion status update
|
||||
-- Notify suggestion author about status change
|
||||
IF NEW.status <> OLD.status THEN
|
||||
IF NEW.status = 'PENDING' THEN
|
||||
suggestion_status_change := 'STATUS_CHANGED_TO_PENDING';
|
||||
ELSIF NEW.status = 'APPROVED' THEN
|
||||
suggestion_status_change := 'STATUS_CHANGED_TO_APPROVED';
|
||||
ELSIF NEW.status = 'REJECTED' THEN
|
||||
suggestion_status_change := 'STATUS_CHANGED_TO_REJECTED';
|
||||
ELSIF NEW.status = 'WITHDRAWN' THEN
|
||||
suggestion_status_change := 'STATUS_CHANGED_TO_WITHDRAWN';
|
||||
END IF;
|
||||
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutSuggestionStatusChange")
|
||||
VALUES (NEW."userId", 'SUGGESTION_STATUS_CHANGE', NEW."id", suggestion_status_change);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Use RETURN NULL for AFTER triggers as the return value is ignored.
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for new messages
|
||||
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
||||
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
||||
AFTER INSERT ON "ServiceSuggestionMessage"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_suggestion_notifications();
|
||||
|
||||
-- Trigger for status updates
|
||||
DROP TRIGGER IF EXISTS service_suggestion_status_notifications_trigger ON "ServiceSuggestion";
|
||||
CREATE TRIGGER service_suggestion_status_notifications_trigger
|
||||
AFTER UPDATE OF status ON "ServiceSuggestion"
|
||||
FOR EACH ROW
|
||||
-- Only run the function if the status actually changed
|
||||
WHEN (OLD.status IS DISTINCT FROM NEW.status)
|
||||
EXECUTE FUNCTION trigger_service_suggestion_notifications();
|
||||
@@ -1,28 +0,0 @@
|
||||
CREATE OR REPLACE FUNCTION trigger_service_events_notifications()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Handle new Event insertions
|
||||
IF TG_TABLE_NAME = 'Event' AND TG_OP = 'INSERT' THEN
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceId", "aboutEventId")
|
||||
SELECT np."userId", 'EVENT_CREATED', NEW."serviceId", NEW.id
|
||||
FROM "_onEventCreatedForServices" oes
|
||||
JOIN "NotificationPreferences" np ON oes."A" = np.id
|
||||
WHERE oes."B" = NEW."serviceId"
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
WHERE n."userId" = np."userId"
|
||||
AND n."type" = 'EVENT_CREATED'
|
||||
AND n."aboutEventId" = NEW.id
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for new Events
|
||||
DROP TRIGGER IF EXISTS eVENT_CREATED_notifications_trigger ON "Event";
|
||||
CREATE TRIGGER eVENT_CREATED_notifications_trigger
|
||||
AFTER INSERT ON "Event"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_events_notifications();
|
||||
@@ -1,37 +0,0 @@
|
||||
CREATE OR REPLACE FUNCTION trigger_service_verification_status_change_notifications()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_status_change "ServiceVerificationStatusChange";
|
||||
BEGIN
|
||||
-- Check if verificationStatus actually changed
|
||||
IF OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus" THEN
|
||||
-- Determine the correct ServiceVerificationStatusChange enum value
|
||||
SELECT CASE NEW."verificationStatus"
|
||||
WHEN 'COMMUNITY_CONTRIBUTED' THEN 'STATUS_CHANGED_TO_COMMUNITY_CONTRIBUTED'
|
||||
WHEN 'APPROVED' THEN 'STATUS_CHANGED_TO_APPROVED'
|
||||
WHEN 'VERIFICATION_SUCCESS' THEN 'STATUS_CHANGED_TO_VERIFICATION_SUCCESS'
|
||||
WHEN 'VERIFICATION_FAILED' THEN 'STATUS_CHANGED_TO_VERIFICATION_FAILED'
|
||||
ELSE NULL
|
||||
END
|
||||
INTO v_status_change;
|
||||
|
||||
-- Only insert if we determined a valid status change enum
|
||||
IF v_status_change IS NOT NULL THEN
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceId", "aboutServiceVerificationStatusChange")
|
||||
SELECT np."userId", 'SERVICE_VERIFICATION_STATUS_CHANGE', NEW.id, v_status_change
|
||||
FROM "_onVerificationChangeForServices" oes
|
||||
JOIN "NotificationPreferences" np ON oes."A" = np.id -- A -> NotificationPreferences.id
|
||||
WHERE oes."B" = NEW.id; -- B -> Service.id
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NULL; -- Return NULL for AFTER trigger
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for Service verificationStatus updates
|
||||
DROP TRIGGER IF EXISTS service_verification_status_change_notifications_trigger ON "Service";
|
||||
CREATE TRIGGER service_verification_status_change_notifications_trigger
|
||||
AFTER UPDATE ON "Service"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_verification_status_change_notifications();
|
||||
@@ -1,62 +0,0 @@
|
||||
CREATE OR REPLACE FUNCTION trigger_user_status_change_notifications()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
status_change "AccountStatusChange";
|
||||
BEGIN
|
||||
-- Check for admin status change
|
||||
IF OLD.admin IS DISTINCT FROM NEW.admin THEN
|
||||
IF NEW.admin = true THEN
|
||||
status_change := 'ADMIN_TRUE';
|
||||
ELSE
|
||||
status_change := 'ADMIN_FALSE';
|
||||
END IF;
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
END IF;
|
||||
|
||||
-- Check for verified status change
|
||||
IF OLD.verified IS DISTINCT FROM NEW.verified THEN
|
||||
IF NEW.verified = true THEN
|
||||
status_change := 'VERIFIED_TRUE';
|
||||
ELSE
|
||||
status_change := 'VERIFIED_FALSE';
|
||||
END IF;
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
END IF;
|
||||
|
||||
-- Check for verifier status change
|
||||
IF OLD.verifier IS DISTINCT FROM NEW.verifier THEN
|
||||
IF NEW.verifier = true THEN
|
||||
status_change := 'VERIFIER_TRUE';
|
||||
ELSE
|
||||
status_change := 'VERIFIER_FALSE';
|
||||
END IF;
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
END IF;
|
||||
|
||||
-- Check for spammer status change
|
||||
IF OLD.spammer IS DISTINCT FROM NEW.spammer THEN
|
||||
IF NEW.spammer = true THEN
|
||||
status_change := 'SPAMMER_TRUE';
|
||||
ELSE
|
||||
status_change := 'SPAMMER_FALSE';
|
||||
END IF;
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
END IF;
|
||||
|
||||
-- Return NULL for AFTER triggers as the return value is ignored.
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the trigger if it exists to ensure a clean setup
|
||||
DROP TRIGGER IF EXISTS user_status_change_notifications_trigger ON "User";
|
||||
|
||||
-- Create the trigger to fire after updates on specific status columns
|
||||
CREATE TRIGGER user_status_change_notifications_trigger
|
||||
AFTER UPDATE OF admin, verified, verifier, spammer ON "User"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_user_status_change_notifications();
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
1333
web/scripts/faker.ts
@@ -1,221 +0,0 @@
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { createAccount } from '../lib/accountCreate'
|
||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { startImpersonating } from '../lib/impersonation'
|
||||
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
||||
import { login, logout, setUserSessionIdCookie } from '../lib/userCookies'
|
||||
import {
|
||||
generateUserSecretToken,
|
||||
hashUserSecretToken,
|
||||
parseUserSecretToken,
|
||||
USER_SECRET_TOKEN_REGEX,
|
||||
} from '../lib/userSecretToken'
|
||||
import { imageFileSchema } from '../lib/zodUtils'
|
||||
|
||||
export const accountActions = {
|
||||
login: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'guest',
|
||||
input: z.object({
|
||||
token: z.string().regex(USER_SECRET_TOKEN_REGEX).transform(parseUserSecretToken),
|
||||
redirect: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await logout(context)
|
||||
|
||||
const tokenHash = hashUserSecretToken(input.token)
|
||||
const matchedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
secretTokenHash: tokenHash,
|
||||
},
|
||||
})
|
||||
|
||||
if (!matchedUser) {
|
||||
throw new ActionError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'No user exists with this token',
|
||||
})
|
||||
}
|
||||
|
||||
await login(context, makeUserWithKarmaUnlocks(matchedUser))
|
||||
|
||||
return {
|
||||
user: matchedUser,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
redirect: input.redirect || context.request.headers.get('referer') || '/',
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
preGenerateToken: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'guest',
|
||||
handler: async () => {
|
||||
const token = generateUserSecretToken()
|
||||
await redisPreGeneratedSecretTokens.storePreGeneratedToken(token)
|
||||
return {
|
||||
token,
|
||||
} as const
|
||||
},
|
||||
}),
|
||||
|
||||
generate: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'guest',
|
||||
input: z
|
||||
.object({
|
||||
token: z.string().regex(USER_SECRET_TOKEN_REGEX).transform(parseUserSecretToken).optional(),
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
...captchaFormSchemaProperties,
|
||||
})
|
||||
.superRefine(captchaFormSchemaSuperRefine),
|
||||
handler: async (input, context) => {
|
||||
await handleHoneypotTrap({
|
||||
input,
|
||||
honeyPotTrapField: 'message',
|
||||
userId: context.locals.user?.id,
|
||||
location: 'account.generate',
|
||||
})
|
||||
|
||||
const isValidToken = input.token
|
||||
? await redisPreGeneratedSecretTokens.validateAndConsumePreGeneratedToken(input.token)
|
||||
: true
|
||||
if (!isValidToken) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid or expired token',
|
||||
})
|
||||
}
|
||||
|
||||
const { token, user: newUser } = await createAccount(input.token)
|
||||
await setUserSessionIdCookie(context.cookies, newUser.secretTokenHash)
|
||||
context.locals.user = makeUserWithKarmaUnlocks(newUser)
|
||||
|
||||
return {
|
||||
token,
|
||||
user: newUser,
|
||||
} as const
|
||||
},
|
||||
}),
|
||||
|
||||
impersonate: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
targetUserId: z.coerce.number().int().positive(),
|
||||
redirect: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const adminUser = context.locals.user
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: input.targetUserId },
|
||||
})
|
||||
|
||||
if (!targetUser) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Target user not found',
|
||||
})
|
||||
}
|
||||
|
||||
if (targetUser.admin) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Cannot impersonate admin user',
|
||||
})
|
||||
}
|
||||
|
||||
await startImpersonating(context, adminUser, makeUserWithKarmaUnlocks(targetUser))
|
||||
|
||||
return {
|
||||
adminUser,
|
||||
impersonatedUser: targetUser,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
redirect: input.redirect || context.request.headers.get('referer') || '/',
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
displayName: z.string().max(100, 'Display name must be 100 characters or less').optional().nullable(),
|
||||
link: z
|
||||
.string()
|
||||
.url('Must be a valid URL')
|
||||
.max(255, 'URL must be 255 characters or less')
|
||||
.optional()
|
||||
.nullable(),
|
||||
pictureFile: imageFileSchema,
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (input.id !== context.locals.user.id) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You can only update your own profile',
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
input.displayName !== undefined &&
|
||||
input.displayName !== context.locals.user.displayName &&
|
||||
!context.locals.user.karmaUnlocks.displayName
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: makeKarmaUnlockMessage(karmaUnlocksById.displayName),
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
input.link !== undefined &&
|
||||
input.link !== context.locals.user.link &&
|
||||
!context.locals.user.karmaUnlocks.websiteLink
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: makeKarmaUnlockMessage(karmaUnlocksById.websiteLink),
|
||||
})
|
||||
}
|
||||
|
||||
if (input.pictureFile !== undefined && !context.locals.user.karmaUnlocks.profilePicture) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: makeKarmaUnlockMessage(karmaUnlocksById.profilePicture),
|
||||
})
|
||||
}
|
||||
|
||||
const pictureUrl =
|
||||
input.pictureFile && input.pictureFile.size > 0
|
||||
? await saveFileLocally(
|
||||
input.pictureFile,
|
||||
input.pictureFile.name,
|
||||
`users/pictures/${String(context.locals.user.id)}`
|
||||
)
|
||||
: null
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: context.locals.user.id },
|
||||
data: {
|
||||
displayName: input.displayName ?? null,
|
||||
link: input.link ?? null,
|
||||
picture: pictureUrl,
|
||||
},
|
||||
})
|
||||
|
||||
return { user }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { AttributeCategory, AttributeType } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const attributeInputSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
category: z.nativeEnum(AttributeCategory),
|
||||
type: z.nativeEnum(AttributeType),
|
||||
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens'),
|
||||
})
|
||||
|
||||
const attributeSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
category: true,
|
||||
type: true,
|
||||
privacyPoints: true,
|
||||
trustPoints: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.AttributeSelect
|
||||
|
||||
export const adminAttributeActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
category: z.nativeEnum(AttributeCategory),
|
||||
type: z.nativeEnum(AttributeType),
|
||||
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const slug = slugify(input.title, { lower: true, strict: true })
|
||||
|
||||
const attribute = await prisma.attribute.create({
|
||||
data: {
|
||||
...input,
|
||||
slug,
|
||||
},
|
||||
select: attributeSelect,
|
||||
})
|
||||
return { attribute }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: attributeInputSchema.extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { id, title, slug, ...data } = input
|
||||
|
||||
const existingAttribute = await prisma.attribute.findUnique({
|
||||
where: { id },
|
||||
select: { title: true, slug: true },
|
||||
})
|
||||
|
||||
if (!existingAttribute) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Attribute not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check for slug uniqueness (ignore current attribute)
|
||||
const slugConflict = await prisma.attribute.findFirst({
|
||||
where: { slug, NOT: { id } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (slugConflict) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Slug already in use',
|
||||
})
|
||||
}
|
||||
|
||||
const attribute = await prisma.attribute.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
slug,
|
||||
...data,
|
||||
},
|
||||
select: attributeSelect,
|
||||
})
|
||||
|
||||
return { attribute }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.coerce.number().int().positive('Attribute ID must be a positive integer.'),
|
||||
}),
|
||||
handler: async ({ id }) => {
|
||||
try {
|
||||
await prisma.attribute.delete({
|
||||
where: { id },
|
||||
})
|
||||
return { success: true, message: 'Attribute deleted successfully.' }
|
||||
} catch (error) {
|
||||
// Prisma throws an error if the record to delete is not found,
|
||||
// or if there are related records that prevent deletion (foreign key constraints).
|
||||
// We can customize the error message based on the type of error if needed.
|
||||
console.error('Error deleting attribute:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to delete attribute. It might be in use or already deleted.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { EventType } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export const adminEventActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z
|
||||
.object({
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
type: z.nativeEnum(EventType).default('NORMAL'),
|
||||
startedAt: z.coerce.date(),
|
||||
endedAt: z.coerce.date().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.endedAt && data.startedAt > data.endedAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['endedAt'],
|
||||
message: 'Ended at must be after started at',
|
||||
})
|
||||
}
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
...input,
|
||||
visible: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
|
||||
toggle: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const existingEvent = await prisma.event.findUnique({ where: { id: input.eventId } })
|
||||
if (!existingEvent) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Event not found',
|
||||
})
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: input.eventId },
|
||||
data: {
|
||||
visible: !existingEvent.visible,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z
|
||||
.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
type: z.nativeEnum(EventType).default('NORMAL'),
|
||||
startedAt: z.coerce.date(),
|
||||
endedAt: z.coerce.date().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.endedAt && data.startedAt > data.endedAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['endedAt'],
|
||||
message: 'Ended at must be after started at',
|
||||
})
|
||||
}
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { eventId, ...data } = input
|
||||
const existingEvent = await prisma.event.findUnique({ where: { id: eventId } })
|
||||
if (!existingEvent) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Event not found',
|
||||
})
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const event = await prisma.event.delete({ where: { id: input.eventId } })
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { adminAttributeActions } from './attribute'
|
||||
import { adminEventActions } from './event'
|
||||
import { adminServiceActions } from './service'
|
||||
import { adminServiceSuggestionActions } from './serviceSuggestion'
|
||||
import { adminUserActions } from './user'
|
||||
import { verificationStep } from './verificationStep'
|
||||
|
||||
export const adminActions = {
|
||||
attribute: adminAttributeActions,
|
||||
event: adminEventActions,
|
||||
service: adminServiceActions,
|
||||
serviceSuggestions: adminServiceSuggestionActions,
|
||||
user: adminUserActions,
|
||||
verificationStep,
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import {
|
||||
imageFileSchema,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
} from '../../lib/zodUtils'
|
||||
|
||||
const serviceSchemaBase = z.object({
|
||||
id: z.number(),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
name: z.string().min(1).max(20),
|
||||
description: z.string().min(1),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
onionUrls: stringListOfUrlsSchema,
|
||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
verificationStatus: z.nativeEnum(VerificationStatus),
|
||||
verificationSummary: z.string().optional().nullable().default(null),
|
||||
verificationProofMd: z.string().optional().nullable().default(null),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
||||
referral: z.string().optional().nullable().default(null),
|
||||
imageFile: imageFileSchema,
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
})
|
||||
|
||||
const addSlugIfMissing = <
|
||||
T extends {
|
||||
slug?: string | null | undefined
|
||||
name: string
|
||||
},
|
||||
>(
|
||||
input: T
|
||||
) => ({
|
||||
...input,
|
||||
slug:
|
||||
input.slug ??
|
||||
slugify(input.name, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
remove: /[^a-zA-Z0-9\-._]/g,
|
||||
replacement: '-',
|
||||
}),
|
||||
})
|
||||
|
||||
const contactMethodSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
label: z.string().min(1).max(50),
|
||||
value: z.string().min(1).max(200),
|
||||
iconId: z.string().min(1).max(50),
|
||||
info: z.string().max(200).optional().default(''),
|
||||
serviceId: z.number(),
|
||||
})
|
||||
|
||||
export const adminServiceActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const existing = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A service with this slug already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const { imageFile, ...serviceData } = input
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
create: input.attributes.map((attributeId) => ({
|
||||
attribute: {
|
||||
connect: { id: attributeId },
|
||||
},
|
||||
})),
|
||||
},
|
||||
imageUrl,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { service }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const { id, categories, attributes, imageFile, ...data } = input
|
||||
|
||||
const existing = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
NOT: { id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A service with this slug already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
|
||||
// Get existing attributes and categories to compute differences
|
||||
const existingService = await prisma.service.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
categories: true,
|
||||
attributes: {
|
||||
include: {
|
||||
attribute: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingService) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Find categories to connect and disconnect
|
||||
const existingCategoryIds = existingService.categories.map((c) => c.id)
|
||||
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
|
||||
|
||||
// Find attributes to connect and disconnect
|
||||
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
|
||||
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
|
||||
|
||||
const service = await prisma.service.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
imageUrl,
|
||||
categories: {
|
||||
connect: categoriesToAdd.map((id) => ({ id })),
|
||||
disconnect: categoriesToRemove.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
// Connect new attributes
|
||||
create: attributesToAdd.map((attributeId) => ({
|
||||
attribute: {
|
||||
connect: { id: attributeId },
|
||||
},
|
||||
})),
|
||||
// Delete specific attributes that are no longer needed
|
||||
deleteMany: attributesToRemove.map((attributeId) => ({
|
||||
attributeId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
return { service }
|
||||
},
|
||||
}),
|
||||
|
||||
createContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: contactMethodSchema.omit({ id: true }),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: input,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
updateContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: contactMethodSchema,
|
||||
handler: async (input) => {
|
||||
const { id, ...data } = input
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
deleteContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ServiceSuggestionStatus } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export const adminServiceSuggestionActions = {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
suggestionId: z.coerce.number().int().positive(),
|
||||
status: z.nativeEnum(ServiceSuggestionStatus),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const suggestion = await prisma.serviceSuggestion.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
serviceId: true,
|
||||
},
|
||||
where: { id: input.suggestionId },
|
||||
})
|
||||
|
||||
if (!suggestion) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Suggestion not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.serviceSuggestion.update({
|
||||
where: { id: suggestion.id },
|
||||
data: {
|
||||
status: input.status,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
message: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
suggestionId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(1000),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const suggestion = await prisma.serviceSuggestion.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
where: { id: input.suggestionId },
|
||||
})
|
||||
|
||||
if (!suggestion) {
|
||||
throw new Error('Suggestion not found')
|
||||
}
|
||||
|
||||
await prisma.serviceSuggestionMessage.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
suggestionId: suggestion.id,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
|
||||
const selectUserReturnFields = {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
link: true,
|
||||
picture: true,
|
||||
admin: true,
|
||||
verified: true,
|
||||
verifier: true,
|
||||
verifiedLink: true,
|
||||
secretTokenHash: true,
|
||||
totalKarma: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
spammer: true,
|
||||
} as const satisfies Prisma.UserSelect
|
||||
|
||||
export const adminUserActions = {
|
||||
search: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
name: z.string().min(1, 'User name is required'),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { name: input.name },
|
||||
select: selectUserReturnFields,
|
||||
})
|
||||
|
||||
return { user }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1, 'Name is required').max(255, 'Name must be less than 255 characters'),
|
||||
link: z
|
||||
.string()
|
||||
.url('Invalid URL')
|
||||
.nullable()
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
picture: z.string().max(255, 'Picture URL must be less than 255 characters').nullable().default(null),
|
||||
pictureFile: z.instanceof(File).optional(),
|
||||
verifier: z.boolean().default(false),
|
||||
admin: z.boolean().default(false),
|
||||
spammer: z.boolean().default(false),
|
||||
verifiedLink: z
|
||||
.string()
|
||||
.url('Invalid URL')
|
||||
.nullable()
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
displayName: z
|
||||
.string()
|
||||
.max(50, 'Display Name must be less than 50 characters')
|
||||
.nullable()
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
}),
|
||||
handler: async ({ id, picture, pictureFile, ...valuesToUpdate }) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'User not found',
|
||||
})
|
||||
}
|
||||
|
||||
let pictureUrl = picture ?? null
|
||||
if (pictureFile && pictureFile.size > 0) {
|
||||
pictureUrl = await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/')
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
...valuesToUpdate,
|
||||
verified: !!valuesToUpdate.verifiedLink,
|
||||
picture: pictureUrl,
|
||||
},
|
||||
select: selectUserReturnFields,
|
||||
})
|
||||
|
||||
return {
|
||||
updatedUser,
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
internalNotes: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(1000),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const note = await prisma.internalUserNote.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
userId: input.userId,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { note }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.internalUserNote.delete({
|
||||
where: {
|
||||
id: input.noteId,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(1000),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const note = await prisma.internalUserNote.update({
|
||||
where: {
|
||||
id: input.noteId,
|
||||
},
|
||||
data: {
|
||||
content: input.content,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { note }
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
serviceAffiliations: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
role: z.enum(['OWNER', 'ADMIN', 'MODERATOR', 'SUPPORT', 'TEAM_MEMBER']),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
// Check if the user exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'User not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the service exists
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if the service affiliation already exists
|
||||
const existingAffiliation = await prisma.serviceUser.findUnique({
|
||||
where: {
|
||||
userId_serviceId: {
|
||||
userId: input.userId,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let serviceAffiliation
|
||||
|
||||
if (existingAffiliation) {
|
||||
// Update existing affiliation
|
||||
serviceAffiliation = await prisma.serviceUser.update({
|
||||
where: {
|
||||
userId_serviceId: {
|
||||
userId: input.userId,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: input.role as ServiceUserRole,
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceAffiliation, serviceName: service.name, updated: true }
|
||||
} else {
|
||||
// Create new affiliation
|
||||
serviceAffiliation = await prisma.serviceUser.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
serviceId: input.serviceId,
|
||||
role: input.role as ServiceUserRole,
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceAffiliation, serviceName: service.name }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error managing service affiliation:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error managing service affiliation',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
remove: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const serviceAffiliation = await prisma.serviceUser.delete({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
include: {
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceAffiliation }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { VerificationStepStatus } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const verificationStepSchemaBase = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(200, 'Description must be 200 characters or less'),
|
||||
status: z.nativeEnum(VerificationStepStatus),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
evidenceMd: z.string().optional().nullable().default(null),
|
||||
})
|
||||
|
||||
const verificationStepUpdateSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
title: z.string().min(1, 'Title is required').optional(),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(200, 'Description must be 200 characters or less')
|
||||
.optional(),
|
||||
status: z.nativeEnum(VerificationStepStatus).optional(),
|
||||
evidenceMd: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
const verificationStepIdSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
|
||||
export const verificationStep = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: verificationStepSchemaBase,
|
||||
handler: async (input) => {
|
||||
const { serviceId, title, description, status, evidenceMd } = input
|
||||
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
const newVerificationStep = await prisma.verificationStep.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
evidenceMd,
|
||||
service: {
|
||||
connect: { id: serviceId },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { verificationStep: newVerificationStep }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: verificationStepUpdateSchema,
|
||||
handler: async (input) => {
|
||||
const { id, ...dataToUpdate } = input
|
||||
|
||||
const existingStep = await prisma.verificationStep.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!existingStep) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Verification step not found',
|
||||
})
|
||||
}
|
||||
|
||||
const updatedVerificationStep = await prisma.verificationStep.update({
|
||||
where: { id },
|
||||
data: dataToUpdate,
|
||||
})
|
||||
|
||||
return { verificationStep: updatedVerificationStep }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: verificationStepIdSchema,
|
||||
handler: async ({ id }) => {
|
||||
const existingStep = await prisma.verificationStep.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!existingStep) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Verification step not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.verificationStep.delete({ where: { id } })
|
||||
|
||||
return { success: true, deletedId: id }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'astro:schema'
|
||||
import { formatDistanceStrict } from 'date-fns'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { makeKarmaUnlockMessage } from '../lib/karmaUnlocks'
|
||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { timeTrapSecretKey } from '../lib/timeTrapSecret'
|
||||
|
||||
import type { CommentStatus, Prisma } from '@prisma/client'
|
||||
|
||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 5
|
||||
const MAX_COMMENTS_PER_WINDOW = 1
|
||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 5
|
||||
|
||||
export const commentActions = {
|
||||
vote: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
commentId: z.coerce.number().int().positive(),
|
||||
downvote: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
try {
|
||||
// Check user karma requirement
|
||||
if (!context.locals.user.karmaUnlocks.voteComments) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: makeKarmaUnlockMessage(karmaUnlocksById.voteComments),
|
||||
})
|
||||
}
|
||||
|
||||
// Handle the vote in a transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Get existing vote if any
|
||||
const existingVote = await tx.commentVote.findUnique({
|
||||
where: {
|
||||
commentId_userId: {
|
||||
commentId: input.commentId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingVote) {
|
||||
// If vote type is the same, remove the vote
|
||||
if (existingVote.downvote === input.downvote) {
|
||||
await tx.commentVote.delete({
|
||||
where: { id: existingVote.id },
|
||||
})
|
||||
} else {
|
||||
// If vote type is different, update the vote
|
||||
await tx.commentVote.update({
|
||||
where: { id: existingVote.id },
|
||||
data: { downvote: input.downvote },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Create new vote
|
||||
await tx.commentVote.create({
|
||||
data: {
|
||||
downvote: input.downvote,
|
||||
commentId: input.commentId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof ActionError) throw error
|
||||
|
||||
console.error('Error voting on comment:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error voting on comment',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z
|
||||
.object({
|
||||
content: z.string().min(10).max(2000),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
parentId: z.coerce.number().optional(),
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
rating: z.coerce.number().int().min(1).max(5).optional(),
|
||||
encTimestamp: z.string().min(1), // time trap field
|
||||
internalNote: z.string().max(500).optional(),
|
||||
issueKycRequested: z.coerce.boolean().optional(),
|
||||
issueFundsBlocked: z.coerce.boolean().optional(),
|
||||
issueScam: z.coerce.boolean().optional(),
|
||||
issueDetails: z.string().max(120).optional(),
|
||||
orderId: z.string().max(100).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.rating && data.parentId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['parentId'],
|
||||
message: 'Ratings cannot be provided for replies',
|
||||
})
|
||||
}
|
||||
if (!data.parentId) {
|
||||
if (data.content.length < 30) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.too_small,
|
||||
minimum: 30,
|
||||
type: 'string',
|
||||
inclusive: true,
|
||||
path: ['content'],
|
||||
message: 'Content must be at least 30 characters',
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (context.locals.user.karmaUnlocks.commentsDisabled) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: makeKarmaUnlockMessage(karmaUnlocksById.commentsDisabled),
|
||||
})
|
||||
}
|
||||
|
||||
await handleHoneypotTrap({
|
||||
input,
|
||||
honeyPotTrapField: 'message',
|
||||
userId: context.locals.user.id,
|
||||
location: 'comment.create',
|
||||
})
|
||||
|
||||
// --- Time Trap Validation Start ---
|
||||
try {
|
||||
const algorithm = 'aes-256-cbc'
|
||||
const decodedValue = Buffer.from(input.encTimestamp, 'base64').toString('utf8')
|
||||
const [ivHex, encryptedHex] = decodedValue.split(':')
|
||||
|
||||
if (!ivHex || !encryptedHex) {
|
||||
throw new Error('Invalid time trap format.')
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(algorithm, timeTrapSecretKey, iv)
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
const originalTimestamp = parseInt(decrypted, 10)
|
||||
if (isNaN(originalTimestamp)) {
|
||||
throw new Error('Invalid timestamp data.')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const timeDiff = now - originalTimestamp
|
||||
const minTimeSeconds = 2 // 2 seconds
|
||||
const maxTimeMinutes = 60 // 1 hour
|
||||
|
||||
if (timeDiff < minTimeSeconds * 1000 || timeDiff > maxTimeMinutes * 60 * 1000) {
|
||||
console.warn(`Time trap triggered: ${(timeDiff / 1000).toLocaleString()}s`)
|
||||
throw new Error('Invalid submission timing.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Time trap validation failed:', err instanceof Error ? err.message : 'Unknown error')
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid request',
|
||||
})
|
||||
}
|
||||
// --- Time Trap Validation End ---
|
||||
|
||||
// --- Rate Limit Check Start ---
|
||||
const isVerifiedUser = context.locals.user.admin || context.locals.user.verified
|
||||
const maxCommentsPerWindow = isVerifiedUser
|
||||
? MAX_COMMENTS_PER_WINDOW_VERIFIED_USER
|
||||
: MAX_COMMENTS_PER_WINDOW
|
||||
|
||||
const windowStart = new Date(Date.now() - COMMENT_RATE_LIMIT_WINDOW_MINUTES * 60 * 1000)
|
||||
const recentCommentCount = await prisma.comment.findMany({
|
||||
where: {
|
||||
authorId: context.locals.user.id,
|
||||
createdAt: {
|
||||
gte: windowStart,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (recentCommentCount.length >= maxCommentsPerWindow) {
|
||||
const oldestCreatedAt = recentCommentCount.reduce<Date | null>((oldestDate, comment) => {
|
||||
if (!oldestDate) return comment.createdAt
|
||||
if (comment.createdAt < oldestDate) return comment.createdAt
|
||||
return oldestDate
|
||||
}, null)
|
||||
|
||||
console.warn(`Rate limit exceeded for user ${context.locals.user.id.toLocaleString()}`)
|
||||
throw new ActionError({
|
||||
code: 'TOO_MANY_REQUESTS', // Use specific 429 code
|
||||
message: `Rate limit exceeded. Please wait ${oldestCreatedAt ? `${formatDistanceStrict(oldestCreatedAt, windowStart)} ` : ''}before commenting again.`,
|
||||
})
|
||||
}
|
||||
// --- Rate Limit Check End ---
|
||||
|
||||
// --- Format Internal Note from Issue Reports ---
|
||||
let formattedInternalNote: string | null = null
|
||||
// Track if this is an issue report
|
||||
const isIssueReport =
|
||||
input.issueKycRequested === true || input.issueFundsBlocked === true || input.issueScam === true
|
||||
|
||||
if (isIssueReport) {
|
||||
const issueTypes = []
|
||||
if (input.issueKycRequested) issueTypes.push('KYC REQUESTED')
|
||||
if (input.issueFundsBlocked) issueTypes.push('FUNDS BLOCKED')
|
||||
if (input.issueScam) issueTypes.push('POTENTIAL SCAM')
|
||||
|
||||
const details = input.issueDetails?.trim() ?? ''
|
||||
|
||||
formattedInternalNote = `[${issueTypes.join(', ')}]${details ? `: ${details}` : ''}`
|
||||
} else if (input.internalNote?.trim()) {
|
||||
formattedInternalNote = input.internalNote.trim()
|
||||
}
|
||||
|
||||
// Determine if admin review is needed (always true for issue reports)
|
||||
const requiresAdminReview = isIssueReport || !!(formattedInternalNote && !context.locals.user.admin)
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// First deactivate any existing ratings if providing a new rating
|
||||
if (input.rating) {
|
||||
await tx.comment.updateMany({
|
||||
where: {
|
||||
serviceId: input.serviceId,
|
||||
authorId: context.locals.user.id,
|
||||
rating: { not: null },
|
||||
},
|
||||
data: {
|
||||
ratingActive: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check for existing orderId for this service if provided
|
||||
if (input.orderId?.trim()) {
|
||||
const existingOrderId = await tx.comment.findFirst({
|
||||
where: {
|
||||
serviceId: input.serviceId,
|
||||
orderId: input.orderId.trim(),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existingOrderId) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This Order ID has already been reported for this service.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare data object with proper type safety
|
||||
const commentData: Prisma.CommentCreateInput = {
|
||||
content: input.content,
|
||||
service: { connect: { id: input.serviceId } },
|
||||
author: { connect: { id: context.locals.user.id } },
|
||||
|
||||
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
||||
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
|
||||
requiresAdminReview,
|
||||
orderId: input.orderId?.trim() ?? null,
|
||||
kycRequested: input.issueKycRequested === true,
|
||||
fundsBlocked: input.issueFundsBlocked === true,
|
||||
}
|
||||
|
||||
if (input.parentId) {
|
||||
commentData.parent = { connect: { id: input.parentId } }
|
||||
}
|
||||
|
||||
if (input.rating) {
|
||||
commentData.rating = input.rating
|
||||
commentData.ratingActive = true
|
||||
}
|
||||
|
||||
if (formattedInternalNote) {
|
||||
commentData.internalNote = formattedInternalNote
|
||||
}
|
||||
|
||||
const newComment = await tx.comment.create({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const notiPref = await getOrCreateNotificationPreferences(
|
||||
context.locals.user.id,
|
||||
{ enableAutowatchMyComments: true },
|
||||
tx
|
||||
)
|
||||
|
||||
if (notiPref.enableAutowatchMyComments) {
|
||||
await tx.notificationPreferences.update({
|
||||
where: { userId: context.locals.user.id },
|
||||
data: {
|
||||
watchedComments: { connect: { id: newComment.id } },
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
if (error instanceof ActionError) throw error
|
||||
|
||||
console.error('Error creating comment:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error creating comment',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
moderate: defineProtectedAction({
|
||||
permissions: ['admin', 'verifier'],
|
||||
input: z.object({
|
||||
commentId: z.number(),
|
||||
userId: z.number(),
|
||||
action: z.enum([
|
||||
'status',
|
||||
'suspicious',
|
||||
'requires-admin-review',
|
||||
'community-note',
|
||||
'internal-note',
|
||||
'private-context',
|
||||
'order-id-status',
|
||||
'kyc-requested',
|
||||
'funds-blocked',
|
||||
]),
|
||||
value: z.union([
|
||||
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
]),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
try {
|
||||
const comment = await prisma.comment.findUnique({
|
||||
where: { id: input.commentId },
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
serviceId: true,
|
||||
createdAt: true,
|
||||
authorId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!comment) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Comment not found',
|
||||
})
|
||||
}
|
||||
|
||||
const updateData: Prisma.CommentUpdateInput = {}
|
||||
|
||||
switch (input.action) {
|
||||
case 'status':
|
||||
updateData.status = input.value as CommentStatus
|
||||
break
|
||||
case 'suspicious': {
|
||||
const isSpam = !!input.value
|
||||
updateData.suspicious = isSpam
|
||||
updateData.ratingActive = false
|
||||
|
||||
if (!isSpam && comment.rating) {
|
||||
const newestRatingOrActiveRating = await prisma.comment.findFirst({
|
||||
where: {
|
||||
serviceId: comment.serviceId,
|
||||
authorId: comment.authorId,
|
||||
id: { not: input.commentId },
|
||||
rating: { not: null },
|
||||
OR: [{ createdAt: { gt: comment.createdAt } }, { ratingActive: true }],
|
||||
},
|
||||
})
|
||||
updateData.ratingActive = !newestRatingOrActiveRating
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'requires-admin-review':
|
||||
updateData.requiresAdminReview = !!input.value
|
||||
break
|
||||
case 'community-note':
|
||||
updateData.communityNote = input.value as string
|
||||
break
|
||||
case 'internal-note':
|
||||
updateData.internalNote = input.value as string
|
||||
break
|
||||
case 'private-context':
|
||||
updateData.privateContext = input.value as string
|
||||
break
|
||||
case 'order-id-status':
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
|
||||
break
|
||||
case 'kyc-requested':
|
||||
updateData.kycRequested = !!input.value
|
||||
break
|
||||
case 'funds-blocked':
|
||||
updateData.fundsBlocked = !!input.value
|
||||
break
|
||||
}
|
||||
|
||||
// Update the comment
|
||||
await prisma.comment.update({
|
||||
where: { id: input.commentId },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
if (error instanceof ActionError) throw error
|
||||
|
||||
console.error('Error moderating comment:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error moderating comment',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { accountActions } from './account'
|
||||
import { adminActions } from './admin'
|
||||
import { commentActions } from './comment'
|
||||
import { notificationActions } from './notifications'
|
||||
import { serviceActions } from './service'
|
||||
import { serviceSuggestionActions } from './serviceSuggestion'
|
||||
|
||||
/**
|
||||
* @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { actions } from 'astro:actions'
|
||||
* import { server } from '~/actions' // WRONG!!!!
|
||||
*
|
||||
* const result = Astro.getActionResult(actions.admin.attribute.create)
|
||||
* ```
|
||||
*/
|
||||
export const server = {
|
||||
account: accountActions,
|
||||
admin: adminActions,
|
||||
comment: commentActions,
|
||||
notification: notificationActions,
|
||||
service: serviceActions,
|
||||
serviceSuggestion: serviceSuggestionActions,
|
||||
}
|
||||
|
||||
// Don't create an object named actions, put the actions in the server object instead. Astro will automatically export the server object as actions.
|
||||
@@ -1,132 +0,0 @@
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
export const notificationActions = {
|
||||
updateReadStatus: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
notificationId: z.literal('all').or(z.coerce.number().int().positive()),
|
||||
read: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notification.updateMany({
|
||||
where:
|
||||
input.notificationId === 'all'
|
||||
? { userId: context.locals.user.id, read: !input.read }
|
||||
: { userId: context.locals.user.id, id: input.notificationId },
|
||||
data: {
|
||||
read: input.read,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
preferences: {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
|
||||
enableAutowatchMyComments: z.coerce.boolean().optional(),
|
||||
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
|
||||
enableAutowatchMyComments: input.enableAutowatchMyComments,
|
||||
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
|
||||
enableAutowatchMyComments: input.enableAutowatchMyComments,
|
||||
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
watchComment: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
commentId: z.coerce.number().int().positive(),
|
||||
watch: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
watchedComments: input.watch
|
||||
? { connect: { id: input.commentId } }
|
||||
: { disconnect: { id: input.commentId } },
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
watchedComments: input.watch ? { connect: { id: input.commentId } } : undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
watchService: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
watchType: z.enum(['all', 'comments', 'events', 'verification']),
|
||||
value: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
onEventCreatedForServices:
|
||||
input.watchType === 'events' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: { disconnect: { id: input.serviceId } }
|
||||
: undefined,
|
||||
onRootCommentCreatedForServices:
|
||||
input.watchType === 'comments' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: { disconnect: { id: input.serviceId } }
|
||||
: undefined,
|
||||
onVerificationChangeForServices:
|
||||
input.watchType === 'verification' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: { disconnect: { id: input.serviceId } }
|
||||
: undefined,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
onEventCreatedForServices:
|
||||
input.watchType === 'events' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: undefined
|
||||
: undefined,
|
||||
onRootCommentCreatedForServices:
|
||||
input.watchType === 'comments' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: undefined
|
||||
: undefined,
|
||||
onVerificationChangeForServices:
|
||||
input.watchType === 'verification' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: undefined
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
export const serviceActions = {
|
||||
requestVerification: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
action: z.enum(['request', 'withdraw']),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: {
|
||||
id: input.serviceId,
|
||||
},
|
||||
select: {
|
||||
verificationStatus: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
message: 'Service not found',
|
||||
code: 'NOT_FOUND',
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
service.verificationStatus === 'VERIFICATION_SUCCESS' ||
|
||||
service.verificationStatus === 'VERIFICATION_FAILED'
|
||||
) {
|
||||
throw new ActionError({
|
||||
message: 'Service is already verified or marked as scam',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
const existingRequest = await prisma.serviceVerificationRequest.findUnique({
|
||||
where: {
|
||||
serviceId_userId: {
|
||||
serviceId: input.serviceId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
switch (input.action) {
|
||||
case 'withdraw': {
|
||||
if (!existingRequest) {
|
||||
throw new ActionError({
|
||||
message: 'You have not requested verification for this service',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
await prisma.serviceVerificationRequest.delete({
|
||||
where: {
|
||||
id: existingRequest.id,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
case 'request': {
|
||||
if (existingRequest) {
|
||||
throw new ActionError({
|
||||
message: 'You have already requested verification for this service',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.serviceVerificationRequest.create({
|
||||
data: {
|
||||
serviceId: input.serviceId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
onVerificationChangeForServices: {
|
||||
connect: { id: input.serviceId },
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
onVerificationChangeForServices: {
|
||||
connect: { id: input.serviceId },
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import {
|
||||
Currency,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceVisibility,
|
||||
VerificationStatus,
|
||||
} from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { formatDistanceStrict } from 'date-fns'
|
||||
|
||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import {
|
||||
imageFileSchemaRequired,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
} from '../lib/zodUtils'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const SUGGESTION_MESSAGE_RATE_LIMIT_WINDOW_MINUTES = 1
|
||||
const MAX_SUGGESTION_MESSAGES_PER_WINDOW = 5
|
||||
|
||||
export const SUGGESTION_NOTES_MAX_LENGTH = 1000
|
||||
export const SUGGESTION_NAME_MAX_LENGTH = 20
|
||||
export const SUGGESTION_SLUG_MAX_LENGTH = 20
|
||||
export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||
|
||||
const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
const possibleDuplicates = await prisma.service.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: input.name,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
return possibleDuplicates
|
||||
}
|
||||
|
||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
input: T,
|
||||
skipKeys: (keyof T)[] = []
|
||||
): string => {
|
||||
return Object.entries(input)
|
||||
.filter(([key]) => !skipKeys.includes(key as keyof T))
|
||||
.map(([key, value]) => {
|
||||
let serializedValue = ''
|
||||
if (typeof value === 'string') {
|
||||
serializedValue = value
|
||||
} else if (value === undefined || value === null) {
|
||||
serializedValue = ''
|
||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
serializedValue = value.toString()
|
||||
} else {
|
||||
try {
|
||||
serializedValue = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
return `- ${key}: ${serializedValue}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export const serviceSuggestionActions = {
|
||||
editService: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'not-spammer',
|
||||
input: z
|
||||
.object({
|
||||
notes: z.string().max(SUGGESTION_NOTES_MAX_LENGTH).optional(),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
extraNotes: z.string().optional(),
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
...captchaFormSchemaProperties,
|
||||
})
|
||||
.superRefine(captchaFormSchemaSuperRefine),
|
||||
handler: async (input, context) => {
|
||||
await handleHoneypotTrap({
|
||||
input,
|
||||
honeyPotTrapField: 'message',
|
||||
userId: context.locals.user.id,
|
||||
location: 'serviceSuggestion.editService',
|
||||
})
|
||||
|
||||
const service = await prisma.service.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: { id: input.serviceId },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
message: 'Service not found',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
// Combine notes and extraNotes if available
|
||||
const combinedNotes = input.extraNotes
|
||||
? `${input.notes ?? ''}\n\nSuggested changes:\n${input.extraNotes}`
|
||||
: input.notes
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: {
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
notes: combinedNotes,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceSuggestion, service }
|
||||
},
|
||||
}),
|
||||
createService: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'not-spammer',
|
||||
input: z
|
||||
.object({
|
||||
notes: z.string().max(SUGGESTION_NOTES_MAX_LENGTH).optional(),
|
||||
name: z.string().min(1).max(SUGGESTION_NAME_MAX_LENGTH),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
.refine(
|
||||
async (slug) => {
|
||||
const exists = await prisma.service.findUnique({
|
||||
select: { id: true },
|
||||
where: { slug },
|
||||
})
|
||||
return !exists
|
||||
},
|
||||
{ message: 'Slug must be unique, try a different one' }
|
||||
),
|
||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
onionUrls: stringListOfUrlsSchema,
|
||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||
imageFile: imageFileSchemaRequired,
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
skipDuplicateCheck: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => value === 'true'),
|
||||
...captchaFormSchemaProperties,
|
||||
})
|
||||
.superRefine(captchaFormSchemaSuperRefine),
|
||||
|
||||
handler: async (input, context) => {
|
||||
await handleHoneypotTrap({
|
||||
input,
|
||||
honeyPotTrapField: 'message',
|
||||
userId: context.locals.user.id,
|
||||
location: 'serviceSuggestion.createService',
|
||||
})
|
||||
|
||||
if (!input.skipDuplicateCheck) {
|
||||
const possibleDuplicates = await findPossibleDuplicates(input)
|
||||
|
||||
if (possibleDuplicates.length > 0) {
|
||||
return {
|
||||
hasDuplicates: true,
|
||||
possibleDuplicates,
|
||||
extraNotes: serializeExtraNotes(input, [
|
||||
'skipDuplicateCheck',
|
||||
'message',
|
||||
'imageFile',
|
||||
'captcha-value',
|
||||
'captcha-solution-hash',
|
||||
]),
|
||||
serviceSuggestion: undefined,
|
||||
service: undefined,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
|
||||
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
||||
const serviceSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
} satisfies Prisma.ServiceSelect
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
serviceUrls: input.serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls: input.onionUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
imageUrl,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: ServiceVisibility.UNLISTED,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
create: input.attributes.map((id) => ({
|
||||
attributeId: id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: serviceSelect,
|
||||
})
|
||||
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: {
|
||||
notes: input.notes,
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
hasDuplicates: false,
|
||||
possibleDuplicates: [],
|
||||
extraNotes: undefined,
|
||||
serviceSuggestion,
|
||||
service,
|
||||
} as const
|
||||
})
|
||||
|
||||
return {
|
||||
hasDuplicates: false,
|
||||
possibleDuplicates: [],
|
||||
extraNotes: undefined,
|
||||
serviceSuggestion,
|
||||
service,
|
||||
} as const
|
||||
},
|
||||
}),
|
||||
message: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
suggestionId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
// --- Rate Limit Check Start --- (Admins are exempt)
|
||||
if (!context.locals.user.admin) {
|
||||
const windowStart = new Date(Date.now() - SUGGESTION_MESSAGE_RATE_LIMIT_WINDOW_MINUTES * 60 * 1000)
|
||||
const recentMessages = await prisma.serviceSuggestionMessage.findMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
createdAt: {
|
||||
gte: windowStart,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }, // Get the oldest first to calculate wait time
|
||||
})
|
||||
|
||||
if (recentMessages.length >= MAX_SUGGESTION_MESSAGES_PER_WINDOW) {
|
||||
const oldestMessageInWindow = recentMessages[0]
|
||||
if (!oldestMessageInWindow) {
|
||||
console.error(
|
||||
'Error determining oldest message for rate limit, but length check passed. User:',
|
||||
context.locals.user.id
|
||||
)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Could not determine rate limit window. Please try again.',
|
||||
})
|
||||
}
|
||||
const timeToWait = formatDistanceStrict(oldestMessageInWindow.createdAt, windowStart)
|
||||
console.warn(
|
||||
`Suggestion message rate limit exceeded for user ${context.locals.user.id.toLocaleString()}`
|
||||
)
|
||||
throw new ActionError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: `Rate limit exceeded. Please wait ${timeToWait} before sending another message.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
// --- Rate Limit Check End ---
|
||||
|
||||
const suggestion = await prisma.serviceSuggestion.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
where: { id: input.suggestionId },
|
||||
})
|
||||
|
||||
if (!suggestion) {
|
||||
throw new ActionError({
|
||||
message: 'Suggestion not found',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
if (suggestion.userId !== context.locals.user.id) {
|
||||
throw new ActionError({
|
||||
message: 'Not authorized to send messages',
|
||||
code: 'UNAUTHORIZED',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.serviceSuggestionMessage.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
suggestionId: suggestion.id,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
Before Width: | Height: | Size: 379 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 76 76">
|
||||
<path
|
||||
d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1h-10Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1h-18ZM15 56a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V63.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V57c0-.6-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L26 71.2l-6.7-14.6a1 1 0 0 0-1-.6H15Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 76 52">
|
||||
<path
|
||||
d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.5.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.5.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 204 28">
|
||||
<path
|
||||
d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3bdb78" viewBox="0 0 124 52">
|
||||
<path
|
||||
d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM1 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4L6.8 32.5A1 1 0 0 0 6 32H1Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1H30Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12H29v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1H50Zm27 0a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V39.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V51c0 .6.5 1 1 1h2c.6 0 1-.4 1-1V33c0-.5-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L88 47.2l-6.7-14.6a1 1 0 0 0-1-.6H77Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4ZM70 48a1 1 0 0 0-1 1v2c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-2Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,11 +0,0 @@
|
||||
---
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
|
||||
type Props = {
|
||||
children: AstroChildren
|
||||
}
|
||||
|
||||
//
|
||||
---
|
||||
|
||||
{!!Astro.locals.user?.admin && <slot />}
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
const badge = tv({
|
||||
slots: {
|
||||
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
|
||||
icon: 'size-3 shrink-0',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
},
|
||||
variants: {
|
||||
color: {
|
||||
red: '',
|
||||
orange: '',
|
||||
amber: '',
|
||||
yellow: '',
|
||||
lime: '',
|
||||
green: '',
|
||||
emerald: '',
|
||||
teal: '',
|
||||
cyan: '',
|
||||
sky: '',
|
||||
blue: '',
|
||||
indigo: '',
|
||||
violet: '',
|
||||
purple: '',
|
||||
fuchsia: '',
|
||||
pink: '',
|
||||
rose: '',
|
||||
slate: '',
|
||||
gray: '',
|
||||
zinc: '',
|
||||
neutral: '',
|
||||
stone: '',
|
||||
white: '',
|
||||
black: '',
|
||||
},
|
||||
variant: {
|
||||
solid: '',
|
||||
faded: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Red
|
||||
{ color: 'red', variant: 'solid', class: { base: 'bg-red-500 text-white' } },
|
||||
{ color: 'red', variant: 'faded', class: { base: 'bg-red-500/30 text-red-300' } },
|
||||
// Orange
|
||||
{ color: 'orange', variant: 'solid', class: { base: 'bg-orange-500 text-white' } },
|
||||
{ color: 'orange', variant: 'faded', class: { base: 'bg-orange-500/30 text-orange-300' } },
|
||||
// Amber
|
||||
{ color: 'amber', variant: 'solid', class: { base: 'bg-amber-500 text-black' } },
|
||||
{ color: 'amber', variant: 'faded', class: { base: 'bg-amber-500/30 text-amber-300' } },
|
||||
// Yellow
|
||||
{ color: 'yellow', variant: 'solid', class: { base: 'bg-yellow-500 text-black' } },
|
||||
{ color: 'yellow', variant: 'faded', class: { base: 'bg-yellow-500/30 text-yellow-300' } },
|
||||
// Lime
|
||||
{ color: 'lime', variant: 'solid', class: { base: 'bg-lime-500 text-black' } },
|
||||
{ color: 'lime', variant: 'faded', class: { base: 'bg-lime-500/30 text-lime-300' } },
|
||||
// Green
|
||||
{ color: 'green', variant: 'solid', class: { base: 'bg-green-500 text-black' } },
|
||||
{ color: 'green', variant: 'faded', class: { base: 'bg-green-500/30 text-green-300' } },
|
||||
// Emerald
|
||||
{ color: 'emerald', variant: 'solid', class: { base: 'bg-emerald-500 text-white' } },
|
||||
{ color: 'emerald', variant: 'faded', class: { base: 'bg-emerald-500/30 text-emerald-300' } },
|
||||
// Teal
|
||||
{ color: 'teal', variant: 'solid', class: { base: 'bg-teal-500 text-white' } },
|
||||
{ color: 'teal', variant: 'faded', class: { base: 'bg-teal-500/30 text-teal-300' } },
|
||||
// Cyan
|
||||
{ color: 'cyan', variant: 'solid', class: { base: 'bg-cyan-500 text-white' } },
|
||||
{ color: 'cyan', variant: 'faded', class: { base: 'bg-cyan-500/30 text-cyan-300' } },
|
||||
// Sky
|
||||
{ color: 'sky', variant: 'solid', class: { base: 'bg-sky-500 text-white' } },
|
||||
{ color: 'sky', variant: 'faded', class: { base: 'bg-sky-500/30 text-sky-300' } },
|
||||
// Blue
|
||||
{ color: 'blue', variant: 'solid', class: { base: 'bg-blue-500 text-white' } },
|
||||
{ color: 'blue', variant: 'faded', class: { base: 'bg-blue-500/30 text-blue-300' } },
|
||||
// Indigo
|
||||
{ color: 'indigo', variant: 'solid', class: { base: 'bg-indigo-500 text-white' } },
|
||||
{ color: 'indigo', variant: 'faded', class: { base: 'bg-indigo-500/30 text-indigo-300' } },
|
||||
// Violet
|
||||
{ color: 'violet', variant: 'solid', class: { base: 'bg-violet-500 text-white' } },
|
||||
{ color: 'violet', variant: 'faded', class: { base: 'bg-violet-500/30 text-violet-300' } },
|
||||
// Purple
|
||||
{ color: 'purple', variant: 'solid', class: { base: 'bg-purple-500 text-white' } },
|
||||
{ color: 'purple', variant: 'faded', class: { base: 'bg-purple-500/30 text-purple-300' } },
|
||||
// Fuchsia
|
||||
{ color: 'fuchsia', variant: 'solid', class: { base: 'bg-fuchsia-500 text-white' } },
|
||||
{ color: 'fuchsia', variant: 'faded', class: { base: 'bg-fuchsia-500/30 text-fuchsia-300' } },
|
||||
// Pink
|
||||
{ color: 'pink', variant: 'solid', class: { base: 'bg-pink-500 text-white' } },
|
||||
{ color: 'pink', variant: 'faded', class: { base: 'bg-pink-500/30 text-pink-300' } },
|
||||
// Rose
|
||||
{ color: 'rose', variant: 'solid', class: { base: 'bg-rose-500 text-white' } },
|
||||
{ color: 'rose', variant: 'faded', class: { base: 'bg-rose-500/30 text-rose-300' } },
|
||||
// Slate
|
||||
{ color: 'slate', variant: 'solid', class: { base: 'bg-slate-500 text-white' } },
|
||||
{ color: 'slate', variant: 'faded', class: { base: 'bg-slate-500/30 text-slate-300' } },
|
||||
// Gray
|
||||
{ color: 'gray', variant: 'solid', class: { base: 'bg-gray-500 text-white' } },
|
||||
{ color: 'gray', variant: 'faded', class: { base: 'bg-gray-500/30 text-gray-300' } },
|
||||
// Zinc
|
||||
{ color: 'zinc', variant: 'solid', class: { base: 'bg-zinc-500 text-white' } },
|
||||
{ color: 'zinc', variant: 'faded', class: { base: 'bg-zinc-500/30 text-zinc-300' } },
|
||||
// Neutral
|
||||
{ color: 'neutral', variant: 'solid', class: { base: 'bg-neutral-500 text-white' } },
|
||||
{ color: 'neutral', variant: 'faded', class: { base: 'bg-neutral-500/30 text-neutral-300' } },
|
||||
// Stone
|
||||
{ color: 'stone', variant: 'solid', class: { base: 'bg-stone-500 text-white' } },
|
||||
{ color: 'stone', variant: 'faded', class: { base: 'bg-stone-500/30 text-stone-300' } },
|
||||
// White
|
||||
{ color: 'white', variant: 'solid', class: { base: 'bg-white text-black' } },
|
||||
{ color: 'white', variant: 'faded', class: { base: 'bg-white-500/30 text-white-300' } },
|
||||
// Black
|
||||
{ color: 'black', variant: 'solid', class: { base: 'bg-black text-white' } },
|
||||
{ color: 'black', variant: 'faded', class: { base: 'bg-black-500/30 text-black-300' } },
|
||||
],
|
||||
defaultVariants: {
|
||||
color: 'gray',
|
||||
variant: 'solid',
|
||||
},
|
||||
})
|
||||
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
||||
VariantProps<typeof badge> & {
|
||||
as: Tag
|
||||
icon?: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
classNames?: {
|
||||
icon?: string
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
icon: iconName,
|
||||
text: textContent,
|
||||
inlineIcon,
|
||||
classNames,
|
||||
|
||||
color,
|
||||
variant,
|
||||
|
||||
class: className,
|
||||
...props
|
||||
} = Astro.props
|
||||
|
||||
const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
|
||||
---
|
||||
|
||||
<Tag {...props} class={base({ class: className })}>
|
||||
{
|
||||
!!iconName && (
|
||||
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
||||
</Tag>
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
|
||||
as: Tag
|
||||
icon: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
}>
|
||||
|
||||
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
---
|
||||
|
||||
<Tag
|
||||
{...divProps}
|
||||
class={cn(
|
||||
'bg-night-900 inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
|
||||
<span>{text}</span>
|
||||
</Tag>
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
|
||||
import { isNotArray } from '../lib/arrays'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
|
||||
import HtmxScript from './HtmxScript.astro'
|
||||
import { makeOgImageUrl } from './OgImage'
|
||||
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
import type { WithContext, BreadcrumbList, ListItem } from 'schema-dts'
|
||||
|
||||
export type BreadcrumArray = [
|
||||
...{
|
||||
name: string
|
||||
url: string
|
||||
}[],
|
||||
{
|
||||
name: string
|
||||
url?: string
|
||||
},
|
||||
]
|
||||
|
||||
type Props = {
|
||||
pageTitle: string
|
||||
/**
|
||||
* Whether to enable htmx.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
htmx?: boolean
|
||||
/**
|
||||
* Page meta description
|
||||
*
|
||||
* @default 'KYCnot.me helps you find services without KYC for better privacy and control over your data.'
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Open Graph image.
|
||||
* - If `string` is provided, it will be used as the image URL.
|
||||
* - If `{ template: string, ...props }` is provided, it will be used to generate an Open Graph image based on the template.
|
||||
*/
|
||||
ogImage?: Parameters<typeof makeOgImageUrl>[0]
|
||||
|
||||
schemas?: ComponentProps<typeof Schema>['item'][]
|
||||
|
||||
breadcrumbs?: BreadcrumArray | BreadcrumArray[]
|
||||
}
|
||||
|
||||
const {
|
||||
pageTitle,
|
||||
htmx = false,
|
||||
description = 'KYCnot.me helps you find services without KYC for better privacy and control over your data.',
|
||||
ogImage,
|
||||
schemas,
|
||||
breadcrumbs,
|
||||
} = Astro.props
|
||||
|
||||
const breadcrumbLists = breadcrumbs?.every(Array.isArray)
|
||||
? (breadcrumbs as BreadcrumArray[])
|
||||
: breadcrumbs?.every(isNotArray)
|
||||
? [breadcrumbs]
|
||||
: []
|
||||
|
||||
const modeName = DEPLOYMENT_MODE === 'production' ? '' : DEPLOYMENT_MODE === 'staging' ? 'PRE' : 'DEV'
|
||||
const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
|
||||
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
---
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
|
||||
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
|
||||
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<title>{fullTitle}</title>
|
||||
<!-- {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} -->
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={fullTitle} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Other -->
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<meta name="theme-color" content="#040505" />
|
||||
|
||||
<!-- Components -->
|
||||
<ClientRouter />
|
||||
<LoadingIndicator color="green" />
|
||||
<TailwindJsPluggin />
|
||||
{htmx && <HtmxScript />}
|
||||
|
||||
<!-- JSON-LD Schemas -->
|
||||
{schemas?.map((item) => <Schema item={item} />)}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
{
|
||||
breadcrumbLists.map((breadcrumbList) => (
|
||||
<Schema
|
||||
item={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbList.map(
|
||||
(item, index) =>
|
||||
({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url ? new URL(item.url, Astro.url).href : undefined,
|
||||
}) satisfies ListItem
|
||||
),
|
||||
} satisfies WithContext<BreadcrumbList>
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
||||
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
||||
VariantProps<typeof button> & {
|
||||
as: Tag
|
||||
label?: string
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
classNames?: {
|
||||
label?: string
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
}
|
||||
dataAstroReload?: boolean
|
||||
children?: never
|
||||
disabled?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export type ButtonProps<Tag extends 'a' | 'button' | 'label' = 'button'> = Props<Tag>
|
||||
|
||||
const button = tv({
|
||||
slots: {
|
||||
base: 'inline-flex items-center justify-center gap-2 rounded-lg border transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-hidden',
|
||||
icon: 'size-4 shrink-0',
|
||||
label: 'text-left whitespace-nowrap',
|
||||
endIcon: 'size-4 shrink-0',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
sm: {
|
||||
base: 'h-8 px-3 text-sm',
|
||||
icon: 'size-4',
|
||||
endIcon: 'size-4',
|
||||
},
|
||||
md: {
|
||||
base: 'h-9 px-4 text-sm',
|
||||
icon: 'size-4',
|
||||
endIcon: 'size-4',
|
||||
label: 'font-medium',
|
||||
},
|
||||
lg: {
|
||||
base: 'h-10 px-5 text-base',
|
||||
icon: 'size-5',
|
||||
endIcon: 'size-5',
|
||||
label: 'font-bold tracking-wider uppercase',
|
||||
},
|
||||
},
|
||||
color: {
|
||||
black: {
|
||||
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
||||
},
|
||||
white: {
|
||||
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
||||
},
|
||||
gray: {
|
||||
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
||||
},
|
||||
success: {
|
||||
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
||||
},
|
||||
error: {
|
||||
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
||||
},
|
||||
warning: {
|
||||
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
info: {
|
||||
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
||||
},
|
||||
},
|
||||
shadow: {
|
||||
true: {
|
||||
base: 'shadow-lg',
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
true: {
|
||||
base: 'cursor-not-allowed',
|
||||
},
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
color: 'black',
|
||||
shadow: true,
|
||||
class: 'shadow-black/30',
|
||||
},
|
||||
{
|
||||
color: 'white',
|
||||
shadow: true,
|
||||
class: 'shadow-white/30',
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
shadow: true,
|
||||
class: 'shadow-day-500/30',
|
||||
},
|
||||
{
|
||||
color: 'success',
|
||||
shadow: true,
|
||||
class: 'shadow-green-500/30',
|
||||
},
|
||||
{
|
||||
color: 'error',
|
||||
shadow: true,
|
||||
class: 'shadow-red-500/30',
|
||||
},
|
||||
{
|
||||
color: 'warning',
|
||||
shadow: true,
|
||||
class: 'shadow-yellow-500/30',
|
||||
},
|
||||
{
|
||||
color: 'info',
|
||||
shadow: true,
|
||||
class: 'shadow-blue-500/30',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'black',
|
||||
shadow: false,
|
||||
disabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label',
|
||||
label,
|
||||
icon,
|
||||
endIcon,
|
||||
size,
|
||||
color,
|
||||
shadow,
|
||||
class: className,
|
||||
classNames,
|
||||
role,
|
||||
dataAstroReload,
|
||||
disabled,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const {
|
||||
base,
|
||||
icon: iconSlot,
|
||||
label: labelSlot,
|
||||
endIcon: endIconSlot,
|
||||
} = button({ size, color, shadow, disabled })
|
||||
|
||||
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||
---
|
||||
|
||||
<ActualTag
|
||||
class={base({ class: className })}
|
||||
role={role ??
|
||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
>
|
||||
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} />}
|
||||
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
|
||||
{
|
||||
!!endIcon && (
|
||||
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })}>
|
||||
{endIcon}
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
</ActualTag>
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { z } from 'astro:content'
|
||||
|
||||
type Props<
|
||||
TAccept extends ActionAccept,
|
||||
TInputSchema extends z.ZodType,
|
||||
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
|
||||
> = HTMLAttributes<'div'> & {
|
||||
action: TAction
|
||||
}
|
||||
|
||||
const { class: className, action: formAction, autofocus, ...htmlProps } = Astro.props
|
||||
|
||||
const result = Astro.getActionResult(formAction)
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
|
||||
const captcha = generateCaptcha()
|
||||
---
|
||||
|
||||
{/* eslint-disable astro/jsx-a11y/no-autofocus */}
|
||||
|
||||
<div {...htmlProps} class={cn('space-y-3', className)}>
|
||||
<p class="sr-only" id="captcha-instructions">
|
||||
This page requires a visual CAPTCHA to ensure you are a human. If you are unable to complete the CAPTCHA,
|
||||
please email us for assistance. <a href="mailto:contact@kycnot.me">contact@kycnot.me</a>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="@container flex flex-wrap items-center justify-center gap-2"
|
||||
style={{
|
||||
'--img-width': `${captcha.image.width}px`,
|
||||
'--img-height': `${captcha.image.height}px`,
|
||||
'--img-aspect-ratio': `${captcha.image.width} / ${captcha.image.height}`,
|
||||
}}
|
||||
>
|
||||
<label for="captcha-value">
|
||||
<Image {...captcha.image} alt="CAPTCHA verification" class="w-full max-w-(--img-width) rounded" />
|
||||
</label>
|
||||
|
||||
<Icon name="ri:arrow-right-line" class="size-6 text-zinc-600 @max-[calc(144px*2+8px*2+24px)]:hidden" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="captcha-value"
|
||||
name="captcha-value"
|
||||
required
|
||||
class={cn(
|
||||
'aspect-(--img-aspect-ratio) w-full max-w-(--img-width) min-w-0 rounded-md border border-zinc-700 bg-black/20 py-1.5 pl-[0.9em] font-mono text-sm text-zinc-200 uppercase placeholder:text-zinc-600',
|
||||
'pr-0 tracking-[0.9em] transition-colors focus:border-green-500/50 focus:ring-1 focus:ring-green-500/30 focus:outline-none',
|
||||
inputErrors['captcha-value'] && 'border-red-500/50 focus:border-red-500/50 focus:ring-red-500/30'
|
||||
)}
|
||||
autocomplete="off"
|
||||
pattern="[A-Za-z0-9]*"
|
||||
placeholder={'•'.repeat(CAPTCHA_LENGTH)}
|
||||
maxlength={CAPTCHA_LENGTH}
|
||||
aria-describedby="captcha-instructions"
|
||||
autofocus={autofocus}
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
data-form-type="other"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
inputErrors['captcha-value'] && (
|
||||
<p class="mt-1 text-center text-xs text-red-500">{inputErrors['captcha-value'].join(', ')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<input type="hidden" name="captcha-solution-hash" value={captcha.solutionHash} />
|
||||
</div>
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
import { isInputError } from 'astro:actions'
|
||||
|
||||
import { SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH } from '../actions/serviceSuggestion'
|
||||
import Button from '../components/Button.astro'
|
||||
import Tooltip from '../components/Tooltip.astro'
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import ChatMessages, { type ChatMessage } from './ChatMessages.astro'
|
||||
|
||||
import type { ActionInputNoFormData, AnyAction } from '../lib/astroActions'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props<TAction extends AnyAction | undefined = AnyAction | undefined> =
|
||||
HTMLAttributes<'section'> & {
|
||||
messages: ChatMessage[]
|
||||
title?: string
|
||||
userId: number | null
|
||||
action: TAction
|
||||
formData?: TAction extends AnyAction
|
||||
? ActionInputNoFormData<TAction> extends Record<string, unknown>
|
||||
? Omit<ActionInputNoFormData<TAction>, 'content'>
|
||||
: ActionInputNoFormData<TAction>
|
||||
: undefined
|
||||
}
|
||||
|
||||
const { messages, title, userId, action, formData, class: className, ...htmlProps } = Astro.props
|
||||
|
||||
const result = action ? Astro.getActionResult(action) : undefined
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
---
|
||||
|
||||
<div class={cn(className)} {...htmlProps}>
|
||||
{!!title && <h3 class="text-day-200 font-title mb-2 text-center text-xl font-bold">{title}</h3>}
|
||||
|
||||
<ChatMessages
|
||||
id="chat-messages"
|
||||
messages={messages}
|
||||
userId={userId}
|
||||
hx-trigger="every 10s"
|
||||
hx-get={Astro.url.pathname}
|
||||
hx-target="#chat-messages"
|
||||
hx-select="#chat-messages>*"
|
||||
/>
|
||||
|
||||
{
|
||||
!!action && (
|
||||
<>
|
||||
<form
|
||||
method="POST"
|
||||
action={action}
|
||||
class="flex items-end gap-2"
|
||||
hx-post={`${Astro.url.pathname}${action}`}
|
||||
hx-target="#chat-messages"
|
||||
hx-select="#chat-messages>*"
|
||||
hx-push-url="true"
|
||||
{...{ 'hx-on::after-request': 'if(event.detail.successful) this.reset()' }}
|
||||
>
|
||||
{typeof formData === 'object' &&
|
||||
formData !== null &&
|
||||
Object.entries(formData).map(([key, value]) => (
|
||||
<input type="hidden" name={key} value={String(value)} />
|
||||
))}
|
||||
<textarea
|
||||
name="content"
|
||||
placeholder="Add a message..."
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
baseInputClassNames.textarea,
|
||||
'max-h-64',
|
||||
!!inputErrors.content && baseInputClassNames.error
|
||||
)}
|
||||
required
|
||||
maxlength={SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<Tooltip text="Send">
|
||||
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
|
||||
</Tooltip>
|
||||
</form>
|
||||
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,110 +0,0 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type ChatMessage = {
|
||||
id: number
|
||||
content: string
|
||||
createdAt: Date
|
||||
user: Prisma.UserGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
name: true
|
||||
picture: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
messages: ChatMessage[]
|
||||
userId: number | null
|
||||
}
|
||||
|
||||
const { messages, userId, class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'mb-1 flex max-h-[60dvh] flex-col-reverse overflow-y-auto mask-t-from-[calc(100%-var(--spacing)*16)] pt-16',
|
||||
className
|
||||
)}
|
||||
{...htmlProps}
|
||||
>
|
||||
<p
|
||||
class="sticky bottom-0 -z-1 flex min-h-7 w-full items-end justify-center text-center text-xs text-balance text-gray-500"
|
||||
>
|
||||
<span class="js:hidden">Refresh the page to see new messages</span>
|
||||
<span class="no-js:hidden" data-refresh-in="10">Refreshing every 10s</span>
|
||||
</p>
|
||||
{
|
||||
messages.length > 0 ? (
|
||||
messages
|
||||
.map((message) => ({
|
||||
...message,
|
||||
formattedCreatedAt: formatDateShort(message.createdAt, {
|
||||
prefix: false,
|
||||
hourPrecision: true,
|
||||
caseType: 'sentence',
|
||||
}),
|
||||
}))
|
||||
.map((message, index, messages) => {
|
||||
const isCurrentUser = message.user.id === userId
|
||||
|
||||
const prev = messages[index - 1]
|
||||
const next = messages[index + 1]
|
||||
const isPrevFromSameUser = !!prev && prev.user.id === message.user.id
|
||||
const isPrevSameDate = !!prev && prev.formattedCreatedAt === message.formattedCreatedAt
|
||||
const isNextFromSameUser = !!next && next.user.id === message.user.id
|
||||
const isNextSameDate = !!next && next.formattedCreatedAt === message.formattedCreatedAt
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-col',
|
||||
isCurrentUser ? 'ml-8 items-end' : 'mr-8 items-start',
|
||||
isNextFromSameUser ? 'mt-1' : 'mt-3'
|
||||
)}
|
||||
>
|
||||
{!isCurrentUser && !isNextFromSameUser && (
|
||||
<p class="text-day-500 mb-0.5 text-xs">
|
||||
{!!message.user.picture && (
|
||||
<Picture
|
||||
src={message.user.picture}
|
||||
height={16}
|
||||
width={16}
|
||||
class="inline-block rounded-full align-[-0.33em]"
|
||||
alt=""
|
||||
formats={['jxl', 'avif', 'webp']}
|
||||
/>
|
||||
)}
|
||||
{message.user.name}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
class={cn(
|
||||
'rounded-xl p-3 text-sm whitespace-pre-wrap',
|
||||
isCurrentUser ? 'bg-blue-900 text-white' : 'bg-night-500 text-day-300',
|
||||
isCurrentUser ? 'rounded-br-xs' : 'rounded-bl-xs',
|
||||
isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tr-xs',
|
||||
!isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tl-xs'
|
||||
)}
|
||||
id={`message-${message.id.toString()}`}
|
||||
>
|
||||
{message.content}
|
||||
</p>
|
||||
{(!isPrevFromSameUser || !isPrevSameDate) && (
|
||||
<p class="text-day-500 mt-0.5 mb-2 text-xs">{message.formattedCreatedAt}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div class="text-day-500 my-16 text-center text-sm italic">No messages yet</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,504 +0,0 @@
|
||||
---
|
||||
import Image from 'astro/components/Image.astro'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { getServiceUserRoleInfo } from '../constants/serviceUserRoles'
|
||||
import { cn } from '../lib/cn'
|
||||
import {
|
||||
makeCommentUrl,
|
||||
MAX_COMMENT_DEPTH,
|
||||
type CommentWithRepliesPopulated,
|
||||
} from '../lib/commentsWithReplies'
|
||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
|
||||
import BadgeSmall from './BadgeSmall.astro'
|
||||
import CommentModeration from './CommentModeration.astro'
|
||||
import CommentReply from './CommentReply.astro'
|
||||
import TimeFormatted from './TimeFormatted.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
comment: CommentWithRepliesPopulated
|
||||
depth?: number
|
||||
showPending?: boolean
|
||||
highlightedCommentId: number | null
|
||||
serviceSlug: string
|
||||
itemReviewedId: string
|
||||
}
|
||||
|
||||
const {
|
||||
comment,
|
||||
depth = 0,
|
||||
showPending = false,
|
||||
highlightedCommentId = null,
|
||||
serviceSlug,
|
||||
itemReviewedId,
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
const user = Astro.locals.user
|
||||
const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
const authorUnlocks = computeKarmaUnlocks(comment.author.totalKarma)
|
||||
|
||||
function checkIsHighlightParent(c: CommentWithRepliesPopulated, highlight: number | null): boolean {
|
||||
if (!highlight) return false
|
||||
if (c.id === highlight) return true
|
||||
if (!c.replies?.length) return false
|
||||
return c.replies.some((r) => checkIsHighlightParent(r, highlight))
|
||||
}
|
||||
const isHighlightParent = checkIsHighlightParent(comment, highlightedCommentId)
|
||||
const isHighlighted = comment.id === highlightedCommentId
|
||||
|
||||
// Get user's current vote if any
|
||||
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
|
||||
|
||||
const isAuthor = user?.id === comment.author.id
|
||||
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
|
||||
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
|
||||
|
||||
// Check if user is new (less than 1 week old)
|
||||
const isNewUser =
|
||||
new Date().getTime() - new Date(comment.author.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
const isRatingActive =
|
||||
comment.rating !== null &&
|
||||
!comment.parentId &&
|
||||
comment.ratingActive &&
|
||||
!comment.suspicious &&
|
||||
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
|
||||
|
||||
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
|
||||
const shouldShow =
|
||||
comment.status === 'APPROVED' ||
|
||||
comment.status === 'VERIFIED' ||
|
||||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'PENDING') ||
|
||||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'HUMAN_PENDING') ||
|
||||
((isHighlightParent || isHighlighted) && comment.status === 'REJECTED') ||
|
||||
isAuthorOrPrivileged
|
||||
if (!shouldShow) return null
|
||||
|
||||
const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin: Astro.url.origin })
|
||||
---
|
||||
|
||||
<style>
|
||||
.collapse-toggle:checked + .comment-header .collapse-symbol::before {
|
||||
content: '[+]';
|
||||
}
|
||||
|
||||
.collapse-symbol::before {
|
||||
content: '[-]';
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
{...htmlProps}
|
||||
id={`comment-${comment.id.toString()}`}
|
||||
class={cn([
|
||||
'group',
|
||||
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
|
||||
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
|
||||
'bg-[#182a1f]',
|
||||
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') && 'bg-[#292815]',
|
||||
comment.status === 'REJECTED' && 'bg-[#2f1f1f]',
|
||||
isHighlighted && 'bg-[#192633]',
|
||||
comment.suspicious &&
|
||||
'opacity-25 transition-opacity not-has-[[data-collapse-toggle]:checked]:opacity-100! focus-within:opacity-100 hover:opacity-100 focus:opacity-100',
|
||||
className,
|
||||
])}
|
||||
>
|
||||
{
|
||||
isRatingActive && comment.rating !== null && (
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
'@id': commentUrl,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: comment.author.displayName ?? comment.author.name,
|
||||
image: comment.author.picture ?? undefined,
|
||||
url: new URL(`/u/${comment.author.name}`, Astro.url).href,
|
||||
},
|
||||
datePublished: comment.createdAt.toISOString(),
|
||||
reviewBody: comment.content,
|
||||
reviewAspect: 'User comment',
|
||||
commentCount: comment.replies?.length ?? 0,
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: comment.rating,
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`collapse-${comment.id.toString()}`}
|
||||
data-collapse-toggle
|
||||
class="collapse-toggle peer/collapse hidden"
|
||||
checked={comment.suspicious}
|
||||
/>
|
||||
|
||||
<div class="comment-header flex items-center gap-2 text-sm">
|
||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||
<span class="collapse-symbol text-xs"></span>
|
||||
<span class="sr-only">Toggle comment visibility</span>
|
||||
</label>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
{
|
||||
comment.author.picture && (
|
||||
<Image
|
||||
src={comment.author.picture}
|
||||
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`}
|
||||
class="size-6 rounded-full bg-zinc-700 object-cover"
|
||||
loading="lazy"
|
||||
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) && (
|
||||
<Tooltip
|
||||
text={`${
|
||||
comment.author.admin || comment.author.verifier
|
||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||
: ''
|
||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
||||
>
|
||||
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
|
||||
{/* User badges - more compact but still with text */}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{
|
||||
comment.author.admin && (
|
||||
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.author.verifier && !comment.author.admin && (
|
||||
<BadgeSmall icon="ri:shield-check-fill" color="teal" text="Moderator" variant="faded" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isNewUser && !comment.author.admin && !comment.author.verifier && (
|
||||
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
|
||||
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.highKarmaBadge.icon}
|
||||
color="lime"
|
||||
text="High Karma"
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
authorUnlocks.negativeKarmaBadge && !authorUnlocks.untrustedBadge && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.negativeKarmaBadge.icon}
|
||||
color="orange"
|
||||
text="Negative Karma"
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
(authorUnlocks.untrustedBadge || comment.author.spammer) && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.untrustedBadge.icon}
|
||||
color="red"
|
||||
text="Untrusted User"
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.author.serviceAffiliations.map((affiliation) => {
|
||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||
return (
|
||||
<BadgeSmall
|
||||
icon={roleInfo.icon}
|
||||
color={roleInfo.color}
|
||||
text={`${roleInfo.label} at ${affiliation.service.name}`}
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-1 text-xs text-zinc-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="ri:arrow-up-line" class="size-3" />
|
||||
{comment.upvotes}
|
||||
</span>
|
||||
|
||||
<span class="text-zinc-700">•</span>
|
||||
|
||||
<a href={commentUrl} class="hover:text-zinc-300">
|
||||
<TimeFormatted date={comment.createdAt} hourPrecision />
|
||||
</a>
|
||||
|
||||
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
|
||||
|
||||
{
|
||||
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
||||
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.rating !== null && !comment.parentId && (
|
||||
<Tooltip
|
||||
text="Not counting for the total"
|
||||
position="right"
|
||||
enabled={!isRatingActive}
|
||||
class={cn('flex items-center gap-1', isRatingActive ? 'text-yellow-400' : 'text-yellow-400/60')}
|
||||
>
|
||||
<Icon name={isRatingActive ? 'ri:star-fill' : 'ri:star-line'} class="size-3" />
|
||||
{comment.rating.toLocaleString()}/5
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.status === 'VERIFIED' && (
|
||||
<BadgeSmall icon="ri:check-double-fill" color="green" text="Verified" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') &&
|
||||
(showPending || isHighlightParent || isAuthorOrPrivileged) && (
|
||||
<BadgeSmall icon="ri:time-fill" color="yellow" text="Unmoderated" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.status === 'REJECTED' && isAuthorOrPrivileged && (
|
||||
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Rejected" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{/* Service usage verification indicators */}
|
||||
{
|
||||
comment.orderId && comment.orderIdStatus === 'APPROVED' && (
|
||||
<BadgeSmall icon="ri:verified-badge-fill" color="green" text="Valid order ID" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.orderId && comment.orderIdStatus === 'REJECTED' && (
|
||||
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Invalid order ID" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.kycRequested && (
|
||||
<BadgeSmall icon="ri:user-forbid-fill" color="red" text="KYC issue" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.fundsBlocked && (
|
||||
<BadgeSmall icon="ri:wallet-3-fill" color="orange" text="Funds blocked" inlineIcon />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class={cn(['comment-body mt-2 peer-checked/collapse:hidden'])}>
|
||||
{
|
||||
isAuthor && comment.status === 'REJECTED' && (
|
||||
<div class="mb-2 inline-block rounded-xs bg-red-500/30 px-2 py-1 text-xs text-red-300">
|
||||
This comment has been rejected and is only visible to you
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="text-sm">
|
||||
{
|
||||
!!comment.content && (
|
||||
<div class="prose prose-invert prose-sm max-w-none overflow-auto">
|
||||
<Markdown content={comment.content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
comment.communityNote && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-zinc-400">Added context:</span>
|
||||
<span class="text-zinc-300">{comment.communityNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.internalNote && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-red-400">Internal note:</span>
|
||||
<span class="text-red-300">{comment.internalNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.privateContext && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-blue-400">Private context:</span>
|
||||
<span class="text-blue-300">{comment.privateContext}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mt-2 flex items-center gap-3 text-xs peer-checked/collapse:hidden">
|
||||
<div class="flex items-center gap-1">
|
||||
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
|
||||
<input type="hidden" name="commentId" value={comment.id} />
|
||||
<input type="hidden" name="downvote" value="false" />
|
||||
<Tooltip
|
||||
as="button"
|
||||
type="submit"
|
||||
disabled={!user?.totalKarma || user.totalKarma < 20}
|
||||
class={cn([
|
||||
'rounded-sm p-1 hover:bg-zinc-800',
|
||||
userVote?.downvote === false ? 'text-blue-500' : 'text-zinc-500',
|
||||
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
|
||||
])}
|
||||
text={user?.totalKarma && user.totalKarma >= 20 ? 'Upvote' : 'Need 20+ karma to vote'}
|
||||
position="right"
|
||||
aria-label="Upvote"
|
||||
>
|
||||
<Icon name="ri:arrow-up-line" class="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
</form>
|
||||
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
|
||||
<input type="hidden" name="commentId" value={comment.id} />
|
||||
<input type="hidden" name="downvote" value="true" />
|
||||
<Tooltip
|
||||
as="button"
|
||||
text={user?.totalKarma && user.totalKarma >= 20 ? 'Downvote' : 'Need 20+ karma to vote'}
|
||||
position="right"
|
||||
disabled={!user?.totalKarma || user.totalKarma < 20}
|
||||
class={cn([
|
||||
'rounded-sm p-1 hover:bg-zinc-800',
|
||||
userVote?.downvote === true ? 'text-red-500' : 'text-zinc-500',
|
||||
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
|
||||
])}
|
||||
aria-label="Downvote"
|
||||
>
|
||||
<Icon name="ri:arrow-down-line" class="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
</form>
|
||||
</div>
|
||||
{
|
||||
user && userCommentsDisabled ? (
|
||||
<span class="text-xs text-red-400">You cannot reply due to low karma.</span>
|
||||
) : (
|
||||
<label
|
||||
for={`reply-toggle-${comment.id.toString()}`}
|
||||
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Icon name="ri:reply-line" class="h-3.5 w-3.5" />
|
||||
Reply
|
||||
</label>
|
||||
)
|
||||
}
|
||||
{
|
||||
user && (
|
||||
<form
|
||||
method="POST"
|
||||
action={`${actions.notification.preferences.watchComment}&comment=${comment.id.toString()}#comment-${comment.id.toString()}`}
|
||||
class="inline"
|
||||
data-astro-reload
|
||||
>
|
||||
<input type="hidden" name="commentId" value={comment.id} />
|
||||
<input type="hidden" name="watch" value={comment.isWatchingReplies ? 'false' : 'true'} />
|
||||
<button
|
||||
type="submit"
|
||||
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Icon name={comment.isWatchingReplies ? 'ri:eye-off-line' : 'ri:eye-line'} class="size-3" />
|
||||
{comment.isWatchingReplies ? 'Unwatch' : 'Watch'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<CommentModeration class="mt-2 peer-checked/collapse:hidden" comment={comment} />
|
||||
|
||||
{
|
||||
user && userCommentsDisabled ? null : (
|
||||
<>
|
||||
<input type="checkbox" id={`reply-toggle-${comment.id.toString()}`} class="peer/reply hidden" />
|
||||
<CommentReply
|
||||
serviceId={comment.serviceId}
|
||||
parentId={comment.id}
|
||||
commentId={comment.id}
|
||||
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.replies && comment.replies.length > 0 && depth < MAX_COMMENT_DEPTH && (
|
||||
<div class="replies mt-3 peer-checked/collapse:hidden">
|
||||
{comment.replies.map((reply) => (
|
||||
<Astro.self
|
||||
comment={reply}
|
||||
depth={depth + 1}
|
||||
showPending={showPending}
|
||||
highlightedCommentId={isHighlightParent ? highlightedCommentId : null}
|
||||
serviceSlug={serviceSlug}
|
||||
itemReviewedId={itemReviewedId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,366 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
comment: Prisma.CommentGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
status: true
|
||||
suspicious: true
|
||||
requiresAdminReview: true
|
||||
kycRequested: true
|
||||
fundsBlocked: true
|
||||
communityNote: true
|
||||
internalNote: true
|
||||
privateContext: true
|
||||
orderId: true
|
||||
orderIdStatus: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const { comment, class: className, ...divProps } = Astro.props
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
// Only render for admin/verifier users
|
||||
if (!user || !user.admin || !user.verifier) return null
|
||||
---
|
||||
|
||||
<div {...divProps} class={cn('text-xs', className)}>
|
||||
<input type="checkbox" id={`mod-toggle-${String(comment.id)}`} class="peer hidden" />
|
||||
<label
|
||||
for={`mod-toggle-${String(comment.id)}`}
|
||||
class="text-day-500 hover:text-day-300 flex cursor-pointer items-center gap-1"
|
||||
>
|
||||
<Icon name="ri:shield-keyhole-line" class="h-3.5 w-3.5" />
|
||||
<span class="text-xs">Moderation</span>
|
||||
<Icon name="ri:arrow-down-s-line" class="h-3.5 w-3.5 transition-transform peer-checked:rotate-180" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="bg-night-600 border-night-500 mt-2 max-h-0 overflow-hidden rounded-md border opacity-0 transition-all duration-200 ease-in-out peer-checked:max-h-[500px] peer-checked:p-2 peer-checked:opacity-100"
|
||||
>
|
||||
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="status"
|
||||
data-value={comment.status === 'REJECTED' ? 'PENDING' : 'REJECTED'}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.suspicious ? 'Not Spam' : 'Spam'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'VERIFIED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
)}
|
||||
data-action="status"
|
||||
data-value={comment.status === 'VERIFIED' ? 'APPROVED' : 'VERIFIED'}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
)}
|
||||
data-action="status"
|
||||
data-value={comment.status === 'PENDING' ? 'APPROVED' : 'PENDING'}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'PENDING' ? 'Approve' : 'Pending'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.kycRequested
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="kyc-requested"
|
||||
data-value={!comment.kycRequested}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.fundsBlocked
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="funds-blocked"
|
||||
data-value={!comment.fundsBlocked}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Community:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Public note..."
|
||||
value={comment.communityNote}
|
||||
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
data-action="community-note"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Internal:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Mod note..."
|
||||
value={comment.internalNote}
|
||||
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
data-action="internal-note"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Private:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Context..."
|
||||
value={comment.privateContext}
|
||||
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
data-action="private-context"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
comment.orderId && (
|
||||
<div class="border-night-500 mt-3 space-y-1.5 border-t pt-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Order ID:</span>
|
||||
<div class="bg-night-700 flex-1 rounded-sm px-1.5 py-0.5 text-xs">{comment.orderId}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Status:</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'APPROVED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="APPROVED"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="REJECTED"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="PENDING"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Pending
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
// Handle button clicks
|
||||
document.querySelectorAll('button[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.getAttribute('data-action')
|
||||
const value = btn.getAttribute('data-value')
|
||||
const commentId = parseInt(btn.getAttribute('data-comment-id') || '0')
|
||||
const userId = parseInt(btn.getAttribute('data-user-id') || '0')
|
||||
|
||||
if (!value || !commentId || !userId) return
|
||||
|
||||
try {
|
||||
const { error } = await actions.comment.moderate({
|
||||
commentId,
|
||||
userId,
|
||||
action: action as any,
|
||||
value:
|
||||
action === 'suspicious' ||
|
||||
action === 'requires-admin-review' ||
|
||||
action === 'kyc-requested' ||
|
||||
action === 'funds-blocked'
|
||||
? value === 'true'
|
||||
: value,
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
// Update button state based on new value
|
||||
if (action === 'status') {
|
||||
window.location.reload()
|
||||
} else if (action === 'suspicious') {
|
||||
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
|
||||
btn.classList.toggle('bg-yellow-500/20')
|
||||
btn.classList.toggle('text-yellow-400')
|
||||
btn.classList.toggle('border-yellow-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'requires-admin-review') {
|
||||
btn.textContent = value === 'true' ? 'No Review' : 'Review'
|
||||
btn.classList.toggle('bg-purple-500/20')
|
||||
btn.classList.toggle('text-purple-400')
|
||||
btn.classList.toggle('border-purple-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'order-id-status') {
|
||||
// Refresh to show updated order ID status
|
||||
window.location.reload()
|
||||
} else if (action === 'kyc-requested') {
|
||||
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'funds-blocked') {
|
||||
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
}
|
||||
} else {
|
||||
console.error('Error moderating comment:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moderating comment:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Handle text input changes
|
||||
document.querySelectorAll('input[data-action]').forEach((input) => {
|
||||
const action = input.getAttribute('data-action')
|
||||
const commentId = parseInt(input.getAttribute('data-comment-id') || '0')
|
||||
const userId = parseInt(input.getAttribute('data-user-id') || '0')
|
||||
|
||||
if (!action || !commentId || !userId) return
|
||||
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(async () => {
|
||||
try {
|
||||
const { error } = await actions.comment.moderate({
|
||||
commentId,
|
||||
userId,
|
||||
action: action as any,
|
||||
value: (input as HTMLInputElement).value,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating note:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
}
|
||||
}, 500) // Debounce for 500ms
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,172 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import FormTimeTrap from './FormTimeTrap.astro'
|
||||
import InputHoneypotTrap from './InputHoneypotTrap.astro'
|
||||
import InputRating from './InputRating.astro'
|
||||
import InputText from './InputText.astro'
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
||||
serviceId: number
|
||||
parentId?: number
|
||||
commentId?: number
|
||||
activeRatingComment?: Prisma.CommentGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
rating: true
|
||||
}
|
||||
}> | null
|
||||
}
|
||||
|
||||
const { serviceId, parentId, commentId, activeRatingComment, class: className, ...htmlProps } = Astro.props
|
||||
|
||||
const MIN_COMMENT_LENGTH = parentId ? 10 : 30
|
||||
|
||||
const user = Astro.locals.user
|
||||
const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
---
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.comment.create}
|
||||
enctype="application/x-www-form-urlencoded"
|
||||
class={cn(className)}
|
||||
{...htmlProps}
|
||||
>
|
||||
<FormTimeTrap />
|
||||
<input type="hidden" name="serviceId" value={serviceId} />
|
||||
{parentId && <input type="hidden" name="parentId" value={parentId} />}
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`use-form-secret-token-${String(commentId ?? 'new')}`}
|
||||
name="useFormUserSecretToken"
|
||||
checked={!user}
|
||||
class="peer/use-form-secret-token hidden"
|
||||
/>
|
||||
|
||||
{
|
||||
user ? (
|
||||
userCommentsDisabled ? (
|
||||
<div class="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-400">
|
||||
<Icon name="ri:forbid-line" class="mr-1 inline h-4 w-4 align-[-0.2em]" />
|
||||
You cannot comment due to low karma.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="text-day-400 flex items-center gap-2 text-xs peer-checked/use-form-secret-token:hidden">
|
||||
<Icon name="ri:user-line" class="size-3.5" />
|
||||
<span>
|
||||
Commenting as: <span class="font-title font-medium text-green-400">{user.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
id={`comment-${String(commentId ?? 'new')}`}
|
||||
name="content"
|
||||
required
|
||||
minlength={MIN_COMMENT_LENGTH}
|
||||
maxlength={2000}
|
||||
rows="4"
|
||||
placeholder="Write your comment..."
|
||||
class="placeholder:text-day-500 focus:ring-day-500 border-night-500 bg-night-800 focus:border-night-600 max-h-128 min-h-16 w-full resize-y rounded-lg border px-2.5 py-2 text-sm focus:ring-1 focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!parentId ? (
|
||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InputRating name="rating" label="Rating" />
|
||||
|
||||
<InputWrapper label="Tags" name="tags">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||
KYC Issue
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||
Funds Blocked
|
||||
</span>
|
||||
</label>
|
||||
</InputWrapper>
|
||||
|
||||
<InputText
|
||||
label="Order ID"
|
||||
name="orderId"
|
||||
inputProps={{
|
||||
maxlength: 100,
|
||||
placeholder: 'Order ID / URL / Proof',
|
||||
class: 'bg-night-800',
|
||||
}}
|
||||
descriptionLabel="Only visible to admins, to verify your comment"
|
||||
class="grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-start justify-end gap-2">
|
||||
{!!activeRatingComment?.rating && (
|
||||
<div
|
||||
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||
data-show-if-rating
|
||||
>
|
||||
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
|
||||
<a
|
||||
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Your previous rating
|
||||
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||
</a>
|
||||
of
|
||||
{[
|
||||
activeRatingComment.rating.toLocaleString(),
|
||||
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
||||
]}
|
||||
won't count for the total.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<a
|
||||
href={makeLoginUrl(Astro.url, { message: 'Login to comment' })}
|
||||
data-astro-reload
|
||||
class="font-title mb-4 inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-1.5 text-xs text-blue-400 shadow-xs transition-colors duration-200 hover:bg-blue-500/20 focus:ring-1 focus:ring-blue-500 focus:outline-hidden"
|
||||
>
|
||||
<Icon name="ri:login-box-line" class="size-3.5" />
|
||||
Login to comment
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,263 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { z } from 'zod'
|
||||
|
||||
import CommentItem from '../components/CommentItem.astro'
|
||||
import CommentReply from '../components/CommentReply.astro'
|
||||
import { getCommentStatusInfo } from '../constants/commentStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import {
|
||||
commentSortSchema,
|
||||
makeCommentsNestedQuery,
|
||||
MAX_COMMENT_DEPTH,
|
||||
type CommentSortOption,
|
||||
type CommentWithReplies,
|
||||
type CommentWithRepliesPopulated,
|
||||
} from '../lib/commentsWithReplies'
|
||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
|
||||
import { makeOgImageUrl } from './OgImage'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { Comment, DiscussionForumPosting, WithContext } from 'schema-dts'
|
||||
|
||||
type Props = {
|
||||
itemReviewedId: string
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
slug: true
|
||||
listedAt: true
|
||||
name: true
|
||||
description: true
|
||||
createdAt: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const { service, itemReviewedId } = Astro.props
|
||||
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
showPending: z.coerce.boolean().default(false),
|
||||
comment: z.coerce.number().int().positive().nullable().default(null),
|
||||
sort: commentSortSchema,
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
const toggleUrl = new URL(Astro.request.url)
|
||||
toggleUrl.hash = '#comments'
|
||||
if (params.showPending) {
|
||||
toggleUrl.searchParams.delete('showPending')
|
||||
} else {
|
||||
toggleUrl.searchParams.set('showPending', 'true')
|
||||
}
|
||||
|
||||
const getSortUrl = (sortOption: CommentSortOption) => {
|
||||
const url = new URL(Astro.request.url)
|
||||
url.searchParams.set('sort', sortOption)
|
||||
return url.toString() + '#comments'
|
||||
}
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.locals.banners.tryMany([
|
||||
[
|
||||
'Failed to fetch comments',
|
||||
async () =>
|
||||
await prisma.comment.findMany(
|
||||
makeCommentsNestedQuery({
|
||||
depth: MAX_COMMENT_DEPTH,
|
||||
user,
|
||||
showPending: params.showPending,
|
||||
serviceId: service.id,
|
||||
sort: params.sort,
|
||||
})
|
||||
),
|
||||
[],
|
||||
],
|
||||
[
|
||||
'Failed to count unmoderated comments',
|
||||
async () =>
|
||||
prisma.comment.count({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
status: { in: ['PENDING', 'HUMAN_PENDING'] },
|
||||
},
|
||||
}),
|
||||
0,
|
||||
],
|
||||
[
|
||||
"Failed to fetch user's service rating",
|
||||
async () =>
|
||||
user
|
||||
? await prisma.comment.findFirst({
|
||||
where: { serviceId: service.id, authorId: user.id, ratingActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
null,
|
||||
],
|
||||
])
|
||||
|
||||
const notiPref = user
|
||||
? await getOrCreateNotificationPreferences(user.id, {
|
||||
watchedComments: { select: { id: true } },
|
||||
})
|
||||
: null
|
||||
|
||||
const populateComment = (comment: CommentWithReplies): CommentWithRepliesPopulated => ({
|
||||
...comment,
|
||||
isWatchingReplies: notiPref?.watchedComments.some((c) => c.id === comment.id) ?? false,
|
||||
replies: comment.replies?.map(populateComment),
|
||||
})
|
||||
const comments = dbComments.map(populateComment)
|
||||
|
||||
function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
const statusInfo = getCommentStatusInfo(comment.status)
|
||||
return {
|
||||
'@type': 'Comment',
|
||||
text: comment.content,
|
||||
datePublished: comment.createdAt.toISOString(),
|
||||
dateCreated: comment.createdAt.toISOString(),
|
||||
creativeWorkStatus: statusInfo.creativeWorkStatus,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: comment.author.displayName ?? comment.author.name,
|
||||
url: new URL(`/u/${comment.author.name}`, Astro.url).href,
|
||||
image: comment.author.picture ?? undefined,
|
||||
},
|
||||
interactionStatistic: [
|
||||
{
|
||||
'@type': 'InteractionCounter',
|
||||
interactionType: { '@type': 'LikeAction' },
|
||||
userInteractionCount: comment.upvotes,
|
||||
},
|
||||
{
|
||||
'@type': 'InteractionCounter',
|
||||
interactionType: { '@type': 'ReplyAction' },
|
||||
userInteractionCount: comment.replies?.length ?? 0,
|
||||
},
|
||||
],
|
||||
commentCount: comment.replies?.length ?? 0,
|
||||
comment: comment.replies?.map(makeReplySchema),
|
||||
} satisfies Comment
|
||||
}
|
||||
---
|
||||
|
||||
<section class="mt-8" id="comments">
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'DiscussionForumPosting',
|
||||
url: new URL(`/service/${service.slug}#comments`, Astro.url).href,
|
||||
mainEntityOfPage: new URL(`/service/${service.slug}#comments`, Astro.url).href,
|
||||
datePublished: service.listedAt?.toISOString(),
|
||||
dateCreated: service.createdAt.toISOString(),
|
||||
headline: `${service.name} comments on KYCnot.me`,
|
||||
text: service.description,
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
image: makeOgImageUrl({ template: 'generic', title: `${service.name} comments` }, Astro.url),
|
||||
|
||||
commentCount: comments.length,
|
||||
comment: comments.map(makeReplySchema),
|
||||
} as WithContext<DiscussionForumPosting>}
|
||||
/>
|
||||
<CommentReply serviceId={service.id} activeRatingComment={activeRatingComment} class="xs:mb-4 mb-2" />
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-1">
|
||||
<a
|
||||
href={getSortUrl('newest')}
|
||||
class={cn([
|
||||
'rounded-md px-2 py-1 text-sm',
|
||||
params.sort === 'newest'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<Icon name="ri:time-line" class="mr-1 inline h-3.5 w-3.5" />
|
||||
Newest
|
||||
</a>
|
||||
<a
|
||||
href={getSortUrl('upvotes')}
|
||||
class={cn([
|
||||
'rounded-md px-2 py-1 text-sm',
|
||||
params.sort === 'upvotes'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<Icon name="ri:arrow-up-line" class="mr-1 inline h-3.5 w-3.5" />
|
||||
Most Upvotes
|
||||
</a>
|
||||
{
|
||||
user && (user.admin || user.verifier) && (
|
||||
<a
|
||||
href={getSortUrl('status')}
|
||||
class={cn([
|
||||
'rounded-md px-2 py-1 text-sm',
|
||||
params.sort === 'status'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<Icon name="ri:filter-line" class="mr-1 inline h-3.5 w-3.5" />
|
||||
Status
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{
|
||||
pendingCommentsCount > 0 && (
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
href={toggleUrl.toString()}
|
||||
class={cn([
|
||||
'flex items-center gap-2 text-sm',
|
||||
params.showPending ? 'text-yellow-500' : 'text-zinc-400 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<div class="relative flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-zinc-700 p-1 transition-colors duration-200 ease-in-out focus:outline-hidden">
|
||||
<span
|
||||
class={cn([
|
||||
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-zinc-400 shadow-sm transition-transform duration-200 ease-in-out',
|
||||
params.showPending && 'translate-x-4 bg-yellow-500',
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<span>Show unmoderated ({pendingCommentsCount})</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{
|
||||
comments.map((comment) => (
|
||||
<CommentItem
|
||||
comment={comment}
|
||||
highlightedCommentId={params.comment}
|
||||
showPending={params.showPending}
|
||||
serviceSlug={service.slug}
|
||||
itemReviewedId={itemReviewedId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { clamp, round, sum, sumBy } from 'lodash-es'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
serviceId: number
|
||||
itemReviewedId: string
|
||||
averageUserRating?: number | null
|
||||
}
|
||||
|
||||
const {
|
||||
serviceId,
|
||||
itemReviewedId,
|
||||
averageUserRating: averageUserRatingFromProps,
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const ratingsFromDb = await prisma.comment.groupBy({
|
||||
by: ['rating'],
|
||||
where: {
|
||||
serviceId,
|
||||
ratingActive: true,
|
||||
status: {
|
||||
in: ['APPROVED', 'VERIFIED'],
|
||||
},
|
||||
parentId: null,
|
||||
suspicious: false,
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
|
||||
const ratings = ([5, 4, 3, 2, 1] as const).map((rating) => ({
|
||||
rating,
|
||||
count: ratingsFromDb.find((stat) => stat.rating === rating)?._count ?? 0,
|
||||
}))
|
||||
|
||||
const totalComments = sumBy(ratings, 'count')
|
||||
|
||||
const averageUserRatingFromQuery =
|
||||
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
|
||||
|
||||
if (averageUserRatingFromProps !== undefined) {
|
||||
if (
|
||||
averageUserRatingFromQuery !== averageUserRatingFromProps ||
|
||||
(averageUserRatingFromQuery !== null &&
|
||||
averageUserRatingFromProps !== null &&
|
||||
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
|
||||
) {
|
||||
console.error(
|
||||
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const averageUserRating =
|
||||
averageUserRatingFromProps === undefined ? averageUserRatingFromQuery : averageUserRatingFromProps
|
||||
---
|
||||
|
||||
<div {...htmlProps} class={cn('flex flex-wrap items-center justify-center gap-4', className)}>
|
||||
{
|
||||
averageUserRating !== null && (
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'AggregateRating',
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
ratingValue: round(averageUserRating, 1),
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
ratingCount: totalComments,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="mb-1 text-5xl">
|
||||
{averageUserRating !== null ? round(averageUserRating, 1).toLocaleString() : '-'}
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
{
|
||||
([1, 2, 3, 4, 5] as const).map((rating) => (
|
||||
<div
|
||||
class="relative size-5"
|
||||
style={`--percent: ${clamp((averageUserRating ?? 0) - (rating - 1), 0, 1) * 100}%`}
|
||||
>
|
||||
<Icon name="ri:star-line" class="absolute inset-0 size-full text-zinc-500" />
|
||||
<Icon
|
||||
name="ri:star-fill"
|
||||
class="absolute inset-0 size-full text-yellow-400 [clip-path:inset(0_calc(100%_-_var(--percent))_0_0)]"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-400">
|
||||
{totalComments.toLocaleString()} ratings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-32 flex-1 grid-cols-[auto_1fr_auto] items-center gap-1">
|
||||
{
|
||||
ratings.map(({ rating, count }) => {
|
||||
const percent = totalComments > 0 ? (count / totalComments) * 100 : null
|
||||
return (
|
||||
<>
|
||||
<div class="text-center text-xs text-zinc-400" aria-label={`${rating} stars`}>
|
||||
{rating.toLocaleString()}
|
||||
</div>
|
||||
<div class="h-2 flex-1 overflow-hidden rounded-full bg-zinc-700">
|
||||
<div class="h-full w-(--percent) bg-yellow-400" style={`--percent: ${percent ?? 0}%`} />
|
||||
</div>
|
||||
<div class="text-right text-xs text-zinc-400">
|
||||
{[<span>{round(percent ?? 0).toLocaleString()}</span>, <span class="text-zinc-500">%</span>]}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button, { type ButtonProps } from './Button.astro'
|
||||
|
||||
import type { Optional } from 'ts-toolbelt/out/Object/Optional'
|
||||
|
||||
type Props = Optional<ButtonProps<'button'>, 'icon' | 'label'> & {
|
||||
copyText: string
|
||||
}
|
||||
|
||||
const { copyText, class: className, icon, label, ...buttonProps } = Astro.props
|
||||
---
|
||||
|
||||
<Button
|
||||
data-copy-text={copyText}
|
||||
data-copy-button
|
||||
{...buttonProps}
|
||||
label={label ?? 'Copy'}
|
||||
icon={icon ?? 'ri:clipboard-line'}
|
||||
class={cn(['no-js:hidden', className])}
|
||||
/>
|
||||
|
||||
<script>
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-copy-button]')
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const text = button.dataset.copyText
|
||||
if (text === undefined) {
|
||||
throw new Error('Copy button must have a data-copy-text attribute')
|
||||
}
|
||||
navigator.clipboard.writeText(text)
|
||||
|
||||
const span = button.querySelector<HTMLSpanElement>('span')
|
||||
if (span) {
|
||||
span.textContent = 'Copied'
|
||||
setTimeout(() => {
|
||||
span.textContent = 'Copy'
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
children: AstroChildren
|
||||
label: string
|
||||
icon?: string
|
||||
classNames?: {
|
||||
label?: string
|
||||
icon?: string
|
||||
arrow?: string
|
||||
button?: string
|
||||
}
|
||||
}
|
||||
|
||||
const buttonId = Astro.locals.makeId('dropdown-button')
|
||||
const menuId = Astro.locals.makeId('dropdown-menu')
|
||||
|
||||
const { label, icon, class: className, classNames, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('group/dropdown relative', className)} {...htmlProps}>
|
||||
<Button
|
||||
class={cn(
|
||||
'group-hover/dropdown:bg-night-900 group-focus-within/dropdown:bg-night-900 group-focus-within/dropdown:text-day-200 group-hover/dropdown:text-day-200',
|
||||
classNames?.button
|
||||
)}
|
||||
icon={icon}
|
||||
label={label}
|
||||
endIcon="ri:arrow-down-s-line"
|
||||
classNames={{
|
||||
label: classNames?.label,
|
||||
icon: classNames?.icon,
|
||||
endIcon: cn(
|
||||
'transition-transform group-focus-within/dropdown:rotate-180 group-hover/dropdown:rotate-180',
|
||||
classNames?.arrow
|
||||
),
|
||||
}}
|
||||
aria-haspopup="true"
|
||||
aria-controls={menuId}
|
||||
id={buttonId}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="border-night-500 bg-night-900 absolute right-0 z-50 mt-1 hidden w-48 items-stretch rounded-md border py-1 shadow-lg group-focus-within/dropdown:block group-hover/dropdown:block before:absolute before:-inset-x-px before:bottom-[calc(100%-1*var(--spacing))] before:box-content before:h-2 before:pb-px"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby={buttonId}
|
||||
id={menuId}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { ActionInputNoFormData, AnyAction } from '../lib/astroActions'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props<TAction extends AnyAction = AnyAction> = Omit<HTMLAttributes<'form'>, 'action'> & {
|
||||
label: string
|
||||
icon?: string
|
||||
action: TAction
|
||||
data: ActionInputNoFormData<TAction>
|
||||
}
|
||||
|
||||
const { label, icon, action, data, class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<form action={action} class={cn('contents', className)} {...htmlProps}>
|
||||
{Object.entries(data).map(([key, value]) => <input type="hidden" name={key} value={String(value)} />)}
|
||||
<button
|
||||
class="text-day-300 hover:bg-night-800 flex w-full items-center px-4 py-2 text-left text-sm hover:text-white"
|
||||
type="submit"
|
||||
>
|
||||
{icon && <Icon name={icon} class="mr-2 size-4" />}
|
||||
<span class="flex-1">{label}</span>
|
||||
<slot name="end" />
|
||||
</button>
|
||||
</form>
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'a'> & {
|
||||
label: string
|
||||
icon?: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const { label, icon, href, class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class={cn(
|
||||
'text-day-300 hover:bg-night-800 flex items-center px-4 py-2 text-sm hover:text-white',
|
||||
className
|
||||
)}
|
||||
{...htmlProps}
|
||||
>
|
||||
{icon && <Icon name={icon} class="mr-2 size-4" />}
|
||||
<span class="flex-1">{label}</span>
|
||||
</a>
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { SOURCE_CODE_URL } from 'astro:env/server'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'footer'>
|
||||
|
||||
const links = [
|
||||
{
|
||||
href: SOURCE_CODE_URL,
|
||||
label: 'Source Code',
|
||||
icon: 'ri:git-repository-line',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About',
|
||||
icon: 'ri:information-line',
|
||||
external: false,
|
||||
},
|
||||
] as const satisfies {
|
||||
href: string
|
||||
label: string
|
||||
icon: string
|
||||
external: boolean
|
||||
}[]
|
||||
|
||||
const { class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
|
||||
{
|
||||
links.map(
|
||||
({ href, label, icon, external }) =>
|
||||
href && (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
|
||||
>
|
||||
<Icon name={icon} class="h-4 w-4" />
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
)
|
||||
}
|
||||
</footer>
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
// Time Trap Component //
|
||||
// This component is used to prevent bots from submitting the form.
|
||||
// It encrypts the current timestamp and stores it in a hidden input field.
|
||||
// The server then decrypts the timestamp and checks if it's valid and
|
||||
// if the time difference is within the allowed range.
|
||||
// If the timestamp is invalid, the form is not submitted.
|
||||
|
||||
import crypto from 'crypto'
|
||||
|
||||
import { timeTrapSecretKey } from '../lib/timeTrapSecret'
|
||||
|
||||
const algorithm = 'aes-256-cbc'
|
||||
const iv = crypto.randomBytes(16) // Generate a random IV for each encryption
|
||||
const timestamp = Date.now().toString()
|
||||
|
||||
const cipher = crypto.createCipheriv(algorithm, timeTrapSecretKey, iv)
|
||||
let encrypted = cipher.update(timestamp, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
// Combine IV and encrypted timestamp, then encode as base64 for the input value
|
||||
const encryptedValue = Buffer.from(`${iv.toString('hex')}:${encrypted}`).toString('base64')
|
||||
|
||||
// --- Time Trap Validation Start ---
|
||||
// try {
|
||||
|
||||
// const algorithm = 'aes-256-cbc'
|
||||
// const decodedValue = Buffer.from(input.encTimestamp, 'base64').toString('utf8')
|
||||
// const [ivHex, encryptedHex] = decodedValue.split(':')
|
||||
|
||||
// if (!ivHex || !encryptedHex) {
|
||||
// throw new Error('Invalid time trap format.')
|
||||
// }
|
||||
|
||||
// const iv = Buffer.from(ivHex, 'hex')
|
||||
// const decipher = crypto.createDecipheriv(algorithm, timeTrapSecretKey, iv)
|
||||
// let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
// decrypted += decipher.final('utf8')
|
||||
|
||||
// const originalTimestamp = parseInt(decrypted, 10)
|
||||
// if (isNaN(originalTimestamp)) {
|
||||
// throw new Error('Invalid timestamp data.')
|
||||
// }
|
||||
|
||||
// const now = Date.now()
|
||||
// const timeDiff = now - originalTimestamp
|
||||
// const minTimeSeconds = 2 // 2 seconds
|
||||
// const maxTimeMinutes = 60 // 1 hour
|
||||
|
||||
// if (timeDiff < minTimeSeconds * 1000 || timeDiff > maxTimeMinutes * 60 * 1000) {
|
||||
// console.warn(`Time trap triggered: ${timeDiff / 1000}s`)
|
||||
// throw new Error('Invalid submission timing.')
|
||||
// }
|
||||
// } catch (err: any) {
|
||||
// console.error('Time trap validation failed:', err.message)
|
||||
// throw new ActionError({
|
||||
// code: 'BAD_REQUEST',
|
||||
// message: 'Invalid request',
|
||||
// })
|
||||
// }
|
||||
// --- Time Trap Validation End ---
|
||||
---
|
||||
|
||||
<input type="hidden" name="encTimestamp" value={encryptedValue} data-time-trap class="hidden" />
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { timeAgo } from '../lib/timeAgo'
|
||||
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'div' | 'li' | 'p' | 'span' = 'span'> = Polymorphic<{
|
||||
as: Tag
|
||||
start: Date
|
||||
end?: Date | null
|
||||
classNames?: {
|
||||
fadedWords?: string
|
||||
}
|
||||
now?: Date
|
||||
}>
|
||||
|
||||
const { start, end = null, classNames = {}, now = new Date(), as: Tag = 'span', ...htmlProps } = Astro.props
|
||||
|
||||
const actualEndedAt = end ?? now
|
||||
const startedAtFormatted = timeAgo.format(start, 'twitter-minute-now')
|
||||
const isUpcoming = now < start
|
||||
const isOngoing = now >= start && (!end || now <= end)
|
||||
const endedAtFormatted = timeAgo.format(actualEndedAt, 'twitter-minute-now')
|
||||
const isOneTimeEvent = start === actualEndedAt || startedAtFormatted === endedAtFormatted
|
||||
---
|
||||
|
||||
<Tag {...htmlProps}>
|
||||
{
|
||||
!end ? (
|
||||
isUpcoming ? (
|
||||
<>
|
||||
Upcoming
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>on</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Ongoing
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>since</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
</>
|
||||
)
|
||||
) : isOneTimeEvent ? (
|
||||
isUpcoming ? (
|
||||
<>
|
||||
Upcoming
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>on</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
</>
|
||||
) : (
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{isUpcoming ? (
|
||||
<>
|
||||
Upcoming
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>from</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
|
||||
<time class="whitespace-nowrap">{endedAtFormatted}</time>
|
||||
</>
|
||||
) : isOngoing ? (
|
||||
<>
|
||||
Ongoing
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>since</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
|
||||
<time class="whitespace-nowrap">{endedAtFormatted}</time>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>From</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
|
||||
<time class="whitespace-nowrap">{endedAtFormatted}</time>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
@@ -1,187 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { sample } from 'lodash-es'
|
||||
|
||||
import { splashTexts } from '../constants/splashTexts'
|
||||
import { cn } from '../lib/cn'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||
|
||||
import AdminOnly from './AdminOnly.astro'
|
||||
import HeaderNotificationIndicator from './HeaderNotificationIndicator.astro'
|
||||
import HeaderSplashTextScript from './HeaderSplashTextScript.astro'
|
||||
import Logo from './Logo.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
const user = Astro.locals.user
|
||||
const actualUser = Astro.locals.actualUser
|
||||
|
||||
type Props = {
|
||||
classNames?: {
|
||||
nav?: string
|
||||
}
|
||||
showSplashText?: boolean
|
||||
}
|
||||
|
||||
const { classNames, showSplashText = false } = Astro.props
|
||||
|
||||
const splashText = showSplashText ? sample(splashTexts) : null
|
||||
---
|
||||
|
||||
<header
|
||||
class={cn(
|
||||
'bg-night-900/80 sticky inset-x-0 top-0 z-50 h-16 border-b border-zinc-800 backdrop-blur-sm [&_~_*_[id]]:scroll-mt-18',
|
||||
{
|
||||
'border-red-900 bg-red-500/60': !!actualUser,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<a href="/" class="relative inline-flex h-full items-center pr-4 pl-4 @[2rem]:pr-0">
|
||||
<Logo
|
||||
class={cn(
|
||||
'h-6 text-green-500 transition-colors hover:text-green-400',
|
||||
{
|
||||
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
|
||||
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
|
||||
},
|
||||
'hidden @[192px]:block'
|
||||
)}
|
||||
transition:name="header-logo"
|
||||
/>
|
||||
<Logo
|
||||
variant="small"
|
||||
class={cn(
|
||||
'font-title h-8 text-green-500 transition-colors hover:text-green-400',
|
||||
{
|
||||
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
|
||||
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
|
||||
},
|
||||
'hidden @[94px]:block @[192px]:hidden'
|
||||
)}
|
||||
transition:name="header-logo"
|
||||
/>
|
||||
<Logo
|
||||
variant="mini"
|
||||
class={cn(
|
||||
'font-title h-8 text-green-500 transition-colors hover:text-green-400',
|
||||
{
|
||||
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
|
||||
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
|
||||
},
|
||||
'hidden @[63px]:block @[94px]:hidden'
|
||||
)}
|
||||
transition:name="header-logo"
|
||||
/>
|
||||
{
|
||||
DEPLOYMENT_MODE !== 'production' && (
|
||||
<span
|
||||
class={cn(
|
||||
'absolute bottom-1 left-9.5 -translate-x-1/2 @[192px]:left-12.5',
|
||||
'text-2xs pointer-events-none hidden rounded-full bg-zinc-800 px-1.25 py-0.75 leading-none font-semibold tracking-wide text-white @[63px]:block',
|
||||
{
|
||||
'border border-red-800 bg-red-950 text-red-400': DEPLOYMENT_MODE === 'development',
|
||||
'border border-cyan-800 bg-cyan-950 text-cyan-400': DEPLOYMENT_MODE === 'staging',
|
||||
}
|
||||
)}
|
||||
transition:name="header-deployment-mode"
|
||||
>
|
||||
{DEPLOYMENT_MODE === 'development' ? 'DEV' : 'PRE'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{
|
||||
!!splashText && (
|
||||
<div
|
||||
class="js:cursor-pointer @container flex min-w-0 flex-1 items-center justify-center"
|
||||
data-splash-text-container
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="font-title line-clamp-2 hidden shrink text-center text-xs text-balance text-lime-500 @[6rem]:inline @4xl:ml-0"
|
||||
data-splash-text
|
||||
>
|
||||
{splashText}
|
||||
</span>
|
||||
<HeaderSplashTextScript />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="flex items-center">
|
||||
<AdminOnly>
|
||||
<Tooltip
|
||||
as="a"
|
||||
href="/admin"
|
||||
class="text-red-500 transition-colors hover:text-red-400"
|
||||
transition:name="header-admin-link"
|
||||
text="Admin Dashboard"
|
||||
position="left"
|
||||
>
|
||||
<Icon name="ri:home-gear-line" class="size-10" />
|
||||
</Tooltip>
|
||||
</AdminOnly>
|
||||
|
||||
{
|
||||
user ? (
|
||||
<>
|
||||
{actualUser && (
|
||||
<span class="text-sm text-white/40 hover:text-white" transition:name="header-actual-user-name">
|
||||
({actualUser.name})
|
||||
</span>
|
||||
)}
|
||||
|
||||
<HeaderNotificationIndicator
|
||||
class="xs:px-3 2xs:px-2 h-full px-1"
|
||||
transition:name="header-notification-indicator"
|
||||
/>
|
||||
|
||||
<a
|
||||
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"
|
||||
transition:name="header-user-link"
|
||||
>
|
||||
{user.name}
|
||||
</a>
|
||||
{actualUser ? (
|
||||
<a
|
||||
href={makeUnimpersonateUrl(Astro.url)}
|
||||
data-astro-reload
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||
transition:name="header-unimpersonate-link"
|
||||
aria-label="Unimpersonate"
|
||||
>
|
||||
<Icon name="ri:user-shared-2-line" class="size-4" />
|
||||
</a>
|
||||
) : (
|
||||
DEPLOYMENT_MODE !== 'production' && (
|
||||
<a
|
||||
href="/account/logout"
|
||||
data-astro-prefetch="tap"
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||
transition:name="header-logout-link"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<Icon name="ri:logout-box-r-line" class="size-4" />
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href={makeLoginUrl(Astro.url)}
|
||||
data-astro-reload
|
||||
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-green-500 transition-colors last:-mr-1 hover:text-green-400"
|
||||
transition:name="header-login-link"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'a'>, 'href'> & {
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
const { count: propsCount, class: className, ...htmlProps } = Astro.props
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
const count =
|
||||
propsCount ??
|
||||
(await Astro.locals.banners.try(
|
||||
'Error getting unread notification count',
|
||||
async () => (user ? await prisma.notification.count({ where: { userId: user.id, read: false } }) : 0),
|
||||
0
|
||||
))
|
||||
---
|
||||
|
||||
{
|
||||
user && (
|
||||
<a
|
||||
href="/notifications"
|
||||
class={cn(
|
||||
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
|
||||
className
|
||||
)}
|
||||
aria-label={`Go to notifications${count > 0 ? ` (${count} unread)` : ''}`}
|
||||
{...htmlProps}
|
||||
>
|
||||
<Icon name="material-symbols:notifications-outline" class="size-5" />
|
||||
{count > 0 && (
|
||||
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
|
||||
{count > 99 ? '★' : count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
////////////////////////////////////////////////////////
|
||||
// Optional script to change the splash text on click //
|
||||
////////////////////////////////////////////////////////
|
||||
|
||||
import { splashTexts } from '../constants/splashTexts'
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLDivElement>('[data-splash-text-container]').forEach((container) => {
|
||||
const updateSplashText = () => {
|
||||
const splashTextElem = container.querySelector<HTMLSpanElement>('[data-splash-text]')
|
||||
if (!splashTextElem) return
|
||||
|
||||
const splashTextsFiltered = splashTexts.filter((text) => text !== splashTextElem.textContent)
|
||||
const newSplashText = splashTextsFiltered[Math.floor(Math.random() * splashTextsFiltered.length)]
|
||||
if (!newSplashText) return
|
||||
|
||||
splashTextElem.textContent = newSplashText
|
||||
}
|
||||
|
||||
container.addEventListener('click', updateSplashText)
|
||||
|
||||
const autoUpdateInterval = setInterval(updateSplashText, 60_000)
|
||||
document.addEventListener('astro:before-swap', () => {
|
||||
clearInterval(autoUpdateInterval)
|
||||
})
|
||||
|
||||
container.addEventListener(
|
||||
'mousedown',
|
||||
(event) => {
|
||||
if (event.detail > 1) event.preventDefault()
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
import * as htmx from 'htmx.org'
|
||||
|
||||
htmx.config.globalViewTransitions = false
|
||||
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
htmx.process(document.body)
|
||||
})
|
||||
|
||||
window.htmx = htmx
|
||||
</script>
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { MarkdownString } from '../lib/markdown'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
iconClass?: string
|
||||
description?: MarkdownString
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValue?: string
|
||||
cardSize?: 'lg' | 'md' | 'sm'
|
||||
iconSize?: 'md' | 'sm'
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
options,
|
||||
disabled,
|
||||
selectedValue,
|
||||
cardSize = 'sm',
|
||||
iconSize = 'sm',
|
||||
class: className,
|
||||
multiple,
|
||||
...wrapperProps
|
||||
} = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} class={cn('@container', className)} {...wrapperProps}>
|
||||
<div
|
||||
class={cn(
|
||||
'grid grid-cols-[repeat(auto-fill,minmax(var(--card-min-size),1fr))] gap-2 rounded-lg',
|
||||
!multiple &&
|
||||
'has-focus-visible:ring-offset-night-900 has-focus-visible:ring-day-200 has-focus-visible:bg-night-900 has-focus-visible:ring-2 has-focus-visible:ring-offset-3',
|
||||
{
|
||||
'[--card-min-size:12rem] @max-[12rem]:grid-cols-1': cardSize === 'sm',
|
||||
'[--card-min-size:16rem] @max-[16rem]:grid-cols-1': cardSize === 'md',
|
||||
'[--card-min-size:32rem] @max-[32rem]:grid-cols-1': cardSize === 'lg',
|
||||
},
|
||||
hasError && 'border border-red-700 p-2'
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map((option) => (
|
||||
<label
|
||||
class={cn(
|
||||
'group border-night-400 bg-night-600 hover:bg-night-500 relative cursor-pointer items-start gap-3 rounded-lg border p-3 transition-all',
|
||||
'has-checked:border-green-700 has-checked:bg-green-700/20 has-checked:ring-1 has-checked:ring-green-700',
|
||||
multiple &&
|
||||
'has-focus-visible:border-day-300 has-focus-visible:ring-2 has-focus-visible:ring-green-700 has-focus-visible:ring-offset-1',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
class="peer sr-only"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{option.icon && (
|
||||
<Icon
|
||||
name={option.icon}
|
||||
class={cn(
|
||||
'text-day-200 group-peer-checked:text-day-300 size-8',
|
||||
{
|
||||
'size-4': iconSize === 'sm',
|
||||
'size-8': iconSize === 'md',
|
||||
},
|
||||
option.iconClass
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<p class="text-day-200 group-peer-checked:text-day-300 flex-1 text-sm leading-none font-medium text-pretty">
|
||||
{option.label}
|
||||
</p>
|
||||
<div class="self-stretch">
|
||||
<div
|
||||
class={cn(
|
||||
'border-day-600 flex size-5 items-center justify-center border-2',
|
||||
'group-has-checked:border-green-600 group-has-checked:bg-green-600',
|
||||
multiple ? 'rounded-md' : 'rounded-full',
|
||||
!!option.description && '-m-1'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="text-day-100 size-3 opacity-0 group-has-checked:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{option.description && (
|
||||
<div class="prose prose-sm prose-invert text-day-400 mt-1 text-xs text-pretty">
|
||||
<Markdown content={option.description} />
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</InputWrapper>
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValues?: string[]
|
||||
}
|
||||
|
||||
const { options, disabled, selectedValues = [], ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
|
||||
<div class="h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5">
|
||||
{
|
||||
options.map((option) => (
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{option.icon && <Icon name={option.icon} class="size-4" />}
|
||||
<span class="text-sm leading-none">{option.label}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</InputWrapper>
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
accept?: string
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
const { accept, disabled, multiple, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<input
|
||||
transition:persist
|
||||
type="file"
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
baseInputClassNames.file,
|
||||
hasError && baseInputClassNames.error,
|
||||
disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
required={wrapperProps.required}
|
||||
disabled={disabled}
|
||||
name={wrapperProps.name}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
/>
|
||||
</InputWrapper>
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
type Props = {
|
||||
name: string
|
||||
}
|
||||
|
||||
//
|
||||
---
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name={Astro.props.name || 'message'}
|
||||
aria-hidden="true"
|
||||
style="display:none !important"
|
||||
autocomplete="off"
|
||||
tabindex="-1"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
data-form-type="other"
|
||||
/>
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
||||
|
||||
import InputFile from './InputFile.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
||||
square?: boolean
|
||||
}
|
||||
|
||||
const { class: className, square, ...inputFileProps } = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
|
||||
<InputFile accept={ACCEPTED_IMAGE_TYPES.join(',')} class="min-w-0 flex-1 basis-2xs" {...inputFileProps} />
|
||||
<img
|
||||
src="#"
|
||||
alt="Preview"
|
||||
class={cn(
|
||||
'block w-26.5 rounded object-cover',
|
||||
'no-js:hidden [&[src="#"]]:hidden',
|
||||
square && 'aspect-square'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for image preview. //
|
||||
// Shows a preview of the selected image before upload. //
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll('[data-preview-image]').forEach((wrapper) => {
|
||||
const input = wrapper.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
if (!input) return
|
||||
|
||||
const previewImageElements = wrapper.querySelectorAll<HTMLImageElement>('img')
|
||||
if (!previewImageElements.length) return
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const fileUrl = URL.createObjectURL(file)
|
||||
previewImageElements.forEach((previewImage) => {
|
||||
previewImage.src = fileUrl
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { includeDevUsers, USER_SECRET_TOKEN_REGEX_STRING } from '../lib/userSecretToken'
|
||||
|
||||
import InputText from './InputText.astro'
|
||||
|
||||
type Props = {
|
||||
name: string
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
const { name, autofocus } = Astro.props
|
||||
---
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="username"
|
||||
id="username"
|
||||
value=""
|
||||
autocomplete="username"
|
||||
data-keep-in-sync-with="#token"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Login Key"
|
||||
name={name}
|
||||
inputIcon="ri:key-2-line"
|
||||
inputIconClass="size-6"
|
||||
inputProps={{
|
||||
type: 'password',
|
||||
id: 'token',
|
||||
placeholder: 'ABCD-EFGH-IJKL-MNOP-1234',
|
||||
required: true,
|
||||
autofocus,
|
||||
pattern: USER_SECRET_TOKEN_REGEX_STRING,
|
||||
title: 'LLLL-LLLL-LLLL-LLLL-DDDD (L: letter, D: digit, dashes are optional)',
|
||||
minlength: includeDevUsers ? undefined : 24,
|
||||
maxlength: 24,
|
||||
autocomplete: 'current-password',
|
||||
autocorrect: 'off',
|
||||
spellcheck: 'false',
|
||||
autocapitalize: 'off',
|
||||
class: cn('2xs:text-lg h-10 font-mono text-sm uppercase'),
|
||||
'data-input-type-text-hack': true,
|
||||
'data-enable-token-autoformat': true,
|
||||
'data-bwautofill': true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for keeping username in sync. //
|
||||
// This way the password manager detects the credentials. //
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-keep-in-sync-with]')
|
||||
inputs.forEach((input) => {
|
||||
const inputId = input.getAttribute('data-keep-in-sync-with')
|
||||
if (!inputId) throw new Error('Username input ID not found')
|
||||
|
||||
const tokenInput = document.querySelector<HTMLInputElement>(inputId)
|
||||
if (!tokenInput) throw new Error('Token input not found')
|
||||
|
||||
tokenInput.addEventListener('input', () => {
|
||||
input.value = tokenInput.value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////
|
||||
// Optional script for token input autoformatting //
|
||||
////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const tokenInputs = document.querySelectorAll<HTMLInputElement>('input[data-enable-token-autoformat]')
|
||||
|
||||
tokenInputs.forEach((tokenInput) => {
|
||||
tokenInput.addEventListener('keydown', (e) => {
|
||||
const cursor = tokenInput.selectionStart
|
||||
if (tokenInput.selectionEnd !== cursor) return
|
||||
if (e.key === 'Delete') {
|
||||
if (cursor !== null && tokenInput.value[cursor] === '-') {
|
||||
tokenInput.selectionStart = cursor + 1
|
||||
}
|
||||
} else if (e.key === 'Backspace') {
|
||||
if (cursor !== null && cursor > 0 && tokenInput.value[cursor - 1] === '-') {
|
||||
tokenInput.selectionEnd = cursor - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tokenInput.addEventListener('input', () => {
|
||||
const value = tokenInput.value
|
||||
const cursor = tokenInput.selectionStart || 0
|
||||
|
||||
// Count dashes before cursor to adjust position
|
||||
const dashesBeforeCursor = (value.substring(0, cursor).match(/-/g) || []).length
|
||||
|
||||
// Remove all non-alphanumeric characters
|
||||
let cleaned = value.replace(/[^A-Za-z0-9]/g, '').toUpperCase()
|
||||
cleaned = cleaned.substring(0, 20) // Limit to 20 chars (24 with dashes)
|
||||
|
||||
// Format with dashes
|
||||
let formatted = ''
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
if (i > 0 && i % 4 === 0) {
|
||||
formatted += '-'
|
||||
}
|
||||
formatted += cleaned[i]
|
||||
}
|
||||
|
||||
// Only update if value changed
|
||||
if (formatted === value) return
|
||||
|
||||
// Calculate new cursor position
|
||||
let newCursor = cursor
|
||||
const dashesBeforeNew = (formatted.substring(0, cursor).match(/-/g) || []).length
|
||||
newCursor += dashesBeforeNew - dashesBeforeCursor
|
||||
|
||||
// Update input
|
||||
tokenInput.value = formatted
|
||||
tokenInput.setSelectionRange(newCursor, newCursor)
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Optional script for making the password visible. //
|
||||
// Otherwise the password manager will not detect it as a passowrd. //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-input-type-text-hack]')
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
input.type = 'text'
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
const ratings = [1, 2, 3, 4, 5] as const
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
||||
value?: number | null
|
||||
required?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
const { value, required, id, ...wrapperProps } = Astro.props
|
||||
const actualValue = value !== undefined && value !== null ? Math.round(value) : null
|
||||
const inputId = id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} required={required} {...wrapperProps}>
|
||||
<div
|
||||
class="group/fieldset has-focus-visible:ring-day-200 has-focus-visible:ring-offset-night-700 relative flex items-center has-focus-visible:rounded-full has-focus-visible:ring-2 has-focus-visible:ring-offset-2 [&>*:has(~_*:hover)]:[&>[data-star]]:opacity-100!"
|
||||
>
|
||||
<label
|
||||
aria-label="Clear"
|
||||
class="has-focus-visible:before:bg-day-200 hover:before:bg-day-200 relative order-last block size-6 p-0.5 text-zinc-500 not-has-checked:cursor-pointer before:absolute before:inset-0.5 before:-z-1 before:rounded-full hover:text-black has-checked:before:hidden has-focus-visible:text-black has-focus-visible:before:block!"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={wrapperProps.name}
|
||||
value=""
|
||||
checked={actualValue === null}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
name="ri:close-line"
|
||||
class="size-full group-hover/fieldset:block group-has-focus-visible/fieldset:block peer-checked:hidden! peer-focus-visible:block! pointer-fine:hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{
|
||||
ratings.toSorted().map((rating) => (
|
||||
<label class="relative cursor-pointer [&:has(~_*:hover),&:hover]:[&>[data-star]]:opacity-100!">
|
||||
<input
|
||||
type="radio"
|
||||
name={wrapperProps.name}
|
||||
value={rating}
|
||||
checked={actualValue === rating}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
<Icon name="ri:star-line" class="size-6 p-0.5 text-zinc-500" />
|
||||
<Icon
|
||||
name="ri:star-fill"
|
||||
class="absolute top-0 left-0 size-6 p-0.5 text-yellow-400 not-peer-checked:opacity-0 group-hover/fieldset:opacity-0"
|
||||
data-star
|
||||
/>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</InputWrapper>
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hideCancel?: boolean
|
||||
icon?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const {
|
||||
hideCancel = false,
|
||||
icon = 'ri:send-plane-2-line',
|
||||
label = 'Submit',
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
||||
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
|
||||
</div>
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { omit } from 'lodash-es'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
||||
inputProps?: Omit<HTMLAttributes<'input'>, 'name'>
|
||||
inputIcon?: string
|
||||
inputIconClass?: string
|
||||
}
|
||||
|
||||
const { inputProps, inputIcon, inputIconClass, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = inputProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} required={inputProps?.required} {...wrapperProps}>
|
||||
{
|
||||
inputIcon ? (
|
||||
<div class="relative">
|
||||
<input
|
||||
transition:persist
|
||||
{...omit(inputProps, ['class', 'id', 'name'])}
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
!!inputIcon && 'pl-10',
|
||||
inputProps?.class,
|
||||
hasError && baseInputClassNames.error,
|
||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
name={wrapperProps.name}
|
||||
/>
|
||||
<Icon
|
||||
name={inputIcon}
|
||||
class={cn(
|
||||
'text-day-300 pointer-events-none absolute top-1/2 left-5.5 size-5 -translate-1/2',
|
||||
inputIconClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
transition:persist
|
||||
{...omit(inputProps, ['class', 'id', 'name'])}
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
!!inputIcon && 'pl-10',
|
||||
inputProps?.class,
|
||||
hasError && baseInputClassNames.error,
|
||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
name={wrapperProps.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</InputWrapper>
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
autofocus?: boolean
|
||||
rows?: number
|
||||
maxlength?: number
|
||||
}
|
||||
|
||||
const { value, placeholder, maxlength, disabled, autofocus, rows = 3, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
{/* eslint-disable astro/jsx-a11y/no-autofocus */}
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<textarea
|
||||
transition:persist
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
baseInputClassNames.textarea,
|
||||
hasError && baseInputClassNames.error,
|
||||
disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
required={wrapperProps.required}
|
||||
disabled={disabled}
|
||||
name={wrapperProps.name}
|
||||
autofocus={autofocus}
|
||||
maxlength={maxlength}
|
||||
rows={rows}>{value}</textarea
|
||||
>
|
||||
</InputWrapper>
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { MarkdownString } from '../lib/markdown'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
children: AstroChildren
|
||||
label: string
|
||||
name: string
|
||||
description?: MarkdownString
|
||||
descriptionLabel?: string
|
||||
required?: HTMLAttributes<'input'>['required']
|
||||
error?: string[] | string
|
||||
icon?: string
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
description,
|
||||
descriptionLabel,
|
||||
required,
|
||||
error,
|
||||
icon,
|
||||
class: className,
|
||||
inputId,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const hasError = !!error && error.length > 0
|
||||
---
|
||||
|
||||
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
|
||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
||||
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
||||
<label for={inputId}>{label}</label>{required && '*'}
|
||||
</legend>
|
||||
{
|
||||
!!descriptionLabel && (
|
||||
<span class="text-day-400 flex-1 basis-24 text-xs text-pretty">{descriptionLabel}</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
{
|
||||
hasError &&
|
||||
(typeof error === 'string' ? (
|
||||
<p class="text-sm text-red-500">{error}</p>
|
||||
) : (
|
||||
<ul class="text-sm text-red-500">
|
||||
{error.map((e) => (
|
||||
<li>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
!!description && (
|
||||
<div class="prose prose-sm prose-invert text-day-400 text-xs text-pretty">
|
||||
<Markdown content={description} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</fieldset>
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import { karmaUnlocks } from '../constants/karmaUnlocks'
|
||||
|
||||
const karmaUnlocksSorted = orderBy(karmaUnlocks, [
|
||||
({ karma }) => (karma >= 0 ? 1 : 2),
|
||||
({ karma }) => Math.abs(karma),
|
||||
'id',
|
||||
])
|
||||
---
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Karma</th>
|
||||
<th>Unlock</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
karmaUnlocksSorted.map((unlock) => (
|
||||
<tr>
|
||||
<td>{unlock.karma.toLocaleString()}</td>
|
||||
<td>{unlock.name}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'svg'>, 'viewBox' | 'xmlns'> & {
|
||||
variant?: 'mini-full' | 'mini' | 'normal' | 'small'
|
||||
}
|
||||
|
||||
const { variant = 'normal', ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
{
|
||||
variant === 'normal' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 204 28"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
variant === 'small' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 124 52"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM1 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4L6.8 32.5A1 1 0 0 0 6 32H1Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1H30Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12H29v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1H50Zm27 0a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V39.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V51c0 .6.5 1 1 1h2c.6 0 1-.4 1-1V33c0-.5-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L88 47.2l-6.7-14.6a1 1 0 0 0-1-.6H77Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4ZM70 48a1 1 0 0 0-1 1v2c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
variant === 'mini' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 76 52"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.5.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.5.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
variant === 'mini-full' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 76 76"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1h-10Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1h-18ZM15 56a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V63.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V57c0-.6-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L26 71.2l-6.7-14.6a1 1 0 0 0-1-.6H15Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { createPageUrl } from '../lib/urls'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'nav'> & {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
currentUrl?: URL | string
|
||||
sortSeed?: string
|
||||
}
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
totalPages,
|
||||
currentUrl = Astro.url,
|
||||
sortSeed,
|
||||
class: className,
|
||||
...navProps
|
||||
} = Astro.props
|
||||
|
||||
const prevPage = currentPage > 1 ? currentPage - 1 : null
|
||||
const nextPage = currentPage < totalPages ? currentPage + 1 : null
|
||||
|
||||
const getVisiblePages = () => {
|
||||
const pages: (number | '...')[] = []
|
||||
|
||||
if (totalPages <= 9) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1)
|
||||
|
||||
if (currentPage > 4) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
// Calculate range around current page
|
||||
let rangeStart = Math.max(2, currentPage - 2)
|
||||
let rangeEnd = Math.min(totalPages - 1, currentPage + 2)
|
||||
|
||||
// Adjust range if at the start or end
|
||||
if (currentPage <= 4) {
|
||||
rangeEnd = 6
|
||||
}
|
||||
if (currentPage >= totalPages - 3) {
|
||||
rangeStart = totalPages - 5
|
||||
}
|
||||
|
||||
// Add range numbers
|
||||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(totalPages)
|
||||
|
||||
return pages
|
||||
}
|
||||
const PrevTag = prevPage ? 'a' : 'span'
|
||||
const NextTag = nextPage ? 'a' : 'span'
|
||||
---
|
||||
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
{...navProps}
|
||||
class={cn('flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-lg sm:flex-nowrap', className)}
|
||||
>
|
||||
<PrevTag
|
||||
href={PrevTag === 'a' && prevPage
|
||||
? createPageUrl(prevPage, currentUrl, { 'sort-seed': sortSeed })
|
||||
: undefined}
|
||||
class={cn(
|
||||
'flex w-[5.5ch] items-center justify-end text-green-500 hover:text-green-400',
|
||||
!prevPage && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<Icon name="ri:arrow-left-s-line" class="size-6 shrink-0" />
|
||||
<span class="text-green-500">Prev</span>
|
||||
</PrevTag>
|
||||
|
||||
<div class="order-first flex w-full items-center justify-center gap-4 sm:order-none sm:w-auto">
|
||||
{
|
||||
getVisiblePages().map((page) => {
|
||||
if (page === '...') {
|
||||
return <span class="text-gray-400">...</span>
|
||||
}
|
||||
|
||||
const isCurrentPage = page === currentPage
|
||||
return isCurrentPage ? (
|
||||
<button
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500 text-white"
|
||||
aria-current="page"
|
||||
aria-label={`Page ${page.toLocaleString()}`}
|
||||
disabled
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={createPageUrl(page, currentUrl, { 'sort-seed': sortSeed })}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label={`Page ${page.toLocaleString()}`}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<NextTag
|
||||
href={NextTag === 'a' && nextPage
|
||||
? createPageUrl(nextPage, currentUrl, { 'sort-seed': sortSeed })
|
||||
: undefined}
|
||||
class={cn(
|
||||
'flex w-[5.5ch] items-center justify-start text-green-500 hover:text-green-400',
|
||||
!nextPage && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<span class="text-green-500">Next</span>
|
||||
<Icon name="ri:arrow-right-s-line" class="size-6 shrink-0" />
|
||||
</NextTag>
|
||||
</nav>
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
name: string
|
||||
options: {
|
||||
value: HTMLAttributes<'input'>['value']
|
||||
label: string
|
||||
}[]
|
||||
selectedValue?: string | null
|
||||
}
|
||||
|
||||
const { name, options, selectedValue, class: className, ...rest } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'bg-night-500 divide-night-700 flex divide-x-2 overflow-hidden rounded-md text-[0.6875rem]',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{
|
||||
options.map((option) => (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
class="peer hidden"
|
||||
/>
|
||||
<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}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { interpolate } from '../lib/numbers'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { Review, WithContext } from 'schema-dts'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
|
||||
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
|
||||
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
|
||||
|
||||
function makeScoreInfo(score: number, total: number) {
|
||||
const formattedScore = Math.round(score).toLocaleString()
|
||||
const angle = interpolate(progress, -100, 100)
|
||||
const n = score / total
|
||||
|
||||
if (n > 1) return { text: 'Excellent', step: 5, formattedScore, angle: 100 }
|
||||
if (n >= 0.9 && n <= 1) return { text: 'Excellent', step: 5, formattedScore, angle }
|
||||
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', step: 5, formattedScore, angle }
|
||||
if (n >= 0.6 && n < 0.8) return { text: 'Good', step: 4, formattedScore, angle }
|
||||
if (n >= 0.45 && n < 0.6) return { text: 'Average', step: 3, formattedScore, angle }
|
||||
if (n >= 0.4 && n < 0.45) return { text: 'Average', step: 3, formattedScore, angle: angle + 5 }
|
||||
if (n >= 0.2 && n < 0.4) return { text: 'Bad', step: 2, formattedScore, angle: angle + 5 }
|
||||
if (n >= 0.1 && n < 0.2) return { text: 'Very Bad', step: 1, formattedScore, angle }
|
||||
if (n >= 0 && n < 0.1) return { text: 'Terrible', step: 1, formattedScore, angle }
|
||||
if (n < 0) return { text: 'Terrible', step: 1, formattedScore, angle: -100 }
|
||||
|
||||
return { text: '', step: undefined, formattedScore, angle: undefined }
|
||||
}
|
||||
|
||||
const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
||||
---
|
||||
|
||||
{
|
||||
!!itemReviewedId && (
|
||||
<Schema
|
||||
item={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
reviewAspect: label,
|
||||
name: `${text} ${transformCase(label, 'lower')}`,
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: score,
|
||||
worstRating: 0,
|
||||
bestRating: total,
|
||||
},
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
} satisfies WithContext<Review>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
>
|
||||
<div
|
||||
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
||||
'text-score-saturate-1 text-shadow-glow': step === 1,
|
||||
'text-score-saturate-2 text-shadow-glow': step === 2,
|
||||
'text-score-saturate-3 text-shadow-glow': step === 3,
|
||||
'text-score-saturate-4 text-shadow-glow': step === 4,
|
||||
'text-score-saturate-5 text-shadow-glow': step === 5,
|
||||
'mr-[0.05em] ml-[-0.025em] text-[1.75rem] leading-[calc(2/1.75)] tracking-[-0.075em]':
|
||||
formattedScore.length > 2,
|
||||
})}
|
||||
>
|
||||
<span>{formattedScore}</span>
|
||||
</div>
|
||||
|
||||
<div class="2xs:mb-0.5 text-base leading-none font-bold tracking-wide uppercase">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||
|
||||
<svg class="absolute inset-0 -z-1 overflow-visible" viewBox="0 0 96 96" aria-hidden="true">
|
||||
<!-- Background segments -->
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M19.84 29.962C20.8221 30.3687 21.3066 31.4709 21.0222 32.4951C20.3586 34.8848 20.0039 37.4031 20.0039 40.0042C20.0039 42.6054 20.3586 45.1237 21.0223 47.5135C21.3067 48.5376 20.8221 49.6398 19.8401 50.0466L7.05214 55.3435C5.88072 55.8288 4.55702 55.1152 4.38993 53.8583C4.13532 51.9431 4.00391 49.989 4.00391 48.0042C4.00391 40.5588 5.85316 33.5454 9.11742 27.3981C9.58659 26.5145 10.656 26.1579 11.5803 26.5407L19.84 29.962Z"
|
||||
class="fill-score-saturate-1"></path>
|
||||
<path
|
||||
d="M33.3538 8.55389C32.9417 7.55882 31.8133 7.06445 30.8219 7.48539C23.7527 10.4869 17.6304 15.2843 13.0275 21.3051C12.2575 22.3122 12.689 23.7526 13.8603 24.2378L20.9906 27.1912C21.9721 27.5978 23.0937 27.1616 23.6168 26.237C26.1243 21.8048 29.805 18.1241 34.2373 15.6167C35.1619 15.0936 35.5981 13.972 35.1915 12.9904L33.3538 8.55389Z"
|
||||
class="fill-score-saturate-2"></path>
|
||||
<path
|
||||
d="M40.4948 13.0224C39.4706 13.3068 38.3684 12.8222 37.9616 11.8402L36.316 7.86723C35.8618 6.77068 36.4564 5.51825 37.6099 5.23888C40.9424 4.43183 44.423 4.00415 48.0035 4.00415C51.5842 4.00415 55.0651 4.43188 58.3977 5.23902C59.5512 5.5184 60.1458 6.77082 59.6916 7.86736L58.046 11.8403C57.6392 12.8224 56.537 13.307 55.5128 13.0225C53.123 12.3588 50.6047 12.0042 48.0035 12.0042C45.4025 12.0042 42.8844 12.3588 40.4948 13.0224Z"
|
||||
class="fill-score-saturate-3"></path>
|
||||
<path
|
||||
d="M75.017 27.1913C74.0355 27.5979 72.9139 27.1617 72.3908 26.2371C69.8834 21.805 66.2028 18.1244 61.7708 15.617C60.8461 15.0938 60.41 13.9723 60.8166 12.9907L62.6542 8.55414C63.0664 7.55905 64.1948 7.06469 65.1862 7.48564C72.2552 10.4871 78.3773 15.2845 82.9801 21.3051C83.75 22.3123 83.3186 23.7527 82.1473 24.2378L75.017 27.1913Z"
|
||||
class="fill-score-saturate-4"></path>
|
||||
<path
|
||||
d="M76.1682 50.0463C75.1862 49.6395 74.7016 48.5373 74.986 47.5131C75.6496 45.1235 76.0043 42.6053 76.0043 40.0042C76.0043 37.4031 75.6496 34.8849 74.986 32.4952C74.7016 31.471 75.1861 30.3688 76.1682 29.962L84.4279 26.5407C85.3521 26.1579 86.4216 26.5145 86.8908 27.3981C90.155 33.5454 92.0043 40.5588 92.0043 48.0042C92.0043 49.9889 91.8729 51.9429 91.6183 53.8579C91.4512 55.1148 90.1275 55.8284 88.9561 55.3432L76.1682 50.0463Z"
|
||||
fill="#7CFF00"></path>
|
||||
</g>
|
||||
|
||||
<!-- Active segments -->
|
||||
<g>
|
||||
{
|
||||
step === 1 && (
|
||||
<path
|
||||
d="M19.84 29.962C20.8221 30.3687 21.3066 31.4709 21.0222 32.4951C20.3586 34.8848 20.0039 37.4031 20.0039 40.0042C20.0039 42.6054 20.3586 45.1237 21.0223 47.5135C21.3067 48.5376 20.8221 49.6398 19.8401 50.0466L7.05214 55.3435C5.88072 55.8288 4.55702 55.1152 4.38993 53.8583C4.13532 51.9431 4.00391 49.989 4.00391 48.0042C4.00391 40.5588 5.85316 33.5454 9.11742 27.3981C9.58659 26.5145 10.656 26.1579 11.5803 26.5407L19.84 29.962Z"
|
||||
class="text-score-saturate-1 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 2 && (
|
||||
<path
|
||||
d="M33.3538 8.55389C32.9417 7.55882 31.8133 7.06445 30.8219 7.48539C23.7527 10.4869 17.6304 15.2843 13.0275 21.3051C12.2575 22.3122 12.689 23.7526 13.8603 24.2378L20.9906 27.1912C21.9721 27.5978 23.0937 27.1616 23.6168 26.237C26.1243 21.8048 29.805 18.1241 34.2373 15.6167C35.1619 15.0936 35.5981 13.972 35.1915 12.9904L33.3538 8.55389Z"
|
||||
class="text-score-saturate-2 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 3 && (
|
||||
<path
|
||||
d="M40.4948 13.0224C39.4706 13.3068 38.3684 12.8222 37.9616 11.8402L36.316 7.86723C35.8618 6.77068 36.4564 5.51825 37.6099 5.23888C40.9424 4.43183 44.423 4.00415 48.0035 4.00415C51.5842 4.00415 55.0651 4.43188 58.3977 5.23902C59.5512 5.5184 60.1458 6.77082 59.6916 7.86736L58.046 11.8403C57.6392 12.8224 56.537 13.307 55.5128 13.0225C53.123 12.3588 50.6047 12.0042 48.0035 12.0042C45.4025 12.0042 42.8844 12.3588 40.4948 13.0224Z"
|
||||
class="text-score-saturate-3 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 4 && (
|
||||
<path
|
||||
d="M75.017 27.1913C74.0355 27.5979 72.9139 27.1617 72.3908 26.2371C69.8834 21.805 66.2028 18.1244 61.7708 15.617C60.8461 15.0938 60.41 13.9723 60.8166 12.9907L62.6542 8.55414C63.0664 7.55905 64.1948 7.06469 65.1862 7.48564C72.2552 10.4871 78.3773 15.2845 82.9801 21.3051C83.75 22.3123 83.3186 23.7527 82.1473 24.2378L75.017 27.1913Z"
|
||||
class="text-score-saturate-4 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 5 && (
|
||||
<path
|
||||
d="M76.1682 50.0463C75.1862 49.6395 74.7016 48.5373 74.986 47.5131C75.6496 45.1235 76.0043 42.6053 76.0043 40.0042C76.0043 37.4031 75.6496 34.8849 74.986 32.4952C74.7016 31.471 75.1861 30.3688 76.1682 29.962L84.4279 26.5407C85.3521 26.1579 86.4216 26.5145 86.8908 27.3981C90.155 33.5454 92.0043 40.5588 92.0043 48.0042C92.0043 49.9889 91.8729 51.9429 91.6183 53.8579C91.4512 55.1148 90.1275 55.8284 88.9561 55.3432L76.1682 50.0463Z"
|
||||
class="text-score-saturate-5 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</g>
|
||||
|
||||
<!-- Arrow -->
|
||||
<path
|
||||
d="M47.134 9.4282C47.3126 9.7376 47.6427 9.9282 48 9.9282C48.3573 9.9282 48.6874 9.7376 48.866 9.4282L52.866 2.5C53.0447 2.1906 53.0447 1.8094 52.866 1.5C52.6874 1.1906 52.3573 1 52 1L44 1C43.6427 1 43.3126 1.1906 43.134 1.5C42.9553 1.8094 42.9553 2.1906 43.134 2.5L47.134 9.4282Z"
|
||||
fill="white"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
||||
class="stroke-night-700"></path>
|
||||
|
||||
<!-- Info icon -->
|
||||
<!-- <path
|
||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||
fill="white"
|
||||
fill-opacity="0.67"></path> -->
|
||||
</svg>
|
||||
</div>
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
|
||||
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
|
||||
export function makeOverallScoreInfo(score: number, total = 10) {
|
||||
const classNamesByColor = {
|
||||
red: 'bg-score-1 text-black',
|
||||
orange: 'bg-score-2 text-black',
|
||||
yellow: 'bg-score-3 text-black',
|
||||
blue: 'bg-score-4 text-black',
|
||||
green: 'bg-score-5 text-black',
|
||||
} as const satisfies Record<string, string>
|
||||
|
||||
const formattedScore = Math.round(score).toLocaleString()
|
||||
const n = score / total
|
||||
|
||||
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
|
||||
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
|
||||
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
|
||||
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
|
||||
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
|
||||
if (n >= 0.5 && n < 0.6) {
|
||||
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
|
||||
}
|
||||
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
|
||||
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
|
||||
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
|
||||
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
|
||||
return { text: '', classNameBg: undefined, formattedScore }
|
||||
}
|
||||
|
||||
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
---
|
||||
|
||||
<div
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
>
|
||||
{
|
||||
!!itemReviewedId && (
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
reviewAspect: label,
|
||||
name: `${text} ${transformCase(label, 'lower')}`,
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: score,
|
||||
worstRating: 0,
|
||||
bestRating: total,
|
||||
},
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<!-- <svg
|
||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
|
||||
></path>
|
||||
</svg> -->
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'2xs:mt-2 2xs:size-12 mt-0.5 mb-1 flex size-10 shrink-0 items-center justify-center rounded-md leading-none font-bold tracking-tight text-black',
|
||||
classNameBg,
|
||||
{
|
||||
'text-[1.75rem] leading-[calc(2/1.75)] tracking-tighter': formattedScore.length > 2,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="2xs:text-[2rem] text-[1.5rem] leading-none font-bold tracking-tight text-black">
|
||||
{formattedScore}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="2xs:mb-0.5 text-base leading-none font-bold tracking-wide uppercase">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||
</div>
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
import defaultImage from '../assets/fallback-service-image.jpg'
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import { makeOverallScoreInfo } from './ScoreSquare.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'a'> & {
|
||||
inlineIcons?: boolean
|
||||
withoutLink?: boolean
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
slug: true
|
||||
description: true
|
||||
overallScore: true
|
||||
kycLevel: true
|
||||
imageUrl: true
|
||||
verificationStatus: true
|
||||
acceptedCurrencies: true
|
||||
categories: {
|
||||
select: {
|
||||
name: true
|
||||
icon: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const {
|
||||
inlineIcons = false,
|
||||
service: {
|
||||
name = 'Unnamed Service',
|
||||
slug,
|
||||
description,
|
||||
overallScore,
|
||||
|
||||
kycLevel,
|
||||
imageUrl,
|
||||
categories,
|
||||
verificationStatus,
|
||||
acceptedCurrencies,
|
||||
},
|
||||
class: className,
|
||||
withoutLink = false,
|
||||
...aProps
|
||||
} = Astro.props
|
||||
|
||||
const statusIcon = {
|
||||
...verificationStatusesByValue,
|
||||
APPROVED: undefined,
|
||||
}[verificationStatus]
|
||||
|
||||
const Element = withoutLink ? 'div' : 'a'
|
||||
|
||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
---
|
||||
|
||||
<Element
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<!-- Header with Icon and Title -->
|
||||
<div class="flex items-center gap-(--gap)">
|
||||
<Image
|
||||
src={// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
imageUrl || (defaultImage as unknown as string)}
|
||||
alt={name || 'Service logo'}
|
||||
class="size-12 shrink-0 rounded-sm object-contain text-white"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
||||
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||
{name}{
|
||||
statusIcon && (
|
||||
<Tooltip text={statusIcon.label} position="right" class="-my-2 shrink-0">
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={statusIcon.icon}
|
||||
class={cn('inline-block size-6 shrink-0 rounded-lg p-1', statusIcon.classNames.icon)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h3>
|
||||
<div class="max-h-2 flex-1"></div>
|
||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||
<span>{category.name}</span>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<Tooltip
|
||||
class={cn(
|
||||
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
||||
overallScoreInfo.classNameBg
|
||||
)}
|
||||
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||
KYC {kycLevel.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<div class="-m-1 ml-auto flex">
|
||||
{
|
||||
currencies.map((currency) => {
|
||||
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||
|
||||
return (
|
||||
<Tooltip text={currency.name}>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={currency.icon}
|
||||
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Element>
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'a'> & {
|
||||
text: string
|
||||
searchParamName: string
|
||||
searchParamValue?: string
|
||||
icon?: string
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
const { text, searchParamName, searchParamValue, icon, iconClass, class: className, ...aProps } = Astro.props
|
||||
|
||||
const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
||||
const url = new URL(Astro.url)
|
||||
url.searchParams.delete(filter, value)
|
||||
return url.toString()
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
href={makeUrlWithoutFilter(searchParamName, searchParamValue)}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} />}
|
||||
{text}
|
||||
<Icon name="ri:close-large-line" class="text-day-400 size-4" />
|
||||
</a>
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
import { z } from 'astro/zod'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { networksBySlug } from '../constants/networks'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'a'>, 'href' | 'rel' | 'target'> & {
|
||||
url: string
|
||||
referral: string | null
|
||||
enableMinWidth?: boolean
|
||||
}
|
||||
|
||||
const { url: baseUrl, referral, class: className, enableMinWidth = false, ...htmlProps } = Astro.props
|
||||
|
||||
function makeLink(url: string, referral: string | null) {
|
||||
const hostname = new URL(url).hostname
|
||||
const urlWithReferral = url + (referral ?? '')
|
||||
|
||||
const onionMatch = /^(?:https?:\/\/)?(.{0,10}).*?(.{0,10})(\.onion)$/.exec(hostname)
|
||||
if (onionMatch) {
|
||||
return {
|
||||
type: 'onion' as const,
|
||||
url: urlWithReferral,
|
||||
textBits: onionMatch.length
|
||||
? [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: onionMatch[1] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: '...',
|
||||
},
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: onionMatch[2] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: onionMatch[3] ?? '',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: hostname,
|
||||
},
|
||||
],
|
||||
icon: networksBySlug.onion.icon,
|
||||
}
|
||||
}
|
||||
|
||||
const i2pMatch = /^(?:https?:\/\/)?(.{0,10}).*?(.{0,8})((?:\.b32)?\.i2p)$/.exec(hostname)
|
||||
if (i2pMatch) {
|
||||
return {
|
||||
type: 'i2p' as const,
|
||||
url: urlWithReferral,
|
||||
textBits: i2pMatch.length
|
||||
? [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: i2pMatch[1] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: '...',
|
||||
},
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: i2pMatch[2] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: i2pMatch[3] ?? '',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: hostname,
|
||||
},
|
||||
],
|
||||
icon: networksBySlug.i2p.icon,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'clearnet' as const,
|
||||
url: urlWithReferral,
|
||||
textBits: [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: hostname.replace(/^www\./, ''),
|
||||
},
|
||||
],
|
||||
icon: networksBySlug.clearnet.icon,
|
||||
}
|
||||
}
|
||||
|
||||
const link = makeLink(baseUrl, referral)
|
||||
|
||||
if (!z.string().url().safeParse(link.url).success) {
|
||||
console.error(`Invalid service URL with referral: ${link.url}`)
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={cn(
|
||||
'2xs:text-sm 2xs:h-8 2xs:gap-2 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black',
|
||||
className
|
||||
)}
|
||||
{...htmlProps}
|
||||
>
|
||||
<Icon name={link.icon} class="2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4" />
|
||||
<span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}>
|
||||
{
|
||||
link.textBits.map((textBit) => (
|
||||
<span class={cn(textBit.style === 'irrelevant' && 'text-zinc-500')}>{textBit.text}</span>
|
||||
))
|
||||
}
|
||||
</span>
|
||||
<Icon
|
||||
name="ri:arrow-right-line"
|
||||
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
|
||||
/>
|
||||
</a>
|
||||
@@ -1,497 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { cn } from '../lib/cn'
|
||||
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import PillsRadioGroup from './PillsRadioGroup.astro'
|
||||
import { makeOverallScoreInfo } from './ScoreSquare.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'form'> & {
|
||||
filters: ServicesFiltersObject
|
||||
hasDefaultFilters: boolean
|
||||
options: ServicesFiltersOptions
|
||||
searchResultsId: string
|
||||
showFiltersId: string
|
||||
}
|
||||
|
||||
const {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
options,
|
||||
searchResultsId,
|
||||
showFiltersId,
|
||||
class: className,
|
||||
...formProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<form
|
||||
method="GET"
|
||||
hx-get={Astro.url.pathname}
|
||||
hx-trigger={`input delay:500ms from:input[type='text'], keyup[key=='Enter'], change from:input:not([data-show-more-input], #${showFiltersId}), change from:select`}
|
||||
hx-target={`#${searchResultsId}`}
|
||||
hx-select={`#${searchResultsId}`}
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-indicator"
|
||||
data-services-filters-form
|
||||
data-default-verification-filter={options.verification
|
||||
.filter((verification) => verification.default)
|
||||
.map((verification) => verification.slug)}
|
||||
{...formProps}
|
||||
class={cn('', className)}
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-title text-xl text-green-500">FILTERS</h2>
|
||||
<a
|
||||
href={Astro.url.pathname}
|
||||
class={cn('text-sm text-green-500 hover:text-green-400', hasDefaultFilters && 'hidden')}
|
||||
id="clear-filters-button">Clear all</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Sort Selector -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="sort">Sort By</label>
|
||||
</legend>
|
||||
<select
|
||||
name="sort"
|
||||
id="sort"
|
||||
class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
|
||||
>
|
||||
{
|
||||
options.sort.map((option) => (
|
||||
<option value={option.value} selected={filters.sort === option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm">
|
||||
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Ties randomly sorted
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Text Search -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="q">Text</label>
|
||||
</legend>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
id="q"
|
||||
value={filters?.q}
|
||||
placeholder="Search..."
|
||||
class="placeholder-day-500 border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Type</legend>
|
||||
<input type="checkbox" id="show-more-categories" class="peer hidden" hx-preserve data-show-more-input />
|
||||
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden">
|
||||
{
|
||||
options.categories?.map((category) => (
|
||||
<li data-show-always={category.showAlways ? '' : undefined}>
|
||||
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="categories"
|
||||
value={category.slug}
|
||||
checked={category.checked}
|
||||
/>
|
||||
<span class="peer-checked:font-bold">
|
||||
{category.name}
|
||||
<span class="text-day-500 font-normal">{category._count.services}</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
{
|
||||
options.categories.filter((category) => category.showAlways).length < options.categories.length && (
|
||||
<>
|
||||
<label
|
||||
for="show-more-categories"
|
||||
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden"
|
||||
>
|
||||
+ Show more
|
||||
</label>
|
||||
<label
|
||||
for="show-more-categories"
|
||||
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block"
|
||||
>
|
||||
- Show less
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<!-- Verification Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Verification</legend>
|
||||
<div>
|
||||
{
|
||||
options.verification.map((verification) => (
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="verification"
|
||||
value={verification.slug}
|
||||
checked={filters.verification.includes(verification.value)}
|
||||
/>
|
||||
<Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} />
|
||||
<span class="peer-checked:font-bold">{verification.labelShort}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Accepted currencies Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<legend class="font-title leading-none text-green-500">Currencies</legend>
|
||||
<PillsRadioGroup
|
||||
name="currency-mode"
|
||||
options={options.modeOptions}
|
||||
selectedValue={filters['currency-mode']}
|
||||
class="-my-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{
|
||||
options.currencies.map((currency) => (
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="currencies"
|
||||
value={currency.slug}
|
||||
checked={filters.currencies?.some((id) => id === currency.id)}
|
||||
/>
|
||||
<Icon name={currency.icon} class="size-4" />
|
||||
<span class="peer-checked:font-bold">{currency.name}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Network Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Networks</legend>
|
||||
<div>
|
||||
{
|
||||
options.network.map((network) => (
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="networks"
|
||||
value={network.slug}
|
||||
checked={filters.networks?.some((slug) => slug === network.slug)}
|
||||
/>
|
||||
<Icon name={network.icon} class="size-4" />
|
||||
<span class="peer-checked:font-bold">{network.name}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- KYC Level Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="max-kyc">KYC Level (max)</label>
|
||||
</legend>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="4"
|
||||
name="max-kyc"
|
||||
id="max-kyc"
|
||||
value={filters['max-kyc'] ?? 4}
|
||||
class="w-full accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
|
||||
{
|
||||
kycLevels.map((level) => (
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
{level.value}
|
||||
<Icon name={level.icon} class="ms-1 size-3 shrink-0" />
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- User Score Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="user-rating">User Rating (min)</label>
|
||||
</legend>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={4}
|
||||
name="user-rating"
|
||||
id="user-rating"
|
||||
value={filters['user-rating']}
|
||||
class="w-full accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-day-400 mt-1 flex justify-between px-2 text-xs">
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">-</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
1<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
2<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
3<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
4<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Attributes Filter -->
|
||||
<fieldset class="mb-6 min-w-0 space-y-2">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<legend class="font-title leading-none text-green-500">Attributes</legend>
|
||||
<PillsRadioGroup
|
||||
name="attribute-mode"
|
||||
options={options.modeOptions}
|
||||
selectedValue={filters['attribute-mode']}
|
||||
class="-my-2"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
options.attributesByCategory.map(({ category, attributes }) => (
|
||||
<fieldset class="min-w-0">
|
||||
<legend class="font-title mb-0.5 text-xs tracking-wide text-white">{category}</legend>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`show-more-attributes-${category}`}
|
||||
class="peer hidden"
|
||||
hx-preserve
|
||||
data-show-more-input
|
||||
/>
|
||||
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden">
|
||||
{attributes.map((attribute) => {
|
||||
const inputName = `attr-${attribute.id}` as const
|
||||
const yesId = `attr-${attribute.id}=yes` as const
|
||||
const noId = `attr-${attribute.id}=no` as const
|
||||
const emptyId = `attr-${attribute.id}=empty` as const
|
||||
const isPositive = attribute.type === 'GOOD' || attribute.type === 'INFO'
|
||||
|
||||
return (
|
||||
<li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer">
|
||||
<fieldset class="flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
|
||||
<legend class="sr-only">
|
||||
{attribute.title} ({attribute._count?.services})
|
||||
</legend>
|
||||
<input
|
||||
type="radio"
|
||||
class="peer/empty hidden"
|
||||
id={emptyId}
|
||||
name={inputName}
|
||||
value=""
|
||||
checked={!attribute.value}
|
||||
aria-label="Ignore"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputName}
|
||||
value="yes"
|
||||
id={yesId}
|
||||
class="peer/yes hidden"
|
||||
checked={attribute.value === 'yes'}
|
||||
aria-label="Include"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputName}
|
||||
value="no"
|
||||
id={noId}
|
||||
class="peer/no hidden"
|
||||
checked={attribute.value === 'no'}
|
||||
aria-label="Exclude"
|
||||
/>
|
||||
|
||||
<label
|
||||
for={yesId}
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-zinc-950 peer-checked/yes:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:check-line" class="size-3" />
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-green-600 peer-checked/yes:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:check-line" class="size-3" />
|
||||
</label>
|
||||
|
||||
<span class="block h-4 w-px border-y-2 border-zinc-950 bg-zinc-800" aria-hidden="true" />
|
||||
|
||||
<label
|
||||
for={noId}
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-zinc-950 peer-checked/no:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:close-line" class="size-3" />
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-red-600 peer-checked/no:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:close-line" class="size-3" />
|
||||
</label>
|
||||
|
||||
<label
|
||||
for={isPositive ? yesId : noId}
|
||||
class="ml-2 flex min-w-0 cursor-pointer items-center font-normal peer-checked/no:hidden peer-checked/yes:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name={attribute.icon}
|
||||
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.iconClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
class="ml-2 hidden min-w-0 cursor-pointer items-center font-bold peer-checked/no:flex peer-checked/yes:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name={attribute.icon}
|
||||
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.iconClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{attributes.filter((attribute) => attribute.showAlways).length < attributes.length && (
|
||||
<>
|
||||
<label
|
||||
for={`show-more-attributes-${category}`}
|
||||
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden"
|
||||
>
|
||||
+ Show more
|
||||
</label>
|
||||
<label
|
||||
for={`show-more-attributes-${category}`}
|
||||
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block"
|
||||
>
|
||||
- Show less
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
))
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<!-- Score Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="min-score">Score (min)</label>
|
||||
</legend>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
name="min-score"
|
||||
id="min-score"
|
||||
value={filters['min-score']}
|
||||
class="w-full accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mx-1.5 mt-2 flex justify-between px-1">
|
||||
{
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((score) => {
|
||||
const info = makeOverallScoreInfo(score)
|
||||
return (
|
||||
<Tooltip
|
||||
text={info.text}
|
||||
position="bottom"
|
||||
class={cn(
|
||||
'flex h-4 w-full max-w-4 min-w-0 cursor-default items-center justify-center rounded-xs text-xs font-bold tracking-tighter',
|
||||
info.classNameBg
|
||||
)}
|
||||
>
|
||||
{score.toLocaleString()}
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="hidden" name="sort-seed" value={filters['sort-seed']} />
|
||||
|
||||
<div
|
||||
class="sm:js:hidden bg-night-700 sticky inset-x-0 bottom-0 mt-4 block rounded-t-md pb-4 shadow-[0_0_16px_16px_var(--color-night-700)]"
|
||||
>
|
||||
<Button type="submit" label="Apply" size="lg" class="w-full" color="success" shadow />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const forms = document.querySelectorAll<HTMLFormElement>('form[data-services-filters-form]')
|
||||
|
||||
forms.forEach((form) => {
|
||||
form.addEventListener('input', () => {
|
||||
form.querySelectorAll<HTMLAnchorElement>('a#clear-filters-button').forEach((button) => {
|
||||
button.classList.remove('hidden')
|
||||
})
|
||||
|
||||
const verificationInputs = form.querySelectorAll<HTMLInputElement>('input[name="verification"]')
|
||||
const noVerificationChecked = Array.from(verificationInputs).every((input) => !input.checked)
|
||||
if (noVerificationChecked) {
|
||||
verificationInputs.forEach((input) => {
|
||||
if (form.dataset.defaultVerificationFilter?.includes(input.value)) {
|
||||
input.checked = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { pluralize } from '../lib/pluralize'
|
||||
import { createPageUrl } from '../lib/urls'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import ServiceCard from './ServiceCard.astro'
|
||||
|
||||
import type { ServicesFiltersObject } from '../pages/index.astro'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hasDefaultFilters?: boolean
|
||||
services: ComponentProps<typeof ServiceCard>['service'][] | undefined
|
||||
currentPage?: number
|
||||
total: number
|
||||
pageSize: number
|
||||
sortSeed?: string
|
||||
filters: ServicesFiltersObject
|
||||
hadToIncludeCommunityContributed: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
services,
|
||||
hasDefaultFilters = false,
|
||||
currentPage = 1,
|
||||
total,
|
||||
pageSize,
|
||||
sortSeed,
|
||||
class: className,
|
||||
filters,
|
||||
hadToIncludeCommunityContributed,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
|
||||
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
|
||||
const hasCommunityContributed =
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
filters.verification.includes('COMMUNITY_CONTRIBUTED') || hadToIncludeCommunityContributed
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
---
|
||||
|
||||
<div {...divProps} class={cn('flex-1', className)}>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<span class="text-day-500 text-sm">
|
||||
{total.toLocaleString()}
|
||||
{pluralize('result', total)}
|
||||
|
||||
<span
|
||||
id="search-indicator"
|
||||
class="htmx-request:opacity-100 text-white opacity-0 transition-opacity duration-500"
|
||||
>
|
||||
<Icon name="ri:loader-4-line" class="inline-block size-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
</span>
|
||||
<Button as="a" href="/service-suggestion/new" label="Add service" icon="ri:add-line" />
|
||||
</div>
|
||||
|
||||
{
|
||||
hasScams && hasCommunityContributed && (
|
||||
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500">
|
||||
<Icon name="ri:alert-fill" class="-mr-1 inline-block size-4 text-red-500" />
|
||||
<Icon name="ri:question-line" class="mr-2 inline-block size-4 text-yellow-500" />
|
||||
Showing SCAM and unverified community-contributed services.
|
||||
{hadToIncludeCommunityContributed && 'Because there were no other results.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
hasScams && !hasCommunityContributed && (
|
||||
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500">
|
||||
<Icon name="ri:alert-fill" class="mr-2 inline-block size-4 text-red-500" />
|
||||
Showing SCAM services!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!hasScams && hasCommunityContributed && (
|
||||
<div class="font-title mb-6 rounded-lg border border-yellow-500/30 bg-yellow-950 p-4 text-sm text-yellow-500">
|
||||
<Icon name="ri:question-line" class="mr-2 inline-block size-4" />
|
||||
|
||||
{hadToIncludeCommunityContributed
|
||||
? 'Showing unverified community-contributed services, because there were no other results. Some might be scams.'
|
||||
: 'Showing unverified community-contributed services, some might be scams.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!services || services.length === 0 ? (
|
||||
<div class="sticky top-20 flex flex-col items-center justify-center rounded-lg border border-green-500/30 bg-black/40 p-12 text-center">
|
||||
<Icon name="ri:emotion-sad-line" class="mb-4 size-16 text-green-500/50" />
|
||||
<h3 class="font-title mb-3 text-xl text-green-500">No services found</h3>
|
||||
<p class="text-day-400">Try adjusting your filters to find more services</p>
|
||||
<a
|
||||
href={Astro.url.pathname}
|
||||
class={cn(
|
||||
'bg-night-800 font-title mt-4 rounded-md px-4 py-2 text-sm tracking-wider text-white uppercase',
|
||||
hasDefaultFilters && 'hidden'
|
||||
)}
|
||||
>
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
|
||||
{services.map((service, i) => (
|
||||
<ServiceCard
|
||||
inlineIcons
|
||||
service={service}
|
||||
data-hx-search-results-card
|
||||
{...(i === services.length - 1 && currentPage < totalPages
|
||||
? {
|
||||
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
|
||||
'hx-trigger': 'revealed',
|
||||
'hx-swap': 'afterend',
|
||||
'hx-select': '[data-hx-search-results-card]',
|
||||
'hx-indicator': '#infinite-scroll-indicator',
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
|
||||
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
|
||||
<Icon name="ri:loader-4-line" class="size-8 animate-spin text-green-500" />
|
||||
Loading more services...
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
type Props = {
|
||||
active: boolean
|
||||
sortOrder: 'asc' | 'desc' | null | undefined
|
||||
class?: string
|
||||
}
|
||||
|
||||
const { active, sortOrder, class: className }: Props = Astro.props
|
||||
---
|
||||
|
||||
{
|
||||
active && sortOrder ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<Icon name="ri:arrow-down-s-line" class={cn('inline-block size-4', className)} />
|
||||
) : (
|
||||
<Icon name="ri:arrow-up-s-line" class={cn('inline-block size-4', className)} />
|
||||
)
|
||||
) : (
|
||||
<Icon name="ri:expand-up-down-line" class={cn('inline-block size-4 text-current/50', className)} />
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
document.body.classList.add('js')
|
||||
|
||||
document.addEventListener('astro:before-swap', (event) => {
|
||||
event.newDocument.body.classList.add('js')
|
||||
})
|
||||
</script>
|
||||