Compare commits

...

11 Commits

Author SHA1 Message Date
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
pluja
e536ca6519 Release 202505261804 2025-05-26 18:04:45 +00:00
pluja
b361ed3aa8 Release 202505261604 2025-05-26 16:04:25 +00:00
pluja
50ede46d50 release-34 2025-05-26 14:49:37 +00:00
pluja
ba809840c6 Release 202505261445 2025-05-26 14:45:22 +00:00
pluja
f2021a3027 Release 202505261035 2025-05-26 10:35:43 +00:00
pluja
6b86a72d1e Release 2025-05-25-GgNU 2025-05-25 12:28:30 +00:00
pluja
8f2b2c34ff Release 2025-05-25-irZj 2025-05-25 11:21:35 +00:00
56 changed files with 2002 additions and 881 deletions

View File

@@ -5,6 +5,7 @@ alwaysApply: false
--- ---
- We use Prisma as ORM. - 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. - 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: - Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this:
```ts ```ts
type Props = { 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
View File

View File

@@ -23,8 +23,7 @@ cd web
nvm install nvm install
npm i npm i
cp -n .env.example .env cp -n .env.example .env
npm run db-push npm run db-reset
npm run db-fill-clean
``` ```
Now open the [.env](web/.env) file and fill in the missing values. Now open the [.env](web/.env) file and fill in the missing values.

View File

@@ -6,24 +6,23 @@
All commands are run from the root of the project, from a terminal: All commands are run from the root of the project, from a terminal:
| Command | Action | | Command | Action |
| :------------------------ | :------------------------------------------------------------------- | | :------------------------ | :------------------------------------------------------------------ |
| `nvm install` | Installs and uses the correct version of node | | `nvm install` | Installs and uses the correct version of node |
| `npm install` | Installs dependencies | | `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` | | `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` | | `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying | | `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI | | `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run db-admin` | Runs Prisma Studio (database admin) | | `npm run db-admin` | Runs Prisma Studio (database admin) |
| `npm run db-gen` | Generates the Prisma client without running migrations | | `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-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-seed` | Seeds 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 format` | Formats the code with Prettier | | `npm run lint` | Lints the code with ESLint |
| `npm run lint` | Lints the code with ESLint | | `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
| `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)

View File

@@ -177,6 +177,12 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
LOGS_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: true,
}),
RELEASE_NUMBER: envField.number({ RELEASE_NUMBER: envField.number({
context: 'server', context: 'server',

View File

@@ -12,13 +12,15 @@
"db-push": "prisma migrate dev", "db-push": "prisma migrate dev",
"db-triggers": "just import-triggers", "db-triggers": "just import-triggers",
"db-update": "prisma migrate dev && 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-reset": "prisma migrate reset -f && prisma migrate dev",
"db-fill": "tsx scripts/faker.ts", "db-seed": "prisma db seed",
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint .", "lint": "eslint .",
"lint-fix": "eslint . --fix && prettier --write ." "lint-fix": "eslint . --fix && prettier --write ."
}, },
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@astrojs/check": "0.9.4", "@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14", "@astrojs/db": "0.14.14",

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';

View File

@@ -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;

View File

@@ -87,6 +87,7 @@ enum ServiceVisibility {
PUBLIC PUBLIC
UNLISTED UNLISTED
HIDDEN HIDDEN
ARCHIVED
} }
enum Currency { enum Currency {
@@ -376,6 +377,7 @@ model Service {
attributes ServiceAttribute[] attributes ServiceAttribute[]
verificationSteps VerificationStep[] verificationSteps VerificationStep[]
suggestions ServiceSuggestion[] suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices") onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices") onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
@@ -441,6 +443,7 @@ model Attribute {
model InternalUserNote { model InternalUserNote {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
/// Markdown
content String content String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@ -455,6 +458,23 @@ model InternalUserNote {
@@index([createdAt]) @@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 { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
@@ -481,6 +501,7 @@ model User {
suggestionMessages ServiceSuggestionMessage[] suggestionMessages ServiceSuggestionMessage[]
internalNotes InternalUserNote[] @relation("UserRecievedNotes") internalNotes InternalUserNote[] @relation("UserRecievedNotes")
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes") addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
verificationRequests ServiceVerificationRequest[] verificationRequests ServiceVerificationRequest[]
notifications Notification[] @relation("NotificationOwner") notifications Notification[] @relation("NotificationOwner")
notificationPreferences NotificationPreferences? notificationPreferences NotificationPreferences?

View File

@@ -1,20 +1,23 @@
import crypto from 'crypto' import crypto from 'crypto'
import { execSync } from 'node:child_process'
import { parseArgs } from 'node:util'
import { faker } from '@faker-js/faker' import { faker } from '@faker-js/faker'
import { import {
AnnouncementType,
AttributeCategory, AttributeCategory,
AttributeType, AttributeType,
CommentStatus, CommentStatus,
Currency, Currency,
EventType,
PrismaClient, PrismaClient,
ServiceSuggestionStatus, ServiceSuggestionStatus,
ServiceSuggestionType, ServiceUserRole,
VerificationStatus, VerificationStatus,
type Prisma, type Prisma,
EventType,
type User, type User,
ServiceUserRole, type ServiceVisibility,
AnnouncementType, ServiceSuggestionType,
} from '@prisma/client' } from '@prisma/client'
import { uniqBy } from 'lodash-es' import { uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
@@ -95,20 +98,6 @@ async function createAccount(preGeneratedToken?: string) {
return { token, user } 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 prisma = new PrismaClient()
const generateFakeAttribute = () => { const generateFakeAttribute = () => {
@@ -611,7 +600,12 @@ const generateFakeEvent = (serviceId: number) => {
} }
const generateFakeService = (users: User[]) => { 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 name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
@@ -623,6 +617,12 @@ const generateFakeService = (users: User[]) => {
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 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, verificationStatus: status,
verificationSummary: verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -636,25 +636,37 @@ const generateFakeService = (users: User[]) => {
}, },
verificationProofMd: verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null, 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 }), 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()), serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()), tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
onionUrls: Array.from( onionUrls: faker.helpers.multiple(
{ length: faker.number.int({ min: 0, max: 2 }) }, () => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion` { count: { min: 0, max: 2 } }
), ),
i2pUrls: Array.from( i2pUrls: faker.helpers.multiple(
{ length: faker.number.int({ min: 0, max: 2 }) }, () => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
() => `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`, imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(), listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null, verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples), tosReview: faker.helpers.arrayElement(tosReviewExamples),
tosReviewAt: faker.date.past(), tosReviewAt: faker.date.past(),
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined, userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(), 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 } as const satisfies Prisma.ServiceCreateInput
} }
@@ -904,7 +916,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
moderator: { moderator: {
name: 'moderator_dev', name: 'moderator_dev',
@@ -916,7 +928,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
verified: { verified: {
name: 'verified_dev', name: 'verified_dev',
@@ -1003,359 +1015,378 @@ const generateFakeAnnouncement = () => {
} as const satisfies Prisma.AnnouncementCreateInput } as const satisfies Prisma.AnnouncementCreateInput
} }
async function runFaker() { async function cleanup() {
await prisma.$transaction( console.info('🧹 Cleaning up existing data...')
async (tx) => {
// ---- Clean up existing data if requested ----
if (shouldCleanup || onlyCleanup) {
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 { try {
await runFaker() await prisma.commentVote.deleteMany()
await prisma.karmaTransaction.deleteMany()
console.info('✅ Fake data generated successfully') 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) { } catch (error) {
console.error('❌ Error generating fake data:', error) console.error('❌ Error cleaning up data:', error)
process.exit(1) throw error
} finally {
await prisma.$disconnect()
} }
} }
main().catch((error: unknown) => { function importTriggers() {
console.error('❌ Fatal error:', error) console.info('🔄 Importing SQL triggers...')
process.exit(1) 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)
})

View File

@@ -6,12 +6,8 @@ import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage' import { saveFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { import { separateServiceUrlsByType } from '../../lib/urls'
imageFileSchema, import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({ const serviceSchemaBase = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
@@ -19,11 +15,10 @@ const serviceSchemaBase = z.object({
.string() .string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens') .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(), .optional(),
name: z.string().min(1).max(20), name: z.string().min(1).max(40),
description: z.string().min(1), description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4), kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), 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), verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null), verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)), 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, imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility), serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
}) })
const addSlugIfMissing = < const addSlugIfMissing = <
@@ -61,7 +62,7 @@ export const adminServiceActions = {
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing), input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
handler: async (input) => { handler: async (input, context) => {
const existing = await prisma.service.findUnique({ const existing = await prisma.service.findUnique({
where: { where: {
slug: input.slug, slug: input.slug,
@@ -75,12 +76,33 @@ export const adminServiceActions = {
}) })
} }
const { imageFile, ...serviceData } = input const imageUrl = input.imageFile
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined ? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({ const service = await prisma.service.create({
data: { 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: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),
}, },
@@ -92,6 +114,14 @@ export const adminServiceActions = {
})), })),
}, },
imageUrl, imageUrl,
internalNotes: input.internalNote
? {
create: {
content: input.internalNote,
addedByUserId: context.locals.user.id,
},
}
: undefined,
}, },
select: { select: {
id: true, id: true,
@@ -106,29 +136,34 @@ export const adminServiceActions = {
update: defineProtectedAction({ update: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: serviceSchemaBase.transform(addSlugIfMissing), input: serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing),
handler: async (input) => { handler: async (input) => {
const { id, categories, attributes, imageFile, ...data } = input const anotherServiceWithNewSlug = await prisma.service.findUnique({
const existing = await prisma.service.findUnique({
where: { where: {
slug: input.slug, slug: input.slug,
NOT: { id }, NOT: { id: input.id },
}, },
}) })
if (existing) { if (anotherServiceWithNewSlug) {
throw new ActionError({ throw new ActionError({
code: 'CONFLICT', code: 'CONFLICT',
message: 'A service with this slug already exists', 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({ const existingService = await prisma.service.findUnique({
where: { id }, where: { id: input.id },
include: { include: {
categories: true, categories: true,
attributes: { attributes: {
@@ -146,96 +181,198 @@ export const adminServiceActions = {
}) })
} }
// Find categories to connect and disconnect
const existingCategoryIds = existingService.categories.map((c) => c.id) const existingCategoryIds = existingService.categories.map((c) => c.id)
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId)) const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.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 existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId)) const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.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({ const service = await prisma.service.update({
where: { id }, where: { id: input.id },
data: { 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, imageUrl,
categories: { categories: {
connect: categoriesToAdd.map((id) => ({ id })), connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })), disconnect: categoriesToRemove.map((id) => ({ id })),
}, },
attributes: { attributes: {
// Connect new attributes
create: attributesToAdd.map((attributeId) => ({ create: attributesToAdd.map((attributeId) => ({
attribute: { attribute: {
connect: { id: attributeId }, connect: { id: attributeId },
}, },
})), })),
// Delete specific attributes that are no longer needed
deleteMany: attributesToRemove.map((attributeId) => ({ deleteMany: attributesToRemove.map((attributeId) => ({
attributeId, attributeId,
})), })),
}, },
}, },
}) })
return { service } return { service }
}, },
}), }),
createContactMethod: defineProtectedAction({ contactMethod: {
accept: 'form', add: defineProtectedAction({
permissions: 'admin', accept: 'form',
input: z.object({ permissions: 'admin',
label: z.string().min(1).max(50).nullable(), input: z.object({
value: z.string().url(), label: z.string().min(1).max(50).nullable(),
serviceId: z.number().int().positive(), 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({ update: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
label: z.string().min(1).max(50).nullable(), label: z.string().min(1).max(50).nullable(),
value: z.string().url(), value: z.string().url(),
serviceId: z.number().int().positive(), 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({ delete: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
id: z.number().int().positive(), 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 }, internalNote: {
}) add: defineProtectedAction({
return { success: true } 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 },
})
},
}),
},
} }

View File

@@ -14,12 +14,8 @@ import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { import { separateServiceUrlsByType } from '../lib/urls'
imageFileSchemaRequired, import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -161,9 +157,8 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' } { message: 'Slug must be unique, try a different one' }
), ),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH), description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)), kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), 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 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 { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = { const serviceSelect = {
id: true, id: true,
@@ -221,9 +222,10 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
imageUrl, imageUrl,

View File

@@ -126,11 +126,13 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
VariantProps<typeof badge> & { VariantProps<typeof badge> & {
as: Tag as: Tag
icon?: string icon?: string
endIcon?: string
text: string text: string
inlineIcon?: boolean inlineIcon?: boolean
classNames?: { classNames?: {
icon?: string icon?: string
text?: string text?: string
endIcon?: string
} }
} }
> >
@@ -138,6 +140,7 @@ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
const { const {
as: Tag = 'div', as: Tag = 'div',
icon: iconName, icon: iconName,
endIcon: endIconName,
text: textContent, text: textContent,
inlineIcon, inlineIcon,
classNames, classNames,
@@ -159,4 +162,9 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
) )
} }
<span class={textSlot({ class: classNames?.text })}>{textContent}</span> <span class={textSlot({ class: classNames?.text })}>{textContent}</span>
{
!!endIconName && (
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
)
}
</Tag> </Tag>

View File

@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{ type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
as: Tag as: Tag
icon: string icon: string
endIcon?: string
text: string text: string
inlineIcon?: boolean 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 <Tag
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
> >
<Icon name={icon} class="size-4" is:inline={inlineIcon} /> <Icon name={icon} class="size-4" is:inline={inlineIcon} />
<span>{text}</span> <span>{text}</span>
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
</Tag> </Tag>

View 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}
/>

View File

@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
/> />
<Tooltip text="Send"> <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> </Tooltip>
</form> </form>
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>} {!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}

