Compare commits
10 Commits
release-20
...
release-39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea40f17d3c | ||
|
|
7e0d41cc7a | ||
|
|
70a097054b | ||
|
|
e536ca6519 | ||
|
|
b361ed3aa8 | ||
|
|
50ede46d50 | ||
|
|
ba809840c6 | ||
|
|
f2021a3027 | ||
|
|
6b86a72d1e | ||
|
|
8f2b2c34ff |
@@ -5,6 +5,7 @@ alwaysApply: false
|
||||
---
|
||||
- We use Prisma as ORM.
|
||||
- Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database.
|
||||
- After making changes to the [schema.prisma](mdc:web/prisma/schema.prisma) database or [seed.ts](mdc:web/prisma/seed.ts), you run `npm run db-reset` (from `/web/` folder) [package.json](mdc:web/package.json). And never do the migrations manually.
|
||||
- Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this:
|
||||
```ts
|
||||
type Props = {
|
||||
@@ -51,4 +52,4 @@ const [user, services] = await Astro.locals.banners.tryMany([
|
||||
],
|
||||
])
|
||||
```
|
||||
- When editing the database, remember to edit the db seeding file [faker.ts](mdc:web/scripts/faker.ts) to generate data for the new schema.
|
||||
- When editing the database, remember to edit the db seeding file [seed.ts](mdc:web/prisma/seed.ts) to generate data for the new schema.
|
||||
|
||||
0
.env.example
Normal file
0
.env.example
Normal file
@@ -23,8 +23,7 @@ cd web
|
||||
nvm install
|
||||
npm i
|
||||
cp -n .env.example .env
|
||||
npm run db-push
|
||||
npm run db-fill-clean
|
||||
npm run db-reset
|
||||
```
|
||||
|
||||
Now open the [.env](web/.env) file and fill in the missing values.
|
||||
|
||||
@@ -6,24 +6,23 @@
|
||||
|
||||
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 |
|
||||
| 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-seed` | Seeds the database with 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-seed` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-seed -- --services=5` will add 5 fake services.
|
||||
|
||||
> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
||||
> **Note**: `db-seed` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
||||
|
||||
@@ -177,6 +177,12 @@ export default defineConfig({
|
||||
url: true,
|
||||
optional: false,
|
||||
}),
|
||||
LOGS_UI_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
url: true,
|
||||
optional: true,
|
||||
}),
|
||||
|
||||
RELEASE_NUMBER: envField.number({
|
||||
context: 'server',
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
"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",
|
||||
"db-reset": "prisma migrate reset -f && prisma migrate dev",
|
||||
"db-seed": "prisma db seed",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix && prettier --write ."
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/db": "0.14.14",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InternalServiceNote" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"addedByUserId" INTEGER,
|
||||
|
||||
CONSTRAINT "InternalServiceNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_serviceId_idx" ON "InternalServiceNote"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_addedByUserId_idx" ON "InternalServiceNote"("addedByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_createdAt_idx" ON "InternalServiceNote"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_addedByUserId_fkey" FOREIGN KEY ("addedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -87,6 +87,7 @@ enum ServiceVisibility {
|
||||
PUBLIC
|
||||
UNLISTED
|
||||
HIDDEN
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
@@ -376,6 +377,7 @@ model Service {
|
||||
attributes ServiceAttribute[]
|
||||
verificationSteps VerificationStep[]
|
||||
suggestions ServiceSuggestion[]
|
||||
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||
|
||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||
@@ -441,6 +443,7 @@ model Attribute {
|
||||
|
||||
model InternalUserNote {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Markdown
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -455,6 +458,23 @@ model InternalUserNote {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model InternalServiceNote {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Markdown
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
service Service @relation("ServiceRecievedNotes", fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
addedByUser User? @relation("UserAddedServiceNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
|
||||
addedByUserId Int?
|
||||
|
||||
@@index([serviceId])
|
||||
@@index([addedByUserId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
@@ -481,6 +501,7 @@ model User {
|
||||
suggestionMessages ServiceSuggestionMessage[]
|
||||
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
|
||||
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
|
||||
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
notifications Notification[] @relation("NotificationOwner")
|
||||
notificationPreferences NotificationPreferences?
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import crypto from 'crypto'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { faker } from '@faker-js/faker'
|
||||
import {
|
||||
AnnouncementType,
|
||||
AttributeCategory,
|
||||
AttributeType,
|
||||
CommentStatus,
|
||||
Currency,
|
||||
EventType,
|
||||
PrismaClient,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceUserRole,
|
||||
VerificationStatus,
|
||||
type Prisma,
|
||||
EventType,
|
||||
type User,
|
||||
ServiceUserRole,
|
||||
AnnouncementType,
|
||||
type ServiceVisibility,
|
||||
ServiceSuggestionType,
|
||||
} from '@prisma/client'
|
||||
import { uniqBy } from 'lodash-es'
|
||||
import { generateUsername } from 'unique-username-generator'
|
||||
@@ -95,20 +98,6 @@ async function createAccount(preGeneratedToken?: string) {
|
||||
return { token, user }
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2)
|
||||
const shouldCleanup = args.includes('--cleanup') || args.includes('-c')
|
||||
const onlyCleanup = args.includes('--only-cleanup') || args.includes('-oc')
|
||||
|
||||
// Parse number of services from --services or -s flag
|
||||
const servicesArg = args.find((arg) => arg.startsWith('--services=') || arg.startsWith('-s='))
|
||||
const numServices = parseIntWithFallback(servicesArg?.split('=')[1], 100) // Default to 100 if not specified
|
||||
|
||||
if (isNaN(numServices) || numServices < 1) {
|
||||
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const generateFakeAttribute = () => {
|
||||
@@ -611,7 +600,12 @@ const generateFakeEvent = (serviceId: number) => {
|
||||
}
|
||||
|
||||
const generateFakeService = (users: User[]) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(VerificationStatus))
|
||||
const status = faker.helpers.weightedArrayElement<VerificationStatus>([
|
||||
{ weight: 20, value: 'VERIFICATION_SUCCESS' },
|
||||
{ weight: 30, value: 'APPROVED' },
|
||||
{ weight: 40, value: 'COMMUNITY_CONTRIBUTED' },
|
||||
{ weight: 10, value: 'VERIFICATION_FAILED' },
|
||||
])
|
||||
const name = faker.helpers.arrayElement(serviceNames)
|
||||
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
||||
|
||||
@@ -623,6 +617,12 @@ const generateFakeService = (users: User[]) => {
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||
{ weight: 80, value: 'PUBLIC' },
|
||||
{ weight: 10, value: 'UNLISTED' },
|
||||
{ weight: 5, value: 'HIDDEN' },
|
||||
{ weight: 5, value: 'ARCHIVED' },
|
||||
]),
|
||||
verificationStatus: status,
|
||||
verificationSummary:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||
@@ -636,25 +636,37 @@ const generateFakeService = (users: User[]) => {
|
||||
},
|
||||
verificationProofMd:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||
referral: `?ref=${faker.string.alphanumeric(6)}`,
|
||||
referral: faker.helpers.arrayElement([
|
||||
`?ref=${faker.string.alphanumeric(6)}`,
|
||||
`/ref/${faker.string.alphanumeric(6)}`,
|
||||
]),
|
||||
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
|
||||
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()),
|
||||
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()),
|
||||
onionUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`
|
||||
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
|
||||
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
|
||||
onionUrls: faker.helpers.multiple(
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
i2pUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`
|
||||
i2pUrls: faker.helpers.multiple(
|
||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||
listedAt: faker.date.past(),
|
||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
||||
tosReviewAt: faker.date.past(),
|
||||
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined,
|
||||
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||
userSentimentAt: faker.date.recent(),
|
||||
internalNotes: faker.helpers.maybe(
|
||||
() => ({
|
||||
create: {
|
||||
content: faker.lorem.paragraph(),
|
||||
addedByUserId: faker.helpers.arrayElement(users.filter((user) => user.admin)).id,
|
||||
},
|
||||
}),
|
||||
{ probability: 0.33 }
|
||||
),
|
||||
} as const satisfies Prisma.ServiceCreateInput
|
||||
}
|
||||
|
||||
@@ -904,7 +916,7 @@ const specialUsersData = {
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
link: 'https://kycnot.me',
|
||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
||||
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||
},
|
||||
moderator: {
|
||||
name: 'moderator_dev',
|
||||
@@ -916,7 +928,7 @@ const specialUsersData = {
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
link: 'https://kycnot.me',
|
||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
||||
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||
},
|
||||
verified: {
|
||||
name: 'verified_dev',
|
||||
@@ -1003,359 +1015,378 @@ const generateFakeAnnouncement = () => {
|
||||
} as const satisfies Prisma.AnnouncementCreateInput
|
||||
}
|
||||
|
||||
async function runFaker() {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// ---- Clean up existing data if requested ----
|
||||
if (shouldCleanup || onlyCleanup) {
|
||||
console.info('🧹 Cleaning up existing data...')
|
||||
async function cleanup() {
|
||||
console.info('🧹 Cleaning up existing data...')
|
||||
|
||||
try {
|
||||
await tx.commentVote.deleteMany()
|
||||
await tx.karmaTransaction.deleteMany()
|
||||
await tx.comment.deleteMany()
|
||||
await tx.serviceAttribute.deleteMany()
|
||||
await tx.serviceContactMethod.deleteMany()
|
||||
await tx.event.deleteMany()
|
||||
await tx.verificationStep.deleteMany()
|
||||
await tx.serviceSuggestionMessage.deleteMany()
|
||||
await tx.serviceSuggestion.deleteMany()
|
||||
await tx.serviceVerificationRequest.deleteMany()
|
||||
await tx.service.deleteMany()
|
||||
await tx.attribute.deleteMany()
|
||||
await tx.category.deleteMany()
|
||||
await tx.internalUserNote.deleteMany()
|
||||
await tx.user.deleteMany()
|
||||
await tx.announcement.deleteMany()
|
||||
console.info('✅ Existing data cleaned up')
|
||||
} catch (error) {
|
||||
console.error('❌ Error cleaning up data:', error)
|
||||
throw error
|
||||
}
|
||||
if (onlyCleanup) return
|
||||
}
|
||||
|
||||
// ---- Get or create categories ----
|
||||
const categories = await Promise.all(
|
||||
categoriesToCreate.map(async (cat) => {
|
||||
const existing = await tx.category.findUnique({
|
||||
where: { name: cat.name },
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
return await tx.category.create({
|
||||
data: cat,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create users ----
|
||||
const specialUsersUntyped = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
||||
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
||||
const secretTokenHash = hashUserSecretToken(secretToken)
|
||||
|
||||
const { envToken, defaultToken, ...userCreateData } = userData
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
notificationPreferences: { create: {} },
|
||||
...userCreateData,
|
||||
secretTokenHash,
|
||||
},
|
||||
})
|
||||
|
||||
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
||||
|
||||
return [key, user] as const
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const specialUsers = specialUsersUntyped as {
|
||||
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
||||
}
|
||||
|
||||
let users = await Promise.all(
|
||||
Array.from({ length: 10 }, async () => {
|
||||
const { user } = await createAccount()
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create attributes ----
|
||||
const attributes = await Promise.all(
|
||||
Array.from({ length: 16 }, async () => {
|
||||
return await tx.attribute.create({
|
||||
data: generateFakeAttribute(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create services ----
|
||||
const services = await Promise.all(
|
||||
Array.from({ length: numServices }, async () => {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create contact methods for each service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
tx.serviceContactMethod.create({
|
||||
data: generateFakeServiceContactMethod(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Link random attributes to the service
|
||||
await Promise.all(
|
||||
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
||||
tx.serviceAttribute.create({
|
||||
data: {
|
||||
serviceId: service.id,
|
||||
attributeId: attr.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Create events for the service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
||||
tx.event.create({
|
||||
data: generateFakeEvent(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return service
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service user affiliations for the service ----
|
||||
await Promise.all(
|
||||
users
|
||||
.filter((user) => user.verified)
|
||||
.map(async (user) => {
|
||||
const servicesToAddAffiliations = uniqBy(
|
||||
faker.helpers.arrayElements(services, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
serviceAffiliations: {
|
||||
createMany: {
|
||||
data: servicesToAddAffiliations.map((service) => ({
|
||||
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
||||
serviceId: service.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
users = await tx.user.findMany({
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
},
|
||||
})
|
||||
|
||||
// ---- Create comments and replies ----
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
// Create parent comments
|
||||
const commentCount = faker.number.int({ min: 1, max: 10 })
|
||||
const commentData = Array.from({ length: commentCount }, () =>
|
||||
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
||||
)
|
||||
const indexesToUpdate = users.map((user) => {
|
||||
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
||||
})
|
||||
commentData.forEach((comment, index) => {
|
||||
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
||||
})
|
||||
|
||||
await tx.comment.createMany({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const comments = await tx.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
parentId: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: commentCount,
|
||||
})
|
||||
|
||||
const affiliatedUsers = undefinedIfEmpty(
|
||||
users.filter((user) =>
|
||||
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
||||
)
|
||||
)
|
||||
|
||||
// Create replies to comments
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
const replyCount = faker.number.int({ min: 0, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: replyCount }, () => {
|
||||
const user = faker.helpers.arrayElement(
|
||||
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
||||
)
|
||||
|
||||
return tx.comment.create({
|
||||
data: generateFakeComment(user.id, service.id, comment.id),
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service suggestions for normal_dev user ----
|
||||
// First create 3 CREATE_SERVICE suggestions with their services
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
tx.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Then create 5 EDIT_SERVICE suggestions
|
||||
await Promise.all(
|
||||
services.slice(0, 5).map(async (service) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
||||
const suggestion = await tx.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
status,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
||||
tx.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create internal notes for users ----
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
// Create 1-3 notes for each user
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
tx.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Add some notes to special users as well
|
||||
await Promise.all(
|
||||
Object.values(specialUsers).map(async (user) => {
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
tx.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create announcement ----
|
||||
await tx.announcement.create({
|
||||
data: generateFakeAnnouncement(),
|
||||
})
|
||||
},
|
||||
{
|
||||
timeout: 1000 * 60 * 10, // 10 minutes
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await runFaker()
|
||||
|
||||
console.info('✅ Fake data generated successfully')
|
||||
await prisma.commentVote.deleteMany()
|
||||
await prisma.karmaTransaction.deleteMany()
|
||||
await prisma.comment.deleteMany()
|
||||
await prisma.serviceAttribute.deleteMany()
|
||||
await prisma.serviceContactMethod.deleteMany()
|
||||
await prisma.event.deleteMany()
|
||||
await prisma.verificationStep.deleteMany()
|
||||
await prisma.serviceSuggestionMessage.deleteMany()
|
||||
await prisma.serviceSuggestion.deleteMany()
|
||||
await prisma.serviceVerificationRequest.deleteMany()
|
||||
await prisma.service.deleteMany()
|
||||
await prisma.attribute.deleteMany()
|
||||
await prisma.category.deleteMany()
|
||||
await prisma.internalUserNote.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
await prisma.announcement.deleteMany()
|
||||
console.info('✅ Existing data cleaned up')
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating fake data:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
console.error('❌ Error cleaning up data:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('❌ Fatal error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
function importTriggers() {
|
||||
console.info('🔄 Importing SQL triggers...')
|
||||
try {
|
||||
execSync('just import-triggers', { stdio: 'inherit' })
|
||||
console.info('✅ Triggers imported')
|
||||
} catch (error) {
|
||||
console.error('❌ Error importing triggers:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values: options } = parseArgs({
|
||||
options: {
|
||||
services: { type: 'string', short: 's', default: '100' },
|
||||
cleanup: { type: 'boolean', short: 'c', default: true },
|
||||
'only-cleanup': { type: 'boolean', short: 'o', default: false },
|
||||
},
|
||||
})
|
||||
const numServices = parseIntWithFallback(options.services, 100)
|
||||
if (isNaN(numServices) || numServices < 1) {
|
||||
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
importTriggers()
|
||||
|
||||
if (options.cleanup || options['only-cleanup']) {
|
||||
await cleanup()
|
||||
if (options['only-cleanup']) return
|
||||
}
|
||||
|
||||
// ---- Get or create categories ----
|
||||
const categories = await Promise.all(
|
||||
categoriesToCreate.map(async (cat) => {
|
||||
const existing = await prisma.category.findUnique({
|
||||
where: { name: cat.name },
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
return await prisma.category.create({
|
||||
data: cat,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create users ----
|
||||
const specialUsersUntyped = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
||||
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
||||
const secretTokenHash = hashUserSecretToken(secretToken)
|
||||
|
||||
const { envToken, defaultToken, ...userCreateData } = userData
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
notificationPreferences: { create: {} },
|
||||
...userCreateData,
|
||||
secretTokenHash,
|
||||
},
|
||||
})
|
||||
|
||||
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
||||
|
||||
return [key, user] as const
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const specialUsers = specialUsersUntyped as {
|
||||
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
||||
}
|
||||
|
||||
let users = await Promise.all(
|
||||
Array.from({ length: 10 }, async () => {
|
||||
const { user } = await createAccount()
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create attributes ----
|
||||
const attributes = await Promise.all(
|
||||
Array.from({ length: 16 }, async () => {
|
||||
return await prisma.attribute.create({
|
||||
data: generateFakeAttribute(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create services ----
|
||||
const services = await Promise.all(
|
||||
Array.from({ length: numServices }, async () => {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create contact methods for each service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
prisma.serviceContactMethod.create({
|
||||
data: generateFakeServiceContactMethod(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Link random attributes to the service
|
||||
await Promise.all(
|
||||
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
||||
prisma.serviceAttribute.create({
|
||||
data: {
|
||||
serviceId: service.id,
|
||||
attributeId: attr.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Create events for the service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
||||
prisma.event.create({
|
||||
data: generateFakeEvent(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return service
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service user affiliations for the service ----
|
||||
await Promise.all(
|
||||
users
|
||||
.filter((user) => user.verified)
|
||||
.map(async (user) => {
|
||||
const servicesToAddAffiliations = uniqBy(
|
||||
faker.helpers.arrayElements(services, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
serviceAffiliations: {
|
||||
createMany: {
|
||||
data: servicesToAddAffiliations.map((service) => ({
|
||||
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
||||
serviceId: service.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
users = await prisma.user.findMany({
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
},
|
||||
})
|
||||
|
||||
// ---- Create comments and replies ----
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
// Create parent comments
|
||||
const commentCount = faker.number.int({ min: 1, max: 10 })
|
||||
const commentData = Array.from({ length: commentCount }, () =>
|
||||
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
||||
)
|
||||
const indexesToUpdate = users.map((user) => {
|
||||
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
||||
})
|
||||
commentData.forEach((comment, index) => {
|
||||
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
||||
})
|
||||
|
||||
await prisma.comment.createMany({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
parentId: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: commentCount,
|
||||
})
|
||||
|
||||
const affiliatedUsers = undefinedIfEmpty(
|
||||
users.filter((user) =>
|
||||
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
||||
)
|
||||
)
|
||||
|
||||
// Create replies to comments
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
const replyCount = faker.number.int({ min: 0, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: replyCount }, () => {
|
||||
const user = faker.helpers.arrayElement(
|
||||
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
||||
)
|
||||
|
||||
return prisma.comment.create({
|
||||
data: generateFakeComment(user.id, service.id, comment.id),
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service suggestions for normal_dev user ----
|
||||
// First create 3 CREATE_SERVICE suggestions with their services
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
prisma.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Then create 5 EDIT_SERVICE suggestions
|
||||
await Promise.all(
|
||||
services.slice(0, 5).map(async (service) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
||||
const suggestion = await prisma.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
status,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
||||
prisma.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create internal notes for users ----
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
// Create 1-3 notes for each user
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
prisma.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Add some notes to special users as well
|
||||
await Promise.all(
|
||||
Object.values(specialUsers).map(async (user) => {
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
prisma.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create announcement ----
|
||||
await prisma.announcement.create({
|
||||
data: generateFakeAnnouncement(),
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
console.info('✅ Fake data generated successfully')
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error(
|
||||
'❌ Fatal error:',
|
||||
typeof error === 'object' && error !== null && 'message' in error ? error.message : 'Unknown error'
|
||||
)
|
||||
console.error(error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -6,12 +6,8 @@ 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'
|
||||
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
|
||||
|
||||
const serviceSchemaBase = z.object({
|
||||
id: z.number().int().positive(),
|
||||
@@ -19,11 +15,10 @@ const serviceSchemaBase = z.object({
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
name: z.string().min(1).max(20),
|
||||
name: z.string().min(1).max(40),
|
||||
description: z.string().min(1),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
allServiceUrls: 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),
|
||||
@@ -31,10 +26,16 @@ const serviceSchemaBase = z.object({
|
||||
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),
|
||||
referral: z
|
||||
.string()
|
||||
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(null),
|
||||
imageFile: imageFileSchema,
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
internalNote: z.string().optional(),
|
||||
})
|
||||
|
||||
const addSlugIfMissing = <
|
||||
@@ -61,7 +62,7 @@ export const adminServiceActions = {
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
handler: async (input, context) => {
|
||||
const existing = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
@@ -75,12 +76,33 @@ export const adminServiceActions = {
|
||||
})
|
||||
}
|
||||
|
||||
const { imageFile, ...serviceData } = input
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
const imageUrl = input.imageFile
|
||||
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
: undefined
|
||||
|
||||
const {
|
||||
web: serviceUrls,
|
||||
onion: onionUrls,
|
||||
i2p: i2pUrls,
|
||||
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -92,6 +114,14 @@ export const adminServiceActions = {
|
||||
})),
|
||||
},
|
||||
imageUrl,
|
||||
internalNotes: input.internalNote
|
||||
? {
|
||||
create: {
|
||||
content: input.internalNote,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -106,29 +136,34 @@ export const adminServiceActions = {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.transform(addSlugIfMissing),
|
||||
input: serviceSchemaBase
|
||||
.extend({
|
||||
removeImage: z.boolean().optional(),
|
||||
})
|
||||
.transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const { id, categories, attributes, imageFile, ...data } = input
|
||||
|
||||
const existing = await prisma.service.findUnique({
|
||||
const anotherServiceWithNewSlug = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
NOT: { id },
|
||||
NOT: { id: input.id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
if (anotherServiceWithNewSlug) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A service with this slug already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
const imageUrl = input.removeImage
|
||||
? null
|
||||
: input.imageFile
|
||||
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
: undefined
|
||||
|
||||
// Get existing attributes and categories to compute differences
|
||||
const existingService = await prisma.service.findUnique({
|
||||
where: { id },
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
categories: true,
|
||||
attributes: {
|
||||
@@ -146,96 +181,198 @@ export const adminServiceActions = {
|
||||
})
|
||||
}
|
||||
|
||||
// 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))
|
||||
const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId))
|
||||
|
||||
// Find attributes to connect and disconnect
|
||||
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
|
||||
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
|
||||
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
|
||||
|
||||
const {
|
||||
web: serviceUrls,
|
||||
onion: onionUrls,
|
||||
i2p: i2pUrls,
|
||||
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||
|
||||
const service = await prisma.service.update({
|
||||
where: { id },
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...data,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
|
||||
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: z.object({
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
contactMethod: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
updateContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
deleteContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
internalNote: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
serviceId: z.number().int().positive(),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
handler: async (input, { locals }) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
serviceId: input.serviceId,
|
||||
addedByUserId: locals.user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.number().int().positive(),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const note = await prisma.internalServiceNote.findUnique({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Note not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.update({
|
||||
where: { id: input.noteId },
|
||||
data: { content: input.content },
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const note = await prisma.internalServiceNote.findUnique({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Note not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.delete({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,12 +14,8 @@ 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 { separateServiceUrlsByType } from '../lib/urls'
|
||||
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
@@ -161,9 +157,8 @@ export const serviceSuggestionActions = {
|
||||
{ message: 'Slug must be unique, try a different one' }
|
||||
),
|
||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
allServiceUrls: 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),
|
||||
@@ -210,6 +205,12 @@ export const serviceSuggestionActions = {
|
||||
|
||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
|
||||
const {
|
||||
web: serviceUrls,
|
||||
onion: onionUrls,
|
||||
i2p: i2pUrls,
|
||||
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||
|
||||
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
||||
const serviceSelect = {
|
||||
id: true,
|
||||
@@ -221,9 +222,10 @@ export const serviceSuggestionActions = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
serviceUrls: input.serviceUrls,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls: input.onionUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
imageUrl,
|
||||
|
||||
@@ -126,11 +126,13 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
||||
VariantProps<typeof badge> & {
|
||||
as: Tag
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
classNames?: {
|
||||
icon?: string
|
||||
text?: string
|
||||
endIcon?: string
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -138,6 +140,7 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
icon: iconName,
|
||||
endIcon: endIconName,
|
||||
text: textContent,
|
||||
inlineIcon,
|
||||
classNames,
|
||||
@@ -159,4 +162,9 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
|
||||
)
|
||||
}
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
||||
{
|
||||
!!endIconName && (
|
||||
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
|
||||
@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
|
||||
as: Tag
|
||||
icon: string
|
||||
endIcon?: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
}>
|
||||
|
||||
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
const { icon, text, class: className, inlineIcon, endIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
---
|
||||
|
||||
<Tag
|
||||
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
|
||||
>
|
||||
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
|
||||
<span>{text}</span>
|
||||
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
|
||||
</Tag>
|
||||
|
||||
41
web/src/components/BadgeStandardFilter.astro
Normal file
41
web/src/components/BadgeStandardFilter.astro
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import { uniq } from 'lodash-es'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import BadgeStandard from './BadgeStandard.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<
|
||||
ComponentProps<typeof BadgeStandard>,
|
||||
'as' | 'endIcon' | 'href' | 'icon' | 'text' | 'variant'
|
||||
> & {
|
||||
name: string
|
||||
value: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const { name, value, label, icon, ...props } = Astro.props
|
||||
|
||||
const selectedValues = Astro.url.searchParams.getAll(name)
|
||||
const isSelected = selectedValues.includes(value)
|
||||
|
||||
const url = new URL(Astro.url)
|
||||
url.searchParams.delete(name)
|
||||
const valuesToSet = uniq(isSelected ? selectedValues.filter((v) => v !== value) : [...selectedValues, value])
|
||||
for (const value of valuesToSet) {
|
||||
url.searchParams.set(name, value)
|
||||
}
|
||||
---
|
||||
|
||||
<BadgeStandard
|
||||
as="a"
|
||||
href={url.href}
|
||||
class={cn(isSelected && 'bg-green-950 text-green-500')}
|
||||
text={label}
|
||||
icon={icon}
|
||||
endIcon={isSelected ? 'ri:close-fill' : undefined}
|
||||
{...props}
|
||||
/>
|
||||
@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
/>
|
||||
|
||||
<Tooltip text="Send">
|
||||
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
|
||||
<Button
|
||||
type="submit"
|
||||
icon="ri:send-plane-fill"
|
||||
size="lg"
|
||||
color="success"
|
||||
class="h-16"
|
||||
label="Send"
|
||||
iconOnly
|
||||
/>
|
||||
</Tooltip>
|
||||
</form>
|
||||
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
|
||||
|
||||
@@ -150,7 +150,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
checked={comment.suspicious}
|
||||
/>
|
||||
|
||||
<div class="comment-header flex items-center gap-2 text-sm">
|
||||
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
|
||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||
<span class="collapse-symbol text-xs"></span>
|
||||
<span class="sr-only">Toggle comment visibility</span>
|
||||
|
||||
@@ -47,12 +47,9 @@ 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))
|
||||
) {
|
||||
const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
|
||||
const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
|
||||
if (a !== b) {
|
||||
console.error(
|
||||
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
|
||||
)
|
||||
|
||||
@@ -160,6 +160,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
||||
<a
|
||||
href={makeUnimpersonateUrl(Astro.url)}
|
||||
data-astro-reload
|
||||
data-astro-prefetch="tap"
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||
transition:name="header-unimpersonate-link"
|
||||
aria-label="Unimpersonate"
|
||||
|
||||
@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist={option.noTransitionPersist ? undefined : true}
|
||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
|
||||
@@ -43,7 +43,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
: Array.from({ length: icons.length }, () => option.iconClassName)
|
||||
: []
|
||||
return (
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1 has-checked:bg-green-800/20 has-checked:hover:bg-green-800/30">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
@@ -54,9 +54,9 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
disabled={disabled}
|
||||
/>
|
||||
{icons.map((icon, index) => (
|
||||
<Icon name={icon} class={cn('size-4', iconClassName[index])} />
|
||||
<Icon name={icon} class={cn('size-4 shrink-0', iconClassName[index])} />
|
||||
))}
|
||||
<span class="text-sm leading-none">{option.label}</span>
|
||||
<span class="truncate text-sm leading-none">{option.label}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,13 +16,20 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
|
||||
}
|
||||
}
|
||||
|
||||
const { accept, disabled, multiple, removeCheckbox, ...wrapperProps } = Astro.props
|
||||
const { accept, disabled, multiple, removeCheckbox, classNames, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<InputWrapper
|
||||
inputId={inputId}
|
||||
classNames={{
|
||||
...classNames,
|
||||
description: cn(classNames?.description, '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'),
|
||||
}}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{
|
||||
!!removeCheckbox && (
|
||||
<label
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
import { cn } from '../lib/cn'
|
||||
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import InputFile from './InputFile.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
||||
square?: boolean
|
||||
value?: string | null
|
||||
downloadButton?: boolean
|
||||
}
|
||||
|
||||
const { class: className, square, value, ...inputFileProps } = Astro.props
|
||||
const { class: className, square, value, downloadButton, ...inputFileProps } = Astro.props
|
||||
|
||||
function makeDownloadFilename(value: string) {
|
||||
const url = new URL(value, Astro.url.origin)
|
||||
return url.pathname.split('/').pop() ?? 'service-image'
|
||||
}
|
||||
---
|
||||
|
||||
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
|
||||
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
|
||||
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
|
||||
)}
|
||||
/>
|
||||
{
|
||||
downloadButton && value && (
|
||||
<Tooltip
|
||||
text="Download"
|
||||
classNames={{
|
||||
tooltip: 'min-2xs:[&:is(:has([data-remove-checkbox]:checked)_~_*_*)]:hidden',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
href={value}
|
||||
download={makeDownloadFilename(value)}
|
||||
icon="ri:download-line"
|
||||
size="sm"
|
||||
label="Download"
|
||||
class={cn(
|
||||
'bg-night-600 border-night-400 text-day-200 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:h-24 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:px-0 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:w-8 shrink-0 rounded-md border'
|
||||
)}
|
||||
classNames={{
|
||||
label: '2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:hidden block ',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
|
||||
icon?: string
|
||||
inputId?: string
|
||||
hideLabel?: boolean
|
||||
classNames?: {
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -32,13 +35,14 @@ const {
|
||||
class: className,
|
||||
inputId,
|
||||
hideLabel,
|
||||
classNames,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const hasError = !!error && error.length > 0
|
||||
---
|
||||
|
||||
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
|
||||
<fieldset class={cn('min-w-0 space-y-1', className)} {...htmlProps}>
|
||||
{
|
||||
!hideLabel && (
|
||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||
@@ -71,7 +75,12 @@ const hasError = !!error && error.length > 0
|
||||
|
||||
{
|
||||
!!description && (
|
||||
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty">
|
||||
<div
|
||||
class={cn(
|
||||
'prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty',
|
||||
classNames?.description
|
||||
)}
|
||||
>
|
||||
<Markdown content={description} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
|
||||
kycLevel: true
|
||||
imageUrl: true
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
acceptedCurrencies: true
|
||||
categories: {
|
||||
select: {
|
||||
@@ -43,11 +45,11 @@ const {
|
||||
slug,
|
||||
description,
|
||||
overallScore,
|
||||
|
||||
kycLevel,
|
||||
imageUrl,
|
||||
categories,
|
||||
verificationStatus,
|
||||
serviceVisibility,
|
||||
acceptedCurrencies,
|
||||
},
|
||||
class: className,
|
||||
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
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)]',
|
||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt={name || 'Service logo'}
|
||||
class="size-12 shrink-0 rounded-sm object-contain text-white"
|
||||
class={cn(
|
||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||
)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
@@ -110,6 +118,23 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
]}
|
||||
</Tooltip>
|
||||
)
|
||||
}{
|
||||
serviceVisibility === 'ARCHIVED' && (
|
||||
<Tooltip
|
||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h3>
|
||||
<div class="max-h-2 flex-1"></div>
|
||||
|
||||
@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
|
||||
pageSize: number
|
||||
sortSeed?: string
|
||||
filters: ServicesFiltersObject
|
||||
includeScams: boolean
|
||||
countCommunityOnly: number | null
|
||||
inlineIcons?: boolean
|
||||
}
|
||||
@@ -35,15 +34,12 @@ const {
|
||||
sortSeed,
|
||||
class: className,
|
||||
filters,
|
||||
includeScams,
|
||||
countCommunityOnly,
|
||||
inlineIcons,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
|
||||
const hasScams =
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
filters.verification.includes('VERIFICATION_FAILED') || includeScams
|
||||
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
|
||||
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
|
||||
|
||||
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
|
||||
|
||||
@@ -8,7 +8,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
|
||||
label: string
|
||||
/** Notice that the first capture group is then used to format the value */
|
||||
matcher: RegExp
|
||||
formatter: (value: string) => string | null
|
||||
formatter: (match: RegExpMatchArray) => string | null
|
||||
icon: string
|
||||
}
|
||||
|
||||
@@ -24,82 +24,96 @@ export const {
|
||||
label: type ? transformCase(type, 'title') : String(type),
|
||||
icon: 'ri:shield-fill',
|
||||
matcher: /(.*)/,
|
||||
formatter: (value) => value,
|
||||
formatter: ([, value]) => value ?? String(value),
|
||||
}),
|
||||
[
|
||||
{
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
matcher: /mailto:(.+)/,
|
||||
formatter: (value) => value,
|
||||
formatter: ([, value]) => value ?? 'Email',
|
||||
icon: 'ri:mail-line',
|
||||
},
|
||||
{
|
||||
type: 'telephone',
|
||||
label: 'Telephone',
|
||||
matcher: /tel:(.+)/,
|
||||
formatter: (value) => {
|
||||
return parsePhoneNumberWithError(value).formatInternational()
|
||||
formatter: ([, value]) => {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
|
||||
},
|
||||
icon: 'ri:phone-line',
|
||||
},
|
||||
{
|
||||
type: 'whatsapp',
|
||||
label: 'WhatsApp',
|
||||
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
||||
formatter: (value) => {
|
||||
return parsePhoneNumberWithError(value).formatInternational()
|
||||
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
||||
formatter: ([, value]) => {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
|
||||
},
|
||||
icon: 'ri:whatsapp-line',
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
label: 'Telegram',
|
||||
matcher: /https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
||||
formatter: (value) => `t.me/${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
|
||||
icon: 'ri:telegram-line',
|
||||
},
|
||||
{
|
||||
type: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
||||
formatter: (value) => `in/${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
|
||||
icon: 'ri:linkedin-box-line',
|
||||
},
|
||||
{
|
||||
type: 'x',
|
||||
label: 'X',
|
||||
matcher: /https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
||||
formatter: (value) => `@${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'X'),
|
||||
icon: 'ri:twitter-x-line',
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
label: 'Instagram',
|
||||
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
||||
formatter: (value) => `@${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
|
||||
icon: 'ri:instagram-line',
|
||||
},
|
||||
{
|
||||
type: 'matrix',
|
||||
label: 'Matrix',
|
||||
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||
formatter: (value) => value,
|
||||
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
||||
icon: 'ri:hashtag',
|
||||
},
|
||||
{
|
||||
type: 'bitcointalk',
|
||||
label: 'BitcoinTalk',
|
||||
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||
formatter: () => 'BitcoinTalk',
|
||||
icon: 'ri:btc-line',
|
||||
},
|
||||
// Website must go last because it's a catch-all
|
||||
{
|
||||
type: 'simplex',
|
||||
label: 'SimpleX Chat',
|
||||
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
||||
formatter: () => 'SimpleX Chat',
|
||||
icon: 'simplex',
|
||||
},
|
||||
{
|
||||
type: 'nostr',
|
||||
label: 'Nostr',
|
||||
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
|
||||
formatter: () => 'Nostr',
|
||||
icon: 'nostr',
|
||||
},
|
||||
{
|
||||
// Website must go last because it's a catch-all
|
||||
type: 'website',
|
||||
label: 'Website',
|
||||
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||
formatter: (value) => value,
|
||||
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||
formatter: ([, value]) => value ?? 'Website',
|
||||
icon: 'ri:global-line',
|
||||
},
|
||||
] as const satisfies ContactMethodInfo[]
|
||||
@@ -107,10 +121,10 @@ export const {
|
||||
|
||||
export function formatContactMethod(url: string) {
|
||||
for (const contactMethod of contactMethods) {
|
||||
const captureGroup = url.match(contactMethod.matcher)?.[1]
|
||||
if (!captureGroup) continue
|
||||
const match = url.match(contactMethod.matcher)
|
||||
if (!match) continue
|
||||
|
||||
const formattedValue = contactMethod.formatter(captureGroup)
|
||||
const formattedValue = contactMethod.formatter(match)
|
||||
if (!formattedValue) continue
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
}
|
||||
icon: string
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
isSolved: boolean
|
||||
showBanner: boolean
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -36,6 +38,8 @@ export const {
|
||||
},
|
||||
icon: 'ri:question-fill',
|
||||
color: 'gray',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
}),
|
||||
[
|
||||
{
|
||||
@@ -46,8 +50,10 @@ export const {
|
||||
classNames: {
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
},
|
||||
icon: 'ri:error-warning-fill',
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'yellow',
|
||||
isSolved: false,
|
||||
showBanner: true,
|
||||
},
|
||||
{
|
||||
id: 'WARNING_SOLVED',
|
||||
@@ -55,10 +61,12 @@ export const {
|
||||
label: 'Warning Solved',
|
||||
description: 'A previously reported warning has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
},
|
||||
icon: 'ri:check-fill',
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'green',
|
||||
isSolved: true,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'ALERT',
|
||||
@@ -68,8 +76,10 @@ export const {
|
||||
classNames: {
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
},
|
||||
icon: 'ri:alert-fill',
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'red',
|
||||
isSolved: false,
|
||||
showBanner: true,
|
||||
},
|
||||
{
|
||||
id: 'ALERT_SOLVED',
|
||||
@@ -77,10 +87,12 @@ export const {
|
||||
label: 'Alert Solved',
|
||||
description: 'A previously reported alert has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
},
|
||||
icon: 'ri:check-fill',
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'green',
|
||||
isSolved: true,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'INFO',
|
||||
@@ -92,6 +104,8 @@ export const {
|
||||
},
|
||||
icon: 'ri:information-fill',
|
||||
color: 'sky',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'NORMAL',
|
||||
@@ -103,6 +117,8 @@ export const {
|
||||
},
|
||||
icon: 'ri:notification-fill',
|
||||
color: 'green',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'UPDATE',
|
||||
@@ -114,6 +130,8 @@ export const {
|
||||
},
|
||||
icon: 'ri:pencil-fill',
|
||||
color: 'sky',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
] as const satisfies EventTypeInfo<EventType>[]
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
||||
export const SUPPORT_EMAIL = 'contact@kycnot.me'
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||
import type { ServiceSuggestionType } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
slug: string
|
||||
label: string
|
||||
icon: string
|
||||
order: number
|
||||
default: boolean
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -25,9 +29,11 @@ export const {
|
||||
(value): ServiceSuggestionTypeInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||
icon: 'ri:question-line',
|
||||
order: Infinity,
|
||||
default: false,
|
||||
color: 'zinc',
|
||||
}),
|
||||
[
|
||||
{
|
||||
@@ -35,14 +41,18 @@ export const {
|
||||
slug: 'create',
|
||||
label: 'Create',
|
||||
icon: 'ri:add-line',
|
||||
order: 1,
|
||||
default: true,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'EDIT_SERVICE',
|
||||
slug: 'edit',
|
||||
label: 'Edit',
|
||||
icon: 'ri:pencil-line',
|
||||
order: 2,
|
||||
default: false,
|
||||
color: 'blue',
|
||||
},
|
||||
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
|
||||
slug: string
|
||||
label: string
|
||||
description: string
|
||||
longDescription: string
|
||||
icon: string
|
||||
iconClass: string
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export const {
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
description: '',
|
||||
longDescription: '',
|
||||
icon: 'ri:eye-line',
|
||||
iconClass: 'text-current/60',
|
||||
}),
|
||||
@@ -37,6 +39,7 @@ export const {
|
||||
slug: 'public',
|
||||
label: 'Public',
|
||||
description: 'Listed in search and browse.',
|
||||
longDescription: 'Listed in search and browse.',
|
||||
icon: 'ri:global-line',
|
||||
iconClass: 'text-green-500',
|
||||
},
|
||||
@@ -45,6 +48,7 @@ export const {
|
||||
slug: 'unlisted',
|
||||
label: 'Unlisted',
|
||||
description: 'Only accessible via direct link.',
|
||||
longDescription: "Unlisted service, only accessible via direct link and won't appear in searches.",
|
||||
icon: 'ri:link',
|
||||
iconClass: 'text-yellow-500',
|
||||
},
|
||||
@@ -53,8 +57,19 @@ export const {
|
||||
slug: 'hidden',
|
||||
label: 'Hidden',
|
||||
description: 'Only visible to moderators.',
|
||||
longDescription: 'Hidden service, only visible to moderators.',
|
||||
icon: 'ri:lock-line',
|
||||
iconClass: 'text-red-500',
|
||||
},
|
||||
{
|
||||
value: 'ARCHIVED',
|
||||
slug: 'archived',
|
||||
label: 'Archived',
|
||||
description: 'No longer operational',
|
||||
longDescription:
|
||||
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
||||
icon: 'ri:archive-line',
|
||||
iconClass: 'text-day-100',
|
||||
},
|
||||
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
|
||||
)
|
||||
|
||||
4
web/src/icons/nostr.svg
Normal file
4
web/src/icons/nostr.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 620 620">
|
||||
<path
|
||||
d="M620 270v328c0 12-10 22-22 22H332c-12 0-22-10-22-22v-61c1-75 9-147 26-179 9-20 26-30 44-36 36-11 98-4 124-5 0 0 80 3 80-42 0-37-36-34-36-34-39 1-69-1-88-9-33-13-34-36-34-44-1-91-134-102-252-79-128 24 2 209 2 456v33c0 12-10 22-22 22H22c-12 0-22-10-22-22V31C0 19 10 9 22 9h124c12 0 22 10 22 22 0 19 20 29 35 18C248 17 305 0 369 0c143 0 251 84 251 270Zm-238-66c0-27-21-48-47-48s-47 21-47 48c0 26 21 48 47 48s47-22 47-48Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
5
web/src/icons/simplex.svg
Normal file
5
web/src/icons/simplex.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd"
|
||||
d="m2.14 6.31 3.95 3.79 3.97-3.81L6.11 2.5 8.13.57l3.94 3.79L16.1.5l1.98 1.9-4.03 3.85L18 10.03l4.03-3.85L24 8.07l-4.03 3.86 3.95 3.78-2.01 1.93-3.95-3.78-4.03 3.85 3.95 3.79-2 1.93-3.96-3.79L7.9 23.5l-1.98-1.9 4.06-3.89-3.95-3.78-4.06 3.89L0 15.92l4.06-3.88L.1 8.26 2.14 6.3Zm13.85 5.65L12 15.77 8.06 12l3.98-3.81 3.95 3.78Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -2,6 +2,7 @@ import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
|
||||
import { formatDateShort } from './timeAgo'
|
||||
@@ -36,6 +37,7 @@ export function makeNonDbAttributes(
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
@@ -134,6 +136,16 @@ export function makeNonDbAttributes(
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
show: service.serviceVisibility === 'ARCHIVED',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||
privacyPoints: 0,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: 'Recently listed',
|
||||
show: service.isRecentlyListed,
|
||||
|
||||
@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
export function separateServiceUrlsByType(allServiceUrls: string[]) {
|
||||
const result: {
|
||||
web: string[]
|
||||
onion: string[]
|
||||
i2p: string[]
|
||||
} = {
|
||||
web: [],
|
||||
onion: [],
|
||||
i2p: [],
|
||||
}
|
||||
|
||||
for (const url of allServiceUrls) {
|
||||
const parsedUrl = new URL(url)
|
||||
if (parsedUrl.origin.endsWith('.onion')) {
|
||||
result.onion.push(url)
|
||||
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
|
||||
result.i2p.push(url)
|
||||
} else {
|
||||
result.web.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
|
||||
}[]
|
||||
|
||||
const env =
|
||||
// This file can also be called from faker.ts, where import.meta.env is not available
|
||||
// This file can also be called from seed.ts, where import.meta.env is not available
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(import.meta.env
|
||||
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))
|
||||
|
||||
@@ -16,10 +16,10 @@ export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
|
||||
export const zodUrlOptionalProtocol = z.preprocess(
|
||||
(input) => {
|
||||
if (typeof input !== 'string') return input
|
||||
const trimmedVal = input.trim()
|
||||
return !/^\w+:\/\//i.test(trimmedVal) ? `https://${trimmedVal}` : trimmedVal
|
||||
const cleanInput = input.trim().replace(/\/$/, '')
|
||||
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
||||
},
|
||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||
message: 'Invalid URL',
|
||||
})
|
||||
)
|
||||
|
||||
@@ -67,6 +67,17 @@ Once submitted, you get a unique tracking page where you can monitor its status
|
||||
|
||||
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
|
||||
|
||||
#### Requirements
|
||||
|
||||
To list a new service, it must fulfill these requirements:
|
||||
|
||||
- Offer a service.
|
||||
- Publicly available website explaining what the service is about
|
||||
- Terms of service or FAQ document
|
||||
|
||||
For examples:
|
||||
- Just a Telegram link or a criptocurrency itself is not a valid service.
|
||||
|
||||
### Suggestion Review Process
|
||||
|
||||
#### First Review
|
||||
|
||||
@@ -50,6 +50,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
|
||||
<a
|
||||
href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })}
|
||||
data-astro-reload
|
||||
data-astro-prefetch="tap"
|
||||
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
||||
>
|
||||
<Icon
|
||||
@@ -62,6 +63,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
|
||||
Astro.locals.actualUser && (
|
||||
<a
|
||||
href={makeUnimpersonateUrl(Astro.url, { redirect })}
|
||||
data-astro-prefetch="tap"
|
||||
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { DATABASE_UI_URL } from 'astro:env/server'
|
||||
import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
|
||||
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../lib/cn'
|
||||
@@ -81,6 +81,18 @@ const adminLinks: AdminLink[] = [
|
||||
base: 'text-gray-300',
|
||||
},
|
||||
},
|
||||
...(LOGS_UI_URL
|
||||
? [
|
||||
{
|
||||
icon: 'ri:menu-search-line',
|
||||
title: 'Logs',
|
||||
href: LOGS_UI_URL,
|
||||
classNames: {
|
||||
base: 'text-cyan-300',
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
---
|
||||
|
||||
@@ -93,25 +105,27 @@ const adminLinks: AdminLink[] = [
|
||||
<nav>
|
||||
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
|
||||
{
|
||||
adminLinks.map((link) => (
|
||||
<li
|
||||
class={cn(
|
||||
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
|
||||
link.classNames.base
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href={link.href}
|
||||
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
|
||||
adminLinks
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<li
|
||||
class={cn(
|
||||
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
|
||||
link.classNames.base
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={link.icon}
|
||||
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
|
||||
/>
|
||||
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
<a
|
||||
href={link.href}
|
||||
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
|
||||
>
|
||||
<Icon
|
||||
name={link.icon}
|
||||
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
|
||||
/>
|
||||
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||
import Button from '../../../components/Button.astro'
|
||||
import Chat from '../../../components/Chat.astro'
|
||||
import ServiceCard from '../../../components/ServiceCard.astro'
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
getServiceSuggestionStatusInfo,
|
||||
serviceSuggestionStatuses,
|
||||
} from '../../../constants/serviceSuggestionStatus'
|
||||
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../../lib/cn'
|
||||
import { parseIntWithFallback } from '../../../lib/numbers'
|
||||
@@ -57,6 +59,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
||||
imageUrl: true,
|
||||
verificationStatus: true,
|
||||
acceptedCurrencies: true,
|
||||
serviceVisibility: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -92,6 +95,7 @@ if (!serviceSuggestion) {
|
||||
}
|
||||
|
||||
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -110,7 +114,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||
label="Back"
|
||||
/>
|
||||
|
||||
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
|
||||
<h1 class="font-title text-day-200 text-xl">Service suggestion</h1>
|
||||
|
||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
@@ -118,12 +124,13 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-green-500/30 bg-black/40 p-4 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
|
||||
>
|
||||
<h2 class="font-title mb-3 text-lg text-green-500">Suggestion Details</h2>
|
||||
<div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
|
||||
<h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
|
||||
|
||||
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<span class="font-title text-gray-400">Type:</span>
|
||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||
|
||||
<span class="font-title text-gray-400">Status:</span>
|
||||
<span
|
||||
class={cn(
|
||||
@@ -142,7 +149,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
|
||||
|
||||
<span class="font-title text-gray-400">Service page:</span>
|
||||
<a href={`/service/${serviceSuggestion.service.slug}`} class="text-green-400 hover:text-green-500">
|
||||
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
|
||||
View Service <Icon
|
||||
name="ri:external-link-line"
|
||||
class="ml-0.5 inline-block size-3 align-[-0.05em]"
|
||||
@@ -164,11 +171,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
|
||||
>
|
||||
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-title text-lg text-green-500">Messages</h2>
|
||||
<h2 class="font-title text-day-200 text-lg">Messages</h2>
|
||||
|
||||
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
|
||||
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { actions } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||
import Button from '../../../components/Button.astro'
|
||||
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
||||
import TimeFormatted from '../../../components/TimeFormatted.astro'
|
||||
@@ -12,59 +13,67 @@ import UserBadge from '../../../components/UserBadge.astro'
|
||||
import {
|
||||
getServiceSuggestionStatusInfo,
|
||||
serviceSuggestionStatuses,
|
||||
serviceSuggestionStatusesZodEnumBySlug,
|
||||
serviceSuggestionStatusSlugToId,
|
||||
} from '../../../constants/serviceSuggestionStatus'
|
||||
import {
|
||||
getServiceSuggestionTypeInfo,
|
||||
serviceSuggestionTypes,
|
||||
serviceSuggestionTypeSlugToId,
|
||||
serviceSuggestionTypesZodEnumBySlug,
|
||||
} from '../../../constants/serviceSuggestionType'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../../lib/redirectUrls'
|
||||
|
||||
import type { Prisma, ServiceSuggestionStatus } from '@prisma/client'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const user = Astro.locals.user
|
||||
if (!user?.admin) {
|
||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
|
||||
}
|
||||
|
||||
const search = Astro.url.searchParams.get('search') ?? ''
|
||||
const statusEnumValues = serviceSuggestionStatuses.map((s) => s.value) as [string, ...string[]]
|
||||
const statusParam = Astro.url.searchParams.get('status')
|
||||
const statusFilter = z
|
||||
.enum(statusEnumValues)
|
||||
.nullable()
|
||||
.parse(statusParam === '' ? null : statusParam) as ServiceSuggestionStatus | null
|
||||
|
||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
'sort-by': z.enum(['service', 'status', 'user', 'createdAt', 'messageCount']).default('createdAt'),
|
||||
search: z.string().optional(),
|
||||
status: serviceSuggestionStatusesZodEnumBySlug
|
||||
.transform((slug) => serviceSuggestionStatusSlugToId(slug))
|
||||
.optional(),
|
||||
type: serviceSuggestionTypesZodEnumBySlug
|
||||
.transform((slug) => serviceSuggestionTypeSlugToId(slug))
|
||||
.optional(),
|
||||
'sort-by': z
|
||||
.enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount'])
|
||||
.default('createdAt'),
|
||||
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
const sortBy = filters['sort-by']
|
||||
const sortOrder = filters['sort-order']
|
||||
|
||||
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
|
||||
if (sortBy === 'createdAt') {
|
||||
prismaOrderBy = { createdAt: sortOrder }
|
||||
if (filters['sort-by'] === 'createdAt') {
|
||||
prismaOrderBy = { createdAt: filters['sort-order'] }
|
||||
}
|
||||
|
||||
let suggestions = await prisma.serviceSuggestion.findMany({
|
||||
where: {
|
||||
...(search
|
||||
...(filters.search
|
||||
? {
|
||||
OR: [
|
||||
{ service: { name: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { name: { contains: search, mode: 'insensitive' } } },
|
||||
{ notes: { contains: search, mode: 'insensitive' } },
|
||||
{ service: { name: { contains: filters.search, mode: 'insensitive' } } },
|
||||
{ user: { name: { contains: filters.search, mode: 'insensitive' } } },
|
||||
{ notes: { contains: filters.search, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
status: statusFilter ?? undefined,
|
||||
status: filters.status,
|
||||
type: filters.type,
|
||||
},
|
||||
orderBy: prismaOrderBy,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
@@ -119,18 +128,33 @@ let suggestions = await prisma.serviceSuggestion.findMany({
|
||||
let suggestionsWithDetails = suggestions.map((s) => ({
|
||||
...s,
|
||||
statusInfo: getServiceSuggestionStatusInfo(s.status),
|
||||
typeInfo: getServiceSuggestionTypeInfo(s.type),
|
||||
messageCount: s._count.messages,
|
||||
lastMessage: s.messages[0],
|
||||
}))
|
||||
|
||||
if (sortBy === 'service') {
|
||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder])
|
||||
} else if (sortBy === 'status') {
|
||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
|
||||
} else if (sortBy === 'user') {
|
||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder])
|
||||
} else if (sortBy === 'messageCount') {
|
||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
|
||||
if (filters['sort-by'] === 'service') {
|
||||
suggestionsWithDetails = orderBy(
|
||||
suggestionsWithDetails,
|
||||
[(s) => s.service.name.toLowerCase()],
|
||||
[filters['sort-order']]
|
||||
)
|
||||
} else if (filters['sort-by'] === 'status') {
|
||||
suggestionsWithDetails = orderBy(
|
||||
suggestionsWithDetails,
|
||||
[(s) => s.statusInfo.label],
|
||||
[filters['sort-order']]
|
||||
)
|
||||
} else if (filters['sort-by'] === 'type') {
|
||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.typeInfo.label], [filters['sort-order']])
|
||||
} else if (filters['sort-by'] === 'user') {
|
||||
suggestionsWithDetails = orderBy(
|
||||
suggestionsWithDetails,
|
||||
[(s) => s.user.name.toLowerCase()],
|
||||
[filters['sort-order']]
|
||||
)
|
||||
} else if (filters['sort-by'] === 'messageCount') {
|
||||
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [filters['sort-order']])
|
||||
}
|
||||
|
||||
const suggestionCount = suggestionsWithDetails.length
|
||||
@@ -162,7 +186,7 @@ const makeSortUrl = (slug: string) => {
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
value={search}
|
||||
value={filters.search}
|
||||
placeholder="Search by service, user, notes..."
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
@@ -177,13 +201,30 @@ const makeSortUrl = (slug: string) => {
|
||||
<option value="">All Statuses</option>
|
||||
{
|
||||
serviceSuggestionStatuses.map((status) => (
|
||||
<option value={status.value} selected={statusFilter === status.value}>
|
||||
<option value={status.slug} selected={filters.status === status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="type-filter" class="block text-xs font-medium text-zinc-400">Type</label>
|
||||
<select
|
||||
name="type"
|
||||
id="type-filter"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{
|
||||
serviceSuggestionTypes.map((type) => (
|
||||
<option value={type.slug} selected={filters.type === type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
as="button"
|
||||
@@ -212,7 +253,7 @@ const makeSortUrl = (slug: string) => {
|
||||
<thead class="bg-zinc-900/30">
|
||||
<tr>
|
||||
<th
|
||||
class="w-[25%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
class="w-[20%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
|
||||
Service <SortArrowIcon
|
||||
@@ -222,7 +263,7 @@ const makeSortUrl = (slug: string) => {
|
||||
</a>
|
||||
</th>
|
||||
<th
|
||||
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
class="w-[12%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200">
|
||||
User <SortArrowIcon
|
||||
@@ -232,7 +273,17 @@ const makeSortUrl = (slug: string) => {
|
||||
</a>
|
||||
</th>
|
||||
<th
|
||||
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
class="w-[10%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a href={makeSortUrl('type')} class="flex items-center hover:text-zinc-200">
|
||||
Type <SortArrowIcon
|
||||
active={filters['sort-by'] === 'type'}
|
||||
sortOrder={filters['sort-order']}
|
||||
/>
|
||||
</a>
|
||||
</th>
|
||||
<th
|
||||
class="w-[13%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200">
|
||||
Status <SortArrowIcon
|
||||
@@ -295,6 +346,13 @@ const makeSortUrl = (slug: string) => {
|
||||
<td class="px-4 py-3">
|
||||
<UserBadge user={suggestion.user} size="md" />
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<BadgeSmall
|
||||
color={suggestion.typeInfo.color}
|
||||
text={suggestion.typeInfo.label}
|
||||
icon={suggestion.typeInfo.icon}
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<form method="POST" action={actions.admin.serviceSuggestions.update}>
|
||||
<input type="hidden" name="suggestionId" value={suggestion.id} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
@@ -76,6 +77,15 @@ const verificationStepUpdateInputErrors = isInputError(verificationStepUpdateRes
|
||||
const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete)
|
||||
Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully')
|
||||
|
||||
const internalNoteCreateResult = Astro.getActionResult(actions.admin.service.internalNote.add)
|
||||
Astro.locals.banners.addIfSuccess(internalNoteCreateResult, 'Internal note added successfully')
|
||||
const internalNoteInputErrors = isInputError(internalNoteCreateResult?.error)
|
||||
? internalNoteCreateResult.error.fields
|
||||
: {}
|
||||
|
||||
const internalNoteDeleteResult = Astro.getActionResult(actions.admin.service.internalNote.delete)
|
||||
Astro.locals.banners.addIfSuccess(internalNoteDeleteResult, 'Internal note deleted successfully')
|
||||
|
||||
const [service, categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
[
|
||||
'Error fetching service',
|
||||
@@ -130,6 +140,20 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
label: 'asc',
|
||||
},
|
||||
},
|
||||
internalNotes: {
|
||||
include: {
|
||||
addedByUser: {
|
||||
select: {
|
||||
name: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
verificationRequests: true,
|
||||
@@ -209,16 +233,30 @@ if (!service) return Astro.rewrite('/404')
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<input type="hidden" name="id" value={service.id} />
|
||||
<InputText
|
||||
label="Name"
|
||||
name="name"
|
||||
inputProps={{
|
||||
required: true,
|
||||
value: service.name,
|
||||
}}
|
||||
error={serviceInputErrors.name}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
||||
<InputText
|
||||
label="Name"
|
||||
name="name"
|
||||
inputProps={{
|
||||
required: true,
|
||||
value: service.name,
|
||||
}}
|
||||
error={serviceInputErrors.name}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Slug"
|
||||
description="Auto-generated if empty"
|
||||
name="slug"
|
||||
inputProps={{
|
||||
value: service.slug,
|
||||
class: 'font-title',
|
||||
}}
|
||||
error={serviceInputErrors.slug}
|
||||
class="font-title"
|
||||
/>
|
||||
</div>
|
||||
<InputTextArea
|
||||
label="Description"
|
||||
name="description"
|
||||
@@ -230,84 +268,64 @@ if (!service) return Astro.rewrite('/404')
|
||||
error={serviceInputErrors.description}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Slug"
|
||||
description="Auto-generated if empty"
|
||||
name="slug"
|
||||
inputProps={{
|
||||
value: service.slug,
|
||||
class: 'font-title',
|
||||
}}
|
||||
error={serviceInputErrors.slug}
|
||||
class="font-title"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InputTextArea
|
||||
label="Service URLs"
|
||||
description="One per line"
|
||||
name="serviceUrls"
|
||||
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
|
||||
name="allServiceUrls"
|
||||
inputProps={{
|
||||
rows: 3,
|
||||
placeholder: 'https://example1.com\nhttps://example2.com',
|
||||
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
|
||||
class: 'grow min-h-24',
|
||||
required: true,
|
||||
}}
|
||||
value={service.serviceUrls.join('\n')}
|
||||
error={serviceInputErrors.serviceUrls}
|
||||
class="row-span-2 flex flex-col self-stretch"
|
||||
value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
|
||||
error={serviceInputErrors.allServiceUrls}
|
||||
/>
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line"
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
rows: 3,
|
||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||
required: true,
|
||||
}}
|
||||
value={service.tosUrls.join('\n')}
|
||||
error={serviceInputErrors.tosUrls}
|
||||
/>
|
||||
<InputTextArea
|
||||
label="Onion URLs"
|
||||
description="One per line"
|
||||
name="onionUrls"
|
||||
<InputText
|
||||
label="Referral link path"
|
||||
name="referral"
|
||||
inputProps={{
|
||||
rows: 3,
|
||||
placeholder: 'http://example1.onion\nhttp://example2.onion',
|
||||
value: service.referral,
|
||||
placeholder: 'e.g. ?ref=123 or /ref/123',
|
||||
}}
|
||||
value={service.onionUrls.join('\n')}
|
||||
error={serviceInputErrors.onionUrls}
|
||||
/>
|
||||
<InputTextArea
|
||||
label="I2P URLs"
|
||||
description="One per line"
|
||||
name="i2pUrls"
|
||||
inputProps={{
|
||||
rows: 3,
|
||||
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
|
||||
}}
|
||||
value={service.i2pUrls.join('\n')}
|
||||
error={serviceInputErrors.referral}
|
||||
class="self-end"
|
||||
description="Will be appended to the service URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputText
|
||||
label="Referral Code/Link"
|
||||
name="referral"
|
||||
inputProps={{
|
||||
value: service.referral ?? undefined,
|
||||
placeholder: 'e.g., REFCODE123 or https://example.com?ref=123',
|
||||
}}
|
||||
error={serviceInputErrors.referral}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<InputImageFile
|
||||
label="Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
|
||||
error={serviceInputErrors.imageFile}
|
||||
square
|
||||
value={service.imageUrl}
|
||||
downloadButton
|
||||
removeCheckbox={service.imageUrl
|
||||
? {
|
||||
name: 'removeImage',
|
||||
label: 'Remove image',
|
||||
}
|
||||
: undefined}
|
||||
class="grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputImageFile
|
||||
label="Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
|
||||
error={serviceInputErrors.imageFile}
|
||||
square
|
||||
value={service.imageUrl}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 items-stretch gap-x-4 gap-y-6 sm:grid-cols-[1fr_2fr]">
|
||||
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
@@ -320,6 +338,7 @@ if (!service) return Astro.rewrite('/404')
|
||||
}))}
|
||||
selectedValues={service.categories.map((c) => c.id.toString())}
|
||||
error={serviceInputErrors.categories}
|
||||
class="min-w-auto"
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
@@ -436,6 +455,108 @@ if (!service) return Astro.rewrite('/404')
|
||||
</form>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="Internal Notes" id="internal-notes">
|
||||
<FormSubSection title="Existing Notes">
|
||||
{
|
||||
service.internalNotes.length === 0 ? (
|
||||
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
|
||||
No internal notes yet.
|
||||
</p>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{service.internalNotes.map((note) => (
|
||||
<div class="border-night-600 bg-night-800 rounded-md border p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer/edit-note sr-only"
|
||||
data-edit-note-checkbox
|
||||
id={`edit-note-${note.id}`}
|
||||
/>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-grow space-y-1">
|
||||
<div
|
||||
data-note-content
|
||||
class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty"
|
||||
>
|
||||
<Markdown content={note.content} />
|
||||
</div>
|
||||
<div class="text-day-500 flex items-center gap-2 text-xs">
|
||||
<TimeFormatted date={note.createdAt} hourPrecision />
|
||||
{note.addedByUser && (
|
||||
<span class="flex items-center gap-1">
|
||||
by <UserBadge user={note.addedByUser} size="sm" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
as="label"
|
||||
for={`edit-note-${note.id}`}
|
||||
variant="faded"
|
||||
size="sm"
|
||||
icon="ri:edit-line"
|
||||
iconOnly
|
||||
label="Edit"
|
||||
/>
|
||||
|
||||
<form method="POST" action={actions.admin.service.internalNote.delete} class="contents">
|
||||
<input type="hidden" name="noteId" value={note.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
icon="ri:delete-bin-line"
|
||||
iconOnly
|
||||
label="Delete"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.admin.service.internalNote.update}
|
||||
data-note-edit-form
|
||||
data-astro-reload
|
||||
class="mt-4 hidden space-y-4 peer-checked/edit-note:block"
|
||||
>
|
||||
<input type="hidden" name="noteId" value={note.id} />
|
||||
<InputTextArea
|
||||
label="Note Content"
|
||||
name="content"
|
||||
value={note.content}
|
||||
inputProps={{ class: 'bg-night-700' }}
|
||||
/>
|
||||
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormSubSection>
|
||||
|
||||
<FormSubSection title="Add New Note">
|
||||
<form method="POST" action={actions.admin.service.internalNote.add} class="space-y-4">
|
||||
<input type="hidden" name="serviceId" value={service.id} />
|
||||
<InputTextArea
|
||||
label="Note Content"
|
||||
name="content"
|
||||
inputProps={{
|
||||
required: true,
|
||||
rows: 4,
|
||||
placeholder: 'Add internal note about this service...',
|
||||
}}
|
||||
error={internalNoteInputErrors.content}
|
||||
/>
|
||||
<InputSubmitButton label="Add Note" icon="ri:add-line" hideCancel />
|
||||
</form>
|
||||
</FormSubSection>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="Events">
|
||||
<FormSubSection title="Existing Events">
|
||||
{
|
||||
@@ -478,28 +599,43 @@ if (!service) return Astro.rewrite('/404')
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<form method="POST" action={actions.admin.event.toggle} class="contents">
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<Tooltip text={event.visible ? 'Hide Event' : 'Show Event'}>
|
||||
<Tooltip text={event.visible ? 'Hide' : 'Show'}>
|
||||
<form method="POST" action={actions.admin.event.toggle} class="contents">
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'}
|
||||
iconOnly
|
||||
label={event.visible ? 'Hide' : 'Show'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</form>
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
icon="ri:pencil-line"
|
||||
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
|
||||
/>
|
||||
<form method="POST" action={actions.admin.event.delete} class="contents">
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
|
||||
</form>
|
||||
</form>
|
||||
</Tooltip>
|
||||
<Tooltip text="Edit">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
icon="ri:pencil-line"
|
||||
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
|
||||
iconOnly
|
||||
label="Edit"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip text="Delete">
|
||||
<form method="POST" action={actions.admin.event.delete} class="contents">
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
icon="ri:delete-bin-line"
|
||||
iconOnly
|
||||
label="Delete"
|
||||
/>
|
||||
</form>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Edit Event Form - Hidden by default */}
|
||||
@@ -531,9 +667,14 @@ if (!service) return Astro.rewrite('/404')
|
||||
label="Started At"
|
||||
name="startedAt"
|
||||
inputProps={{
|
||||
type: 'date',
|
||||
type: 'datetime-local',
|
||||
required: true,
|
||||
value: new Date(event.startedAt).toISOString().split('T')[0],
|
||||
value: new Date(
|
||||
new Date(event.startedAt).getTime() -
|
||||
new Date(event.startedAt).getTimezoneOffset() * 60000
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16),
|
||||
}}
|
||||
error={eventUpdateInputErrors.startedAt}
|
||||
/>
|
||||
@@ -542,7 +683,15 @@ if (!service) return Astro.rewrite('/404')
|
||||
label="Ended At"
|
||||
name="endedAt"
|
||||
inputProps={{
|
||||
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '',
|
||||
type: 'datetime-local',
|
||||
value: event.endedAt
|
||||
? new Date(
|
||||
new Date(event.endedAt).getTime() -
|
||||
new Date(event.endedAt).getTimezoneOffset() * 60000
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: '',
|
||||
}}
|
||||
error={eventUpdateInputErrors.endedAt}
|
||||
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
|
||||
@@ -602,9 +751,11 @@ if (!service) return Astro.rewrite('/404')
|
||||
label="Started At"
|
||||
name="startedAt"
|
||||
inputProps={{
|
||||
type: 'date',
|
||||
type: 'datetime-local',
|
||||
required: true,
|
||||
value: new Date().toISOString().split('T')[0],
|
||||
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16),
|
||||
}}
|
||||
error={eventInputErrors.startedAt}
|
||||
/>
|
||||
@@ -613,7 +764,10 @@ if (!service) return Astro.rewrite('/404')
|
||||
label="Ended At"
|
||||
name="endedAt"
|
||||
inputProps={{
|
||||
value: new Date().toISOString().split('T')[0],
|
||||
type: 'datetime-local',
|
||||
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16),
|
||||
}}
|
||||
error={eventInputErrors.endedAt}
|
||||
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
|
||||
@@ -673,17 +827,30 @@ if (!service) return Astro.rewrite('/404')
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
icon="ri:pencil-line"
|
||||
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`}
|
||||
/>
|
||||
<form method="POST" action={actions.admin.verificationStep.delete} class="inline">
|
||||
<input type="hidden" name="id" value={step.id} />
|
||||
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
|
||||
</form>
|
||||
<Tooltip text="Edit">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
icon="ri:pencil-line"
|
||||
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`}
|
||||
iconOnly
|
||||
label="Edit"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip text="Delete">
|
||||
<form method="POST" action={actions.admin.verificationStep.delete} class="inline">
|
||||
<input type="hidden" name="id" value={step.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
icon="ri:delete-bin-line"
|
||||
iconOnly
|
||||
label="Delete"
|
||||
/>
|
||||
</form>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -844,21 +1011,34 @@ if (!service) return Astro.rewrite('/404')
|
||||
<p class="text-day-400 text-sm text-pretty">{method.value}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
icon="ri:pencil-line"
|
||||
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
|
||||
/>
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.admin.service.deleteContactMethod}
|
||||
class="contents"
|
||||
>
|
||||
<input type="hidden" name="id" value={method.id} />
|
||||
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
|
||||
</form>
|
||||
<Tooltip text="Edit">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
icon="ri:pencil-line"
|
||||
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
|
||||
iconOnly
|
||||
label="Edit"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip text="Delete">
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.admin.service.contactMethod.delete}
|
||||
class="contents"
|
||||
>
|
||||
<input type="hidden" name="id" value={method.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
icon="ri:delete-bin-line"
|
||||
iconOnly
|
||||
label="Delete"
|
||||
/>
|
||||
</form>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -866,7 +1046,7 @@ if (!service) return Astro.rewrite('/404')
|
||||
<form
|
||||
id={`edit-contact-method-${method.id}`}
|
||||
method="POST"
|
||||
action={actions.admin.service.updateContactMethod}
|
||||
action={actions.admin.service.contactMethod.update}
|
||||
class="border-night-500 bg-night-700 mt-3 hidden space-y-3 rounded-md border p-3"
|
||||
>
|
||||
<input type="hidden" name="id" value={method.id} />
|
||||
@@ -901,7 +1081,7 @@ if (!service) return Astro.rewrite('/404')
|
||||
</FormSubSection>
|
||||
|
||||
<FormSubSection title="Add New Contact Method">
|
||||
<form method="POST" action={actions.admin.service.createContactMethod} class="space-y-2">
|
||||
<form method="POST" action={actions.admin.service.contactMethod.add} class="space-y-2">
|
||||
<input type="hidden" name="serviceId" value={service.id} />
|
||||
|
||||
<InputText label="Override Label" name="label" />
|
||||
|
||||
@@ -8,7 +8,8 @@ import MyPicture from '../../../components/MyPicture.astro'
|
||||
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
|
||||
import Tooltip from '../../../components/Tooltip.astro'
|
||||
import { getKycLevelInfo } from '../../../constants/kycLevels'
|
||||
import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
|
||||
import { serviceVisibilities } from '../../../constants/serviceVisibility'
|
||||
import { getVerificationStatusInfo, verificationStatuses } from '../../../constants/verificationStatus'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../../lib/cn'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
|
||||
@@ -110,6 +111,7 @@ const services = await Astro.locals.banners.try(
|
||||
_count: {
|
||||
select: {
|
||||
verificationRequests: true,
|
||||
internalNotes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -209,11 +211,11 @@ const truncate = (text: string, length: number) => {
|
||||
id="visibility"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Visibilities</option>
|
||||
<option value="">All</option>
|
||||
{
|
||||
Object.values(ServiceVisibility).map((status) => (
|
||||
<option value={status} selected={filters.visibility === status}>
|
||||
{status}
|
||||
serviceVisibilities.map((visibility) => (
|
||||
<option value={visibility.value} selected={filters.visibility === visibility.value}>
|
||||
{visibility.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
@@ -227,11 +229,11 @@ const truncate = (text: string, length: number) => {
|
||||
id="verificationStatus"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="">All</option>
|
||||
{
|
||||
Object.values(VerificationStatus).map((status) => (
|
||||
<option value={status} selected={filters.verificationStatus === status}>
|
||||
{status}
|
||||
verificationStatuses.map((status) => (
|
||||
<option value={status.value} selected={filters.verificationStatus === status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
@@ -439,9 +441,18 @@ const truncate = (text: string, length: number) => {
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
|
||||
{service._count.verificationRequests}
|
||||
</span>
|
||||
<div class="flex flex-col items-center justify-center gap-1">
|
||||
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
|
||||
{service._count.verificationRequests}
|
||||
</span>
|
||||
|
||||
{service._count.internalNotes > 0 && (
|
||||
<span class="inline-flex items-center text-purple-400">
|
||||
<Icon name="ri:sticky-note-line" class="mr-0.5 size-4" />
|
||||
{service._count.internalNotes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
|
||||
@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label>
|
||||
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
|
||||
<textarea
|
||||
transition:persist
|
||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
||||
name="serviceUrls"
|
||||
id="serviceUrls"
|
||||
name="allServiceUrls"
|
||||
id="allServiceUrls"
|
||||
rows={3}
|
||||
placeholder="https://example1.com https://example2.com"
|
||||
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
|
||||
set:text=""
|
||||
/>
|
||||
{
|
||||
inputErrors.serviceUrls && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
|
||||
inputErrors.allServiceUrls && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
|
||||
<textarea
|
||||
transition:persist
|
||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
||||
name="onionUrls"
|
||||
id="onionUrls"
|
||||
rows={3}
|
||||
placeholder="http://example.onion"
|
||||
set:text=""
|
||||
/>
|
||||
{
|
||||
inputErrors.onionUrls && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
|
||||
<div class="space-y-2">
|
||||
@@ -350,13 +332,13 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral</label>
|
||||
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral link path</label>
|
||||
<input
|
||||
transition:persist
|
||||
type="text"
|
||||
name="referral"
|
||||
id="referral"
|
||||
placeholder="Optional referral code/link"
|
||||
placeholder="e.g. ?ref=123 or /ref/123"
|
||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
||||
/>
|
||||
{
|
||||
@@ -366,6 +348,26 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="internalNote" class="font-title mb-2 block text-sm text-green-500">internalNote</label>
|
||||
<div class="space-y-2">
|
||||
<textarea
|
||||
transition:persist
|
||||
name="internalNote"
|
||||
id="internalNote"
|
||||
rows={4}
|
||||
placeholder="Markdown supported"
|
||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
||||
set:text=""
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
inputErrors.internalNote && (
|
||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.internalNote.join(', ')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
|
||||
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||
@@ -304,8 +305,8 @@ if (!user) return Astro.rewrite('/404')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-note-content>
|
||||
<p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
|
||||
<div data-note-content class="prose prose-sm text-day-200 prose-invert max-w-none text-pretty">
|
||||
<Markdown content={note.content} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
|
||||
|
||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
|
||||
'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
|
||||
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
||||
search: z.string().optional(),
|
||||
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
|
||||
@@ -29,7 +29,10 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
|
||||
// Set up Prisma orderBy with correct typing
|
||||
const prismaOrderBy =
|
||||
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma'
|
||||
filters['sort-by'] === 'name' ||
|
||||
filters['sort-by'] === 'createdAt' ||
|
||||
filters['sort-by'] === 'lastLoginAt' ||
|
||||
filters['sort-by'] === 'karma'
|
||||
? {
|
||||
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
|
||||
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
|
||||
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
|
||||
totalKarma: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastLoginAt: true,
|
||||
internalNotes: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
<th
|
||||
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
<a
|
||||
href={makeSortUrl('createdAt')}
|
||||
class="flex items-center justify-center hover:text-zinc-200"
|
||||
>
|
||||
Joined <SortArrowIcon
|
||||
active={filters['sort-by'] === 'createdAt'}
|
||||
sortOrder={filters['sort-order']}
|
||||
/>
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
<a
|
||||
href={makeSortUrl('lastLoginAt')}
|
||||
class="flex items-center justify-center hover:text-zinc-200"
|
||||
>
|
||||
Login <SortArrowIcon
|
||||
active={filters['sort-by'] === 'lastLoginAt'}
|
||||
sortOrder={filters['sort-order']}
|
||||
/>
|
||||
</a>
|
||||
<span class="text-zinc-600">/</span>
|
||||
<a
|
||||
href={makeSortUrl('createdAt')}
|
||||
class="flex items-center justify-center hover:text-zinc-200"
|
||||
>
|
||||
Joined <SortArrowIcon
|
||||
active={filters['sort-by'] === 'createdAt'}
|
||||
sortOrder={filters['sort-order']}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<th
|
||||
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||
>
|
||||
@@ -305,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
{user.totalKarma}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-zinc-400">
|
||||
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} />
|
||||
<td class="px-4 py-3 text-center text-sm">
|
||||
<div class="flex flex-wrap items-center justify-center gap-1 text-center">
|
||||
<TimeFormatted
|
||||
class="text-zinc-300"
|
||||
date={user.lastLoginAt}
|
||||
hourPrecision
|
||||
hoursShort
|
||||
prefix={false}
|
||||
/>
|
||||
<span class="text-zinc-600">/</span>
|
||||
<TimeFormatted
|
||||
class="text-zinc-400"
|
||||
date={user.createdAt}
|
||||
hourPrecision
|
||||
hoursShort
|
||||
prefix={false}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-center gap-3">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
import { ServiceVisibility } from '@prisma/client'
|
||||
import { z } from 'astro:schema'
|
||||
import { groupBy, omit, orderBy, uniq } from 'lodash-es'
|
||||
import seedrandom from 'seedrandom'
|
||||
@@ -222,7 +221,9 @@ const where = {
|
||||
verificationStatus: {
|
||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||
},
|
||||
serviceVisibility: ServiceVisibility.PUBLIC,
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
overallScore: { gte: filters['min-score'] },
|
||||
acceptedCurrencies: filters.currencies.length
|
||||
? filters['currency-mode'] === 'and'
|
||||
@@ -324,7 +325,9 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: 'PUBLIC',
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -372,6 +375,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
imageUrl: true,
|
||||
verificationStatus: true,
|
||||
acceptedCurrencies: true,
|
||||
serviceVisibility: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attribute: {
|
||||
@@ -708,7 +712,6 @@ const showFiltersId = 'show-filters'
|
||||
pageSize={PAGE_SIZE}
|
||||
sortSeed={filters['sort-seed']}
|
||||
filters={filters}
|
||||
includeScams={includeScams}
|
||||
countCommunityOnly={countCommunityOnly}
|
||||
inlineIcons
|
||||
/>
|
||||
|
||||
@@ -3,9 +3,12 @@ import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import AdminOnly from '../../components/AdminOnly.astro'
|
||||
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||
import Button from '../../components/Button.astro'
|
||||
import Chat from '../../components/Chat.astro'
|
||||
import ServiceCard from '../../components/ServiceCard.astro'
|
||||
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
|
||||
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { parseIntWithFallback } from '../../lib/numbers'
|
||||
@@ -28,6 +31,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
||||
prisma.serviceSuggestion.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
@@ -42,6 +46,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
||||
imageUrl: true,
|
||||
verificationStatus: true,
|
||||
acceptedCurrencies: true,
|
||||
serviceVisibility: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -59,6 +64,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
},
|
||||
},
|
||||
@@ -81,6 +87,7 @@ if (!serviceSuggestion) {
|
||||
}
|
||||
|
||||
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -104,17 +111,22 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
|
||||
},
|
||||
]}
|
||||
>
|
||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
|
||||
<div class="mt-12 mb-6 flex flex-col items-center justify-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||
<AdminOnly>
|
||||
<Button
|
||||
as="a"
|
||||
href={`/admin/service-suggestions/${serviceSuggestionIdRaw}`}
|
||||
size="sm"
|
||||
icon="ri:lock-line"
|
||||
label="View in admin"
|
||||
/>
|
||||
</AdminOnly>
|
||||
</div>
|
||||
|
||||
<AdminOnly>
|
||||
<a
|
||||
href={`/admin/service-suggestions/${serviceSuggestionIdRaw}`}
|
||||
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
||||
>
|
||||
<Icon name="ri:lock-line" class="size-4" />
|
||||
View in admin
|
||||
</a>
|
||||
</AdminOnly>
|
||||
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
|
||||
</div>
|
||||
|
||||
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ const service = await Astro.locals.banners.try(
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
serviceVisibility: true,
|
||||
},
|
||||
where: { id: params.serviceId },
|
||||
}),
|
||||
@@ -101,7 +102,7 @@ if (!service) return Astro.rewrite('/404')
|
||||
error={inputErrors.notes}
|
||||
/>
|
||||
|
||||
<Captcha action={actions.serviceSuggestion.createService} />
|
||||
<Captcha action={actions.serviceSuggestion.editService} />
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||
import BadgeStandardFilter from '../../components/BadgeStandardFilter.astro'
|
||||
import Button from '../../components/Button.astro'
|
||||
import MyPicture from '../../components/MyPicture.astro'
|
||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||
@@ -10,10 +12,16 @@ import Tooltip from '../../components/Tooltip.astro'
|
||||
import {
|
||||
getServiceSuggestionStatusInfo,
|
||||
serviceSuggestionStatuses,
|
||||
serviceSuggestionStatusesZodEnumBySlug,
|
||||
serviceSuggestionStatusSlugToId,
|
||||
} from '../../constants/serviceSuggestionStatus'
|
||||
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
|
||||
import {
|
||||
getServiceSuggestionTypeInfo,
|
||||
serviceSuggestionTypes,
|
||||
serviceSuggestionTypeSlugToId,
|
||||
serviceSuggestionTypesZodEnumBySlug,
|
||||
} from '../../constants/serviceSuggestionType'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { zodEnumFromConstant } from '../../lib/arrays'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
@@ -26,8 +34,13 @@ if (!user) {
|
||||
|
||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
serviceId: z.array(z.number().int().positive()).default([]),
|
||||
status: z.array(zodEnumFromConstant(serviceSuggestionStatuses, 'value')).default([]),
|
||||
serviceId: z.array(z.number().int().positive()),
|
||||
status: z.array(
|
||||
serviceSuggestionStatusesZodEnumBySlug.transform((slug) => serviceSuggestionStatusSlugToId(slug))
|
||||
),
|
||||
type: z.array(
|
||||
serviceSuggestionTypesZodEnumBySlug.transform((slug) => serviceSuggestionTypeSlugToId(slug))
|
||||
),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
@@ -52,6 +65,7 @@ const serviceSuggestions = await Astro.locals.banners.try('Error fetching servic
|
||||
where: {
|
||||
id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined,
|
||||
status: filters.status.length > 0 ? { in: filters.status } : undefined,
|
||||
type: filters.type.length > 0 ? { in: filters.type } : undefined,
|
||||
userId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
@@ -104,6 +118,23 @@ const success = !!createResult && !createResult.error
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="text-day-200 mb-2 font-medium">Filter by:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{
|
||||
serviceSuggestionTypes.map((type) => (
|
||||
<BadgeStandardFilter name="type" value={type.slug} label={type.label} icon={type.icon} />
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
serviceSuggestionStatuses.map((status) => (
|
||||
<BadgeStandardFilter name="status" value={status.slug} label={status.label} icon={status.icon} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
serviceSuggestions.length === 0 ? (
|
||||
<p class="text-day-400">No suggestions yet.</p>
|
||||
@@ -137,15 +168,7 @@ const success = !!createResult && !createResult.error
|
||||
<span class="shrink truncate">{suggestion.service.name}</span>
|
||||
</a>
|
||||
|
||||
<Tooltip
|
||||
as="span"
|
||||
class="inline-flex items-center gap-1"
|
||||
text={typeInfo.label}
|
||||
classNames={{ tooltip: 'md:hidden!' }}
|
||||
>
|
||||
<Icon name={typeInfo.icon} class="size-4" />
|
||||
<span class="hidden md:inline">{typeInfo.label}</span>
|
||||
</Tooltip>
|
||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||
|
||||
<Tooltip
|
||||
as="span"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import {
|
||||
SUGGESTION_DESCRIPTION_MAX_LENGTH,
|
||||
@@ -16,6 +17,8 @@ import InputImageFile from '../../components/InputImageFile.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||
import { currencies } from '../../constants/currencies'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
@@ -55,6 +58,8 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
category: true,
|
||||
type: true,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
@@ -196,35 +201,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
error={inputErrors.description}
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Service URLs"
|
||||
name="serviceUrls"
|
||||
inputProps={{
|
||||
required: true,
|
||||
placeholder: 'https://example1.com\nhttps://example2.org',
|
||||
}}
|
||||
error={inputErrors.serviceUrls}
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Terms of Service URLs"
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
required: true,
|
||||
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms',
|
||||
}}
|
||||
error={inputErrors.tosUrls}
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Onion URLs"
|
||||
name="onionUrls"
|
||||
inputProps={{
|
||||
required: true,
|
||||
placeholder: 'http://example1.onion\nhttp://example2.onion',
|
||||
}}
|
||||
error={inputErrors.onionUrls}
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InputTextArea
|
||||
label="Service URLs"
|
||||
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
|
||||
name="allServiceUrls"
|
||||
inputProps={{
|
||||
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
|
||||
class: 'min-h-24',
|
||||
required: true,
|
||||
}}
|
||||
class="row-span-2 flex flex-col self-stretch"
|
||||
error={inputErrors.allServiceUrls}
|
||||
/>
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line"
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||
class: 'md:min-h-24',
|
||||
required: true,
|
||||
}}
|
||||
error={inputErrors.tosUrls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevel"
|
||||
@@ -241,27 +242,41 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
error={inputErrors.kycLevel}
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
error={inputErrors.categories}
|
||||
/>
|
||||
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
size="lg"
|
||||
error={inputErrors.categories}
|
||||
class="min-w-auto"
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="attributes"
|
||||
label="Attributes"
|
||||
options={attributes.map((attribute) => ({
|
||||
label: attribute.title,
|
||||
value: attribute.id.toString(),
|
||||
}))}
|
||||
error={inputErrors.attributes}
|
||||
/>
|
||||
<InputCheckboxGroup
|
||||
name="attributes"
|
||||
label="Attributes"
|
||||
options={orderBy(
|
||||
attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
categoryInfo: getAttributeCategoryInfo(attribute.category),
|
||||
typeInfo: getAttributeTypeInfo(attribute.type),
|
||||
})),
|
||||
['categoryInfo.order', 'typeInfo.order']
|
||||
).map((attribute) => ({
|
||||
label: attribute.title,
|
||||
value: attribute.id.toString(),
|
||||
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
|
||||
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
|
||||
}))}
|
||||
error={inputErrors.attributes}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputCardGroup
|
||||
name="acceptedCurrencies"
|
||||
@@ -305,7 +320,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
|
||||
<script>
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const triggerInputs = document.querySelectorAll<HTMLInputElement>('[data-generate-slug] input')
|
||||
const triggerInputs = document.querySelectorAll<HTMLInputElement>('input[data-generate-slug]')
|
||||
const slugInputs = document.querySelectorAll<HTMLInputElement>('input[name="slug"]')
|
||||
|
||||
triggerInputs.forEach((triggerInput) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { VerificationStepStatus } from '@prisma/client'
|
||||
import { VerificationStepStatus, EventType } from '@prisma/client'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
@@ -16,6 +16,8 @@ import DropdownButton from '../../components/DropdownButton.astro'
|
||||
import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro'
|
||||
import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro'
|
||||
import FormatTimeInterval from '../../components/FormatTimeInterval.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import MyPicture from '../../components/MyPicture.astro'
|
||||
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage'
|
||||
import ScoreGauge from '../../components/ScoreGauge.astro'
|
||||
@@ -23,6 +25,7 @@ import ScoreSquare from '../../components/ScoreSquare.astro'
|
||||
import ServiceLinkButton from '../../components/ServiceLinkButton.astro'
|
||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||
import Tooltip from '../../components/Tooltip.astro'
|
||||
import UserBadge from '../../components/UserBadge.astro'
|
||||
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
|
||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||
@@ -30,7 +33,7 @@ import { formatContactMethod } from '../../constants/contactMethods'
|
||||
import { currencies, getCurrencyInfo } from '../../constants/currencies'
|
||||
import { getEventTypeInfo } from '../../constants/eventTypes'
|
||||
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
|
||||
import { serviceVisibilitiesById } from '../../constants/serviceVisibility'
|
||||
import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
|
||||
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
|
||||
import { getUserSentimentInfo } from '../../constants/userSentiment'
|
||||
import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus'
|
||||
@@ -159,6 +162,21 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
internalNotes: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
addedByUser: {
|
||||
select: {
|
||||
name: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
comments: {
|
||||
@@ -240,7 +258,11 @@ const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.
|
||||
|
||||
if (!service) return Astro.rewrite('/404')
|
||||
|
||||
if (service.serviceVisibility !== 'PUBLIC' && service.serviceVisibility !== 'UNLISTED') {
|
||||
if (
|
||||
service.serviceVisibility !== 'PUBLIC' &&
|
||||
service.serviceVisibility !== 'UNLISTED' &&
|
||||
service.serviceVisibility !== 'ARCHIVED'
|
||||
) {
|
||||
return Astro.rewrite('/404')
|
||||
}
|
||||
|
||||
@@ -356,6 +378,12 @@ const ogImageTemplateData = {
|
||||
score: service.overallScore,
|
||||
imageUrl: service.imageUrl,
|
||||
} satisfies OgImageAllTemplatesWithProps
|
||||
|
||||
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
|
||||
|
||||
const activeAlertOrWarningEvents = service.events.filter(
|
||||
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -457,16 +485,43 @@ const ogImageTemplateData = {
|
||||
]}
|
||||
>
|
||||
{
|
||||
service.serviceVisibility === 'UNLISTED' && (
|
||||
<div class={cn('mb-4 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400')}>
|
||||
activeAlertOrWarningEvents.length > 0 && (
|
||||
<a
|
||||
href="#events"
|
||||
class={cn(
|
||||
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
|
||||
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
|
||||
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={serviceVisibilitiesById.UNLISTED.icon}
|
||||
class={cn('me-1.5 inline-block size-4 align-[-0.15em]', serviceVisibilitiesById.UNLISTED.iconClass)}
|
||||
name={
|
||||
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||
? 'ri:alert-fill'
|
||||
: 'ri:alarm-warning-fill'
|
||||
}
|
||||
class="me-1.5 inline-block size-4 align-[-0.15em]"
|
||||
/>
|
||||
Unlisted service, only accessible via direct link and won't appear in searches.
|
||||
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||
? 'There is an active alert for this service. Click to see details.'
|
||||
: 'There is an active warning for this service. Click to see details.'}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
|
||||
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
|
||||
<Icon
|
||||
name={serviceVisibilityInfo.icon}
|
||||
class="me-1.5 inline-block size-4 align-[-0.15em] text-yellow-500"
|
||||
/>
|
||||
{serviceVisibilityInfo.longDescription}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<VerificationWarningBanner service={service} />
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -742,6 +797,61 @@ const ogImageTemplateData = {
|
||||
)
|
||||
}
|
||||
|
||||
<AdminOnly>
|
||||
<div class="border-day-500 mt-6 mb-3 flex items-center justify-between border-b">
|
||||
<h2 class="font-title text-day-100 text-lg font-bold">Internal Notes</h2>
|
||||
<a
|
||||
href={`/admin/services/${service.slug}/edit#internal-notes`}
|
||||
class="text-day-500 hover:text-day-200 inline-flex items-center gap-1 text-xs leading-none transition-colors hover:underline"
|
||||
>
|
||||
Manage
|
||||
<Icon name="ri:arrow-right-s-line" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{
|
||||
service.internalNotes.length === 0 ? (
|
||||
<p class="text-day-400 mt-2 text-center text-xs">No internal notes yet.</p>
|
||||
) : (
|
||||
<div
|
||||
class={cn(
|
||||
'grid grid-cols-1 items-start gap-2',
|
||||
service.internalNotes.length > 1 && 'sm:grid-cols-2'
|
||||
)}
|
||||
>
|
||||
{service.internalNotes.map((note) => (
|
||||
<div class="border-night-600 bg-night-800 rounded-lg border p-3">
|
||||
<div class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty">
|
||||
<Markdown content={note.content} />
|
||||
</div>
|
||||
|
||||
<div class="text-day-500 mt-2 flex items-center gap-1 text-xs">
|
||||
<TimeFormatted date={note.createdAt} hourPrecision />
|
||||
{note.addedByUser && (
|
||||
<span class="flex items-center gap-1">
|
||||
by <UserBadge user={note.addedByUser} size="sm" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.admin.service.internalNote.add}
|
||||
data-note-edit-form
|
||||
class="mt-4 space-y-4"
|
||||
>
|
||||
<input type="hidden" name="serviceId" value={service.id} />
|
||||
|
||||
<InputTextArea label="Add a note" name="content" />
|
||||
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
|
||||
</form>
|
||||
</AdminOnly>
|
||||
|
||||
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="scores">
|
||||
Scores
|
||||
</h2>
|
||||
@@ -1102,6 +1212,7 @@ const ogImageTemplateData = {
|
||||
|
||||
<div class="mt-3 max-w-md pe-8">
|
||||
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
|
||||
{typeInfo.isSolved && <BadgeSmall text="Solved" icon="ri:check-line" color="green" />}
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
@@ -1245,7 +1356,8 @@ const ogImageTemplateData = {
|
||||
<div class="flex flex-col gap-2">
|
||||
{
|
||||
service.verificationStatus !== 'VERIFICATION_SUCCESS' &&
|
||||
service.verificationStatus !== 'VERIFICATION_FAILED' && (
|
||||
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||
service.serviceVisibility !== 'ARCHIVED' && (
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.service.requestVerification}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { actions } from 'astro:actions'
|
||||
import { sortBy } from 'lodash-es'
|
||||
|
||||
@@ -47,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
|
||||
verifiedLink: true,
|
||||
totalKarma: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
comments: true,
|
||||
@@ -468,6 +470,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<AdminOnly>
|
||||
<li class="flex items-start">
|
||||
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
|
||||
<div>
|
||||
<p class="text-day-500 text-xs">Last login</p>
|
||||
<p class="text-day-300">
|
||||
{
|
||||
formatDateShort(user.lastLoginAt, {
|
||||
prefix: false,
|
||||
hourPrecision: true,
|
||||
caseType: 'sentence',
|
||||
})
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</AdminOnly>
|
||||
|
||||
{
|
||||
user.verifiedLink && (
|
||||
<li class="flex items-start">
|
||||
@@ -525,7 +545,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
<TimeFormatted date={note.createdAt} hourPrecision />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
|
||||
<div class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty">
|
||||
<Markdown content={note.content} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
|
||||
}
|
||||
|
||||
@utility scrollbar-w-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@utility checkbox-force-checked {
|
||||
&:not(:checked) {
|
||||
@apply border-transparent! bg-current/50!;
|
||||
|
||||
Reference in New Issue
Block a user