View File

@@ -150,7 +150,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
checked={comment.suspicious} 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"> <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="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span> <span class="sr-only">Toggle comment visibility</span>

View File

@@ -47,12 +47,9 @@ const averageUserRatingFromQuery =
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
if (averageUserRatingFromProps !== undefined) { if (averageUserRatingFromProps !== undefined) {
if ( const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
averageUserRatingFromQuery !== averageUserRatingFromProps || const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
(averageUserRatingFromQuery !== null && if (a !== b) {
averageUserRatingFromProps !== null &&
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
) {
console.error( console.error(
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}` `The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
) )

View File

@@ -160,6 +160,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
<a <a
href={makeUnimpersonateUrl(Astro.url)} href={makeUnimpersonateUrl(Astro.url)}
data-astro-reload 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" 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" transition:name="header-unimpersonate-link"
aria-label="Unimpersonate" aria-label="Unimpersonate"

View File

@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)} )}
> >
<input <input
transition:persist={option.noTransitionPersist ? undefined : true} transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
type={multiple ? 'checkbox' : 'radio'} type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name} name={wrapperProps.name}
value={option.value} value={option.value}

View File

@@ -43,7 +43,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
: Array.from({ length: icons.length }, () => option.iconClassName) : Array.from({ length: icons.length }, () => option.iconClassName)
: [] : []
return ( 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 <input
transition:persist transition:persist
type="checkbox" type="checkbox"
@@ -54,9 +54,9 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
disabled={disabled} disabled={disabled}
/> />
{icons.map((icon, index) => ( {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> </label>
) )
}) })

View File

@@ -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 inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 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 && ( !!removeCheckbox && (
<label <label

View File

@@ -2,16 +2,24 @@
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils' import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
import Button from './Button.astro'
import InputFile from './InputFile.astro' import InputFile from './InputFile.astro'
import Tooltip from './Tooltip.astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & { type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
square?: boolean square?: boolean
value?: string | null 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> <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' '[&: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> </div>
<script> <script>

View File

@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
icon?: string icon?: string
inputId?: string inputId?: string
hideLabel?: boolean hideLabel?: boolean
classNames?: {
description?: string
}
} }
const { const {
@@ -32,13 +35,14 @@ const {
class: className, class: className,
inputId, inputId,
hideLabel, hideLabel,
classNames,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
const hasError = !!error && error.length > 0 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 && ( !hideLabel && (
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}> <div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
@@ -71,7 +75,12 @@ const hasError = !!error && error.length > 0
{ {
!!description && ( !!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} /> <Markdown content={description} />
</div> </div>
) )

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { currencies } from '../constants/currencies' import { currencies } from '../constants/currencies'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
kycLevel: true kycLevel: true
imageUrl: true imageUrl: true
verificationStatus: true verificationStatus: true
serviceVisibility: true
acceptedCurrencies: true acceptedCurrencies: true
categories: { categories: {
select: { select: {
@@ -43,11 +45,11 @@ const {
slug, slug,
description, description,
overallScore, overallScore,
kycLevel, kycLevel,
imageUrl, imageUrl,
categories, categories,
verificationStatus, verificationStatus,
serviceVisibility,
acceptedCurrencies, acceptedCurrencies,
}, },
class: className, class: className,
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
href={Element === 'a' ? `/service/${slug}` : undefined} href={Element === 'a' ? `/service/${slug}` : undefined}
{...aProps} {...aProps}
class={cn( 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 className
)} )}
> >
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
src={imageUrl} src={imageUrl}
fallback="service" fallback="service"
alt={name || 'Service logo'} 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} width={48}
height={48} height={48}
/> />
@@ -110,6 +118,23 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
]} ]}
</Tooltip> </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> </h3>
<div class="max-h-2 flex-1"></div> <div class="max-h-2 flex-1"></div>

View File

@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number pageSize: number
sortSeed?: string sortSeed?: string
filters: ServicesFiltersObject filters: ServicesFiltersObject
includeScams: boolean
countCommunityOnly: number | null countCommunityOnly: number | null
inlineIcons?: boolean inlineIcons?: boolean
} }
@@ -35,15 +34,12 @@ const {
sortSeed, sortSeed,
class: className, class: className,
filters, filters,
includeScams,
countCommunityOnly, countCommunityOnly,
inlineIcons, inlineIcons,
...divProps ...divProps
} = Astro.props } = Astro.props
const hasScams = const hasScams = filters.verification.includes('VERIFICATION_FAILED')
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED')) const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED') const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')

View File

@@ -8,7 +8,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
label: string label: string
/** Notice that the first capture group is then used to format the value */ /** Notice that the first capture group is then used to format the value */
matcher: RegExp matcher: RegExp
formatter: (value: string) => string | null formatter: (match: RegExpMatchArray) => string | null
icon: string icon: string
} }
@@ -24,82 +24,96 @@ export const {
label: type ? transformCase(type, 'title') : String(type), label: type ? transformCase(type, 'title') : String(type),
icon: 'ri:shield-fill', icon: 'ri:shield-fill',
matcher: /(.*)/, matcher: /(.*)/,
formatter: (value) => value, formatter: ([, value]) => value ?? String(value),
}), }),
[ [
{ {
type: 'email', type: 'email',
label: 'Email', label: 'Email',
matcher: /mailto:(.+)/, matcher: /mailto:(.+)/,
formatter: (value) => value, formatter: ([, value]) => value ?? 'Email',
icon: 'ri:mail-line', icon: 'ri:mail-line',
}, },
{ {
type: 'telephone', type: 'telephone',
label: 'Telephone', label: 'Telephone',
matcher: /tel:(.+)/, matcher: /tel:(.+)/,
formatter: (value) => { formatter: ([, value]) => {
return parsePhoneNumberWithError(value).formatInternational() return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
}, },
icon: 'ri:phone-line', icon: 'ri:phone-line',
}, },
{ {
type: 'whatsapp', type: 'whatsapp',
label: 'WhatsApp', label: 'WhatsApp',
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.+)/, matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: (value) => { formatter: ([, value]) => {
return parsePhoneNumberWithError(value).formatInternational() return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
}, },
icon: 'ri:whatsapp-line', icon: 'ri:whatsapp-line',
}, },
{ {
type: 'telegram', type: 'telegram',
label: 'Telegram', label: 'Telegram',
matcher: /https?:\/\/(?:www\.)?t\.me\/(.+)/, matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: (value) => `t.me/${value}`, formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
icon: 'ri:telegram-line', icon: 'ri:telegram-line',
}, },
{ {
type: 'linkedin', type: 'linkedin',
label: 'LinkedIn', label: 'LinkedIn',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/, matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: (value) => `in/${value}`, formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
icon: 'ri:linkedin-box-line', icon: 'ri:linkedin-box-line',
}, },
{ {
type: 'x', type: 'x',
label: 'X', label: 'X',
matcher: /https?:\/\/(?:www\.)?x\.com\/(.+)/, matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: (value) => `@${value}`, formatter: ([, value]) => (value ? `@${value}` : 'X'),
icon: 'ri:twitter-x-line', icon: 'ri:twitter-x-line',
}, },
{ {
type: 'instagram', type: 'instagram',
label: 'Instagram', label: 'Instagram',
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.+)/, matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: (value) => `@${value}`, formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
icon: 'ri:instagram-line', icon: 'ri:instagram-line',
}, },
{ {
type: 'matrix', type: 'matrix',
label: 'Matrix', label: 'Matrix',
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/, matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: (value) => value, formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
icon: 'ri:hashtag', icon: 'ri:hashtag',
}, },
{ {
type: 'bitcointalk', type: 'bitcointalk',
label: 'BitcoinTalk', label: 'BitcoinTalk',
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/, matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk', formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line', 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', type: 'website',
label: 'Website', label: 'Website',
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/, matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: (value) => value, formatter: ([, value]) => value ?? 'Website',
icon: 'ri:global-line', icon: 'ri:global-line',
}, },
] as const satisfies ContactMethodInfo[] ] as const satisfies ContactMethodInfo[]
@@ -107,10 +121,10 @@ export const {
export function formatContactMethod(url: string) { export function formatContactMethod(url: string) {
for (const contactMethod of contactMethods) { for (const contactMethod of contactMethods) {
const captureGroup = url.match(contactMethod.matcher)?.[1] const match = url.match(contactMethod.matcher)
if (!captureGroup) continue if (!match) continue
const formattedValue = contactMethod.formatter(captureGroup) const formattedValue = contactMethod.formatter(match)
if (!formattedValue) continue if (!formattedValue) continue
return { return {

View File

@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
} }
icon: string icon: string
color: ComponentProps<typeof BadgeSmall>['color'] color: ComponentProps<typeof BadgeSmall>['color']
isSolved: boolean
showBanner: boolean
} }
export const { export const {
@@ -36,6 +38,8 @@ export const {
}, },
icon: 'ri:question-fill', icon: 'ri:question-fill',
color: 'gray', color: 'gray',
isSolved: false,
showBanner: false,
}), }),
[ [
{ {
@@ -46,8 +50,10 @@ export const {
classNames: { classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:error-warning-fill', icon: 'ri:alert-fill',
color: 'yellow', color: 'yellow',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'WARNING_SOLVED', id: 'WARNING_SOLVED',
@@ -55,10 +61,12 @@ export const {
label: 'Warning Solved', label: 'Warning Solved',
description: 'A previously reported warning has been solved', description: 'A previously reported warning has been solved',
classNames: { 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', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'ALERT', id: 'ALERT',
@@ -68,8 +76,10 @@ export const {
classNames: { classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:alert-fill', icon: 'ri:spam-fill',
color: 'red', color: 'red',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'ALERT_SOLVED', id: 'ALERT_SOLVED',
@@ -77,10 +87,12 @@ export const {
label: 'Alert Solved', label: 'Alert Solved',
description: 'A previously reported alert has been solved', description: 'A previously reported alert has been solved',
classNames: { 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', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'INFO', id: 'INFO',
@@ -92,6 +104,8 @@ export const {
}, },
icon: 'ri:information-fill', icon: 'ri:information-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'NORMAL', id: 'NORMAL',
@@ -103,6 +117,8 @@ export const {
}, },
icon: 'ri:notification-fill', icon: 'ri:notification-fill',
color: 'green', color: 'green',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'UPDATE', id: 'UPDATE',
@@ -114,6 +130,8 @@ export const {
}, },
icon: 'ri:pencil-fill', icon: 'ri:pencil-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
] as const satisfies EventTypeInfo<EventType>[] ] as const satisfies EventTypeInfo<EventType>[]
) )

View File

@@ -1 +1 @@
export const SUPPORT_EMAIL = 'support@kycnot.me' export const SUPPORT_EMAIL = 'contact@kycnot.me'

View File

@@ -1,14 +1,18 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { ServiceSuggestionType } from '@prisma/client' import type { ServiceSuggestionType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = { type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
value: T value: T
slug: string slug: string
label: string label: string
icon: string icon: string
order: number
default: boolean default: boolean
color: ComponentProps<typeof BadgeSmall>['color']
} }
export const { export const {
@@ -25,9 +29,11 @@ export const {
(value): ServiceSuggestionTypeInfo<typeof value> => ({ (value): ServiceSuggestionTypeInfo<typeof value> => ({
value, value,
slug: value ? value.toLowerCase() : '', slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value), label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
icon: 'ri:question-line', icon: 'ri:question-line',
order: Infinity,
default: false, default: false,
color: 'zinc',
}), }),
[ [
{ {
@@ -35,14 +41,18 @@ export const {
slug: 'create', slug: 'create',
label: 'Create', label: 'Create',
icon: 'ri:add-line', icon: 'ri:add-line',
order: 1,
default: true, default: true,
color: 'green',
}, },
{ {
value: 'EDIT_SERVICE', value: 'EDIT_SERVICE',
slug: 'edit', slug: 'edit',
label: 'Edit', label: 'Edit',
icon: 'ri:pencil-line', icon: 'ri:pencil-line',
order: 2,
default: false, default: false,
color: 'blue',
}, },
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[] ] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
) )

View File

@@ -8,6 +8,7 @@ type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
slug: string slug: string
label: string label: string
description: string description: string
longDescription: string
icon: string icon: string
iconClass: string iconClass: string
} }
@@ -28,6 +29,7 @@ export const {
slug: value ? value.toLowerCase() : '', slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value), label: value ? transformCase(value, 'title') : String(value),
description: '', description: '',
longDescription: '',
icon: 'ri:eye-line', icon: 'ri:eye-line',
iconClass: 'text-current/60', iconClass: 'text-current/60',
}), }),
@@ -37,6 +39,7 @@ export const {
slug: 'public', slug: 'public',
label: 'Public', label: 'Public',
description: 'Listed in search and browse.', description: 'Listed in search and browse.',
longDescription: 'Listed in search and browse.',
icon: 'ri:global-line', icon: 'ri:global-line',
iconClass: 'text-green-500', iconClass: 'text-green-500',
}, },
@@ -45,6 +48,7 @@ export const {
slug: 'unlisted', slug: 'unlisted',
label: 'Unlisted', label: 'Unlisted',
description: 'Only accessible via direct link.', description: 'Only accessible via direct link.',
longDescription: "Unlisted service, only accessible via direct link and won't appear in searches.",
icon: 'ri:link', icon: 'ri:link',
iconClass: 'text-yellow-500', iconClass: 'text-yellow-500',
}, },
@@ -53,8 +57,19 @@ export const {
slug: 'hidden', slug: 'hidden',
label: 'Hidden', label: 'Hidden',
description: 'Only visible to moderators.', description: 'Only visible to moderators.',
longDescription: 'Hidden service, only visible to moderators.',
icon: 'ri:lock-line', icon: 'ri:lock-line',
iconClass: 'text-red-500', 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>[] ] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
) )

4
web/src/icons/nostr.svg Normal file
View 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

View 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

View File

@@ -2,6 +2,7 @@ import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus' import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo' import { formatDateShort } from './timeAgo'
@@ -36,6 +37,7 @@ export function makeNonDbAttributes(
service: Prisma.ServiceGetPayload<{ service: Prisma.ServiceGetPayload<{
select: { select: {
verificationStatus: true verificationStatus: true
serviceVisibility: true
isRecentlyListed: true isRecentlyListed: true
listedAt: true listedAt: true
createdAt: 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', title: 'Recently listed',
show: service.isRecentlyListed, show: service.isRecentlyListed,

View File

@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
} }
return url.origin 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
}

View File

@@ -50,7 +50,7 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
}[] }[]
const env = 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 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(import.meta.env (import.meta.env
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]])) ? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))

View File

@@ -16,10 +16,10 @@ export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
export const zodUrlOptionalProtocol = z.preprocess( export const zodUrlOptionalProtocol = z.preprocess(
(input) => { (input) => {
if (typeof input !== 'string') return input if (typeof input !== 'string') return input
const trimmedVal = input.trim() const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(trimmedVal) ? `https://${trimmedVal}` : trimmedVal 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', message: 'Invalid URL',
}) })
) )

View File

@@ -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**. 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 ### Suggestion Review Process
#### First Review #### First Review
@@ -190,6 +201,41 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## API
Access basic service data via our public API.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
### `GET /api/v1/service/[id]`
Fetches details for a single service.
- **`[id]`**: Can be a service ID, slug, name, or any registered URL (including .onion/.i2p).
**Example Requests:**
```
/api/v1/service/bisq
/api/v1/service/https://bisq.network
```
**Example Response (200 OK):**
```json
{
"name": "Bisq",
"description": "Decentralized Bitcoin exchange network.",
"kycLevel": 0,
"categories": ["exchange"],
"serviceUrls": ["https://bisq.network/"],
"onionUrls": [],
"i2pUrls": [],
"tosUrls": ["https://bisq.network/terms-of-service/"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
## Support ## Support
If you like this project, you can **support** it through these methods: If you like this project, you can **support** it through these methods:

View File

@@ -50,6 +50,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
<a <a
href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })} href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })}
data-astro-reload 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" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
> >
<Icon <Icon
@@ -62,6 +63,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
Astro.locals.actualUser && ( Astro.locals.actualUser && (
<a <a
href={makeUnimpersonateUrl(Astro.url, { redirect })} 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" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
> >
<Icon <Icon

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' 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 BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
@@ -81,6 +81,18 @@ const adminLinks: AdminLink[] = [
base: 'text-gray-300', 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> <nav>
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4"> <ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
{ {
adminLinks.map((link) => ( adminLinks
<li .filter((link) => link.href)
class={cn( .map((link) => (
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105', <li
link.classNames.base class={cn(
)} 'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
> link.classNames.base
<a )}
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
> >
<Icon <a
name={link.icon} href={link.href}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100" 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"
/> >
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span> <Icon
</a> name={link.icon}
</li> 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> </ol>
</nav> </nav>

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro' import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro' import ServiceCard from '../../../components/ServiceCard.astro'
@@ -10,6 +11,7 @@ import {
getServiceSuggestionStatusInfo, getServiceSuggestionStatusInfo,
serviceSuggestionStatuses, serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus' } from '../../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn' import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers' import { parseIntWithFallback } from '../../../lib/numbers'
@@ -57,6 +59,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
acceptedCurrencies: true, acceptedCurrencies: true,
serviceVisibility: true,
categories: { categories: {
select: { select: {
name: true, name: true,
@@ -92,6 +95,7 @@ if (!serviceSuggestion) {
} }
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status) const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
--- ---
<BaseLayout <BaseLayout
@@ -110,7 +114,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
label="Back" 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>
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <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" /> <ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
</div> </div>
<div <div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
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 text-day-200 mb-3 text-lg">Suggestion Details</h2>
>
<h2 class="font-title mb-3 text-lg text-green-500">Suggestion Details</h2>
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm"> <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="font-title text-gray-400">Status:</span>
<span <span
class={cn( class={cn(
@@ -142,7 +149,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span> <span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
<span class="font-title text-gray-400">Service page:</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 View Service <Icon
name="ri:external-link-line" name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]" class="ml-0.5 inline-block size-3 align-[-0.05em]"
@@ -164,11 +171,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
</div> </div>
</div> </div>
<div <div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
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="flex items-center justify-between"> <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"> <form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} /> <input type="hidden" name="suggestionId" value={serviceSuggestion.id} />

View File

@@ -4,6 +4,7 @@ import { actions } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro' import TimeFormatted from '../../../components/TimeFormatted.astro'
@@ -12,59 +13,67 @@ import UserBadge from '../../../components/UserBadge.astro'
import { import {
getServiceSuggestionStatusInfo, getServiceSuggestionStatusInfo,
serviceSuggestionStatuses, serviceSuggestionStatuses,
serviceSuggestionStatusesZodEnumBySlug,
serviceSuggestionStatusSlugToId,
} from '../../../constants/serviceSuggestionStatus' } from '../../../constants/serviceSuggestionStatus'
import {
getServiceSuggestionTypeInfo,
serviceSuggestionTypes,
serviceSuggestionTypeSlugToId,
serviceSuggestionTypesZodEnumBySlug,
} from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls' import { makeLoginUrl } from '../../../lib/redirectUrls'
import type { Prisma, ServiceSuggestionStatus } from '@prisma/client' import type { Prisma } from '@prisma/client'
const user = Astro.locals.user const user = Astro.locals.user
if (!user?.admin) { if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' })) 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( 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'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
}, },
Astro Astro
) )
const sortBy = filters['sort-by']
const sortOrder = filters['sort-order']
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' } let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
if (sortBy === 'createdAt') { if (filters['sort-by'] === 'createdAt') {
prismaOrderBy = { createdAt: sortOrder } prismaOrderBy = { createdAt: filters['sort-order'] }
} }
let suggestions = await prisma.serviceSuggestion.findMany({ let suggestions = await prisma.serviceSuggestion.findMany({
where: { where: {
...(search ...(filters.search
? { ? {
OR: [ OR: [
{ service: { name: { contains: search, mode: 'insensitive' } } }, { service: { name: { contains: filters.search, mode: 'insensitive' } } },
{ user: { name: { contains: search, mode: 'insensitive' } } }, { user: { name: { contains: filters.search, mode: 'insensitive' } } },
{ notes: { contains: search, mode: 'insensitive' } }, { notes: { contains: filters.search, mode: 'insensitive' } },
], ],
} }
: {}), : {}),
status: statusFilter ?? undefined, status: filters.status,
type: filters.type,
}, },
orderBy: prismaOrderBy, orderBy: prismaOrderBy,
select: { select: {
id: true, id: true,
type: true,
status: true, status: true,
notes: true, notes: true,
createdAt: true, createdAt: true,
@@ -119,18 +128,33 @@ let suggestions = await prisma.serviceSuggestion.findMany({
let suggestionsWithDetails = suggestions.map((s) => ({ let suggestionsWithDetails = suggestions.map((s) => ({
...s, ...s,
statusInfo: getServiceSuggestionStatusInfo(s.status), statusInfo: getServiceSuggestionStatusInfo(s.status),
typeInfo: getServiceSuggestionTypeInfo(s.type),
messageCount: s._count.messages, messageCount: s._count.messages,
lastMessage: s.messages[0], lastMessage: s.messages[0],
})) }))
if (sortBy === 'service') { if (filters['sort-by'] === 'service') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder]) suggestionsWithDetails = orderBy(
} else if (sortBy === 'status') { suggestionsWithDetails,
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder]) [(s) => s.service.name.toLowerCase()],
} else if (sortBy === 'user') { [filters['sort-order']]
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder]) )
} else if (sortBy === 'messageCount') { } else if (filters['sort-by'] === 'status') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder]) 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 const suggestionCount = suggestionsWithDetails.length
@@ -162,7 +186,7 @@ const makeSortUrl = (slug: string) => {
type="text" type="text"
name="search" name="search"
id="search" id="search"
value={search} value={filters.search}
placeholder="Search by service, user, notes..." 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" 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> <option value="">All Statuses</option>
{ {
serviceSuggestionStatuses.map((status) => ( serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={statusFilter === status.value}> <option value={status.slug} selected={filters.status === status.value}>
{status.label} {status.label}
</option> </option>
)) ))
} }
</select> </select>
</div> </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"> <div class="flex items-end">
<Button <Button
as="button" as="button"
@@ -212,7 +253,7 @@ const makeSortUrl = (slug: string) => {
<thead class="bg-zinc-900/30"> <thead class="bg-zinc-900/30">
<tr> <tr>
<th <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"> <a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
Service <SortArrowIcon Service <SortArrowIcon
@@ -222,7 +263,7 @@ const makeSortUrl = (slug: string) => {
</a> </a>
</th> </th>
<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"> <a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200">
User <SortArrowIcon User <SortArrowIcon
@@ -232,7 +273,17 @@ const makeSortUrl = (slug: string) => {
</a> </a>
</th> </th>
<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"> <a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200">
Status <SortArrowIcon Status <SortArrowIcon
@@ -295,6 +346,13 @@ const makeSortUrl = (slug: string) => {
<td class="px-4 py-3"> <td class="px-4 py-3">
<UserBadge user={suggestion.user} size="md" /> <UserBadge user={suggestion.user} size="md" />
</td> </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"> <td class="px-4 py-3">
<form method="POST" action={actions.admin.serviceSuggestions.update}> <form method="POST" action={actions.admin.serviceSuggestions.update}>
<input type="hidden" name="suggestionId" value={suggestion.id} /> <input type="hidden" name="suggestionId" value={suggestion.id} />

View File

@@ -1,5 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
@@ -76,6 +77,15 @@ const verificationStepUpdateInputErrors = isInputError(verificationStepUpdateRes
const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete) const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete)
Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully') 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([ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
[ [
'Error fetching service', 'Error fetching service',
@@ -130,6 +140,20 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
label: 'asc', label: 'asc',
}, },
}, },
internalNotes: {
include: {
addedByUser: {
select: {
name: true,
displayName: true,
picture: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
_count: { _count: {
select: { select: {
verificationRequests: true, verificationRequests: true,
@@ -209,16 +233,30 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<input type="hidden" name="id" value={service.id} /> <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 <InputTextArea
label="Description" label="Description"
name="description" name="description"
@@ -230,84 +268,64 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description} error={serviceInputErrors.description}
/> />
<InputText <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
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">
<InputTextArea <InputTextArea
label="Service URLs" label="Service URLs"
description="One per line" description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="serviceUrls" name="allServiceUrls"
inputProps={{ inputProps={{
rows: 3, placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
placeholder: 'https://example1.com\nhttps://example2.com', class: 'grow min-h-24',
required: true,
}} }}
value={service.serviceUrls.join('\n')} class="row-span-2 flex flex-col self-stretch"
error={serviceInputErrors.serviceUrls} value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/> />
<InputTextArea <InputTextArea
label="ToS URLs" label="ToS URLs"
description="One per line" description="One per line"
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos', placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true,
}} }}
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
/> />
<InputTextArea <InputText
label="Onion URLs" label="Referral link path"
description="One per line" name="referral"
name="onionUrls"
inputProps={{ inputProps={{
rows: 3, value: service.referral,
placeholder: 'http://example1.onion\nhttp://example2.onion', placeholder: 'e.g. ?ref=123 or /ref/123',
}} }}
value={service.onionUrls.join('\n')} error={serviceInputErrors.referral}
error={serviceInputErrors.onionUrls} class="self-end"
/> description="Will be appended to the service URL"
<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')}
/> />
</div> </div>
<InputText <div class="flex items-center justify-between gap-2">
label="Referral Code/Link" <InputImageFile
name="referral" label="Image"
inputProps={{ name="imageFile"
value: service.referral ?? undefined, description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
placeholder: 'e.g., REFCODE123 or https://example.com?ref=123', error={serviceInputErrors.imageFile}
}} square
error={serviceInputErrors.referral} value={service.imageUrl}
/> downloadButton
removeCheckbox={service.imageUrl
? {
name: 'removeImage',
label: 'Remove image',
}
: undefined}
class="grow"
/>
</div>
<InputImageFile <div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
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]">
<InputCheckboxGroup <InputCheckboxGroup
name="categories" name="categories"
label="Categories" label="Categories"
@@ -320,6 +338,7 @@ if (!service) return Astro.rewrite('/404')
}))} }))}
selectedValues={service.categories.map((c) => c.id.toString())} selectedValues={service.categories.map((c) => c.id.toString())}
error={serviceInputErrors.categories} error={serviceInputErrors.categories}
class="min-w-auto"
/> />
<InputCheckboxGroup <InputCheckboxGroup
@@ -436,6 +455,108 @@ if (!service) return Astro.rewrite('/404')
</form> </form>
</FormSection> </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"> <FormSection title="Events">
<FormSubSection title="Existing Events"> <FormSubSection title="Existing Events">
{ {
@@ -478,28 +599,43 @@ if (!service) return Astro.rewrite('/404')
</div> </div>
</div> </div>
<div class="flex shrink-0 gap-1.5"> <div class="flex shrink-0 gap-1.5">
<form method="POST" action={actions.admin.event.toggle} class="contents"> <Tooltip text={event.visible ? 'Hide' : 'Show'}>
<input type="hidden" name="eventId" value={event.id} /> <form method="POST" action={actions.admin.event.toggle} class="contents">
<Tooltip text={event.visible ? 'Hide Event' : 'Show Event'}> <input type="hidden" name="eventId" value={event.id} />
<Button <Button
type="submit" type="submit"
variant="faded" variant="faded"
size="sm" size="sm"
icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'} icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'}
iconOnly
label={event.visible ? 'Hide' : 'Show'}
/> />
</Tooltip> </form>
</form> </Tooltip>
<Button <Tooltip text="Edit">
type="button" <Button
variant="faded" type="button"
size="sm" variant="faded"
icon="ri:pencil-line" size="sm"
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`} icon="ri:pencil-line"
/> onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
<form method="POST" action={actions.admin.event.delete} class="contents"> iconOnly
<input type="hidden" name="eventId" value={event.id} /> label="Edit"
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" /> />
</form> </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>
</div> </div>
{/* Edit Event Form - Hidden by default */} {/* Edit Event Form - Hidden by default */}
@@ -531,9 +667,14 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, 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} error={eventUpdateInputErrors.startedAt}
/> />
@@ -542,7 +683,15 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ 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} error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." 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" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, 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} error={eventInputErrors.startedAt}
/> />
@@ -613,7 +764,10 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ 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} error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." 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> </p>
</div> </div>
<div class="flex shrink-0 gap-1.5"> <div class="flex shrink-0 gap-1.5">
<Button <Tooltip text="Edit">
type="button" <Button
size="sm" type="button"
variant="faded" size="sm"
icon="ri:pencil-line" variant="faded"
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`} 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"> iconOnly
<input type="hidden" name="id" value={step.id} /> label="Edit"
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" /> />
</form> </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>
</div> </div>
@@ -844,21 +1011,34 @@ if (!service) return Astro.rewrite('/404')
<p class="text-day-400 text-sm text-pretty">{method.value}</p> <p class="text-day-400 text-sm text-pretty">{method.value}</p>
</div> </div>
<div class="flex shrink-0 gap-1.5"> <div class="flex shrink-0 gap-1.5">
<Button <Tooltip text="Edit">
type="button" <Button
variant="faded" type="button"
size="sm" variant="faded"
icon="ri:pencil-line" size="sm"
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`} icon="ri:pencil-line"
/> onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
<form iconOnly
method="POST" label="Edit"
action={actions.admin.service.deleteContactMethod} />
class="contents" </Tooltip>
> <Tooltip text="Delete">
<input type="hidden" name="id" value={method.id} /> <form
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" /> method="POST"
</form> 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>
</div> </div>
@@ -866,7 +1046,7 @@ if (!service) return Astro.rewrite('/404')
<form <form
id={`edit-contact-method-${method.id}`} id={`edit-contact-method-${method.id}`}
method="POST" 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" 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} /> <input type="hidden" name="id" value={method.id} />
@@ -901,7 +1081,7 @@ if (!service) return Astro.rewrite('/404')
</FormSubSection> </FormSubSection>
<FormSubSection title="Add New Contact Method"> <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} /> <input type="hidden" name="serviceId" value={service.id} />
<InputText label="Override Label" name="label" /> <InputText label="Override Label" name="label" />

View File

@@ -8,7 +8,8 @@ import MyPicture from '../../../components/MyPicture.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import Tooltip from '../../../components/Tooltip.astro' import Tooltip from '../../../components/Tooltip.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels' 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 BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn' import { cn } from '../../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
@@ -110,6 +111,7 @@ const services = await Astro.locals.banners.try(
_count: { _count: {
select: { select: {
verificationRequests: true, verificationRequests: true,
internalNotes: true,
}, },
}, },
}, },
@@ -209,11 +211,11 @@ const truncate = (text: string, length: number) => {
id="visibility" 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" 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) => ( serviceVisibilities.map((visibility) => (
<option value={status} selected={filters.visibility === status}> <option value={visibility.value} selected={filters.visibility === visibility.value}>
{status} {visibility.label}
</option> </option>
)) ))
} }
@@ -227,11 +229,11 @@ const truncate = (text: string, length: number) => {
id="verificationStatus" 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" 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) => ( verificationStatuses.map((status) => (
<option value={status} selected={filters.verificationStatus === status}> <option value={status.value} selected={filters.verificationStatus === status.value}>
{status} {status.label}
</option> </option>
)) ))
} }
@@ -439,9 +441,18 @@ const truncate = (text: string, length: number) => {
</div> </div>
</td> </td>
<td class="px-4 py-3 text-center"> <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"> <div class="flex flex-col items-center justify-center gap-1">
{service._count.verificationRequests} <span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
</span> {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>
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td> <td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">

View File

@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div> </div>
<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 <textarea
transition:persist 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" 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" name="allServiceUrls"
id="serviceUrls" id="allServiceUrls"
rows={3} rows={3}
placeholder="https://example1.com https://example2.com" placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text="" set:text=""
/> />
{ {
inputErrors.serviceUrls && ( inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
) )
} }
</div> </div>
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
} }
</div> </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> <div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label> <label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2"> <div class="space-y-2">
@@ -350,13 +332,13 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div> </div>
<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 <input
transition:persist transition:persist
type="text" type="text"
name="referral" name="referral"
id="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" 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>
<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 <button
type="submit" 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" 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"

View File

@@ -1,5 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro' import BadgeSmall from '../../../components/BadgeSmall.astro'
@@ -304,8 +305,8 @@ if (!user) return Astro.rewrite('/404')
</div> </div>
</div> </div>
<div data-note-content> <div data-note-content class="prose prose-sm text-day-200 prose-invert max-w-none text-pretty">
<p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} /> <Markdown content={note.content} />
</div> </div>
<form <form

View File

@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors( 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'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(), search: z.string().optional(),
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).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 // Set up Prisma orderBy with correct typing
const prismaOrderBy = 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-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc', filters['sort-order'] === 'asc' ? 'asc' : 'desc',
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
lastLoginAt: true,
internalNotes: { internalNotes: {
select: { select: {
id: true, id: true,
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
<a <div class="flex flex-wrap items-center justify-center gap-1">
href={makeSortUrl('createdAt')} <a
class="flex items-center justify-center hover:text-zinc-200" href={makeSortUrl('lastLoginAt')}
> class="flex items-center justify-center hover:text-zinc-200"
Joined <SortArrowIcon >
active={filters['sort-by'] === 'createdAt'} Login <SortArrowIcon
sortOrder={filters['sort-order']} active={filters['sort-by'] === 'lastLoginAt'}
/> sortOrder={filters['sort-order']}
</a> />
</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>
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" 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} {user.totalKarma}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm text-zinc-400"> <td class="px-4 py-3 text-center text-sm">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} /> <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>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex justify-center gap-3"> <div class="flex justify-center gap-3">

View File

@@ -0,0 +1,121 @@
import { prisma } from '../../../../lib/prisma'
import type { Prisma } from '@prisma/client'
import type { APIRoute } from 'astro'
const MAX_ID_LENGTH = 2048
export const GET: APIRoute = async ({ params }) => {
const { id } = params
if (!id) {
return new Response(JSON.stringify({ error: 'ID parameter is missing' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
}
if (id.length > MAX_ID_LENGTH) {
return new Response(JSON.stringify({ error: 'ID parameter is too long' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
}
const orConditions: Prisma.ServiceWhereInput[] = [
{ slug: id },
{ name: id },
{ serviceUrls: { has: id } },
{ onionUrls: { has: id } },
{ i2pUrls: { has: id } },
]
// Try direct ID lookup first
const numericId = parseInt(id, 10)
if (!isNaN(numericId)) {
orConditions.push({ id: numericId })
}
if (id.startsWith('http://') || id.startsWith('https://')) {
let alternativeId: string
if (id.endsWith('/')) {
alternativeId = id.slice(0, -1) // Remove trailing slash
} else {
alternativeId = id + '/' // Add trailing slash
}
orConditions.push({ serviceUrls: { has: alternativeId } })
orConditions.push({ onionUrls: { has: alternativeId } })
orConditions.push({ i2pUrls: { has: alternativeId } })
} else {
// For non-HTTP/S IDs, check as is (could be a direct onion/i2p address without protocol)
orConditions.push({ serviceUrls: { has: id } })
orConditions.push({ onionUrls: { has: id } })
orConditions.push({ i2pUrls: { has: id } })
}
try {
const service = await prisma.service.findFirst({
where: {
OR: orConditions,
},
select: {
name: true,
slug: true,
description: true,
kycLevel: true,
categories: {
select: {
name: true,
slug: true,
icon: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
},
})
if (!service) {
return new Response(JSON.stringify({ error: 'Service not found' }), {
status: 404,
headers: {
'Content-Type': 'application/json',
},
})
}
const responseData = {
name: service.name,
description: service.description,
kycLevel: service.kycLevel,
categories: service.categories.map((category) => category.slug),
serviceUrls: service.serviceUrls,
onionUrls: service.onionUrls,
i2pUrls: service.i2pUrls,
tosUrls: service.tosUrls,
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
}
return new Response(JSON.stringify(responseData), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
} catch (error) {
console.error('Error fetching service:', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
})
}
}

View File

@@ -1,5 +1,4 @@
--- ---
import { ServiceVisibility } from '@prisma/client'
import { z } from 'astro:schema' import { z } from 'astro:schema'
import { groupBy, omit, orderBy, uniq } from 'lodash-es' import { groupBy, omit, orderBy, uniq } from 'lodash-es'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
@@ -222,7 +221,9 @@ const where = {
verificationStatus: { verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification, in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
}, },
serviceVisibility: ServiceVisibility.PUBLIC, serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
overallScore: { gte: filters['min-score'] }, overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and' ? filters['currency-mode'] === 'and'
@@ -324,7 +325,9 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
select: { select: {
services: { services: {
where: { where: {
serviceVisibility: 'PUBLIC', serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
}, },
}, },
}, },
@@ -372,6 +375,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
acceptedCurrencies: true, acceptedCurrencies: true,
serviceVisibility: true,
attributes: { attributes: {
select: { select: {
attribute: { attribute: {
@@ -708,7 +712,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']} sortSeed={filters['sort-seed']}
filters={filters} filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly} countCommunityOnly={countCommunityOnly}
inlineIcons inlineIcons
/> />

View File

@@ -3,9 +3,12 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import AdminOnly from '../../components/AdminOnly.astro' 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 Chat from '../../components/Chat.astro'
import ServiceCard from '../../components/ServiceCard.astro' import ServiceCard from '../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus' import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
import { parseIntWithFallback } from '../../lib/numbers' import { parseIntWithFallback } from '../../lib/numbers'
@@ -28,6 +31,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
prisma.serviceSuggestion.findUnique({ prisma.serviceSuggestion.findUnique({
select: { select: {
id: true, id: true,
type: true,
status: true, status: true,
notes: true, notes: true,
createdAt: true, createdAt: true,
@@ -42,6 +46,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
acceptedCurrencies: true, acceptedCurrencies: true,
serviceVisibility: true,
categories: { categories: {
select: { select: {
name: true, name: true,
@@ -59,6 +64,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
select: { select: {
id: true, id: true,
name: true, name: true,
displayName: true,
picture: true, picture: true,
}, },
}, },
@@ -81,6 +87,7 @@ if (!serviceSuggestion) {
} }
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status) const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
--- ---
<BaseLayout <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> <h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
<a </div>
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>
<ServiceCard service={serviceSuggestion.service} class="mb-6" /> <ServiceCard service={serviceSuggestion.service} class="mb-6" />

View File

@@ -53,6 +53,7 @@ const service = await Astro.locals.banners.try(
icon: true, icon: true,
}, },
}, },
serviceVisibility: true,
}, },
where: { id: params.serviceId }, where: { id: params.serviceId },
}), }),
@@ -101,7 +102,7 @@ if (!service) return Astro.rewrite('/404')
error={inputErrors.notes} error={inputErrors.notes}
/> />
<Captcha action={actions.serviceSuggestion.createService} /> <Captcha action={actions.serviceSuggestion.editService} />
<InputHoneypotTrap name="message" /> <InputHoneypotTrap name="message" />

View File

@@ -3,6 +3,8 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import BadgeSmall from '../../components/BadgeSmall.astro'
import BadgeStandardFilter from '../../components/BadgeStandardFilter.astro'
import Button from '../../components/Button.astro' import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro' import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
@@ -10,10 +12,16 @@ import Tooltip from '../../components/Tooltip.astro'
import { import {
getServiceSuggestionStatusInfo, getServiceSuggestionStatusInfo,
serviceSuggestionStatuses, serviceSuggestionStatuses,
serviceSuggestionStatusesZodEnumBySlug,
serviceSuggestionStatusSlugToId,
} from '../../constants/serviceSuggestionStatus' } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType' import {
getServiceSuggestionTypeInfo,
serviceSuggestionTypes,
serviceSuggestionTypeSlugToId,
serviceSuggestionTypesZodEnumBySlug,
} from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { zodEnumFromConstant } from '../../lib/arrays'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
@@ -26,8 +34,13 @@ if (!user) {
const { data: filters } = zodParseQueryParamsStoringErrors( const { data: filters } = zodParseQueryParamsStoringErrors(
{ {
serviceId: z.array(z.number().int().positive()).default([]), serviceId: z.array(z.number().int().positive()),
status: z.array(zodEnumFromConstant(serviceSuggestionStatuses, 'value')).default([]), status: z.array(
serviceSuggestionStatusesZodEnumBySlug.transform((slug) => serviceSuggestionStatusSlugToId(slug))
),
type: z.array(
serviceSuggestionTypesZodEnumBySlug.transform((slug) => serviceSuggestionTypeSlugToId(slug))
),
}, },
Astro Astro
) )
@@ -52,6 +65,7 @@ const serviceSuggestions = await Astro.locals.banners.try('Error fetching servic
where: { where: {
id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined, id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined,
status: filters.status.length > 0 ? { in: filters.status } : undefined, status: filters.status.length > 0 ? { in: filters.status } : undefined,
type: filters.type.length > 0 ? { in: filters.type } : undefined,
userId: user.id, userId: user.id,
}, },
orderBy: { 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 ? ( serviceSuggestions.length === 0 ? (
<p class="text-day-400">No suggestions yet.</p> <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> <span class="shrink truncate">{suggestion.service.name}</span>
</a> </a>
<Tooltip <BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
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>
<Tooltip <Tooltip
as="span" as="span"

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es'
import { import {
SUGGESTION_DESCRIPTION_MAX_LENGTH, SUGGESTION_DESCRIPTION_MAX_LENGTH,
@@ -16,6 +17,8 @@ import InputImageFile from '../../components/InputImageFile.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro' import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro' import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro' import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { currencies } from '../../constants/currencies' import { currencies } from '../../constants/currencies'
import { kycLevels } from '../../constants/kycLevels' import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
@@ -55,6 +58,8 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
select: { select: {
id: true, id: true,
title: true, title: true,
category: true,
type: true,
}, },
}), }),
[], [],
@@ -196,35 +201,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.description} error={inputErrors.description}
/> />
<InputTextArea <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="Service URLs" <InputTextArea
name="serviceUrls" label="Service URLs"
inputProps={{ description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
required: true, name="allServiceUrls"
placeholder: 'https://example1.com\nhttps://example2.org', inputProps={{
}} placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
error={inputErrors.serviceUrls} class: 'min-h-24',
/> required: true,
}}
<InputTextArea class="row-span-2 flex flex-col self-stretch"
label="Terms of Service URLs" error={inputErrors.allServiceUrls}
name="tosUrls" />
inputProps={{ <InputTextArea
required: true, label="ToS URLs"
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms', description="One per line"
}} name="tosUrls"
error={inputErrors.tosUrls} inputProps={{
/> placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
<InputTextArea required: true,
label="Onion URLs" }}
name="onionUrls" error={inputErrors.tosUrls}
inputProps={{ />
required: true, </div>
placeholder: 'http://example1.onion\nhttp://example2.onion',
}}
error={inputErrors.onionUrls}
/>
<InputCardGroup <InputCardGroup
name="kycLevel" name="kycLevel"
@@ -241,27 +242,41 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.kycLevel} error={inputErrors.kycLevel}
/> />
<InputCheckboxGroup <div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
name="categories" <InputCheckboxGroup
label="Categories" name="categories"
required label="Categories"
options={categories.map((category) => ({ required
label: category.name, options={categories.map((category) => ({
value: category.id.toString(), label: category.name,
icon: category.icon, value: category.id.toString(),
}))} icon: category.icon,
error={inputErrors.categories} }))}
/> size="lg"
error={inputErrors.categories}
class="min-w-auto"
/>
<InputCheckboxGroup <InputCheckboxGroup
name="attributes" name="attributes"
label="Attributes" label="Attributes"
options={attributes.map((attribute) => ({ options={orderBy(
label: attribute.title, attributes.map((attribute) => ({
value: attribute.id.toString(), ...attribute,
}))} categoryInfo: getAttributeCategoryInfo(attribute.category),
error={inputErrors.attributes} 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 <InputCardGroup
name="acceptedCurrencies" name="acceptedCurrencies"
@@ -305,7 +320,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
<script> <script>
document.addEventListener('astro:page-load', () => { 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"]') const slugInputs = document.querySelectorAll<HTMLInputElement>('input[name="slug"]')
triggerInputs.forEach((triggerInput) => { triggerInputs.forEach((triggerInput) => {

View File

@@ -1,5 +1,5 @@
--- ---
import { VerificationStepStatus } from '@prisma/client' import { VerificationStepStatus, EventType } from '@prisma/client'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
@@ -16,6 +16,8 @@ import DropdownButton from '../../components/DropdownButton.astro'
import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro' import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro'
import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro' import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro'
import FormatTimeInterval from '../../components/FormatTimeInterval.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 MyPicture from '../../components/MyPicture.astro'
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage' import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage'
import ScoreGauge from '../../components/ScoreGauge.astro' import ScoreGauge from '../../components/ScoreGauge.astro'
@@ -23,6 +25,7 @@ import ScoreSquare from '../../components/ScoreSquare.astro'
import ServiceLinkButton from '../../components/ServiceLinkButton.astro' import ServiceLinkButton from '../../components/ServiceLinkButton.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro' import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories' import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes' import { getAttributeTypeInfo } from '../../constants/attributeTypes'
@@ -30,7 +33,7 @@ import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies' import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes' import { getEventTypeInfo } from '../../constants/eventTypes'
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels' import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
import { serviceVisibilitiesById } from '../../constants/serviceVisibility' import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating' import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
import { getUserSentimentInfo } from '../../constants/userSentiment' import { getUserSentimentInfo } from '../../constants/userSentiment'
import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus' import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus'
@@ -159,6 +162,21 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
updatedAt: true, updatedAt: true,
}, },
}, },
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
addedByUser: {
select: {
name: true,
displayName: true,
picture: true,
},
},
},
orderBy: { createdAt: 'desc' },
},
_count: { _count: {
select: { select: {
comments: { comments: {
@@ -240,7 +258,11 @@ const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.
if (!service) return Astro.rewrite('/404') 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') return Astro.rewrite('/404')
} }
@@ -356,6 +378,12 @@ const ogImageTemplateData = {
score: service.overallScore, score: service.overallScore,
imageUrl: service.imageUrl, imageUrl: service.imageUrl,
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
)
--- ---
<BaseLayout <BaseLayout
@@ -457,16 +485,43 @@ const ogImageTemplateData = {
]} ]}
> >
{ {
service.serviceVisibility === 'UNLISTED' && ( activeAlertOrWarningEvents.length > 0 && (
<div class={cn('mb-4 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400')}> <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 <Icon
name={serviceVisibilitiesById.UNLISTED.icon} name={
class={cn('me-1.5 inline-block size-4 align-[-0.15em]', serviceVisibilitiesById.UNLISTED.iconClass)} 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> </div>
) )
} }
<VerificationWarningBanner service={service} /> <VerificationWarningBanner service={service} />
<div class="flex items-center gap-4"> <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"> <h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="scores">
Scores Scores
</h2> </h2>
@@ -1102,6 +1212,7 @@ const ogImageTemplateData = {
<div class="mt-3 max-w-md pe-8"> <div class="mt-3 max-w-md pe-8">
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white"> <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} {event.title}
</h3> </h3>
@@ -1245,7 +1356,8 @@ const ogImageTemplateData = {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{ {
service.verificationStatus !== 'VERIFICATION_SUCCESS' && service.verificationStatus !== 'VERIFICATION_SUCCESS' &&
service.verificationStatus !== 'VERIFICATION_FAILED' && ( service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.serviceVisibility !== 'ARCHIVED' && (
<form <form
method="POST" method="POST"
action={actions.service.requestVerification} action={actions.service.requestVerification}

View File

@@ -1,5 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { sortBy } from 'lodash-es' import { sortBy } from 'lodash-es'
@@ -47,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
lastLoginAt: true,
_count: { _count: {
select: { select: {
comments: true, comments: true,
@@ -468,6 +470,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
</li> </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 && ( user.verifiedLink && (
<li class="flex items-start"> <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 /> <TimeFormatted date={note.createdAt} hourPrecision />
</span> </span>
</div> </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>
))} ))}
</div> </div>

View File

@@ -103,6 +103,10 @@
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent)); drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
} }
@utility scrollbar-w-none {
scrollbar-width: none;
}
@utility checkbox-force-checked { @utility checkbox-force-checked {
&:not(:checked) { &:not(:checked) {
@apply border-transparent! bg-current/50!; @apply border-transparent! bg-current/50!;