Compare commits

..

21 Commits

Author SHA1 Message Date
pluja
cdfdcfc122 Release 2025-05-23-R3WZ 2025-05-23 11:52:16 +00:00
pluja
f4525e3d32 Release 2025-05-22-SwZ1 2025-05-22 23:07:55 +00:00
pluja
ecc8f67fc4 Release 2025-05-22-XDxe 2025-05-22 22:58:18 +00:00
pluja
72c238a4dc Release 2025-05-22-16vM 2025-05-22 22:38:41 +00:00
pluja
d79bedf219 Release 2025-05-22-5X5Q 2025-05-22 19:43:20 +00:00
pluja
2362d2cc73 Release 2025-05-22-Uvv4 2025-05-22 19:19:07 +00:00
pluja
a69c0aeed4 Release 2025-05-22-GmO6 2025-05-22 11:10:18 +00:00
pluja
ed86f863e3 Release 2025-05-21-MXjT 2025-05-21 14:31:33 +00:00
pluja
845aa1185c Release 2025-05-21-AQ5C 2025-05-21 07:03:39 +00:00
pluja
17b3642f7e Update favicon 2025-05-20 11:27:55 +00:00
pluja
d64268d396 fix logout issue 2025-05-20 11:12:55 +00:00
pluja
9c289753dd fix generate 2025-05-20 11:00:28 +00:00
pluja
8bdbe8ea36 small updates 2025-05-20 10:29:03 +00:00
pluja
af7ebe813b announcements style 2025-05-20 10:20:09 +00:00
pluja
dabc4e5c47 donation component 2025-05-20 08:02:55 +00:00
pluja
af3df8f79a Release 2025-05-20-0D8p 2025-05-20 01:47:50 +00:00
pluja
587480d140 pyworker fixes and ogimages fixes 2025-05-19 22:13:13 +00:00
pluja
74e6a50f14 fix karma trigger 2025-05-19 21:51:45 +00:00
pluja
3eb9b28ea0 triggers fix and ogimages 2025-05-19 21:38:37 +00:00
pluja
a21dc81099 updates 2025-05-19 21:31:29 +00:00
pluja
636057f8e0 announcements 2025-05-19 16:57:10 +00:00
114 changed files with 5521 additions and 2365 deletions

View File

@@ -143,7 +143,12 @@
<BaseLayout <BaseLayout
pageTitle="Edit service" pageTitle="Edit service"
description="Suggest an edit to service" description="Suggest an edit to service"
ogImage={{ template: 'generic', title: 'Edit service' }} ogImage={{
template: 'generic',
title: 'Edit service',
description: 'Suggest an edit to service',
icon: 'ri:edit-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
> >
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1> <h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
@@ -199,7 +204,7 @@
label="Note for Moderators" label="Note for Moderators"
name="notes" name="notes"
value={params.notes} value={params.notes}
rows={10} inputProps={{ rows: 10 }}
error={inputErrors.notes} error={inputErrors.notes}
/> />
@@ -207,7 +212,7 @@
<InputHoneypotTrap name="message" /> <InputHoneypotTrap name="message" />
<InputSubmitButton /> <InputSubmitButton hideCancel />
</form> </form>
</BaseLayout> </BaseLayout>
``` ```

1
.gitignore vendored
View File

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

View File

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

View File

@@ -70,7 +70,7 @@ services:
expose: expose:
- 4321 - 4321
healthcheck: healthcheck:
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321"] test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

107
justfile
View File

@@ -15,14 +15,24 @@ import-triggers:
docker compose exec -T database psql -U ${DATABASE_USER:-kycnot} -d ${DATABASE_NAME:-kycnot} < "$sql_file" docker compose exec -T database psql -U ${DATABASE_USER:-kycnot} -d ${DATABASE_NAME:-kycnot} < "$sql_file"
done done
# Create a database backup that includes the Prisma migrations table (recommended)
dump-db: dump-db:
#!/bin/bash #!/bin/bash
mkdir -p backups mkdir -p backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "Creating database backup (excluding _prisma_migrations table)..." echo "Creating complete database backup (including _prisma_migrations table)..."
docker compose exec -T database pg_dump -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c -F c -T _prisma_migrations > backups/db_backup_${TIMESTAMP}.dump docker compose exec -T database pg_dump -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c -F c > backups/db_backup_${TIMESTAMP}.dump
echo "Backup saved to backups/db_backup_${TIMESTAMP}.dump" echo "Backup saved to backups/db_backup_${TIMESTAMP}.dump"
# Create a database backup without the migrations table (legacy format)
dump-db-no-migrations:
#!/bin/bash
mkdir -p backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "Creating database backup (excluding _prisma_migrations table)..."
docker compose exec -T database pg_dump -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c -F c -T _prisma_migrations > backups/db_backup_no_migrations_${TIMESTAMP}.dump
echo "Backup saved to backups/db_backup_no_migrations_${TIMESTAMP}.dump"
# Import a database backup. Usage: just import-db [filename] # Import a database backup. Usage: just import-db [filename]
# If no filename is provided, it will use the most recent backup # If no filename is provided, it will use the most recent backup
import-db file="": import-db file="":
@@ -44,7 +54,96 @@ import-db file="":
echo "Restoring database from $BACKUP_FILE..." echo "Restoring database from $BACKUP_FILE..."
# First drop all connections to the database # First drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
# Then restore the database
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --clean --if-exists # Drop and recreate database
echo "Dropping and recreating the database..."
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
# Restore the database
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
echo "Database restored successfully!" echo "Database restored successfully!"
# Import triggers
echo "Importing triggers..."
just import-triggers
echo "Database import completed!"
# Check if migrations need to be run
cd web && npx prisma migrate status
#!/bin/bash
if [ -z "{{file}}" ]; then
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
if [ -z "$BACKUP_FILE" ]; then
echo "Error: No backup files found in the backups directory"
exit 1
fi
else
BACKUP_FILE="{{file}}"
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file '$BACKUP_FILE' not found"
exit 1
fi
fi
echo "=== STEP 1: PREPARING DATABASE ==="
# Drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
# Drop and recreate database
echo "Dropping and recreating the database..."
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
echo "=== STEP 2: RESTORING PRODUCTION DATA ==="
# Restore the database
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
echo "Database data restored successfully!"
echo "=== STEP 3: CREATING PRISMA MIGRATIONS TABLE ==="
# Create the _prisma_migrations table if it doesn't exist
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c "
CREATE TABLE IF NOT EXISTS _prisma_migrations (
id VARCHAR(36) PRIMARY KEY NOT NULL,
checksum VARCHAR(64) NOT NULL,
finished_at TIMESTAMP WITH TIME ZONE,
migration_name VARCHAR(255) NOT NULL,
logs TEXT,
rolled_back_at TIMESTAMP WITH TIME ZONE,
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
applied_steps_count INTEGER NOT NULL DEFAULT 0
);"
echo "=== STEP 4: REGISTERING MIGRATIONS ==="
# Only register migrations if the table is empty
migration_count=$(docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -t -c "SELECT COUNT(*) FROM _prisma_migrations;")
if [ "$migration_count" -eq "0" ]; then
# For each migration, insert a record into _prisma_migrations
for migration_dir in $(find web/prisma/migrations -maxdepth 1 -mindepth 1 -type d | sort); do
migration_name=$(basename "$migration_dir")
sql_file="$migration_dir/migration.sql"
if [ -f "$sql_file" ]; then
echo "Registering migration: $migration_name"
checksum=$(sha256sum "$sql_file" | cut -d' ' -f1)
uuid=$(uuidgen)
now=$(date -u +"%Y-%m-%d %H:%M:%S")
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c "
INSERT INTO _prisma_migrations (id, checksum, migration_name, logs, started_at, finished_at, applied_steps_count)
VALUES ('$uuid', '$checksum', '$migration_name', 'Registered during import', '$now', '$now', 1)
ON CONFLICT (migration_name) DO NOTHING;"
fi
done
else
echo "Migrations table already has entries. Skipping registration."
fi
echo "=== STEP 5: IMPORTING TRIGGERS ==="
just import-triggers
echo "Production database import completed successfully!"
echo "Migration status:"
cd web && npx prisma migrate status

View File

@@ -126,9 +126,9 @@ class TaskScheduler:
self.logger.info(f"Running task '{task_name}'") self.logger.info(f"Running task '{task_name}'")
# Use task instance as a context manager to ensure # Use task instance as a context manager to ensure
# a single database connection is used for the entire task # a single database connection is used for the entire task
with task_info["instance"] as task_instance: with task_info["instance"]:
# Execute the task instance's run method directly # Execute the registered task function with its arguments
task_instance.run() task_info["func"](*task_info["args"], **task_info["kwargs"])
self.logger.info(f"Task '{task_name}' completed") self.logger.info(f"Task '{task_name}' completed")
except Exception as e: except Exception as e:
self.logger.exception(f"Error running task '{task_name}': {e}") self.logger.exception(f"Error running task '{task_name}': {e}")

View File

@@ -2,3 +2,5 @@ DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379" REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com" SOURCE_CODE_URL="https://github.com"
SITE_URL="https://localhost:4321" SITE_URL="https://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"

View File

@@ -19,9 +19,8 @@ ENV HOST=0.0.0.0
ENV PORT=4321 ENV PORT=4321
EXPOSE 4321 EXPOSE 4321
# Add entrypoint script and make it executable # Add knm-migrate command script and make it executable
COPY docker-entrypoint.sh /usr/local/bin/ COPY migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/knm-migrate
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "./dist/server/entry.mjs"] CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -42,6 +42,10 @@ export default defineConfig({
open: false, open: false,
allowedHosts: [new URL(SITE_URL).hostname], allowedHosts: [new URL(SITE_URL).hostname],
}, },
image: {
domains: [new URL(SITE_URL).hostname],
remotePatterns: [{ protocol: 'https' }],
},
redirects: { redirects: {
// #region Redirects from old website // #region Redirects from old website
'/pending': '/?verification=verified&verification=approved&verification=community', '/pending': '/?verification=verified&verification=approved&verification=community',
@@ -70,6 +74,18 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
I2P_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
ONION_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
REDIS_URL: envField.string({ REDIS_URL: envField.string({
context: 'server', context: 'server',

View File

@@ -16,6 +16,4 @@ for trigger_file in prisma/triggers/*.sql; do
fi fi
done done
# Start the application echo "Migrations completed."
echo "Starting the application..."
exec "$@"

878
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"@astrojs/sitemap": "3.4.0", "@astrojs/sitemap": "3.4.0",
"@fontsource-variable/space-grotesk": "5.2.7", "@fontsource-variable/space-grotesk": "5.2.7",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.5",
"@fontsource/space-grotesk": "5.2.7",
"@prisma/client": "6.8.2", "@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7", "@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
@@ -43,10 +44,12 @@
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.0.1", "redis": "5.0.1",
"schema-dts": "1.1.5", "schema-dts": "1.1.5",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"sharp": "0.34.1",
"slugify": "1.6.6", "slugify": "1.6.6",
"tailwind-merge": "3.3.0", "tailwind-merge": "3.3.0",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
@@ -66,6 +69,7 @@
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.4",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.32.1",

View File

@@ -0,0 +1,19 @@
/*
Warnings:
- Changed the type of `action` on the `KarmaTransaction` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "KarmaTransactionAction" AS ENUM ('COMMENT_APPROVED', 'COMMENT_VERIFIED', 'COMMENT_SPAM', 'COMMENT_SPAM_REVERTED', 'COMMENT_UPVOTE', 'COMMENT_DOWNVOTE', 'COMMENT_VOTE_REMOVED', 'SUGGESTION_APPROVED', 'MANUAL_ADJUSTMENT');
-- AlterTable
ALTER TABLE "KarmaTransaction" ADD COLUMN "grantedByUserId" INTEGER,
DROP COLUMN "action",
ADD COLUMN "action" "KarmaTransactionAction" NOT NULL;
-- CreateIndex
CREATE INDEX "KarmaTransaction_grantedByUserId_idx" ON "KarmaTransaction"("grantedByUserId");
-- AddForeignKey
ALTER TABLE "KarmaTransaction" ADD CONSTRAINT "KarmaTransaction_grantedByUserId_fkey" FOREIGN KEY ("grantedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,20 @@
-- CreateEnum
CREATE TYPE "AnnouncementType" AS ENUM ('INFO', 'WARNING', 'ALERT');
-- CreateTable
CREATE TABLE "Announcement" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"type" "AnnouncementType" NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Announcement_isActive_startDate_endDate_idx" ON "Announcement"("isActive", "startDate", "endDate");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Announcement" ADD COLUMN "link" TEXT;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `title` on the `Announcement` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Announcement" DROP COLUMN "title";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Announcement" ADD COLUMN "linkText" TEXT;

View File

@@ -0,0 +1,11 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'KARMA_CHANGE';
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "aboutKarmaTransactionId" INTEGER;
-- AlterTable
ALTER TABLE "NotificationPreferences" ADD COLUMN "karmaNotificationThreshold" INTEGER NOT NULL DEFAULT 10;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_aboutKarmaTransactionId_fkey" FOREIGN KEY ("aboutKarmaTransactionId") REFERENCES "KarmaTransaction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `iconId` on the `ServiceContactMethod` table. All the data in the column will be lost.
- You are about to drop the column `info` on the `ServiceContactMethod` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "ServiceContactMethod" DROP COLUMN "iconId",
DROP COLUMN "info",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN "label" DROP NOT NULL;

View File

@@ -135,6 +135,7 @@ enum NotificationType {
SUGGESTION_MESSAGE SUGGESTION_MESSAGE
SUGGESTION_STATUS_CHANGE SUGGESTION_STATUS_CHANGE
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code. // KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
KARMA_CHANGE
/// Marked as spammer, promoted to admin, etc. /// Marked as spammer, promoted to admin, etc.
ACCOUNT_STATUS_CHANGE ACCOUNT_STATUS_CHANGE
EVENT_CREATED EVENT_CREATED
@@ -166,6 +167,24 @@ enum ServiceSuggestionStatusChange {
STATUS_CHANGED_TO_WITHDRAWN STATUS_CHANGED_TO_WITHDRAWN
} }
enum KarmaTransactionAction {
COMMENT_APPROVED
COMMENT_VERIFIED
COMMENT_SPAM
COMMENT_SPAM_REVERTED
COMMENT_UPVOTE
COMMENT_DOWNVOTE
COMMENT_VOTE_REMOVED
SUGGESTION_APPROVED
MANUAL_ADJUSTMENT
}
enum AnnouncementType {
INFO
WARNING
ALERT
}
model Notification { model Notification {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
@@ -189,6 +208,8 @@ model Notification {
aboutCommentStatusChange CommentStatusChange? aboutCommentStatusChange CommentStatusChange?
aboutServiceVerificationStatusChange ServiceVerificationStatusChange? aboutServiceVerificationStatusChange ServiceVerificationStatusChange?
aboutSuggestionStatusChange ServiceSuggestionStatusChange? aboutSuggestionStatusChange ServiceSuggestionStatusChange?
aboutKarmaTransaction KarmaTransaction? @relation(fields: [aboutKarmaTransactionId], references: [id])
aboutKarmaTransactionId Int?
@@index([userId]) @@index([userId])
@@index([read]) @@index([read])
@@ -211,6 +232,7 @@ model NotificationPreferences {
enableOnMyCommentStatusChange Boolean @default(true) enableOnMyCommentStatusChange Boolean @default(true)
enableAutowatchMyComments Boolean @default(true) enableAutowatchMyComments Boolean @default(true)
enableNotifyPendingRepliesOnWatch Boolean @default(false) enableNotifyPendingRepliesOnWatch Boolean @default(false)
karmaNotificationThreshold Int @default(10)
onEventCreatedForServices Service[] @relation("onEventCreatedForServices") onEventCreatedForServices Service[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices") onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices")
@@ -376,11 +398,14 @@ model Service {
model ServiceContactMethod { model ServiceContactMethod {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
label String /// Only include it if you want to override the formatted value.
label String?
/// Including the protocol (e.g. "mailto:", "tel:", "https://") /// Including the protocol (e.g. "mailto:", "tel:", "https://")
value String value String
iconId String
info String createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade) services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade)
serviceId Int serviceId Int
} }
@@ -450,6 +475,7 @@ model User {
lastLoginAt DateTime @default(now()) lastLoginAt DateTime @default(now())
comments Comment[] comments Comment[]
karmaTransactions KarmaTransaction[] karmaTransactions KarmaTransaction[]
grantedKarmaTransactions KarmaTransaction[] @relation("KarmaGrantedBy")
commentVotes CommentVote[] commentVotes CommentVote[]
suggestions ServiceSuggestion[] suggestions ServiceSuggestion[]
suggestionMessages ServiceSuggestionMessage[] suggestionMessages ServiceSuggestionMessage[]
@@ -492,7 +518,7 @@ model KarmaTransaction {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int userId Int
action String action KarmaTransactionAction
points Int @default(0) points Int @default(0)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade) comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId Int? commentId Int?
@@ -501,12 +527,16 @@ model KarmaTransaction {
description String description String
processed Boolean @default(false) processed Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull)
grantedByUserId Int?
Notification Notification[]
@@index([createdAt]) @@index([createdAt])
@@index([userId]) @@index([userId])
@@index([processed]) @@index([processed])
@@index([suggestionId]) @@index([suggestionId])
@@index([commentId]) @@index([commentId])
@@index([grantedByUserId])
} }
enum VerificationStepStatus { enum VerificationStepStatus {
@@ -588,3 +618,18 @@ model ServiceUser {
@@index([serviceId]) @@index([serviceId])
@@index([role]) @@index([role])
} }
model Announcement {
id Int @id @default(autoincrement())
content String
type AnnouncementType
link String?
linkText String?
startDate DateTime
endDate DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([isActive, startDate, endDate])
}

View File

@@ -8,6 +8,7 @@ DROP TRIGGER IF EXISTS comment_suspicious_change_trigger ON "Comment";
DROP TRIGGER IF EXISTS comment_upvote_change_trigger ON "Comment"; DROP TRIGGER IF EXISTS comment_upvote_change_trigger ON "Comment";
DROP TRIGGER IF EXISTS comment_vote_change_trigger ON "CommentVote"; DROP TRIGGER IF EXISTS comment_vote_change_trigger ON "CommentVote";
DROP TRIGGER IF EXISTS suggestion_status_change_trigger ON "ServiceSuggestion"; DROP TRIGGER IF EXISTS suggestion_status_change_trigger ON "ServiceSuggestion";
DROP TRIGGER IF EXISTS manual_karma_adjustment_trigger ON "KarmaTransaction";
-- Drop existing functions -- Drop existing functions
DROP FUNCTION IF EXISTS handle_comment_upvote_change(); DROP FUNCTION IF EXISTS handle_comment_upvote_change();
@@ -19,6 +20,7 @@ DROP FUNCTION IF EXISTS handle_comment_vote_change();
DROP FUNCTION IF EXISTS insert_karma_transaction(); DROP FUNCTION IF EXISTS insert_karma_transaction();
DROP FUNCTION IF EXISTS update_user_karma(); DROP FUNCTION IF EXISTS update_user_karma();
DROP FUNCTION IF EXISTS handle_suggestion_status_change(); DROP FUNCTION IF EXISTS handle_suggestion_status_change();
DROP FUNCTION IF EXISTS handle_manual_karma_adjustment();
-- Helper function to insert karma transaction -- Helper function to insert karma transaction
CREATE OR REPLACE FUNCTION insert_karma_transaction( CREATE OR REPLACE FUNCTION insert_karma_transaction(
@@ -31,14 +33,17 @@ CREATE OR REPLACE FUNCTION insert_karma_transaction(
) RETURNS VOID AS $$ ) RETURNS VOID AS $$
BEGIN BEGIN
INSERT INTO "KarmaTransaction" ( INSERT INTO "KarmaTransaction" (
"userId", "points", "action", "commentId", "userId", "points", "action", "commentId", "suggestionId", "description", "processed", "createdAt"
"suggestionId",
"description", "processed", "createdAt"
) )
VALUES ( VALUES (
p_user_id, p_points, p_action, p_comment_id, p_user_id,
p_points,
p_action::"KarmaTransactionAction",
p_comment_id,
p_suggestion_id, p_suggestion_id,
p_description, true, NOW() p_description,
true,
NOW()
); );
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -65,7 +70,7 @@ BEGIN
PERFORM insert_karma_transaction( PERFORM insert_karma_transaction(
NEW."authorId", NEW."authorId",
1, 1,
'comment_approved', 'COMMENT_APPROVED',
NEW.id, NEW.id,
format('Your comment #comment-%s in %s has been approved!', format('Your comment #comment-%s in %s has been approved!',
NEW.id, NEW.id,
@@ -86,7 +91,7 @@ BEGIN
PERFORM insert_karma_transaction( PERFORM insert_karma_transaction(
NEW."authorId", NEW."authorId",
5, 5,
'comment_verified', 'COMMENT_VERIFIED',
NEW.id, NEW.id,
format('Your comment #comment-%s in %s has been verified!', format('Your comment #comment-%s in %s has been verified!',
NEW.id, NEW.id,
@@ -108,7 +113,7 @@ BEGIN
PERFORM insert_karma_transaction( PERFORM insert_karma_transaction(
NEW."authorId", NEW."authorId",
-10, -10,
'comment_spam', 'COMMENT_SPAM',
NEW.id, NEW.id,
format('Your comment #comment-%s in %s has been marked as spam.', format('Your comment #comment-%s in %s has been marked as spam.',
NEW.id, NEW.id,
@@ -120,7 +125,7 @@ BEGIN
PERFORM insert_karma_transaction( PERFORM insert_karma_transaction(
NEW."authorId", NEW."authorId",
10, 10,
'comment_spam_reverted', 'COMMENT_SPAM_REVERTED',
NEW.id, NEW.id,
format('Your comment #comment-%s in %s is no longer marked as spam.', format('Your comment #comment-%s in %s is no longer marked as spam.',
NEW.id, NEW.id,
@@ -136,7 +141,7 @@ CREATE OR REPLACE FUNCTION handle_comment_vote_change()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
DECLARE DECLARE
karma_points INT; karma_points INT;
vote_action TEXT; vote_action "KarmaTransactionAction";
vote_description TEXT; vote_description TEXT;
comment_author_id INT; comment_author_id INT;
service_name TEXT; service_name TEXT;
@@ -151,7 +156,7 @@ BEGIN
IF TG_OP = 'INSERT' THEN IF TG_OP = 'INSERT' THEN
-- New vote -- New vote
karma_points := CASE WHEN NEW.downvote THEN -1 ELSE 1 END; karma_points := CASE WHEN NEW.downvote THEN -1 ELSE 1 END;
vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END; vote_action := CASE WHEN NEW.downvote THEN 'COMMENT_DOWNVOTE' ELSE 'COMMENT_UPVOTE' END;
vote_description := format('Your comment #comment-%s in %s received %s', vote_description := format('Your comment #comment-%s in %s received %s',
NEW."commentId", NEW."commentId",
service_name, service_name,
@@ -160,7 +165,7 @@ BEGIN
ELSIF TG_OP = 'DELETE' THEN ELSIF TG_OP = 'DELETE' THEN
-- Removed vote -- Removed vote
karma_points := CASE WHEN OLD.downvote THEN 1 ELSE -1 END; karma_points := CASE WHEN OLD.downvote THEN 1 ELSE -1 END;
vote_action := 'comment_vote_removed'; vote_action := 'COMMENT_VOTE_REMOVED';
vote_description := format('A vote was removed from your comment #comment-%s in %s', vote_description := format('A vote was removed from your comment #comment-%s in %s',
OLD."commentId", OLD."commentId",
service_name); service_name);
@@ -168,7 +173,7 @@ BEGIN
ELSIF TG_OP = 'UPDATE' THEN ELSIF TG_OP = 'UPDATE' THEN
-- Changed vote (from upvote to downvote or vice versa) -- Changed vote (from upvote to downvote or vice versa)
karma_points := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; karma_points := CASE WHEN NEW.downvote THEN -2 ELSE 2 END;
vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END; vote_action := CASE WHEN NEW.downvote THEN 'COMMENT_DOWNVOTE' ELSE 'COMMENT_UPVOTE' END;
vote_description := format('Your comment #comment-%s in %s vote changed to %s', vote_description := format('Your comment #comment-%s in %s vote changed to %s',
NEW."commentId", NEW."commentId",
service_name, service_name,
@@ -243,7 +248,7 @@ BEGIN
PERFORM insert_karma_transaction( PERFORM insert_karma_transaction(
NEW."userId", NEW."userId",
10, 10,
'suggestion_approved', 'SUGGESTION_APPROVED',
NULL, -- p_comment_id (not applicable) NULL, -- p_comment_id (not applicable)
format('Your suggestion for service ''%s'' has been approved!', service_name), format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id NEW.id -- p_suggestion_id
@@ -263,3 +268,24 @@ CREATE TRIGGER suggestion_status_change_trigger
ON "ServiceSuggestion" ON "ServiceSuggestion"
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION handle_suggestion_status_change(); EXECUTE FUNCTION handle_suggestion_status_change();
-- Function to handle manual karma adjustments
CREATE OR REPLACE FUNCTION handle_manual_karma_adjustment()
RETURNS TRIGGER AS $$
BEGIN
-- Only process MANUAL_ADJUSTMENT transactions that are not yet processed
IF NEW.processed = false AND NEW.action = 'MANUAL_ADJUSTMENT' THEN
-- Update user's total karma
PERFORM update_user_karma(NEW."userId", NEW.points);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for manual karma adjustments
CREATE TRIGGER manual_karma_adjustment_trigger
AFTER INSERT
ON "KarmaTransaction"
FOR EACH ROW
EXECUTE FUNCTION handle_manual_karma_adjustment();

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 692 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

View File

@@ -14,6 +14,7 @@ import {
EventType, EventType,
type User, type User,
ServiceUserRole, ServiceUserRole,
AnnouncementType,
} from '@prisma/client' } from '@prisma/client'
import { uniqBy } from 'lodash-es' import { uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
@@ -844,40 +845,29 @@ const generateFakeComment = (userId: number, serviceId: number, parentId?: numbe
const generateFakeServiceContactMethod = (serviceId: number) => { const generateFakeServiceContactMethod = (serviceId: number) => {
const types = [ const types = [
{ {
label: 'Email',
value: `mailto:${faker.internet.email()}`, value: `mailto:${faker.internet.email()}`,
iconId: 'ri:mail-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Phone',
value: `tel:${faker.phone.number({ style: 'international' })}`, value: `tel:${faker.phone.number({ style: 'international' })}`,
iconId: 'ri:phone-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'WhatsApp',
value: `https://wa.me/${faker.phone.number({ style: 'international' })}`, value: `https://wa.me/${faker.phone.number({ style: 'international' })}`,
iconId: 'ri:whatsapp-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Telegram',
value: `https://t.me/${faker.internet.username()}`, value: `https://t.me/${faker.internet.username()}`,
iconId: 'ri:telegram-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'Website', value: `https://x.com/${faker.internet.username()}`,
},
{
value: faker.internet.url(),
},
{
label: faker.lorem.word({ length: 2 }),
value: faker.internet.url(), value: faker.internet.url(),
iconId: 'ri:global-line',
info: faker.lorem.sentence(),
}, },
{ {
label: 'LinkedIn',
value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`, value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
iconId: 'ri:linkedin-box-line',
info: faker.lorem.sentence(),
}, },
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[] ] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
@@ -981,6 +971,22 @@ const generateFakeInternalNote = (userId: number, addedByUserId?: number) =>
addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined, addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined,
}) satisfies Prisma.InternalUserNoteCreateInput }) satisfies Prisma.InternalUserNoteCreateInput
const generateFakeAnnouncement = () => {
const type = faker.helpers.arrayElement(Object.values(AnnouncementType))
const startDate = faker.date.past()
const endDate = faker.helpers.maybe(() => faker.date.future(), { probability: 0.3 })
return {
content: faker.lorem.sentence(),
type,
link: faker.internet.url(),
linkText: faker.lorem.word({ length: 2 }),
startDate,
endDate,
isActive: true,
} as const satisfies Prisma.AnnouncementCreateInput
}
async function runFaker() { async function runFaker() {
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
@@ -1004,6 +1010,7 @@ async function runFaker() {
await tx.category.deleteMany() await tx.category.deleteMany()
await tx.internalUserNote.deleteMany() await tx.internalUserNote.deleteMany()
await tx.user.deleteMany() await tx.user.deleteMany()
await tx.announcement.deleteMany()
console.info('✅ Existing data cleaned up') console.info('✅ Existing data cleaned up')
} catch (error) { } catch (error) {
console.error('❌ Error cleaning up data:', error) console.error('❌ Error cleaning up data:', error)
@@ -1307,6 +1314,11 @@ async function runFaker() {
) )
}) })
) )
// ---- Create announcement ----
await tx.announcement.create({
data: generateFakeAnnouncement(),
})
}, },
{ {
timeout: 1000 * 60 * 10, // 10 minutes timeout: 1000 * 60 * 10, // 10 minutes

View File

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

View File

@@ -0,0 +1,184 @@
import { type Prisma, type PrismaClient } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
const selectAnnouncementReturnFields = {
id: true,
content: true,
type: true,
link: true,
linkText: true,
startDate: true,
endDate: true,
isActive: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.AnnouncementSelect
export const adminAnnouncementActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
content: z
.string()
.min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true),
}),
handler: async (input) => {
const announcement = await prisma.announcement.create({
data: {
content: input.content,
type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
},
select: selectAnnouncementReturnFields,
})
return { announcement }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.coerce.number().int().positive(),
content: z
.string()
.min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true),
}),
handler: async (input) => {
const announcement = await prisma.announcement.findUnique({
where: {
id: input.id,
},
select: {
id: true,
},
})
if (!announcement) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Announcement not found',
})
}
const updatedAnnouncement = await prisma.announcement.update({
where: { id: announcement.id },
data: {
content: input.content,
type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
},
select: selectAnnouncementReturnFields,
})
return { updatedAnnouncement }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const announcement = await prisma.announcement.findUnique({
where: {
id: input.id,
},
select: {
id: true,
},
})
if (!announcement) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Announcement not found',
})
}
await prisma.announcement.delete({
where: { id: announcement.id },
})
return { success: true }
},
}),
toggleActive: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.coerce.number().int().positive(),
isActive: z.coerce.boolean(),
}),
handler: async (input) => {
const announcement = await prisma.announcement.findUnique({
where: {
id: input.id,
},
select: {
id: true,
},
})
if (!announcement) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Announcement not found',
})
}
const updatedAnnouncement = await prisma.announcement.update({
where: { id: announcement.id },
data: {
isActive: input.isActive,
},
select: selectAnnouncementReturnFields,
})
return { updatedAnnouncement }
},
}),
}

View File

@@ -1,3 +1,4 @@
import { adminAnnouncementActions } from './announcement'
import { adminAttributeActions } from './attribute' import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event' import { adminEventActions } from './event'
import { adminServiceActions } from './service' import { adminServiceActions } from './service'
@@ -7,6 +8,7 @@ import { verificationStep } from './verificationStep'
export const adminActions = { export const adminActions = {
attribute: adminAttributeActions, attribute: adminAttributeActions,
announcement: adminAnnouncementActions,
event: adminEventActions, event: adminEventActions,
service: adminServiceActions, service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions, serviceSuggestions: adminServiceSuggestionActions,

View File

@@ -14,7 +14,7 @@ import {
} from '../../lib/zodUtils' } from '../../lib/zodUtils'
const serviceSchemaBase = z.object({ const serviceSchemaBase = z.object({
id: z.number(), id: z.number().int().positive(),
slug: z slug: z
.string() .string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens') .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
@@ -56,15 +56,6 @@ const addSlugIfMissing = <
}), }),
}) })
const contactMethodSchema = z.object({
id: z.number().optional(),
label: z.string().min(1).max(50),
value: z.string().min(1).max(200),
iconId: z.string().min(1).max(50),
info: z.string().max(200).optional().default(''),
serviceId: z.number(),
})
export const adminServiceActions = { export const adminServiceActions = {
create: defineProtectedAction({ create: defineProtectedAction({
accept: 'form', accept: 'form',
@@ -195,7 +186,11 @@ export const adminServiceActions = {
createContactMethod: defineProtectedAction({ createContactMethod: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: contactMethodSchema.omit({ id: true }), input: z.object({
label: z.string().min(1).max(50).optional(),
value: z.string().url(),
serviceId: z.number().int().positive(),
}),
handler: async (input) => { handler: async (input) => {
const contactMethod = await prisma.serviceContactMethod.create({ const contactMethod = await prisma.serviceContactMethod.create({
data: input, data: input,
@@ -207,7 +202,12 @@ export const adminServiceActions = {
updateContactMethod: defineProtectedAction({ updateContactMethod: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: contactMethodSchema, input: z.object({
id: z.number().int().positive().optional(),
label: z.string().min(1).max(50).optional(),
value: z.string().url(),
serviceId: z.number().int().positive(),
}),
handler: async (input) => { handler: async (input) => {
const { id, ...data } = input const { id, ...data } = input
const contactMethod = await prisma.serviceContactMethod.update({ const contactMethod = await prisma.serviceContactMethod.update({
@@ -222,7 +222,7 @@ export const adminServiceActions = {
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
id: z.number(), id: z.number().int().positive(),
}), }),
handler: async (input) => { handler: async (input) => {
await prisma.serviceContactMethod.delete({ await prisma.serviceContactMethod.delete({

View File

@@ -54,11 +54,8 @@ export const adminUserActions = {
.nullable() .nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing .default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null), .transform((val) => val || null),
picture: z.string().max(255, 'Picture URL must be less than 255 characters').nullable().default(null),
pictureFile: z.instanceof(File).optional(), pictureFile: z.instanceof(File).optional(),
verifier: z.boolean().default(false), type: z.array(z.enum(['admin', 'verifier', 'spammer'])),
admin: z.boolean().default(false),
spammer: z.boolean().default(false),
verifiedLink: z verifiedLink: z
.string() .string()
.url('Invalid URL') .url('Invalid URL')
@@ -72,7 +69,7 @@ export const adminUserActions = {
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing .default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null), .transform((val) => val || null),
}), }),
handler: async ({ id, picture, pictureFile, ...valuesToUpdate }) => { handler: async ({ id, pictureFile, type, ...valuesToUpdate }) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id, id,
@@ -89,17 +86,23 @@ export const adminUserActions = {
}) })
} }
let pictureUrl = picture ?? null const pictureUrl =
if (pictureFile && pictureFile.size > 0) { pictureFile && pictureFile.size > 0
pictureUrl = await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/') ? await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/')
} : null
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
...valuesToUpdate, name: valuesToUpdate.name,
link: valuesToUpdate.link,
verifiedLink: valuesToUpdate.verifiedLink,
displayName: valuesToUpdate.displayName,
verified: !!valuesToUpdate.verifiedLink, verified: !!valuesToUpdate.verifiedLink,
picture: pictureUrl, picture: pictureUrl,
admin: type.includes('admin'),
verifier: type.includes('verifier'),
spammer: type.includes('spammer'),
}, },
select: selectUserReturnFields, select: selectUserReturnFields,
}) })
@@ -293,10 +296,9 @@ export const adminUserActions = {
input: z.object({ input: z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
points: z.coerce.number().int(), points: z.coerce.number().int(),
action: z.string().min(1, 'Action is required'),
description: z.string().min(1, 'Description is required'), description: z.string().min(1, 'Description is required'),
}), }),
handler: async (input) => { handler: async (input, context) => {
// Check if the user exists // Check if the user exists
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: input.userId }, where: { id: input.userId },
@@ -314,9 +316,9 @@ export const adminUserActions = {
data: { data: {
userId: input.userId, userId: input.userId,
points: input.points, points: input.points,
action: input.action, action: 'MANUAL_ADJUSTMENT',
description: input.description, description: input.description,
processed: true, grantedByUserId: context.locals.user.id,
}, },
}) })
}, },

View File

@@ -14,9 +14,9 @@ import { timeTrapSecretKey } from '../lib/timeTrapSecret'
import type { CommentStatus, Prisma } from '@prisma/client' import type { CommentStatus, Prisma } from '@prisma/client'
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 5 const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
const MAX_COMMENTS_PER_WINDOW = 1 const MAX_COMMENTS_PER_WINDOW = 1
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 5 const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
export const commentActions = { export const commentActions = {
vote: defineProtectedAction({ vote: defineProtectedAction({

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

BIN
web/src/assets/ogimage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

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

View File

@@ -19,6 +19,7 @@ type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
dataAstroReload?: boolean dataAstroReload?: boolean
children?: never children?: never
disabled?: boolean disabled?: boolean
inlineIcon?: boolean
} }
> >
@@ -26,7 +27,7 @@ export type ButtonProps<Tag extends 'a' | 'button' | 'label' = 'button'> = Props
const button = tv({ const button = tv({
slots: { slots: {
base: 'inline-flex items-center justify-center gap-2 rounded-lg border transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-hidden', base: 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-hidden',
icon: 'size-4 shrink-0', icon: 'size-4 shrink-0',
label: 'text-left whitespace-nowrap', label: 'text-left whitespace-nowrap',
endIcon: 'size-4 shrink-0', endIcon: 'size-4 shrink-0',
@@ -51,6 +52,11 @@ const button = tv({
label: 'font-bold tracking-wider uppercase', label: 'font-bold tracking-wider uppercase',
}, },
}, },
iconOnly: {
true: {
base: 'p-0',
},
},
color: { color: {
black: { black: {
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white', base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
@@ -121,12 +127,28 @@ const button = tv({
shadow: true, shadow: true,
class: 'shadow-blue-500/30', class: 'shadow-blue-500/30',
}, },
{
iconOnly: true,
size: 'sm',
class: 'w-8',
},
{
iconOnly: true,
size: 'md',
class: 'w-9',
},
{
iconOnly: true,
size: 'lg',
class: 'w-10',
},
], ],
defaultVariants: { defaultVariants: {
size: 'md', size: 'md',
color: 'black', color: 'black',
shadow: false, shadow: false,
disabled: false, disabled: false,
iconOnly: false,
}, },
}) })
@@ -143,6 +165,7 @@ const {
role, role,
dataAstroReload, dataAstroReload,
disabled, disabled,
inlineIcon,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
@@ -151,7 +174,7 @@ const {
icon: iconSlot, icon: iconSlot,
label: labelSlot, label: labelSlot,
endIcon: endIconSlot, endIcon: endIconSlot,
} = button({ size, color, shadow, disabled }) } = button({ size, color, shadow, disabled, iconOnly: !label && !(!!icon && !!endIcon) })
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
--- ---
@@ -164,11 +187,11 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }} {...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps} {...htmlProps}
> >
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} />} {!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>} {!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
{ {
!!endIcon && ( !!endIcon && (
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })}> <Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>
{endIcon} {endIcon}
</Icon> </Icon>
) )

View File

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

View File

@@ -1,10 +1,10 @@
--- ---
import Image from 'astro/components/Image.astro'
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'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { commentStatusById } from '../constants/commentStatus'
import { karmaUnlocksById } from '../constants/karmaUnlocks' import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { getServiceUserRoleInfo } from '../constants/serviceUserRoles' import { getServiceUserRoleInfo } from '../constants/serviceUserRoles'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -21,6 +21,7 @@ import CommentModeration from './CommentModeration.astro'
import CommentReply from './CommentReply.astro' import CommentReply from './CommentReply.astro'
import TimeFormatted from './TimeFormatted.astro' import TimeFormatted from './TimeFormatted.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import UserBadge from './UserBadge.astro'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
@@ -156,28 +157,11 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
</label> </label>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
{ <UserBadge
comment.author.picture && ( user={comment.author}
<Image size="md"
src={comment.author.picture} class={cn('text-day-300', isAuthor && 'font-medium text-green-500')}
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`}
class="size-6 rounded-full bg-zinc-700 object-cover"
loading="lazy"
height={24}
width={24}
/> />
)
}
<a
href={`/u/${comment.author.name}`}
class={cn([
'font-title text-day-300 font-medium hover:underline focus-visible:underline',
isAuthor && 'font-medium text-green-500',
])}
>
{comment.author.displayName ?? comment.author.name}
</a>
{ {
(comment.author.verified || comment.author.admin || comment.author.verifier) && ( (comment.author.verified || comment.author.admin || comment.author.verifier) && (
@@ -203,7 +187,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
} }
{ {
comment.author.verifier && !comment.author.admin && ( comment.author.verifier && !comment.author.admin && (
<BadgeSmall icon="ri:shield-check-fill" color="teal" text="Moderator" variant="faded" inlineIcon /> <BadgeSmall
icon="ri:graduation-cap-fill"
color="teal"
text="Moderator"
variant="faded"
inlineIcon
/>
) )
} }
@@ -302,20 +292,35 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{ {
comment.status === 'VERIFIED' && ( comment.status === 'VERIFIED' && (
<BadgeSmall icon="ri:check-double-fill" color="green" text="Verified" inlineIcon /> <BadgeSmall
icon={commentStatusById.VERIFIED.icon}
color={commentStatusById.VERIFIED.color}
text={commentStatusById.VERIFIED.label}
inlineIcon
/>
) )
} }
{ {
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') && (comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') &&
(showPending || isHighlightParent || isAuthorOrPrivileged) && ( (showPending || isHighlightParent || isAuthorOrPrivileged) && (
<BadgeSmall icon="ri:time-fill" color="yellow" text="Unmoderated" inlineIcon /> <BadgeSmall
icon={commentStatusById.PENDING.icon}
color={commentStatusById.PENDING.color}
text={commentStatusById.PENDING.label}
inlineIcon
/>
) )
} }
{ {
comment.status === 'REJECTED' && isAuthorOrPrivileged && ( comment.status === 'REJECTED' && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Rejected" inlineIcon /> <BadgeSmall
icon={commentStatusById.REJECTED.icon}
color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label}
inlineIcon
/>
) )
} }
@@ -366,8 +371,9 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.communityNote && ( comment.communityNote && (
<div class="mt-2 peer-checked/collapse:hidden"> <div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs"> <div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs">
<span class="font-medium text-zinc-400">Added context:</span> <span class="prose prose-sm prose-invert prose-strong:text-zinc-300/90 text-xs text-zinc-300">
<span class="text-zinc-300">{comment.communityNote}</span> <Markdown content={`**Added context:** ${comment.communityNote}`} />
</span>
</div> </div>
</div> </div>
) )

View File

@@ -33,10 +33,10 @@ if (!user || !user.admin || !user.verifier) return null
--- ---
<div {...divProps} class={cn('text-xs', className)}> <div {...divProps} class={cn('text-xs', className)}>
<input type="checkbox" id={`mod-toggle-${String(comment.id)}`} class="peer hidden" /> <input type="checkbox" id={`mod-toggle-${String(comment.id)}`} class="peer sr-only" />
<label <label
for={`mod-toggle-${String(comment.id)}`} for={`mod-toggle-${String(comment.id)}`}
class="text-day-500 hover:text-day-300 flex cursor-pointer items-center gap-1" class="text-day-500 hover:text-day-300 peer-focus-visible:ring-offset-night-700 inline-flex cursor-pointer items-center gap-1 rounded-sm peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
<Icon name="ri:shield-keyhole-line" class="h-3.5 w-3.5" /> <Icon name="ri:shield-keyhole-line" class="h-3.5 w-3.5" />
<span class="text-xs">Moderation</span> <span class="text-xs">Moderation</span>
@@ -44,7 +44,7 @@ if (!user || !user.admin || !user.verifier) return null
</label> </label>
<div <div
class="bg-night-600 border-night-500 mt-2 max-h-0 overflow-hidden rounded-md border opacity-0 transition-all duration-200 ease-in-out peer-checked:max-h-[500px] peer-checked:p-2 peer-checked:opacity-100" class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
> >
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2"> <div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
<button <button
@@ -110,16 +110,18 @@ if (!user || !user.admin || !user.verifier) return null
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING' comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400' ? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400' : 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)} )}
data-action="status" data-action="status"
data-value={comment.status === 'PENDING' ? 'APPROVED' : 'PENDING'} data-value={comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'APPROVED'
: 'PENDING'}
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.status === 'PENDING' ? 'Approve' : 'Pending'} {comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</button> </button>
<button <button

View File

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

View File

@@ -0,0 +1,65 @@
---
import { Icon } from 'astro-icon/components'
import * as QRCode from 'qrcode'
import { cn } from '../lib/cn'
type Props = {
cryptoName: string
cryptoIcon: string
address: string
className?: string
}
const { cryptoName, cryptoIcon, address, className } = Astro.props
function getAddressURI(address: string, cryptoName: string) {
if (cryptoName.toLowerCase() === 'monero') {
return `monero:${address}?tx_description=KYCnot.me%20Donation`
}
if (cryptoName.toLowerCase() === 'bitcoin') {
return `bitcoin:${address}?label=KYCnot.me%20Donation`
}
return address
}
const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName), {
width: 256,
margin: 1,
color: {
dark: '#ffffff',
light: '#171721',
},
})
---
<div class={cn('bg-night-800 border-night-600 flex items-center gap-2 rounded-lg border px-3', className)}>
<div class="flex flex-1 flex-col gap-1 py-3">
<div class="flex items-center gap-2 px-4 pt-3">
<Icon name={cryptoIcon} class="size-6 text-white" />
<span class="font-title text-base font-semibold text-white">{cryptoName}</span>
</div>
<p
class="cursor-pointer px-7 font-mono text-base leading-snug tracking-wide break-all text-white select-all"
>
{
address.length > 12
? [
<span class="mr-0.5 font-bold text-green-500">{address.substring(0, 6)}</span>,
address.substring(6, address.length - 6),
<span class="ml-0.5 font-bold text-green-500">{address.substring(address.length - 6)}</span>,
]
: address
}
</p>
</div>
<img
src={qrCodeDataURL}
alt={`${cryptoName} QR code`}
width="128"
height="128"
class="mr-4 hidden size-32 rounded sm:block"
/>
</div>

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { SOURCE_CODE_URL } from 'astro:env/server' import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -11,10 +11,22 @@ type Props = HTMLAttributes<'footer'>
const links = [ const links = [
{ {
href: SOURCE_CODE_URL, href: SOURCE_CODE_URL,
label: 'Source Code', label: 'Code',
icon: 'ri:git-repository-line', icon: 'ri:git-repository-line',
external: true, external: true,
}, },
{
href: ONION_ADDRESS,
label: 'Tor',
icon: 'onion',
external: true,
},
{
href: I2P_ADDRESS,
label: 'I2P',
icon: 'i2p',
external: true,
},
{ {
href: '/about', href: '/about',
label: 'About', label: 'About',

View File

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

View File

@@ -9,38 +9,42 @@ import InputWrapper from './InputWrapper.astro'
import type { MarkdownString } from '../lib/markdown' import type { MarkdownString } from '../lib/markdown'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & { type Props<Multiple extends boolean = false> = Omit<
ComponentProps<typeof InputWrapper>,
'children' | 'inputId'
> & {
options: { options: {
label: string label: string
value: string value: string
icon?: string icon?: string
iconClass?: string iconClass?: string
description?: MarkdownString description?: MarkdownString
disabled?: boolean
noTransitionPersist?: boolean
}[] }[]
disabled?: boolean disabled?: boolean
selectedValue?: string selectedValue?: Multiple extends true ? string[] : string
cardSize?: 'lg' | 'md' | 'sm' cardSize?: 'lg' | 'md' | 'sm'
iconSize?: 'md' | 'sm' iconSize?: 'md' | 'sm'
multiple?: boolean multiple?: Multiple
} }
const { const {
options, options,
disabled, disabled,
selectedValue, selectedValue = undefined as string[] | string | undefined,
cardSize = 'sm', cardSize = 'sm',
iconSize = 'sm', iconSize = 'sm',
class: className, class: className,
multiple, multiple = false as boolean,
...wrapperProps ...wrapperProps
} = Astro.props } = Astro.props
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} class={cn('@container', className)} {...wrapperProps}> {/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */}
<InputWrapper class={cn('@container', className)} {...wrapperProps}>
<div <div
class={cn( class={cn(
'grid grid-cols-[repeat(auto-fill,minmax(var(--card-min-size),1fr))] gap-2 rounded-lg', 'grid grid-cols-[repeat(auto-fill,minmax(var(--card-min-size),1fr))] gap-2 rounded-lg',
@@ -62,17 +66,21 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
'has-checked:border-green-700 has-checked:bg-green-700/20 has-checked:ring-1 has-checked:ring-green-700', 'has-checked:border-green-700 has-checked:bg-green-700/20 has-checked:ring-1 has-checked:ring-green-700',
multiple && multiple &&
'has-focus-visible:border-day-300 has-focus-visible:ring-2 has-focus-visible:ring-green-700 has-focus-visible:ring-offset-1', 'has-focus-visible:border-day-300 has-focus-visible:ring-2 has-focus-visible:ring-green-700 has-focus-visible:ring-offset-1',
disabled && 'cursor-not-allowed opacity-50' 'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50'
)} )}
> >
<input <input
transition:persist transition:persist={option.noTransitionPersist ? undefined : true}
type={multiple ? 'checkbox' : 'radio'} type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name} name={wrapperProps.name}
value={option.value} value={option.value}
checked={selectedValue === option.value} checked={
Array.isArray(selectedValue)
? selectedValue.includes(option.value)
: selectedValue === option.value
}
class="peer sr-only" class="peer sr-only"
disabled={disabled} disabled={disabled || option.disabled}
/> />
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
{option.icon && ( {option.icon && (

View File

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

View File

@@ -42,7 +42,7 @@ const inputId = id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
{ {
ratings.toSorted().map((rating) => ( ratings.toSorted().map((rating) => (
<label class="relative cursor-pointer [&:has(~_*:hover),&:hover]:[&>[data-star]]:opacity-100!"> <label class="relative cursor-pointer [&:has(~_*_*:checked)]:[&>[data-star]]:opacity-100 [&:has(~_*:hover),&:hover]:[&>[data-star]]:opacity-100!">
<input <input
type="radio" type="radio"
name={wrapperProps.name} name={wrapperProps.name}
@@ -54,7 +54,7 @@ const inputId = id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
<Icon name="ri:star-line" class="size-6 p-0.5 text-zinc-500" /> <Icon name="ri:star-line" class="size-6 p-0.5 text-zinc-500" />
<Icon <Icon
name="ri:star-fill" name="ri:star-fill"
class="absolute top-0 left-0 size-6 p-0.5 text-yellow-400 not-peer-checked:opacity-0 group-hover/fieldset:opacity-0" class="absolute top-0 left-0 size-6 p-0.5 text-yellow-400 not-peer-checked:opacity-0 group-hover/fieldset:opacity-0!"
data-star data-star
/> />
</label> </label>

View File

@@ -0,0 +1,48 @@
---
import { omit } from 'lodash-es'
import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs'
import InputWrapper from './InputWrapper.astro'
import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
options: {
label: string
value: string
disabled?: boolean
}[]
selectProps?: Omit<HTMLAttributes<'select'>, 'name'>
}
const { options, selectProps, ...wrapperProps } = Astro.props
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
<InputWrapper inputId={inputId} required={selectProps?.required} {...wrapperProps}>
<select
transition:persist
{...omit(selectProps, ['class', 'id', 'name'])}
id={inputId}
class={cn(
baseInputClassNames.input,
'appearance-none',
selectProps?.class,
hasError && baseInputClassNames.error,
!!selectProps?.disabled && baseInputClassNames.disabled
)}
name={wrapperProps.name}
>
{
options.map((option) => (
<option value={option.value} disabled={option.disabled}>
{option.label}
</option>
))
}
</select>
</InputWrapper>

View File

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

View File

@@ -1,44 +1,37 @@
--- ---
import { omit } from 'lodash-es'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs' import { baseInputClassNames } from '../lib/formInputs'
import InputWrapper from './InputWrapper.astro' import InputWrapper from './InputWrapper.astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & { type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
inputProps?: Omit<HTMLAttributes<'textarea'>, 'name'>
value?: string value?: string
placeholder?: string
disabled?: boolean
autofocus?: boolean
rows?: number
maxlength?: number
} }
const { value, placeholder, maxlength, disabled, autofocus, rows = 3, ...wrapperProps } = Astro.props const { inputProps, value, ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`) const inputId = inputProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
--- ---
{/* eslint-disable astro/jsx-a11y/no-autofocus */} <InputWrapper inputId={inputId} required={inputProps?.required} {...wrapperProps}>
<InputWrapper inputId={inputId} {...wrapperProps}>
<textarea <textarea
transition:persist transition:persist
{...omit(inputProps, ['class', 'id', 'name'])}
id={inputId} id={inputId}
class={cn( class={cn(
baseInputClassNames.input, baseInputClassNames.input,
baseInputClassNames.textarea, baseInputClassNames.textarea,
inputProps?.class,
hasError && baseInputClassNames.error, hasError && baseInputClassNames.error,
disabled && baseInputClassNames.disabled !!inputProps?.disabled && baseInputClassNames.disabled
)} )}
placeholder={placeholder}
required={wrapperProps.required}
disabled={disabled}
name={wrapperProps.name} name={wrapperProps.name}
autofocus={autofocus} set:text={value}
maxlength={maxlength} />
rows={rows}>{value}</textarea
>
</InputWrapper> </InputWrapper>

View File

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

View File

@@ -0,0 +1,48 @@
---
import type { ComponentProps } from 'react'
import { Picture } from 'astro:assets'
import defaultServiceImage from '../assets/fallback-service-image.jpg'
import { cn } from '../lib/cn'
const fallbackImages = {
service: defaultServiceImage,
} as const satisfies Record<string, typeof defaultServiceImage>
type Props = Omit<ComponentProps<typeof Picture>, 'src'> & {
src: ComponentProps<typeof Picture>['src'] | null | undefined
fallback?: keyof typeof fallbackImages
}
const {
src,
formats = ['avif', 'webp'],
fallback = undefined as keyof typeof fallbackImages | undefined,
height,
width,
pictureAttributes,
...props
} = Astro.props
const fallbackImage = fallback ? fallbackImages[fallback] : undefined
---
{/* eslint-disable @typescript-eslint/no-explicit-any */}
{
!!(src ?? fallbackImage) && (
<Picture
src={
typeof src === 'string' ? new URL(src, Astro.url).href : ((src ?? fallbackImage) as unknown as string)
}
formats={formats}
height={height ? Number(height) * 2 : undefined}
width={width ? Number(width) * 2 : undefined}
pictureAttributes={{
...pictureAttributes,
class: cn('shrink-0', pictureAttributes?.class),
}}
{...(props as any)}
/>
)
}

File diff suppressed because one or more lines are too long

View File

@@ -9,15 +9,16 @@ type Props = HTMLAttributes<'div'> & {
value: HTMLAttributes<'input'>['value'] value: HTMLAttributes<'input'>['value']
label: string label: string
}[] }[]
inputProps?: Omit<HTMLAttributes<'input'>, 'checked' | 'class' | 'name' | 'type' | 'value'>
selectedValue?: string | null selectedValue?: string | null
} }
const { name, options, selectedValue, class: className, ...rest } = Astro.props const { name, options, selectedValue, inputProps, class: className, ...rest } = Astro.props
--- ---
<div <div
class={cn( class={cn(
'bg-night-500 divide-night-700 flex divide-x-2 overflow-hidden rounded-md text-[0.6875rem]', 'bg-night-500 divide-night-700 has-focus-visible:ring-offset-night-700 flex divide-x-2 overflow-hidden rounded-md text-[0.6875rem] has-focus-visible:ring-2 has-focus-visible:ring-blue-500 has-focus-visible:ring-offset-2',
className className
)} )}
{...rest} {...rest}
@@ -30,7 +31,8 @@ const { name, options, selectedValue, class: className, ...rest } = Astro.props
name={name} name={name}
value={option.value} value={option.value}
checked={selectedValue === option.value} checked={selectedValue === option.value}
class="peer hidden" class="peer sr-only"
{...inputProps}
/> />
<span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500"> <span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500">
{option.label} {option.label}

View File

@@ -2,6 +2,7 @@
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
@@ -16,33 +17,6 @@ export type Props = HTMLAttributes<'div'> & {
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
export function makeOverallScoreInfo(score: number, total = 10) {
const classNamesByColor = {
red: 'bg-score-1 text-black',
orange: 'bg-score-2 text-black',
yellow: 'bg-score-3 text-black',
blue: 'bg-score-4 text-black',
green: 'bg-score-5 text-black',
} as const satisfies Record<string, string>
const formattedScore = Math.round(score).toLocaleString()
const n = score / total
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
if (n >= 0.5 && n < 0.6) {
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
}
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
return { text: '', classNameBg: undefined, formattedScore }
}
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total) const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
--- ---

View File

@@ -1,14 +1,13 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import defaultImage from '../assets/fallback-service-image.jpg'
import { currencies } from '../constants/currencies' import { currencies } from '../constants/currencies'
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 { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import { makeOverallScoreInfo } from './ScoreSquare.astro' import MyPicture from './MyPicture.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -76,9 +75,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
> >
<!-- Header with Icon and Title --> <!-- Header with Icon and Title -->
<div class="flex items-center gap-(--gap)"> <div class="flex items-center gap-(--gap)">
<Image <MyPicture
src={// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing src={imageUrl}
imageUrl || (defaultImage as unknown as string)} fallback="service"
alt={name || 'Service logo'} alt={name || 'Service logo'}
class="size-12 shrink-0 rounded-sm object-contain text-white" class="size-12 shrink-0 rounded-sm object-contain text-white"
width={48} width={48}
@@ -89,12 +88,26 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white"> <h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
{name}{ {name}{
statusIcon && ( statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-my-2 shrink-0"> <Tooltip
text={statusIcon.label}
position="right"
class="-my-2 shrink-0 whitespace-nowrap"
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
>
{[
<Icon <Icon
is:inline={inlineIcons} is:inline={inlineIcons}
name={statusIcon.icon} name={statusIcon.icon}
class={cn('inline-block size-6 shrink-0 rounded-lg p-1', statusIcon.classNames.icon)} class={cn(
/> 'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
statusIcon.classNames.icon
)}
/>,
verificationStatus === 'VERIFICATION_FAILED' && (
<span class="text-sm font-bold text-red-500">SCAM</span>
),
]}
</Tooltip> </Tooltip>
) )
} }

View File

@@ -112,7 +112,7 @@ if (!z.string().url().safeParse(link.url).success) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class={cn( class={cn(
'2xs:text-sm 2xs:h-8 2xs:gap-2 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black', '2xs:text-sm 2xs:h-8 2xs:gap-2 focus-visible:ring-offset-night-700 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black focus-visible:ring-4 focus-visible:ring-orange-500 focus-visible:ring-offset-2 focus-visible:outline-none',
className className
)} )}
{...htmlProps} {...htmlProps}

View File

@@ -3,11 +3,11 @@ import { Icon } from 'astro-icon/components'
import { kycLevels } from '../constants/kycLevels' import { kycLevels } from '../constants/kycLevels'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro' import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
import Button from './Button.astro' import Button from './Button.astro'
import PillsRadioGroup from './PillsRadioGroup.astro' import PillsRadioGroup from './PillsRadioGroup.astro'
import { makeOverallScoreInfo } from './ScoreSquare.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
@@ -34,7 +34,8 @@ const {
<form <form
method="GET" method="GET"
hx-get={Astro.url.pathname} hx-get={Astro.url.pathname}
hx-trigger={`input delay:500ms from:input[type='text'], keyup[key=='Enter'], change from:input:not([data-show-more-input], #${showFiltersId}), change from:select`} hx-trigger={// NOTE: I need to do the [data-trigger-on-change] hack, because HTMX doesnt suport the :not() selector, and I need to exclude the Show more buttons, and not trigger for inputs outside the form
"input delay:500ms from:([data-services-filters-form] input[type='text']), keyup[key=='Enter'], change from:([data-services-filters-form] [data-trigger-on-change])"}
hx-target={`#${searchResultsId}`} hx-target={`#${searchResultsId}`}
hx-select={`#${searchResultsId}`} hx-select={`#${searchResultsId}`}
hx-push-url="true" hx-push-url="true"
@@ -44,7 +45,11 @@ const {
.filter((verification) => verification.default) .filter((verification) => verification.default)
.map((verification) => verification.slug)} .map((verification) => verification.slug)}
{...formProps} {...formProps}
class={cn('', className)} class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
className
)}
> >
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="font-title text-xl text-green-500">FILTERS</h2> <h2 class="font-title text-xl text-green-500">FILTERS</h2>
@@ -64,6 +69,7 @@ const {
name="sort" name="sort"
id="sort" id="sort"
class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden" class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
data-trigger-on-change
> >
{ {
options.sort.map((option) => ( options.sort.map((option) => (
@@ -97,8 +103,7 @@ const {
<!-- Type Filter --> <!-- Type Filter -->
<fieldset class="mb-6"> <fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">Type</legend> <legend class="font-title mb-3 leading-none text-green-500">Type</legend>
<input type="checkbox" id="show-more-categories" class="peer hidden" hx-preserve data-show-more-input /> <ul class="[&:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden">
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden">
{ {
options.categories?.map((category) => ( options.categories?.map((category) => (
<li data-show-always={category.showAlways ? '' : undefined}> <li data-show-always={category.showAlways ? '' : undefined}>
@@ -109,6 +114,7 @@ const {
name="categories" name="categories"
value={category.slug} value={category.slug}
checked={category.checked} checked={category.checked}
data-trigger-on-change
/> />
<span class="peer-checked:font-bold"> <span class="peer-checked:font-bold">
{category.name} {category.name}
@@ -122,15 +128,16 @@ const {
{ {
options.categories.filter((category) => category.showAlways).length < options.categories.length && ( options.categories.filter((category) => category.showAlways).length < options.categories.length && (
<> <>
<input type="checkbox" id="show-more-categories" class="peer sr-only" hx-preserve />
<label <label
for="show-more-categories" for="show-more-categories"
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden" class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
+ Show more + Show more
</label> </label>
<label <label
for="show-more-categories" for="show-more-categories"
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block" class="peer-focus-visible:ring-offset-night-700 mt-2 hidden cursor-pointer rounded-sm text-sm text-green-500 peer-checked:block peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
- Show less - Show less
</label> </label>
@@ -152,6 +159,7 @@ const {
name="verification" name="verification"
value={verification.slug} value={verification.slug}
checked={filters.verification.includes(verification.value)} checked={filters.verification.includes(verification.value)}
data-trigger-on-change
/> />
<Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} /> <Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} />
<span class="peer-checked:font-bold">{verification.labelShort}</span> <span class="peer-checked:font-bold">{verification.labelShort}</span>
@@ -170,6 +178,9 @@ const {
options={options.modeOptions} options={options.modeOptions}
selectedValue={filters['currency-mode']} selectedValue={filters['currency-mode']}
class="-my-2" class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/> />
</div> </div>
<div> <div>
@@ -182,6 +193,7 @@ const {
name="currencies" name="currencies"
value={currency.slug} value={currency.slug}
checked={filters.currencies?.some((id) => id === currency.id)} checked={filters.currencies?.some((id) => id === currency.id)}
data-trigger-on-change
/> />
<Icon name={currency.icon} class="size-4" /> <Icon name={currency.icon} class="size-4" />
<span class="peer-checked:font-bold">{currency.name}</span> <span class="peer-checked:font-bold">{currency.name}</span>
@@ -204,6 +216,7 @@ const {
name="networks" name="networks"
value={network.slug} value={network.slug}
checked={filters.networks?.some((slug) => slug === network.slug)} checked={filters.networks?.some((slug) => slug === network.slug)}
data-trigger-on-change
/> />
<Icon name={network.icon} class="size-4" /> <Icon name={network.icon} class="size-4" />
<span class="peer-checked:font-bold">{network.name}</span> <span class="peer-checked:font-bold">{network.name}</span>
@@ -227,6 +240,7 @@ const {
id="max-kyc" id="max-kyc"
value={filters['max-kyc'] ?? 4} value={filters['max-kyc'] ?? 4}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs"> <div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
@@ -255,6 +269,7 @@ const {
id="user-rating" id="user-rating"
value={filters['user-rating']} value={filters['user-rating']}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-2 text-xs"> <div class="text-day-400 mt-1 flex justify-between px-2 text-xs">
@@ -283,20 +298,24 @@ const {
options={options.modeOptions} options={options.modeOptions}
selectedValue={filters['attribute-mode']} selectedValue={filters['attribute-mode']}
class="-my-2" class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/> />
</div> </div>
{ {
options.attributesByCategory.map(({ category, attributes }) => ( options.attributesByCategory.map(({ categoryInfo, attributes }) => (
<fieldset class="min-w-0"> <fieldset class="min-w-0">
<legend class="font-title mb-0.5 text-xs tracking-wide text-white">{category}</legend> <legend class="font-title mb-0.5 inline-flex items-center gap-1 text-[0.8125rem] tracking-wide text-white uppercase">
<input <Icon
type="checkbox" name={categoryInfo.icon}
id={`show-more-attributes-${category}`} class={cn('size-4 shrink-0 opacity-80', categoryInfo.classNames.icon)}
class="peer hidden" aria-hidden="true"
hx-preserve
data-show-more-input
/> />
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden"> {categoryInfo.label}
</legend>
<ul class="[:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden">
{attributes.map((attribute) => { {attributes.map((attribute) => {
const inputName = `attr-${attribute.id}` as const const inputName = `attr-${attribute.id}` as const
const yesId = `attr-${attribute.id}=yes` as const const yesId = `attr-${attribute.id}=yes` as const
@@ -306,65 +325,73 @@ const {
return ( return (
<li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer"> <li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer">
<fieldset class="flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white"> <fieldset class="relative flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
<legend class="sr-only"> <legend class="sr-only">
{attribute.title} ({attribute._count?.services}) {attribute.title} ({attribute._count?.services})
</legend> </legend>
<input <input
type="radio" type="radio"
class="peer/empty hidden" class="peer/empty sr-only"
id={emptyId} id={emptyId}
name={inputName} name={inputName}
value="" value=""
checked={!attribute.value} checked={!attribute.value}
aria-label="Ignore" aria-label="Ignore"
data-trigger-on-change
/> />
<input <input
type="radio" type="radio"
name={inputName} name={inputName}
value="yes" value="yes"
id={yesId} id={yesId}
class="peer/yes hidden" class="peer/yes sr-only"
checked={attribute.value === 'yes'} checked={attribute.value === 'yes'}
aria-label="Include" aria-label="Include"
data-trigger-on-change
/> />
<input <input
type="radio" type="radio"
name={inputName} name={inputName}
value="no" value="no"
id={noId} id={noId}
class="peer/no hidden" class="peer/no sr-only"
checked={attribute.value === 'no'} checked={attribute.value === 'no'}
aria-label="Exclude" aria-label="Exclude"
data-trigger-on-change
/> />
<div class="pointer-events-none absolute inset-y-0 -left-[2px] hidden w-[calc(var(--spacing)*4.5*2+1px)] rounded-md border-2 border-blue-500 peer-focus-visible/empty:block peer-focus-visible/no:block peer-focus-visible/yes:block" />
<label <label
for={yesId} for={yesId}
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-zinc-950 peer-checked/yes:hidden" class="border-night-500 bg-night-600 relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm border border-r-0 peer-checked/yes:hidden before:absolute before:-inset-[3px] before:-right-[0.5px]"
aria-hidden="true" aria-hidden="true"
> >
<Icon name="ri:check-line" class="size-3" /> <Icon name="ri:check-line" class="size-3" />
</label> </label>
<label <label
for={emptyId} for={emptyId}
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-green-600 peer-checked/yes:flex" class="relative hidden h-4 w-[calc(var(--spacing)*4+0.5px)] shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-green-600 peer-checked/yes:flex before:absolute before:-inset-[2px] before:-right-[0.5px]"
aria-hidden="true" aria-hidden="true"
> >
<Icon name="ri:check-line" class="size-3" /> <Icon name="ri:check-line" class="size-3" />
</label> </label>
<span class="block h-4 w-px border-y-2 border-zinc-950 bg-zinc-800" aria-hidden="true" /> <span
class="bg-night-400 border-night-500 before:bg-night-400 before:border-night-600 pointer-events-none block h-4 w-px border-y peer-checked/no:w-[0.5px] peer-checked/yes:w-[0.5px] before:h-full before:w-px before:border-y-2"
aria-hidden="true"
/>
<label <label
for={noId} for={noId}
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-zinc-950 peer-checked/no:hidden" class="border-night-500 bg-night-600 relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm border border-l-0 peer-checked/no:hidden before:absolute before:-inset-[3px] before:-left-[0.5px]"
aria-hidden="true" aria-hidden="true"
> >
<Icon name="ri:close-line" class="size-3" /> <Icon name="ri:close-line" class="size-3" />
</label> </label>
<label <label
for={emptyId} for={emptyId}
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-red-600 peer-checked/no:flex" class="relative hidden size-4 w-[calc(var(--spacing)*4+0.5px)] shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-red-600 peer-checked/no:flex before:absolute before:-inset-[2px] before:-left-[0.5px]"
aria-hidden="true" aria-hidden="true"
> >
<Icon name="ri:close-line" class="size-3" /> <Icon name="ri:close-line" class="size-3" />
@@ -376,8 +403,8 @@ const {
aria-hidden="true" aria-hidden="true"
> >
<Icon <Icon
name={attribute.icon} name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.iconClass)} class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -391,8 +418,8 @@ const {
aria-hidden="true" aria-hidden="true"
> >
<Icon <Icon
name={attribute.icon} name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.iconClass)} class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -405,17 +432,24 @@ const {
) )
})} })}
</ul> </ul>
{attributes.filter((attribute) => attribute.showAlways).length < attributes.length && ( {attributes.filter((attribute) => attribute.showAlways).length < attributes.length && (
<> <>
<input
type="checkbox"
id={`show-more-attributes-${categoryInfo.slug}`}
class="peer sr-only"
hx-preserve
/>
<label <label
for={`show-more-attributes-${category}`} for={`show-more-attributes-${categoryInfo.slug}`}
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden" class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
+ Show more + Show more
</label> </label>
<label <label
for={`show-more-attributes-${category}`} for={`show-more-attributes-${categoryInfo.slug}`}
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block" class="peer-focus-visible:ring-offset-night-700 mt-2 hidden cursor-pointer rounded-sm text-sm text-green-500 peer-checked:block peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
- Show less - Show less
</label> </label>
@@ -440,6 +474,7 @@ const {
id="min-score" id="min-score"
value={filters['min-score']} value={filters['min-score']}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="-mx-1.5 mt-2 flex justify-between px-1"> <div class="-mx-1.5 mt-2 flex justify-between px-1">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ export const {
value, value,
slug: value ? value.toLowerCase() : '', slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value), label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line', icon: 'ri:question-fill',
order: Infinity, order: Infinity,
classNames: { classNames: {
container: 'bg-current/30', container: 'bg-current/30',
@@ -50,7 +50,7 @@ export const {
value: 'BAD', value: 'BAD',
slug: 'bad', slug: 'bad',
label: 'Bad', label: 'Bad',
icon: 'ri:close-line', icon: 'ri:close-circle-fill',
order: 1, order: 1,
classNames: { classNames: {
container: 'bg-red-600/30', container: 'bg-red-600/30',
@@ -65,7 +65,7 @@ export const {
value: 'WARNING', value: 'WARNING',
slug: 'warning', slug: 'warning',
label: 'Warning', label: 'Warning',
icon: 'ri:alert-line', icon: 'ri:alert-fill',
order: 2, order: 2,
classNames: { classNames: {
container: 'bg-yellow-600/30', container: 'bg-yellow-600/30',
@@ -80,7 +80,7 @@ export const {
value: 'GOOD', value: 'GOOD',
slug: 'good', slug: 'good',
label: 'Good', label: 'Good',
icon: 'ri:check-line', icon: 'ri:checkbox-circle-fill',
order: 3, order: 3,
classNames: { classNames: {
container: 'bg-green-600/30', container: 'bg-green-600/30',
@@ -95,7 +95,7 @@ export const {
value: 'INFO', value: 'INFO',
slug: 'info', slug: 'info',
label: 'Info', label: 'Info',
icon: 'ri:information-line', icon: 'ri:information-fill',
order: 4, order: 4,
classNames: { classNames: {
container: 'bg-blue-600/30', container: 'bg-blue-600/30',

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
type ContactMethodInfo<T extends string | null | undefined = string> = {
type: T
label: string
/** Notice that the first capture group is then used to format the value */
matcher: RegExp
formatter: (value: string) => string | null
icon: string
}
export const {
dataArray: contactMethods,
dataObject: contactMethodsById,
/** Use {@link formatContactMethod} instead */
getFn: getContactMethodInfo,
} = makeHelpersForOptions(
'type',
(type): ContactMethodInfo<typeof type> => ({
type,
label: type ? transformCase(type, 'title') : String(type),
icon: 'ri:shield-fill',
matcher: /(.*)/,
formatter: (value) => value,
}),
[
{
type: 'email',
label: 'Email',
matcher: /mailto:(.*)/,
formatter: (value) => value,
icon: 'ri:mail-line',
},
{
type: 'telephone',
label: 'Telephone',
matcher: /tel:(.*)/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
icon: 'ri:phone-line',
},
{
type: 'whatsapp',
label: 'WhatsApp',
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.*)\/?/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
icon: 'ri:whatsapp-line',
},
{
type: 'telegram',
label: 'Telegram',
matcher: /https?:\/\/(?:www\.)?t\.me\/(.*)\/?/,
formatter: (value) => `t.me/${value}`,
icon: 'ri:telegram-line',
},
{
type: 'linkedin',
label: 'LinkedIn',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/,
formatter: (value) => `in/${value}`,
icon: 'ri:linkedin-box-line',
},
{
type: 'website',
label: 'Website',
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: (value) => value,
icon: 'ri:global-line',
},
{
type: 'x',
label: 'X',
matcher: /https?:\/\/(?:www\.)?x\.com\/(.*)\/?/,
formatter: (value) => `@${value}`,
icon: 'ri:twitter-x-line',
},
{
type: 'instagram',
label: 'Instagram',
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.*)\/?/,
formatter: (value) => `@${value}`,
icon: 'ri:instagram-line',
},
{
type: 'matrix',
label: 'Matrix',
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.*)\/?/,
formatter: (value) => value,
icon: 'ri:hashtag',
},
{
type: 'bitcointalk',
label: 'BitcoinTalk',
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line',
},
] as const satisfies ContactMethodInfo[]
)
export function formatContactMethod(url: string) {
for (const contactMethod of contactMethods) {
const captureGroup = url.match(contactMethod.matcher)?.[1]
if (!captureGroup) continue
const formattedValue = contactMethod.formatter(captureGroup)
if (!formattedValue) continue
return {
...contactMethod,
formattedValue,
} as const
}
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
}

View File

@@ -0,0 +1,86 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { KarmaTransactionAction } from '@prisma/client'
type KarmaTransactionActionInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
icon: string
}
export const {
dataArray: karmaTransactionActions,
dataObject: karmaTransactionActionsById,
getFn: getKarmaTransactionActionInfo,
getFnSlug: getKarmaTransactionActionInfoBySlug,
zodEnumBySlug: karmaTransactionActionsZodEnumBySlug,
zodEnumById: karmaTransactionActionsZodEnumById,
keyToSlug: karmaTransactionActionIdToSlug,
slugToKey: karmaTransactionActionSlugToId,
} = makeHelpersForOptions(
'value',
(value): KarmaTransactionActionInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
icon: 'ri:question-line',
}),
[
{
value: 'COMMENT_APPROVED',
slug: 'comment-approved',
label: 'Comment approved',
icon: 'ri:check-line',
},
{
value: 'COMMENT_VERIFIED',
slug: 'comment-verified',
label: 'Comment verified',
icon: 'ri:verified-badge-line',
},
{
value: 'COMMENT_SPAM',
slug: 'comment-spam',
label: 'Comment marked as SPAM',
icon: 'ri:spam-2-line',
},
{
value: 'COMMENT_SPAM_REVERTED',
slug: 'comment-spam-reverted',
label: 'Comment SPAM reverted',
icon: 'ri:spam-2-line',
},
{
value: 'COMMENT_UPVOTE',
slug: 'comment-upvote',
label: 'Comment upvoted',
icon: 'ri:thumb-up-line',
},
{
value: 'COMMENT_DOWNVOTE',
slug: 'comment-downvote',
label: 'Comment downvoted',
icon: 'ri:thumb-down-line',
},
{
value: 'COMMENT_VOTE_REMOVED',
slug: 'comment-vote-removed',
label: 'Comment vote removed',
icon: 'ri:thumb-up-line',
},
{
value: 'SUGGESTION_APPROVED',
slug: 'suggestion-approved',
label: 'Suggestion approved',
icon: 'ri:lightbulb-line',
},
{
value: 'MANUAL_ADJUSTMENT',
slug: 'gift',
label: 'Gift',
icon: 'ri:gift-line',
},
] as const satisfies KarmaTransactionActionInfo<KarmaTransactionAction>[]
)

View File

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

View File

@@ -49,7 +49,7 @@ export const {
value: 'MODERATOR', value: 'MODERATOR',
slug: 'moderator', slug: 'moderator',
label: 'Moderator', label: 'Moderator',
icon: 'ri:glasses-2-line', icon: 'ri:graduation-cap-fill',
order: 3, order: 3,
color: 'teal', color: 'teal',
}, },

View File

@@ -14,4 +14,7 @@ export const splashTexts: string[] = [
'Ditch the gatekeepers.', 'Ditch the gatekeepers.',
'Own your identity.', 'Own your identity.',
'Financial privacy matters.', 'Financial privacy matters.',
'Surveillance is the enemy of the soul.',
'Privacy is freedom.',
'Privacy is the freedom to try things out.',
] ]

View File

@@ -70,7 +70,7 @@ export const {
description: description:
'Thoroughly tested and verified by the team. But things might change, this is not a guarantee.', 'Thoroughly tested and verified by the team. But things might change, this is not a guarantee.',
privacyPoints: 0, privacyPoints: 0,
trustPoints: 5, trustPoints: 10,
classNames: { classNames: {
icon: 'text-[#40e6c2]', icon: 'text-[#40e6c2]',
badgeBig: 'bg-green-800/50 text-green-100', badgeBig: 'bg-green-800/50 text-green-100',

View File

@@ -1,8 +1,10 @@
--- ---
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import BaseHead from '../components/BaseHead.astro' import BaseHead from '../components/BaseHead.astro'
import Footer from '../components/Footer.astro' import Footer from '../components/Footer.astro'
import Header from '../components/Header.astro' import Header from '../components/Header.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { prisma } from '../lib/prisma'
import type { AstroChildren } from '../lib/astro' import type { AstroChildren } from '../lib/astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
@@ -42,6 +44,31 @@ const {
const actualErrors = [...errors, ...Astro.locals.banners.errors] const actualErrors = [...errors, ...Astro.locals.banners.errors]
const actualSuccess = [...success, ...Astro.locals.banners.successes] const actualSuccess = [...success, ...Astro.locals.banners.successes]
const currentDate = new Date()
const announcement = await Astro.locals.banners.try(
'Unable to load announcements.',
() =>
prisma.announcement.findFirst({
where: {
isActive: true,
startDate: { lte: currentDate },
OR: [{ endDate: null }, { endDate: { gt: currentDate } }],
},
select: {
id: true,
content: true,
type: true,
link: true,
linkText: true,
startDate: true,
endDate: true,
isActive: true,
},
orderBy: [{ type: 'desc' }, { createdAt: 'desc' }],
}),
null
)
--- ---
<html lang="en" transition:name="root" transition:animate="none"> <html lang="en" transition:name="root" transition:animate="none">
@@ -51,6 +78,7 @@ const actualSuccess = [...success, ...Astro.locals.banners.successes]
<BaseHead {...baseHeadProps} /> <BaseHead {...baseHeadProps} />
</head> </head>
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}> <body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}>
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
<Header <Header
classNames={{ classNames={{
nav: cn( nav: cn(

View File

@@ -16,6 +16,7 @@ type Props = ComponentProps<typeof BaseLayout> &
author: string author: string
pubDate: string pubDate: string
description: string description: string
icon?: string
}> }>
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
@@ -23,6 +24,8 @@ const publishDate = frontmatter.pubDate ? new Date(frontmatter.pubDate) : null
const ogImageTemplateData = { const ogImageTemplateData = {
template: 'generic', template: 'generic',
title: frontmatter.title, title: frontmatter.title,
description: frontmatter.description,
icon: frontmatter.icon,
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me' const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
--- ---

View File

@@ -1,60 +0,0 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js'
type Formatter = {
id: string
matcher: RegExp
formatter: (value: string) => string | null
}
const formatters = [
{
id: 'email',
matcher: /mailto:(.*)/,
formatter: (value) => value,
},
{
id: 'telephone',
matcher: /tel:(.*)/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
},
{
id: 'whatsapp',
matcher: /https?:\/\/wa\.me\/(.*)\/?/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
},
{
id: 'telegram',
matcher: /https?:\/\/t\.me\/(.*)\/?/,
formatter: (value) => `t.me/${value}`,
},
{
id: 'linkedin',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/,
formatter: (value) => `in/${value}`,
},
{
id: 'website',
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: (value) => value,
},
] as const satisfies Formatter[]
export function formatContactMethod(url: string) {
for (const formatter of formatters) {
const captureGroup = url.match(formatter.matcher)?.[1]
if (!captureGroup) continue
const formattedValue = formatter.formatter(captureGroup)
if (!formattedValue) continue
return {
type: formatter.id,
formattedValue,
} as const
}
return null
}

View File

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

View File

@@ -0,0 +1,26 @@
export function makeOverallScoreInfo(score: number, total = 10) {
const classNamesByColor = {
red: 'bg-score-1 text-black',
orange: 'bg-score-2 text-black',
yellow: 'bg-score-3 text-black',
blue: 'bg-score-4 text-black',
green: 'bg-score-5 text-black',
} as const satisfies Record<string, string>
const formattedScore = Math.round(score).toLocaleString()
const n = score / total
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
if (n >= 0.5 && n < 0.6) {
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
}
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
return { text: '', classNameBg: undefined, formattedScore }
}

8
web/src/lib/tailwind.ts Normal file
View File

@@ -0,0 +1,8 @@
import { parseIntWithFallback } from './numbers'
const TW_SIZING_TO_PX_RATIO = 4
export function getSizePxFromTailwindClasses(className: string, fallbackPxSize: number) {
const twSizing = /(?: |^|\n)(?:(?:size-(\d+))|(?:w-(\d+))|(?:h-(\d+)))(?: |$|\n)/.exec(className)?.[1]
return parseIntWithFallback(twSizing, fallbackPxSize / TW_SIZING_TO_PX_RATIO) * TW_SIZING_TO_PX_RATIO
}

View File

@@ -50,7 +50,6 @@ export const ACCEPTED_IMAGE_TYPES = [
'image/svg+xml', 'image/svg+xml',
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/jxl',
'image/avif', 'image/avif',
'image/webp', 'image/webp',
] as const satisfies string[] ] as const satisfies string[]
@@ -66,7 +65,7 @@ export const imageFileSchema = z
) )
.refine( .refine(
(file) => !file || ACCEPTED_IMAGE_TYPES.some((type) => file.type === type), (file) => !file || ACCEPTED_IMAGE_TYPES.some((type) => file.type === type),
'Only SVG, PNG, JPG, JPEG XL, AVIF, WebP formats are supported.' 'Only SVG, PNG, JPG, AVIF, WebP formats are supported.'
) )
export const imageFileSchemaRequired = imageFileSchema.refine((file) => !!file, 'Required') export const imageFileSchemaRequired = imageFileSchema.refine((file) => !!file, 'Required')

View File

@@ -39,16 +39,19 @@ const {
</p> </p>
{ {
(DEPLOYMENT_MODE !== 'production' || Astro.locals.user?.admin) && ( (DEPLOYMENT_MODE !== 'production' || Astro.locals.user?.admin) && (
<div class="bg-night-800 mt-4 block max-h-96 min-h-32 w-full max-w-4xl overflow-auto rounded-lg p-4 text-left text-sm break-words whitespace-pre-wrap"> <div
{error instanceof Error class="bg-night-800 mt-4 block max-h-96 min-h-32 w-full max-w-4xl overflow-auto rounded-lg p-4 text-left text-sm wrap-anywhere whitespace-pre-wrap"
set:text={
error instanceof Error
? error.message ? error.message
: error === undefined : error === undefined
? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
message || 'undefined' message || 'undefined'
: typeof error === 'object' : typeof error === 'object'
? JSON.stringify(error, null, 2) ? JSON.stringify(error, null, 2)
: String(error as unknown)} : String(error as unknown)
</div> }
/>
) )
} }

View File

@@ -4,8 +4,11 @@ title: About
author: KYCnot.me author: KYCnot.me
pubDate: 2025-05-15 pubDate: 2025-05-15
description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.' description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.'
icon: 'ri:information-line'
--- ---
import DonationAddress from '../components/DonationAddress.astro'
## What is this page? ## What is this page?
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy. KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
@@ -187,22 +190,22 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## Support the project ## Support
If you like this project, you can support it through these methods: If you like this project, you can **support** it through these methods:
- Monero: <DonationAddress
- `88V2Xi2mvcu3NdnHkVeZGyPtACg2w3iXZdUMJugUiPvFQHv5mVkih3o43ceVGz6cVs9uTBMt4MRMVW2xFgfGdh8DTCQ7vtp` cryptoName="Monero"
cryptoIcon="monero"
address="86nkJeHWarEYZJh3gcPGKcQeueKbq2uRRC2NX6kopBpdHFfY1j4vmrVAwRG1T4pNBwBwfJ4U4USLUZ6CjDtacp8x4y8v3rq"
/>
## Contact ## Contact
You can contact via direct chat or via email. You can contact via direct chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion) - [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
- If you use ProtonMail or Tutanota, you can have E2E encrypted communications with us directly. We also offer a [PGP Key](/pgp). Otherwise, we recommend reaching out via SimpleX chat for encrypted communications.
- [tuta.io](https://tuta.io) - <kycnotme@tuta.io>
- [proton.me](https://proton.me) - <contact@kycnot.me>
## Disclaimer ## Disclaimer

View File

@@ -34,9 +34,10 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
<h1 class="font-title mt-8 text-3xl font-semibold tracking-wide text-white sm:mt-12 sm:text-5xl"> <h1 class="font-title mt-8 text-3xl font-semibold tracking-wide text-white sm:mt-12 sm:text-5xl">
Access denied Access denied
</h1> </h1>
<p class="mt-8 text-lg leading-7 text-balance whitespace-pre-wrap text-red-400"> <p
{reason} class="mt-8 text-lg leading-7 text-balance wrap-anywhere whitespace-pre-wrap text-red-400"
</p> set:text={reason}
/>
<div class="mt-12 flex flex-wrap items-center justify-center"> <div class="mt-12 flex flex-wrap items-center justify-center">
<a href="/" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"> <a href="/" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white">

View File

@@ -22,9 +22,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
--- ---
<MiniLayout <MiniLayout
pageTitle={`Edit Profile - ${user.name}`} pageTitle={`Edit Profile - ${user.displayName ?? user.name}`}
description="Edit your user profile" description="Edit your user profile"
ogImage={{ template: 'generic', title: 'Edit Profile' }} ogImage={{ template: 'generic', title: 'Edit Profile', icon: 'ri:user-settings-line' }}
layoutHeader={{ layoutHeader={{
icon: 'ri:edit-line', icon: 'ri:edit-line',
title: 'Edit profile', title: 'Edit profile',
@@ -48,7 +48,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="displayName" name="displayName"
error={inputErrors.displayName} error={inputErrors.displayName}
inputProps={{ inputProps={{
value: user.displayName ?? '', value: user.displayName,
maxlength: 100, maxlength: 100,
disabled: !user.karmaUnlocks.displayName, disabled: !user.karmaUnlocks.displayName,
}} }}
@@ -62,7 +62,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="link" name="link"
error={inputErrors.link} error={inputErrors.link}
inputProps={{ inputProps={{
value: user.link ?? '', value: user.link,
type: 'url', type: 'url',
placeholder: 'https://example.com', placeholder: 'https://example.com',
disabled: !user.karmaUnlocks.websiteLink, disabled: !user.karmaUnlocks.websiteLink,

View File

@@ -25,7 +25,12 @@ const prettyToken = preGeneratedToken ? prettifyUserSecretToken(preGeneratedToke
<MiniLayout <MiniLayout
pageTitle="Create Account" pageTitle="Create Account"
description="Create a new account" description="Create a new account"
ogImage={{ template: 'generic', title: 'Create Account' }} ogImage={{
template: 'generic',
title: 'Create Account',
description: 'Zero data, 100% anonymous',
icon: 'ri:user-add-line',
}}
layoutHeader={{ layoutHeader={{
icon: 'ri:user-add-line', icon: 'ri:user-add-line',
title: 'New account', title: 'New account',

View File

@@ -1,14 +1,15 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { Picture } from 'astro:assets'
import { sortBy } from 'lodash-es' import { sortBy } from 'lodash-es'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
import Button from '../../components/Button.astro' import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks' import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project' import { SUPPORT_EMAIL } from '../../constants/project'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus' import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
@@ -61,6 +62,13 @@ const user = await Astro.locals.banners.try('user', async () => {
action: true, action: true,
description: true, description: true,
createdAt: true, createdAt: true,
grantedBy: {
select: {
name: true,
displayName: true,
picture: true,
},
},
comment: { comment: {
select: { select: {
id: true, id: true,
@@ -152,9 +160,14 @@ if (!user) return Astro.rewrite('/404')
--- ---
<BaseLayout <BaseLayout
pageTitle={`${user.name} - Account`} pageTitle={`${user.displayName ?? user.name} - Account`}
description="Manage your user profile" description="Manage your user profile"
ogImage={{ template: 'generic', title: `${user.name} | Account` }} ogImage={{
template: 'generic',
title: `${user.displayName ?? user.name} | Account`,
description: 'Manage your user profile',
icon: 'ri:user-3-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
className={{ className={{
main: 'space-y-6', main: 'space-y-6',
@@ -170,44 +183,52 @@ if (!user) return Astro.rewrite('/404')
]} ]}
> >
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center gap-4"> <header class="flex flex-wrap items-center justify-center gap-4">
<div class="flex grow flex-wrap items-center justify-center gap-4">
{ {
user.picture ? ( user.picture ? (
<img src={user.picture} alt="" class="ring-day-500/30 size-16 rounded-full ring-2" /> <MyPicture
src={user.picture}
alt=""
class="ring-day-500/30 xs:size-14 size-12 rounded-full ring-2 sm:size-16"
width={64}
height={64}
/>
) : ( ) : (
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2"> <div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" /> <Icon name="ri:user-3-line" class="size-8" />
</div> </div>
) )
} }
<div> <div class="grow">
<h1 class="font-title text-lg font-bold tracking-wider text-white">{user.name}</h1> <h1 class="font-title text-lg font-bold tracking-wider text-white">
{user.displayName && <p class="text-day-200">{user.displayName}</p>} {user.displayName ?? user.name}
<div class="mt-1 flex gap-2"> </h1>
{user.displayName && <p class="text-day-200 font-title">{user.name}</p>}
{ {
user.admin && ( (user.admin || user.verified || user.verifier) && (
<div class="mt-1 flex gap-2">
{user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400"> <span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin admin
</span> </span>
) )}
} {user.verified && (
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400"> <span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified verified
</span> </span>
) )}
} {user.verifier && (
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400"> <span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier verifier
</span> </span>
)}
</div>
) )
} }
</div> </div>
</div> </div>
<nav class="ml-auto flex items-center gap-2"> <nav class="flex flex-wrap items-center justify-center gap-2">
<Tooltip <Tooltip
as="a" as="a"
href={`/u/${user.name}`} href={`/u/${user.name}`}
@@ -412,7 +433,7 @@ if (!user) return Astro.rewrite('/404')
<li> <li>
<Button <Button
as="a" as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=User verification request - ${user.name}&body=I would like to be verified as related to https://www.example.com`} href={`mailto:${SUPPORT_EMAIL}?subject=User verification request - ${user.displayName ?? user.name}&body=I would like to be verified as related to https://www.example.com`}
label="Request verification" label="Request verification"
size="sm" size="sm"
/> />
@@ -431,7 +452,7 @@ if (!user) return Astro.rewrite('/404')
</h2> </h2>
<Button <Button
as="a" as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=Service Affiliation Verification Request - ${user.name}&body=I would like to be verified as related to the services ACME as Admin and XYZ as Team Member. Here is the proof...`} href={`mailto:${SUPPORT_EMAIL}?subject=Service Affiliation Verification Request - ${user.displayName ?? user.name}&body=I would like to be verified as related to the services ACME as Admin and XYZ as Team Member. Here is the proof...`}
label="Request" label="Request"
size="md" size="md"
/> />
@@ -453,8 +474,9 @@ if (!user) return Astro.rewrite('/404')
href={`/service/${affiliation.service.slug}`} href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm" class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
> >
<Picture <MyPicture
src={affiliation.service.imageUrl ?? (defaultServiceImage as unknown as string)} src={affiliation.service.imageUrl}
fallback="service"
alt={affiliation.service.name} alt={affiliation.service.name}
width={40} width={40}
height={40} height={40}
@@ -516,8 +538,16 @@ if (!user) return Astro.rewrite('/404')
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3"> <div class="space-y-3">
<h3 class="font-title border-day-700/20 text-day-200 border-b pb-2 text-sm">Positive unlocks</h3> <input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
<label
for="positive-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{ {
sortBy( sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0), karmaUnlocks.filter((unlock) => unlock.karma >= 0),
@@ -537,7 +567,10 @@ if (!user) return Astro.rewrite('/404')
</span> </span>
<div> <div>
<p <p
class={cn('font-medium', user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400')} class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)}
> >
{unlock.name} {unlock.name}
</p> </p>
@@ -559,10 +592,20 @@ if (!user) return Astro.rewrite('/404')
)) ))
} }
</div> </div>
</div>
<div class="space-y-3"> <div class="space-y-3">
<h3 class="font-title border-b border-red-500/20 pb-2 text-sm text-red-400">Negative unlocks</h3> <input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
<label
for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{ {
sortBy( sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0), karmaUnlocks.filter((unlock) => unlock.karma < 0),
@@ -616,6 +659,7 @@ if (!user) return Astro.rewrite('/404')
</p> </p>
</div> </div>
</div> </div>
</div>
</section> </section>
<div class="space-y-6"> <div class="space-y-6">
@@ -840,7 +884,10 @@ if (!user) return Astro.rewrite('/404')
} }
</section> </section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> <section
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
id="karma-transactions"
>
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" /> <Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
@@ -864,9 +911,22 @@ if (!user) return Astro.rewrite('/404')
</tr> </tr>
</thead> </thead>
<tbody class="divide-night-400/10 divide-y"> <tbody class="divide-night-400/10 divide-y">
{user.karmaTransactions.map((transaction) => ( {user.karmaTransactions.map((transaction) => {
const actionInfo = getKarmaTransactionActionInfo(transaction.action)
return (
<tr class="hover:bg-night-500/5"> <tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">{transaction.action}</td> <td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center gap-1">
<Icon name={actionInfo.icon} class="size-4" />
{actionInfo.label}
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
<>
<span class="text-day-500">from</span>
<UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
</>
)}
</span>
</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td> <td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td <td
class={cn( class={cn(
@@ -878,10 +938,16 @@ if (!user) return Astro.rewrite('/404')
{transaction.points} {transaction.points}
</td> </td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap"> <td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
{new Date(transaction.createdAt).toLocaleDateString()} <TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td> </td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -30,7 +30,12 @@ const message = Astro.url.searchParams.get('message')
<MiniLayout <MiniLayout
pageTitle="Login" pageTitle="Login"
description="Login to your account" description="Login to your account"
ogImage={{ template: 'generic', title: 'Login' }} ogImage={{
template: 'generic',
title: 'Login',
description: message ?? 'Enter your login key',
icon: 'ri:login-box-line',
}}
layoutHeader={{ layoutHeader={{
icon: 'ri:user-line', icon: 'ri:user-line',
title: 'Welcome back', title: 'Welcome back',

View File

@@ -17,7 +17,12 @@ const prettyToken = result ? prettifyUserSecretToken(result.data.token) : null
<MiniLayout <MiniLayout
pageTitle="Welcome" pageTitle="Welcome"
description="New account welcome page" description="New account welcome page"
ogImage={{ template: 'generic', title: 'Welcome' }} ogImage={{
template: 'generic',
title: 'Welcome',
description: 'New account welcome page',
icon: 'ri:key-2-line',
}}
layoutHeader={{ layoutHeader={{
icon: 'ri:key-2-line', icon: 'ri:key-2-line',
title: 'Save your Login Key', title: 'Save your Login Key',

View File

@@ -0,0 +1,764 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:schema'
import { adminAnnouncementActions } from '../../../actions/admin/announcement'
import Button from '../../../components/Button.astro'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
import {
announcementTypes,
getAnnouncementTypeInfo,
zodAnnouncementTypesById,
} from '../../../constants/announcementTypes'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma'
import type { AnnouncementType, Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z
.enum(['title', 'type', 'startDate', 'endDate', 'isActive', 'createdAt'])
.default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
type: zodAnnouncementTypesById.optional(),
status: z.enum(['active', 'inactive']).optional(),
},
Astro
)
// Set up Prisma orderBy with correct typing
const prismaOrderBy = {
[filters['sort-by']]: filters['sort-order'] === 'asc' ? 'asc' : 'desc',
} as const satisfies Prisma.AnnouncementOrderByWithRelationInput
// Build where clause based on filters
const whereClause: Prisma.AnnouncementWhereInput = {}
if (filters.search) {
whereClause.OR = [{ content: { contains: filters.search, mode: 'insensitive' } }]
}
if (filters.type) {
whereClause.type = filters.type as AnnouncementType
}
if (filters.status) {
whereClause.isActive = filters.status === 'active'
}
// Retrieve announcements from the database
const announcements = await prisma.announcement.findMany({
where: whereClause,
orderBy: prismaOrderBy,
})
// Helper for generating sort URLs
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/announcements?${searchParams.toString()}`
}
// Current date for form min values
const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm
// Default new announcement
const newAnnouncement = {
content: '',
type: 'INFO' as const,
link: null,
linkText: null,
startDate: currentDate,
endDate: '',
isActive: true as boolean,
} satisfies Prisma.AnnouncementCreateInput
// Get action results
const createResult = Astro.getActionResult(adminAnnouncementActions.create)
const updateResult = Astro.getActionResult(adminAnnouncementActions.update)
const deleteResult = Astro.getActionResult(adminAnnouncementActions.delete)
const toggleResult = Astro.getActionResult(adminAnnouncementActions.toggleActive)
const createInputErrors = isInputError(createResult?.error) ? createResult.error.fields : {}
// Add success messages to banners
Astro.locals.banners.addIfSuccess(createResult, 'Announcement created successfully!')
Astro.locals.banners.addIfSuccess(updateResult, 'Announcement updated successfully!')
Astro.locals.banners.addIfSuccess(deleteResult, 'Announcement deleted successfully!')
Astro.locals.banners.addIfSuccess(
toggleResult,
(data) => `Announcement ${data.updatedAnnouncement.isActive ? 'activated' : 'deactivated'} successfully!`
)
// Add error messages to banners
if (createResult?.error) {
const err = createResult.error
Astro.locals.banners.add({
uiMessage: err.message,
type: 'error',
origin: 'action',
error: err,
})
}
if (updateResult?.error) {
const err = updateResult.error
Astro.locals.banners.add({
uiMessage: err.message,
type: 'error',
origin: 'action',
error: err,
})
}
if (deleteResult?.error) {
const err = deleteResult.error
Astro.locals.banners.add({
uiMessage: err.message,
type: 'error',
origin: 'action',
error: err,
})
}
if (toggleResult?.error) {
const err = toggleResult.error
Astro.locals.banners.add({
uiMessage: err.message,
type: 'error',
origin: 'action',
error: err,
})
}
---
<BaseLayout pageTitle="Announcement Management" widthClassName="max-w-screen-xl">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-title text-2xl font-bold text-white">Announcement Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{announcements.length} announcements</span>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg">
<form method="GET" class="grid gap-3 md:grid-cols-3" autocomplete="off">
<div>
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<input
type="text"
name="search"
id="search"
value={filters.search}
placeholder="Search by title or content..."
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"
/>
</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="" selected={!filters.type}>All Types</option>
{
announcementTypes.map((type) => (
<option value={type.value} selected={filters.type === type.value}>
{type.label}
</option>
))
}
</select>
</div>
<div>
<label for="status-filter" class="block text-xs font-medium text-zinc-400">Status</label>
<div class="mt-1 flex">
<select
name="status"
id="status-filter"
class="w-full rounded-l-md border border-r-0 border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="" selected={!filters.status}>All Statuses</option>
<option value="active" selected={filters.status === 'active'}>Active</option>
<option value="inactive" selected={filters.status === 'inactive'}>Inactive</option>
</select>
<button
type="submit"
class="inline-flex items-center rounded-r-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
</div>
</div>
</form>
</div>
<!-- Create New Announcement Form -->
<div class="mb-6">
<button
id="toggle-new-announcement-form"
class="mb-4 inline-flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:add-line" class="mr-1 h-4 w-4" />
Create New Announcement
</button>
<div
id="new-announcement-form"
class="hidden rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg"
>
<h2 class="font-title mb-4 text-lg font-semibold text-blue-400">Create New Announcement</h2>
<form method="POST" action={actions.admin.announcement.create} class="grid gap-4 md:grid-cols-2">
<div class="space-y-3 md:col-span-2">
<InputTextArea
label="Content"
name="content"
error={createInputErrors.content}
value={newAnnouncement.content}
inputProps={{
required: true,
maxlength: 1000,
rows: 3,
placeholder: 'Announcement Content',
}}
/>
</div>
<div class="space-y-3">
<InputText
label="Link"
name="link"
error={createInputErrors.link}
inputProps={{
type: 'url',
placeholder: 'https://example.com',
}}
/>
<InputText
label="Link Text "
name="linkText"
error={createInputErrors.linkText}
inputProps={{
placeholder: 'Link Text',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
label="Type"
name="type"
options={announcementTypes.map((type) => ({
label: type.label,
value: type.value,
icon: type.icon,
}))}
cardSize="sm"
required
selectedValue={newAnnouncement.type}
/>
<InputText
label="Start Date & Time"
name="startDate"
error={createInputErrors.startDate}
inputProps={{
type: 'datetime-local',
required: true,
value: newAnnouncement.startDate,
}}
/>
<InputText
label="End Date & Time"
name="endDate"
error={createInputErrors.endDate}
inputProps={{
type: 'datetime-local',
value: newAnnouncement.endDate,
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
error={createInputErrors.isActive}
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4">
<InputSubmitButton label="Create Announcement" icon="ri:save-line" hideCancel />
<button
type="button"
id="cancel-create"
class="ml-2 inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Cancel
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<dialog
id="delete-confirmation-modal"
class="m-auto max-w-md rounded-lg border border-zinc-700 bg-zinc-800 p-0 backdrop:bg-black/70"
>
<div class="p-4">
<div class="mb-4 flex items-center justify-between border-b border-zinc-700 pb-3">
<h3 class="font-title text-lg font-semibold text-red-400">Confirm Deletion</h3>
<button type="button" class="close-modal text-zinc-400 hover:text-zinc-200">
<Icon name="ri:close-line" class="h-6 w-6" />
</button>
</div>
<p class="mb-4 text-sm text-zinc-300">
Are you sure you want to delete this announcement? This action cannot be undone.
</p>
<form method="POST" action={actions.admin.announcement.delete} id="delete-form">
<input type="hidden" name="id" id="delete-id" />
<div class="flex justify-end gap-2">
<button
type="button"
class="close-modal inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Cancel
</button>
<button
type="submit"
class="inline-flex items-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:delete-bin-line" class="mr-1 h-4 w-4" />
Delete
</button>
</div>
</form>
</div>
</dialog>
<!-- Announcements Table -->
<div class="rounded-lg border border-zinc-700 bg-zinc-800/50 shadow-lg">
<div class="sticky top-0 z-10 border-b border-zinc-700 bg-zinc-800/90 px-4 py-3 backdrop-blur-sm">
<h2 class="font-title font-semibold text-blue-400">Announcements List</h2>
<div class="mt-1 text-xs text-zinc-400 md:hidden">
<span>Scroll horizontally to see more →</span>
</div>
</div>
<div class="scrollbar-thin max-w-full overflow-x-auto">
<div class="min-w-[1200px]">
<table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30">
<tr>
<th
class="w-[20%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('title')} class="flex items-center hover:text-zinc-200">
Title
<SortArrowIcon active={filters['sort-by'] === 'title'} sortOrder={filters['sort-order']} />
</a>
</th>
<th
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-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('startDate')} class="flex items-center hover:text-zinc-200">
Start Date
<SortArrowIcon
active={filters['sort-by'] === 'startDate'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('endDate')} class="flex items-center hover:text-zinc-200">
End Date
<SortArrowIcon
active={filters['sort-by'] === 'endDate'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('isActive')}
class="flex items-center justify-center hover:text-zinc-200"
>
Status
<SortArrowIcon
active={filters['sort-by'] === 'isActive'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('createdAt')} class="flex items-center hover:text-zinc-200">
Created At
<SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700 bg-zinc-800/10">
{
announcements.length === 0 && (
<tr>
<td colspan="7" class="px-4 py-8 text-center text-zinc-400">
<Icon name="ri:information-line" class="mb-2 inline-block size-8" />
<p class="text-lg">No announcements found matching your criteria.</p>
<p class="text-sm">Try adjusting your search or filters, or create a new announcement.</p>
</td>
</tr>
)
}
{
announcements.map((announcement) => {
const announcementTypeInfo = getAnnouncementTypeInfo(announcement.type)
return (
<>
<tr class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm">
<div class="line-clamp-2 text-zinc-400">{announcement.content}</div>
</td>
<td class="px-4 py-3 text-left text-sm">
<span
class={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-medium ${announcementTypeInfo.classNames.badge}`}
>
<Icon name={announcementTypeInfo.icon} class="me-1 size-3" />
{announcementTypeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
<TimeFormatted date={announcement.startDate} hourPrecision={false} prefix={false} />
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
{announcement.endDate ? (
<TimeFormatted date={announcement.endDate} hourPrecision={false} prefix={false} />
) : (
<span class="text-zinc-500">—</span>
)}
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${announcement.isActive ? 'bg-green-900/30 text-green-400' : 'bg-zinc-700/50 text-zinc-400'}`}
>
{announcement.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-400">
<TimeFormatted
date={announcement.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="button"
type="button"
data-id={announcement.id}
class="edit-button inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
<form
method="POST"
action={actions.admin.announcement.toggleActive}
class="inline-block"
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
>
<input type="hidden" name="id" value={announcement.id} />
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
<button
type="submit"
class={`rounded-md border px-1 py-1 text-xs transition-colors ${
announcement.isActive
? 'border-yellow-500/50 bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'border-green-500/50 bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
>
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
<Icon
name={
announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'
}
class="size-4"
/>
</Tooltip>
</button>
</form>
<form
method="POST"
action={actions.admin.announcement.delete}
class="inline-block"
data-confirm="Are you sure you want to delete this announcement?"
>
<input type="hidden" name="id" value={announcement.id} />
<button
type="submit"
class="rounded-md border border-red-500/50 bg-red-500/20 px-1 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/30"
>
<Tooltip text="Delete">
<Icon name="ri:delete-bin-line" class="size-4" />
</Tooltip>
</button>
</form>
</div>
</td>
</tr>
<tr id={`edit-announcement-form-${announcement.id}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-3 font-semibold text-blue-300">
Edit: {announcement.content}
</h3>
<form
method="POST"
action={actions.admin.announcement.update}
class="grid gap-4 md:grid-cols-2"
>
<input type="hidden" name="id" value={announcement.id} />
<div class="space-y-3 md:col-span-2">
<InputTextArea
label="Content"
name="content"
value={announcement.content}
inputProps={{
required: true,
maxlength: 1000,
rows: 3,
}}
/>
</div>
<div class="space-y-3">
<InputText
label="Link"
name="link"
inputProps={{
type: 'url',
placeholder: 'https://example.com',
value: announcement.link,
}}
/>
<InputText
label="Link Text"
name="linkText"
inputProps={{
placeholder: 'Link Text',
value: announcement.linkText,
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
label="Type"
name="type"
options={announcementTypes.map((type) => ({
label: type.label,
value: type.value,
icon: type.icon,
}))}
cardSize="sm"
required
selectedValue={announcement.type}
/>
<InputText
label="Start Date & Time"
name="startDate"
inputProps={{
type: 'datetime-local',
required: true,
value: new Date(announcement.startDate).toISOString().slice(0, 16),
}}
/>
<InputText
label="End Date & Time"
name="endDate"
inputProps={{
type: 'datetime-local',
value: announcement.endDate
? new Date(announcement.endDate).toISOString().slice(0, 16)
: '',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4">
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel />
<Button
type="button"
label="Cancel"
color="gray"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
class="ml-2"
/>
</div>
</div>
</form>
</td>
</tr>
</>
)
})
}
</tbody>
</table>
</div>
</div>
</div>
</BaseLayout>
<style>
.text-2xs {
font-size: 0.6875rem;
line-height: 1rem;
}
.scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.2);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.5);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.6);
}
@media (max-width: 768px) {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2);
-webkit-overflow-scrolling: touch;
}
}
/* Fix for date input appearance in dark mode */
input[type='date'] {
color-scheme: dark;
}
input[type='datetime-local'] {
color-scheme: dark;
}
</style>
<script>
// Toggle Create Form
const toggleFormBtn = document.getElementById('toggle-new-announcement-form')
const newAnnouncementForm = document.getElementById('new-announcement-form')
const cancelCreateBtn = document.getElementById('cancel-create')
toggleFormBtn?.addEventListener('click', () => {
newAnnouncementForm?.classList.toggle('hidden')
})
cancelCreateBtn?.addEventListener('click', () => {
newAnnouncementForm?.classList.add('hidden')
})
// Delete Modal functionality
const deleteModal = document.getElementById('delete-confirmation-modal') as HTMLDialogElement
const deleteButtons = document.querySelectorAll('.delete-button')
// const deleteForm = document.getElementById('delete-form') as HTMLFormElement // Not strictly needed if not manipulating it
deleteButtons.forEach((button) => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-id')
const deleteIdInput = document.getElementById('delete-id') as HTMLInputElement
if (deleteIdInput) {
deleteIdInput.value = id || ''
}
deleteModal?.showModal()
})
})
// Close Modal buttons
const closeModalButtons = document.querySelectorAll('.close-modal')
closeModalButtons.forEach((button) => {
button.addEventListener('click', () => {
const modal = button.closest('dialog')
modal?.close()
})
})
// Close modals when clicking outside
const dialogs = document.querySelectorAll('dialog')
dialogs.forEach((dialog) => {
dialog.addEventListener('click', (e) => {
const rect = dialog.getBoundingClientRect()
const isInDialog =
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width
if (!isInDialog) {
dialog.close()
}
})
})
</script>

View File

@@ -180,7 +180,8 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
required required
rows="3" rows="3"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none" class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none"
></textarea> set:text=""
/>
{ {
createInputErrors.description && ( createInputErrors.description && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.description.join(', ')}</p> <p class="mt-1 text-sm text-red-400">{createInputErrors.description.join(', ')}</p>
@@ -593,9 +594,8 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
required required
rows="3" rows="3"
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none" class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> set:text={attribute.description}
{attribute.description} />
</textarea>
</div> </div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-4 sm:grid-cols-4">

View File

@@ -1,7 +1,12 @@
--- ---
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import BadgeSmall from '../../components/BadgeSmall.astro'
import CommentModeration from '../../components/CommentModeration.astro' import CommentModeration from '../../components/CommentModeration.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import UserBadge from '../../components/UserBadge.astro'
import { import {
commentStatusFilters, commentStatusFilters,
commentStatusFiltersZodEnum, commentStatusFiltersZodEnum,
@@ -36,11 +41,18 @@ const [comments = [], totalComments = 0] = await Astro.locals.banners.try(
prisma.comment.findManyAndCount({ prisma.comment.findManyAndCount({
where: statusFilter.whereClause, where: statusFilter.whereClause,
include: { include: {
author: true, author: {
select: {
name: true,
displayName: true,
picture: true,
},
},
service: { service: {
select: { select: {
name: true, name: true,
slug: true, slug: true,
imageUrl: true,
}, },
}, },
parent: { parent: {
@@ -70,12 +82,13 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
<a <a
href={urlWithParams(Astro.url, { status: filter.value })} href={urlWithParams(Astro.url, { status: filter.value })}
class={cn([ class={cn([
'font-title rounded-md border px-3 py-1 text-sm', 'font-title flex items-center gap-2 rounded-md border px-3 py-1 text-sm',
params.status === filter.value params.status === filter.value
? filter.styles.filter ? filter.classNames.filter
: 'border-zinc-700 transition-colors hover:border-green-500/50', : 'border-zinc-700 transition-colors hover:border-green-500/50',
])} ])}
> >
<Icon name={filter.icon} class="size-4 shrink-0" />
{filter.label} {filter.label}
</a> </a>
)) ))
@@ -98,22 +111,7 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
> >
<div class="mb-4 flex flex-wrap items-center gap-2"> <div class="mb-4 flex flex-wrap items-center gap-2">
{/* Author Info */} {/* Author Info */}
<span class="font-title text-sm">{comment.author.name}</span> <UserBadge user={comment.author} size="md" />
{comment.author.admin && (
<span class="rounded-sm bg-yellow-500/20 px-2 py-0.5 text-[12px] font-medium text-yellow-500">
admin
</span>
)}
{comment.author.verified && !comment.author.admin && (
<span class="rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500">
verified
</span>
)}
{comment.author.verifier && !comment.author.admin && (
<span class="rounded-sm bg-green-500/20 px-2 py-0.5 text-[12px] font-medium text-green-500">
verifier
</span>
)}
{/* Service Link */} {/* Service Link */}
<span class="text-xs text-zinc-500">•</span> <span class="text-xs text-zinc-500">•</span>
@@ -121,21 +119,55 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
href={`/service/${comment.service.slug}#comment-${comment.id.toString()}`} href={`/service/${comment.service.slug}#comment-${comment.id.toString()}`}
class="text-sm text-blue-400 transition-colors hover:text-blue-300" class="text-sm text-blue-400 transition-colors hover:text-blue-300"
> >
{!!comment.service.imageUrl && (
<MyPicture
src={comment.service.imageUrl}
height={16}
width={16}
class="inline-block size-4 rounded-full align-[-0.2em]"
alt=""
/>
)}
{comment.service.name} {comment.service.name}
</a> </a>
{/* Date */} {/* Date */}
<span class="text-xs text-zinc-500">•</span> <span class="text-xs text-zinc-500">•</span>
<span class="text-sm text-zinc-400">{new Date(comment.createdAt).toLocaleString()}</span> <TimeFormatted
date={comment.createdAt}
hourPrecision
caseType="sentence"
class="text-sm text-zinc-400"
/>
<span class="text-xs text-zinc-500">•</span>
{/* Status Badges */} {/* Status Badges */}
<span class={comment.statusFilterInfo.styles.badge}>{comment.statusFilterInfo.label}</span> <BadgeSmall
color={comment.statusFilterInfo.color}
text={comment.statusFilterInfo.label}
icon={comment.statusFilterInfo.icon}
inlineIcon
/>
<span class="text-xs text-zinc-500">•</span>
{/* Link to Comment */}
<a
href={`/service/${comment.service.slug}?showPending=true#comment-${comment.id.toString()}`}
class="text-whit/50 flex items-center gap-1 text-sm transition-colors hover:underline"
>
<Icon name="ri:link" class="size-4" />
Open
</a>
</div> </div>
{/* Parent Comment Context */} {/* Parent Comment Context */}
{comment.parent && ( {comment.parent && (
<div class="mb-4 border-l-2 border-zinc-700 pl-4"> <div class="mb-4 border-l-2 border-zinc-700 pl-4">
<div class="mb-1 text-sm text-zinc-500">Replying to {comment.parent.author.name}:</div> <div class="mb-1 text-sm opacity-50">
Replying to <UserBadge user={comment.parent.author} size="md" />
</div>
<div class="text-sm text-zinc-400">{comment.parent.content}</div> <div class="text-sm text-zinc-400">{comment.parent.content}</div>
</div> </div>
)} )}

View File

@@ -43,6 +43,12 @@ const adminLinks: AdminLink[] = [
href: '/admin/service-suggestions', href: '/admin/service-suggestions',
description: 'Review and manage service suggestions', description: 'Review and manage service suggestions',
}, },
{
icon: 'ri:megaphone-line',
title: 'Announcements',
href: '/admin/announcements',
description: 'Manage site announcements',
},
] ]
--- ---

View File

@@ -4,6 +4,7 @@ import { actions } from 'astro:actions'
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 UserBadge from '../../../components/UserBadge.astro'
import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus' import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn' import { cn } from '../../../lib/cn'
@@ -37,6 +38,8 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
select: { select: {
id: true, id: true,
name: true, name: true,
displayName: true,
picture: true,
}, },
}, },
service: { service: {
@@ -66,6 +69,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
user: { user: {
select: { select: {
id: true, id: true,
displayName: true,
name: true, name: true,
picture: true, picture: true,
}, },
@@ -126,11 +130,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
</span> </span>
<span class="font-title text-gray-400">Submitted by:</span> <span class="font-title text-gray-400">Submitted by:</span>
<span class="text-gray-300"> <UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
<a href={`/admin/users?name=${serviceSuggestion.user.name}`} class="hover:text-green-500">
{serviceSuggestion.user.name}
</a>
</span>
<span class="font-title text-gray-400">Submitted at:</span> <span class="font-title text-gray-400">Submitted at:</span>
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span> <span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
@@ -148,9 +148,10 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
serviceSuggestion.notes && ( serviceSuggestion.notes && (
<div class="mb-4"> <div class="mb-4">
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3> <h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
<div class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm whitespace-pre-wrap text-gray-300"> <div
{serviceSuggestion.notes} class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm wrap-anywhere whitespace-pre-wrap text-gray-300"
</div> set:text={serviceSuggestion.notes}
/>
</div> </div>
) )
} }

View File

@@ -2,10 +2,11 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro' import TimeFormatted from '../../../components/TimeFormatted.astro'
import UserBadge from '../../../components/UserBadge.astro'
import { import {
getServiceSuggestionStatusInfo, getServiceSuggestionStatusInfo,
serviceSuggestionStatuses, serviceSuggestionStatuses,
@@ -67,8 +68,9 @@ let suggestions = await prisma.serviceSuggestion.findMany({
createdAt: true, createdAt: true,
user: { user: {
select: { select: {
id: true, displayName: true,
name: true, name: true,
picture: true,
}, },
}, },
service: { service: {
@@ -120,21 +122,13 @@ let suggestionsWithDetails = suggestions.map((s) => ({
})) }))
if (sortBy === 'service') { if (sortBy === 'service') {
suggestionsWithDetails = lodashOrderBy( suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder])
suggestionsWithDetails,
[(s) => s.service.name.toLowerCase()],
[sortOrder]
)
} else if (sortBy === 'status') { } else if (sortBy === 'status') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder]) suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
} else if (sortBy === 'user') { } else if (sortBy === 'user') {
suggestionsWithDetails = lodashOrderBy( suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder])
suggestionsWithDetails,
[(s) => s.user.name.toLowerCase()],
[sortOrder]
)
} else if (sortBy === 'messageCount') { } else if (sortBy === 'messageCount') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, ['messageCount'], [sortOrder]) suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
} }
const suggestionCount = suggestionsWithDetails.length const suggestionCount = suggestionsWithDetails.length
@@ -293,9 +287,7 @@ const makeSortUrl = (slug: string) => {
</div> </div>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href={`/admin/users?name=${suggestion.user.name}`} class="hover:text-green-500"> <UserBadge user={suggestion.user} size="md" />
{suggestion.user.name}
</a>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<form method="POST" action={actions.admin.serviceSuggestions.update}> <form method="POST" action={actions.admin.serviceSuggestions.update}>

View File

@@ -1,20 +1,23 @@
--- ---
import { import { EventType, VerificationStepStatus } from '@prisma/client'
AttributeCategory,
Currency,
EventType,
VerificationStatus,
VerificationStepStatus,
} from '@prisma/client'
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 { Image } from 'astro:assets'
import InputCardGroup from '../../../../components/InputCardGroup.astro'
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../../components/InputImageFile.astro'
import InputSubmitButton from '../../../../components/InputSubmitButton.astro'
import InputText from '../../../../components/InputText.astro'
import InputTextArea from '../../../../components/InputTextArea.astro'
import UserBadge from '../../../../components/UserBadge.astro'
import { formatContactMethod } from '../../../../constants/contactMethods'
import { currencies } from '../../../../constants/currencies'
import { kycLevels } from '../../../../constants/kycLevels'
import { serviceVisibilities } from '../../../../constants/serviceVisibility' import { serviceVisibilities } from '../../../../constants/serviceVisibility'
import { 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 { prisma } from '../../../../lib/prisma' import { prisma } from '../../../../lib/prisma'
import { ACCEPTED_IMAGE_TYPES } from '../../../../lib/zodUtils'
const { slug } = Astro.params const { slug } = Astro.params
@@ -80,9 +83,9 @@ const service = await Astro.locals.banners.try('Error fetching service', () =>
id: true, id: true,
user: { user: {
select: { select: {
id: true,
name: true, name: true,
displayName: true, displayName: true,
picture: true,
}, },
}, },
createdAt: true, createdAt: true,
@@ -130,15 +133,7 @@ const attributes = await Astro.locals.banners.try(
[] []
) )
const inputBaseClasses = // Button style constants for admin sections (Events, etc.)
'w-full rounded-md border-zinc-600 bg-zinc-700/80 p-2 text-zinc-200 placeholder-zinc-400 focus:border-sky-500 focus:ring-1 focus:ring-sky-500 text-sm'
const labelBaseClasses = 'block text-sm font-medium text-zinc-300 mb-1'
const errorTextClasses = 'mt-1 text-xs text-red-400'
const checkboxLabelClasses = 'inline-flex items-center text-sm text-zinc-300'
const checkboxInputClasses =
'rounded-sm border-zinc-500 bg-zinc-700 text-sky-500 focus:ring-sky-500 focus:ring-offset-zinc-800'
// Button style constants
const buttonPrimaryClasses = const buttonPrimaryClasses =
'inline-flex items-center justify-center rounded-md border border-transparent bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-zinc-900' 'inline-flex items-center justify-center rounded-md border border-transparent bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-zinc-900'
@@ -159,6 +154,12 @@ const buttonSmallWarningClasses = cn(
buttonSmallBaseClasses, buttonSmallBaseClasses,
'text-yellow-400 hover:bg-yellow-700/30 hover:text-yellow-300' 'text-yellow-400 hover:bg-yellow-700/30 hover:text-yellow-300'
) )
// Legacy classes for existing admin forms (Events, Verification Steps, Contact Methods)
const inputBaseClasses =
'w-full rounded-md border-zinc-600 bg-zinc-700/80 p-2 text-zinc-200 placeholder-zinc-400 focus:border-sky-500 focus:ring-1 focus:ring-sky-500 text-sm'
const labelBaseClasses = 'block text-sm font-medium text-zinc-300 mb-1'
const errorTextClasses = 'mt-1 text-xs text-red-400'
--- ---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}> <BaseLayout pageTitle={`Edit Service: ${service.name}`}>
@@ -189,427 +190,227 @@ const buttonSmallWarningClasses = cn(
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<input type="hidden" name="id" value={service.id} /> <input type="hidden" name="id" value={service.id} />
<div> <InputText
<label for="name" class={labelBaseClasses}>Name</label> label="Name"
<input
transition:persist
type="text"
name="name" name="name"
id="name" inputProps={{
required required: true,
value={service.name} value: service.name,
class={inputBaseClasses} }}
error={serviceInputErrors.name}
/> />
{serviceInputErrors.name && <p class={errorTextClasses}>{serviceInputErrors.name.join(', ')}</p>}
</div>
<div> <InputTextArea
<label for="description" class={labelBaseClasses}>Description</label> label="Description"
<textarea
transition:persist
name="description" name="description"
id="description" inputProps={{
required required: true,
rows={4} rows: 4,
class={inputBaseClasses}>{service.description}</textarea }}
> value={service.description}
{ error={serviceInputErrors.description}
serviceInputErrors.description && ( />
<p class={errorTextClasses}>{serviceInputErrors.description.join(', ')}</p>
)
}
</div>
<div> <InputText
<label for="slug" class={cn(labelBaseClasses, 'font-title')}>Slug (auto-generated if empty)</label> label="Slug (auto-generated if empty)"
<input
transition:persist
type="text"
name="slug" name="slug"
id="slug" inputProps={{
value={service.slug} value: service.slug,
class={cn(inputBaseClasses, 'font-title')} class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/> />
{serviceInputErrors.slug && <p class={errorTextClasses}>{serviceInputErrors.slug.join(', ')}</p>}
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <InputTextArea
<label for="serviceUrls" class={labelBaseClasses}>Service URLs (one per line)</label> label="Service URLs (one per line)"
<textarea
transition:persist
class={inputBaseClasses}
name="serviceUrls" name="serviceUrls"
id="serviceUrls" inputProps={{
rows={3} rows: 3,
placeholder="https://example1.com\nhttps://example2.com" placeholder: 'https://example1.com\nhttps://example2.com',
>{service.serviceUrls.join('\n')}</textarea }}
> value={service.serviceUrls.join('\n')}
{ error={serviceInputErrors.serviceUrls}
serviceInputErrors.serviceUrls && ( />
<p class={errorTextClasses}>{serviceInputErrors.serviceUrls.join(', ')}</p> <InputTextArea
) label="ToS URLs (one per line)"
}
</div>
<div>
<label for="tosUrls" class={labelBaseClasses}>ToS URLs (one per line)</label>
<textarea
transition:persist
class={inputBaseClasses}
name="tosUrls" name="tosUrls"
id="tosUrls" inputProps={{
rows={3} rows: 3,
placeholder="https://example1.com/tos\nhttps://example2.com/tos" placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
>{service.tosUrls.join('\n')}</textarea }}
> value={service.tosUrls.join('\n')}
{ error={serviceInputErrors.tosUrls}
serviceInputErrors.tosUrls && ( />
<p class={errorTextClasses}>{serviceInputErrors.tosUrls.join(', ')}</p> <InputTextArea
) label="Onion URLs (one per line)"
}
</div>
<div>
<label for="onionUrls" class={labelBaseClasses}>Onion URLs (one per line)</label>
<textarea
transition:persist
class={inputBaseClasses}
name="onionUrls" name="onionUrls"
id="onionUrls" inputProps={{
rows={3} rows: 3,
placeholder="http://example1.onion\nhttp://example2.onion" placeholder: 'http://example1.onion\nhttp://example2.onion',
>{service.onionUrls.join('\n')}</textarea }}
> value={service.onionUrls.join('\n')}
{ error={serviceInputErrors.onionUrls}
serviceInputErrors.onionUrls && ( />
<p class={errorTextClasses}>{serviceInputErrors.onionUrls.join(', ')}</p> <InputTextArea
) label="I2P URLs (one per line)"
}
</div>
<div>
<label for="i2pUrls" class={labelBaseClasses}>I2P URLs (one per line)</label>
<textarea
transition:persist
class={inputBaseClasses}
name="i2pUrls" name="i2pUrls"
id="i2pUrls" inputProps={{
rows={3} rows: 3,
placeholder="http://example1.b32.i2p\nhttp://example2.b32.i2p" placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
>{service.i2pUrls.join('\n')}</textarea }}
> value={service.i2pUrls.join('\n')}
{/* Assuming i2pUrls might have errors, add error display if schema supports it */} />
{
/* serviceInputErrors.i2pUrls && (
<p class={errorTextClasses}>{serviceInputErrors.i2pUrls.join(', ')}</p>
) */
}
</div>
</div> </div>
<div> <div>
<div class="flex items-end gap-4"> <InputImageFile
<div class="grow"> label="Service Image"
<label for="imageFile" class={labelBaseClasses}>Service Image</label>
<div class="space-y-1">
<input
transition:persist
type="file"
name="imageFile" name="imageFile"
id="imageFile" description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
accept={ACCEPTED_IMAGE_TYPES.join(',')} error={serviceInputErrors.imageFile}
class={cn( square
inputBaseClasses, value={service.imageUrl}
'p-0 file:mr-3 file:rounded-md file:border-0 file:bg-zinc-600 file:px-3 file:py-2 file:font-medium file:text-zinc-200 hover:file:bg-zinc-500'
)}
/> />
<p class="text-xs text-zinc-400">
Leave empty to keep the current image. Square images (e.g., 256x256) recommended.
</p>
</div>
{
serviceInputErrors.imageFile && (
<p class={errorTextClasses}>{serviceInputErrors.imageFile.join(', ')}</p>
)
}
</div>
{
service.imageUrl ? (
<div class="mt-2 shrink-0">
<Image
src={service.imageUrl}
alt="Current service image"
width={100}
height={100}
class="h-24 w-24 rounded-md border border-zinc-600 object-cover"
/>
</div>
) : (
<div class="flex h-24 w-24 shrink-0 items-center justify-center rounded-lg border border-zinc-600 bg-zinc-700/50 text-3xl leading-none text-zinc-500">
<Icon name="ri:image-add-line" class="size-10" />
</div>
)
}
</div>
</div> </div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label class={labelBaseClasses} for="categories">Categories</label> <InputCheckboxGroup
<div
class="mt-1 max-h-48 space-y-1 space-x-2 overflow-y-auto rounded-md border border-zinc-600 bg-zinc-700/30 p-3"
>
{
categories.map((category) => (
<label class={checkboxLabelClasses}>
<input
transition:persist
type="checkbox"
name="categories" name="categories"
value={category.id} label="Categories"
checked={service.categories.some((c) => c.id === category.id)} required
class={cn(checkboxInputClasses, 'mr-1')} options={categories.map((category) => ({
label: category.name,
value: category.id.toString(),
icon: category.icon,
}))}
selectedValues={service.categories.map((c) => c.id.toString())}
error={serviceInputErrors.categories}
/> />
<span>{category.name}</span>
</label>
))
}
</div>
{
serviceInputErrors.categories && (
<p class={errorTextClasses}>{serviceInputErrors.categories.join(', ')}</p>
)
}
</div> </div>
<div> <div>
<label for="kycLevel" class={labelBaseClasses}>KYC Level (0-4)</label> <InputCardGroup
<input
transition:persist
class={inputBaseClasses}
type="number"
name="kycLevel" name="kycLevel"
id="kycLevel" label="KYC Level"
min={0} options={kycLevels.map((kycLevel) => ({
max={4} label: `${kycLevel.name} (${kycLevel.value}/4)`,
value={service.kycLevel} value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: kycLevel.description,
}))}
selectedValue={service.kycLevel.toString()}
iconSize="md"
cardSize="md"
error={serviceInputErrors.kycLevel}
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
/> />
{
serviceInputErrors.kycLevel && (
<p class={errorTextClasses}>{serviceInputErrors.kycLevel.join(', ')}</p>
)
}
</div>
</div> </div>
<div> <div>
<label class={labelBaseClasses} for="attributes">Attributes</label> <InputCheckboxGroup
<div class="space-y-3">
{
Object.values(AttributeCategory).map((categoryValue) => (
<div class="rounded-md border border-zinc-700 bg-zinc-700/30 p-4">
<h4 class="font-title text-md mb-2 font-medium text-zinc-200">{categoryValue}</h4>
<div class="grid grid-cols-1 gap-x-4 gap-y-1 sm:grid-cols-2">
{attributes
.filter((attr) => attr.category === categoryValue)
.map((attr) => (
<label class={checkboxLabelClasses}>
<input
transition:persist
type="checkbox"
name="attributes" name="attributes"
value={attr.id} label="Attributes"
checked={service.attributes.some((a) => a.attribute.id === attr.id)} options={attributes.map((attribute) => ({
class={cn(checkboxInputClasses, 'mr-2')} label: `${attribute.title} (${attribute.category})`,
value: attribute.id.toString(),
}))}
selectedValues={service.attributes.map((a) => a.attribute.id.toString())}
error={serviceInputErrors.attributes}
/> />
<span class="flex items-center gap-1.5">
{attr.title}
<span
class={cn(
'rounded-sm border px-1 py-0.5 text-xs font-medium',
{
'border-green-500/50 bg-green-500/20 text-green-300': attr.type === 'GOOD',
},
{
'border-red-500/50 bg-red-500/20 text-red-300': attr.type === 'BAD',
},
{
'border-yellow-500/50 bg-yellow-500/20 text-yellow-300':
attr.type === 'WARNING',
},
{
'border-sky-500/50 bg-sky-500/20 text-sky-300': attr.type === 'INFO',
}
)}
>
{attr.type}
</span>
</span>
</label>
))}
</div>
{serviceInputErrors.attributes && (
<p class={errorTextClasses}>{serviceInputErrors.attributes.join(', ')}</p>
)}
</div>
))
}
</div>
</div> </div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label class={labelBaseClasses} for="verificationStatus">Verification Status</label> <InputCardGroup
<select
transition:persist
class={inputBaseClasses}
name="verificationStatus" name="verificationStatus"
id="verificationStatus" label="Verification Status"
> options={verificationStatuses.map((status) => ({
{ label: status.label,
Object.values(VerificationStatus).map((status) => ( value: status.value,
<option value={status} selected={service.verificationStatus === status}> icon: status.icon,
{status} iconClass: status.classNames.icon,
</option> description: status.description,
)) }))}
} selectedValue={service.verificationStatus}
</select> error={serviceInputErrors.verificationStatus}
{ cardSize="md"
serviceInputErrors.verificationStatus && ( iconSize="md"
<p class={errorTextClasses}>{serviceInputErrors.verificationStatus.join(', ')}</p> class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
) />
}
</div> </div>
<div> <div>
<label class={labelBaseClasses} for="acceptedCurrencies">Accepted Currencies</label> <InputCardGroup
<div
class="mt-1 max-h-32 space-y-1 space-x-2 overflow-y-auto rounded-md border border-zinc-600 bg-zinc-700/30 p-3"
>
{
Object.values(Currency).map((currency) => (
<label class={checkboxLabelClasses}>
<input
transition:persist
type="checkbox"
name="acceptedCurrencies" name="acceptedCurrencies"
value={currency} label="Accepted Currencies"
checked={service.acceptedCurrencies.includes(currency)} options={currencies.map((currency) => ({
class={cn(checkboxInputClasses, 'mr-1')} label: currency.name,
value: currency.id,
icon: currency.icon,
}))}
selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies}
required
multiple
/> />
<span>{currency}</span>
</label>
))
}
</div>
{
serviceInputErrors.acceptedCurrencies && (
<p class={errorTextClasses}>{serviceInputErrors.acceptedCurrencies.join(', ')}</p>
)
}
</div>
</div> </div>
<div> <div>
<label class={labelBaseClasses} for="verificationSummary">Verification Summary (Markdown)</label> <InputTextArea
<textarea label="Verification Summary (Markdown)"
transition:persist
class={inputBaseClasses}
name="verificationSummary" name="verificationSummary"
id="verificationSummary" inputProps={{
rows={4}>{service.verificationSummary}</textarea rows: 4,
> }}
{ value={service.verificationSummary ?? undefined}
serviceInputErrors.verificationSummary && ( error={serviceInputErrors.verificationSummary}
<p class={errorTextClasses}>{serviceInputErrors.verificationSummary.join(', ')}</p> />
)
}
</div> </div>
<div> <div>
<label class={labelBaseClasses} for="verificationProofMd">Verification Proof (Markdown)</label> <InputTextArea
<textarea label="Verification Proof (Markdown)"
transition:persist
class={inputBaseClasses}
name="verificationProofMd" name="verificationProofMd"
id="verificationProofMd" inputProps={{
rows={8}>{service.verificationProofMd}</textarea rows: 8,
> }}
{ value={service.verificationProofMd ?? undefined}
serviceInputErrors.verificationProofMd && ( error={serviceInputErrors.verificationProofMd}
<p class={errorTextClasses}>{serviceInputErrors.verificationProofMd.join(', ')}</p> />
)
}
</div> </div>
<div> <div>
<label class={labelBaseClasses} for="referral">Referral Code/Link (Optional)</label> <InputText
<input label="Referral Code/Link (Optional)"
transition:persist
type="text"
name="referral" name="referral"
id="referral" inputProps={{
value={service.referral} value: service.referral ?? undefined,
placeholder="e.g., REFCODE123 or https://example.com?ref=123" placeholder: 'e.g., REFCODE123 or https://example.com?ref=123',
class={inputBaseClasses} }}
error={serviceInputErrors.referral}
/> />
{
serviceInputErrors.referral && (
<p class={errorTextClasses}>{serviceInputErrors.referral.join(', ')}</p>
)
}
</div> </div>
<div> <div>
<label class={labelBaseClasses} for="serviceVisibility">Service Visibility</label> <InputCardGroup
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
{
serviceVisibilities.map((visibility) => (
<div class="contents">
<input
transition:persist
type="radio"
name="serviceVisibility" name="serviceVisibility"
id={`serviceVisibility-${visibility.value}`} label="Service Visibility"
value={visibility.value} options={serviceVisibilities.map((visibility) => ({
checked={service.serviceVisibility === visibility.value} label: visibility.label,
class="peer sr-only" value: visibility.value,
icon: visibility.icon,
iconClass: visibility.iconClass,
description: visibility.description,
}))}
selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility}
cardSize="md"
iconSize="md"
/> />
<label
for={`serviceVisibility-${visibility.value}`}
class="group relative cursor-pointer items-start gap-3 rounded-lg border border-zinc-700 bg-zinc-700/50 p-3 transition-all peer-checked:border-sky-500 peer-checked:bg-sky-500/20 peer-checked:ring-1 peer-checked:ring-sky-500 hover:bg-zinc-700/80"
>
<div class="mb-1 flex items-center gap-1.5">
<Icon
name={visibility.icon}
class={cn(
'size-4 text-zinc-300 group-peer-checked:text-sky-400',
visibility.iconClass
)}
/>
<span class="text-sm font-medium text-zinc-200 group-peer-checked:text-sky-300">
{visibility.label}
</span>
</div>
<p class="text-xs leading-snug text-zinc-400 group-peer-checked:text-sky-400/80">
{visibility.description}
</p>
</label>
</div>
))
}
</div>
{
serviceInputErrors.serviceVisibility && (
<p class={errorTextClasses}>{serviceInputErrors.serviceVisibility.join(', ')}</p>
)
}
</div> </div>
<button <InputSubmitButton label="Update Service" />
type="submit"
class="inline-flex justify-center rounded-md border border-transparent bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Update Service
</button>
</form> </form>
</section> </section>
@@ -731,9 +532,8 @@ const buttonSmallWarningClasses = cn(
required required
rows={3} rows={3}
class={inputBaseClasses} class={inputBaseClasses}
> set:text={event.content}
{event.content} />
</textarea>
{eventUpdateInputErrors.content && ( {eventUpdateInputErrors.content && (
<p class={errorTextClasses}>{eventUpdateInputErrors.content.join(', ')}</p> <p class={errorTextClasses}>{eventUpdateInputErrors.content.join(', ')}</p>
)} )}
@@ -847,8 +647,14 @@ const buttonSmallWarningClasses = cn(
</div> </div>
<div> <div>
<label for="newEventContent" class={labelBaseClasses}>Content</label> <label for="newEventContent" class={labelBaseClasses}>Content</label>
<textarea name="content" id="newEventContent" required rows={3} class={inputBaseClasses} <textarea
></textarea> name="content"
id="newEventContent"
required
rows={3}
class={inputBaseClasses}
set:text=""
/>
{ {
eventInputErrors.content && ( eventInputErrors.content && (
<p class={errorTextClasses}>{eventInputErrors.content.join(', ')}</p> <p class={errorTextClasses}>{eventInputErrors.content.join(', ')}</p>
@@ -1014,9 +820,8 @@ const buttonSmallWarningClasses = cn(
id={`stepDescriptionEdit-${step.id}`} id={`stepDescriptionEdit-${step.id}`}
rows={2} rows={2}
class={inputBaseClasses} class={inputBaseClasses}
> set:text={step.description}
{step.description} />
</textarea>
{verificationStepUpdateInputErrors.description && ( {verificationStepUpdateInputErrors.description && (
<p class={errorTextClasses}> <p class={errorTextClasses}>
{verificationStepUpdateInputErrors.description.join(', ')} {verificationStepUpdateInputErrors.description.join(', ')}
@@ -1032,9 +837,8 @@ const buttonSmallWarningClasses = cn(
id={`stepEvidenceMdEdit-${step.id}`} id={`stepEvidenceMdEdit-${step.id}`}
rows={4} rows={4}
class={inputBaseClasses} class={inputBaseClasses}
> set:text={step.evidenceMd}
{step.evidenceMd} />
</textarea>
{verificationStepUpdateInputErrors.evidenceMd && ( {verificationStepUpdateInputErrors.evidenceMd && (
<p class={errorTextClasses}> <p class={errorTextClasses}>
{verificationStepUpdateInputErrors.evidenceMd.join(', ')} {verificationStepUpdateInputErrors.evidenceMd.join(', ')}
@@ -1104,8 +908,14 @@ const buttonSmallWarningClasses = cn(
</div> </div>
<div> <div>
<label for="newStepDescription" class={labelBaseClasses}>Description (Max 200 chars)</label> <label for="newStepDescription" class={labelBaseClasses}>Description (Max 200 chars)</label>
<textarea name="description" id="newStepDescription" required rows={3} class={inputBaseClasses} <textarea
></textarea> name="description"
id="newStepDescription"
required
rows={3}
class={inputBaseClasses}
set:text=""
/>
{ {
verificationStepInputErrors.description && ( verificationStepInputErrors.description && (
<p class={errorTextClasses}>{verificationStepInputErrors.description.join(', ')}</p> <p class={errorTextClasses}>{verificationStepInputErrors.description.join(', ')}</p>
@@ -1114,7 +924,13 @@ const buttonSmallWarningClasses = cn(
</div> </div>
<div> <div>
<label for="newStepEvidenceMd" class={labelBaseClasses}>Evidence (Markdown)</label> <label for="newStepEvidenceMd" class={labelBaseClasses}>Evidence (Markdown)</label>
<textarea name="evidenceMd" id="newStepEvidenceMd" rows={5} class={inputBaseClasses}></textarea> <textarea
name="evidenceMd"
id="newStepEvidenceMd"
rows={5}
class={inputBaseClasses}
set:text=""
/>
{ {
verificationStepInputErrors.evidenceMd && ( verificationStepInputErrors.evidenceMd && (
<p class={errorTextClasses}>{verificationStepInputErrors.evidenceMd.join(', ')}</p> <p class={errorTextClasses}>{verificationStepInputErrors.evidenceMd.join(', ')}</p>
@@ -1165,7 +981,9 @@ const buttonSmallWarningClasses = cn(
<tbody class="divide-y divide-zinc-700/80"> <tbody class="divide-y divide-zinc-700/80">
{service.verificationRequests.map((request) => ( {service.verificationRequests.map((request) => (
<tr class="transition-colors hover:bg-zinc-700/40"> <tr class="transition-colors hover:bg-zinc-700/40">
<td class="p-3 text-zinc-300">{request.user.displayName ?? request.user.name}</td> <td class="p-3 text-zinc-300">
<UserBadge user={request.user} size="md" />
</td>
<td class="p-3 text-zinc-400">{new Date(request.createdAt).toLocaleString()}</td> <td class="p-3 text-zinc-400">{new Date(request.createdAt).toLocaleString()}</td>
</tr> </tr>
))} ))}
@@ -1192,16 +1010,22 @@ const buttonSmallWarningClasses = cn(
service.contactMethods.length > 0 && ( service.contactMethods.length > 0 && (
<div class="space-y-3"> <div class="space-y-3">
<h3 class="font-title mb-2 text-lg font-medium text-zinc-200">Existing Contact Methods</h3> <h3 class="font-title mb-2 text-lg font-medium text-zinc-200">Existing Contact Methods</h3>
{service.contactMethods.map((method) => ( {service.contactMethods.map((method) => {
const contactMethodInfo = formatContactMethod(method.value)
return (
<div class="rounded-md border border-zinc-700 bg-zinc-700/30 p-3"> <div class="rounded-md border border-zinc-700 bg-zinc-700/30 p-3">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="flex-grow space-y-1"> <div class="flex-grow space-y-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Icon name={method.iconId} class="size-4 text-zinc-300" /> <Icon name={contactMethodInfo.icon} class="size-4 text-zinc-300" />
<span class="text-md font-semibold text-zinc-100">{method.label}</span> <span class="text-md font-semibold text-zinc-100">
{method.label ?? contactMethodInfo.label}
</span>
</div> </div>
<p class="text-sm text-pretty text-zinc-400">{method.value}</p> <p class="text-sm text-pretty text-zinc-400">
{method.info && <p class="text-xs text-zinc-500">{method.info}</p>} {contactMethodInfo.formattedValue}{' '}
<span class="text-zinc-500">({method.value})</span>
</p>
</div> </div>
<div class="flex shrink-0 gap-1.5"> <div class="flex shrink-0 gap-1.5">
<button <button
@@ -1236,7 +1060,7 @@ const buttonSmallWarningClasses = cn(
<input type="hidden" name="serviceId" value={service.id} /> <input type="hidden" name="serviceId" value={service.id} />
<div> <div>
<label for={`contactLabel-${method.id}`} class={labelBaseClasses}> <label for={`contactLabel-${method.id}`} class={labelBaseClasses}>
Label Override Label (Optional)
</label> </label>
<input <input
type="text" type="text"
@@ -1244,6 +1068,7 @@ const buttonSmallWarningClasses = cn(
id={`contactLabel-${method.id}`} id={`contactLabel-${method.id}`}
value={method.label} value={method.label}
class={inputBaseClasses} class={inputBaseClasses}
placeholder={contactMethodInfo.label}
/> />
</div> </div>
<div> <div>
@@ -1259,32 +1084,7 @@ const buttonSmallWarningClasses = cn(
class={inputBaseClasses} class={inputBaseClasses}
/> />
</div> </div>
<div>
<label for={`contactIcon-${method.id}`} class={labelBaseClasses}>
Icon ID
</label>
<input
type="text"
name="iconId"
id={`contactIcon-${method.id}`}
value={method.iconId}
placeholder="e.g., ri:mail-line or ri:telegram-line"
class={inputBaseClasses}
/>
</div>
<div>
<label for={`contactInfo-${method.id}`} class={labelBaseClasses}>
Additional Info (Optional)
</label>
<input
type="text"
name="info"
id={`contactInfo-${method.id}`}
value={method.info}
placeholder="e.g., Available 24/7 or Response within 24h"
class={inputBaseClasses}
/>
</div>
<div class="mt-2 flex justify-end gap-2"> <div class="mt-2 flex justify-end gap-2">
<button <button
type="button" type="button"
@@ -1299,7 +1099,8 @@ const buttonSmallWarningClasses = cn(
</div> </div>
</form> </form>
</div> </div>
))} )
})}
</div> </div>
) )
} }
@@ -1323,8 +1124,8 @@ const buttonSmallWarningClasses = cn(
> >
<input type="hidden" name="serviceId" value={service.id} /> <input type="hidden" name="serviceId" value={service.id} />
<div> <div>
<label for="newContactLabel" class={labelBaseClasses}>Label</label> <label for="newContactLabel" class={labelBaseClasses}>Override Label (Optional)</label>
<input type="text" name="label" id="newContactLabel" required class={inputBaseClasses} /> <input type="text" name="label" id="newContactLabel" class={inputBaseClasses} />
</div> </div>
<div> <div>
<label for="newContactValue" class={labelBaseClasses}>Value (with protocol)</label> <label for="newContactValue" class={labelBaseClasses}>Value (with protocol)</label>
@@ -1337,27 +1138,7 @@ const buttonSmallWarningClasses = cn(
class={inputBaseClasses} class={inputBaseClasses}
/> />
</div> </div>
<div>
<label for="newContactIcon" class={labelBaseClasses}>Icon ID</label>
<input
type="text"
name="iconId"
id="newContactIcon"
required
placeholder="e.g., ri:mail-line or ri:telegram-line"
class={inputBaseClasses}
/>
</div>
<div>
<label for="newContactInfo" class={labelBaseClasses}>Additional Info (Optional)</label>
<input
type="text"
name="info"
id="newContactInfo"
placeholder="e.g., Available 24/7 or Response within 24h"
class={inputBaseClasses}
/>
</div>
<button type="submit" class={buttonPrimaryClasses}>Add Contact Method</button> <button type="submit" class={buttonPrimaryClasses}>Add Contact Method</button>
</form> </form>
</details> </details>

View File

@@ -2,9 +2,8 @@
import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/client' import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import defaultImage from '../../../assets/fallback-service-image.jpg' import MyPicture from '../../../components/MyPicture.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels' import { getKycLevelInfo } from '../../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../../constants/verificationStatus' import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
@@ -343,23 +342,14 @@ const truncate = (text: string, length: number) => {
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="h-10 w-10 flex-shrink-0"> <div class="h-10 w-10 flex-shrink-0">
{service.imageUrl ? ( <MyPicture
<Image
src={service.imageUrl} src={service.imageUrl}
fallback="service"
alt={service.name} alt={service.name}
width={40} width={40}
height={40} height={40}
class="h-10 w-10 rounded-md object-cover" class="h-10 w-10 rounded-md object-cover"
/> />
) : (
<Image
src={defaultImage}
alt={service.name}
width={40}
height={40}
class="h-10 w-10 rounded-md object-cover"
/>
)}
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm font-medium text-zinc-200">{service.name}</div> <div class="text-sm font-medium text-zinc-200">{service.name}</div>

View File

@@ -64,7 +64,8 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
id="description" id="description"
required required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
></textarea> set:text=""
/>
{ {
inputErrors.description && ( inputErrors.description && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p>
@@ -80,7 +81,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="serviceUrls" name="serviceUrls"
id="serviceUrls" id="serviceUrls"
rows={3} rows={3}
placeholder="https://example1.com https://example2.com"></textarea> placeholder="https://example1.com https://example2.com"
set:text=""
/>
{ {
inputErrors.serviceUrls && ( inputErrors.serviceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
@@ -96,7 +99,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="tosUrls" name="tosUrls"
id="tosUrls" id="tosUrls"
rows={3} rows={3}
placeholder="https://example1.com/tos https://example2.com/tos"></textarea> placeholder="https://example1.com/tos https://example2.com/tos"
set:text=""
/>
{ {
inputErrors.tosUrls && ( inputErrors.tosUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p>
@@ -112,7 +117,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
name="onionUrls" name="onionUrls"
id="onionUrls" id="onionUrls"
rows={3} rows={3}
placeholder="http://example.onion"></textarea> placeholder="http://example.onion"
set:text=""
/>
{ {
inputErrors.onionUrls && ( inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
@@ -266,7 +273,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationSummary" name="verificationSummary"
id="verificationSummary" id="verificationSummary"
rows={3}></textarea> rows={3}
set:text=""
/>
{ {
inputErrors.verificationSummary && ( inputErrors.verificationSummary && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p>
@@ -283,7 +292,9 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationProofMd" name="verificationProofMd"
id="verificationProofMd" id="verificationProofMd"
rows={10}></textarea> rows={10}
set:text=""
/>
{ {
inputErrors.verificationProofMd && ( inputErrors.verificationProofMd && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p>

View File

@@ -2,11 +2,19 @@
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 Tooltip from '../../../components/Tooltip.astro' import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputImageFile from '../../../components/InputImageFile.astro'
import InputSelect from '../../../components/InputSelect.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro'
import MyPicture from '../../../components/MyPicture.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import { getServiceUserRoleInfo, serviceUserRoles } from '../../../constants/serviceUserRoles'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
import { transformCase } from '../../../lib/strings'
import { timeAgo } from '../../../lib/timeAgo'
const { username } = Astro.params const { username } = Astro.params
@@ -56,6 +64,8 @@ const [user, allServices] = await Astro.locals.banners.tryMany([
select: { select: {
id: true, id: true,
name: true, name: true,
displayName: true,
picture: true,
}, },
}, },
}, },
@@ -105,344 +115,215 @@ const [user, allServices] = await Astro.locals.banners.tryMany([
if (!user) return Astro.rewrite('/404') if (!user) return Astro.rewrite('/404')
--- ---
<BaseLayout pageTitle={`User: ${user.name}`} htmx> <BaseLayout
<div class="container mx-auto max-w-2xl py-8"> pageTitle={`${user.displayName ?? user.name} - User`}
<div class="mb-6 flex items-center justify-between"> widthClassName="max-w-screen-lg"
<h1 class="font-title text-2xl text-green-400">User Profile: {user.name}</h1> className={{ main: 'space-y-24' }}
<a >
href="/admin/users" <div class="mt-12">
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" {
!!user.picture && (
<MyPicture
src={user.picture}
alt=""
width={80}
height={80}
class="mx-auto mb-2 block size-20 rounded-full"
/>
)
}
<h1
class="font-title mb-2 flex items-center justify-center gap-2 text-center text-3xl leading-none font-bold"
> >
<Icon name="ri:arrow-left-line" class="mr-1 size-4" /> {user.displayName ? `${user.displayName} (${user.name})` : user.name}
Back to Users </h1>
</a>
<div class="mb-4 flex flex-wrap justify-center gap-2">
{user.admin && <BadgeSmall color="green" text="Admin" icon="ri:shield-star-fill" />}
{user.verified && <BadgeSmall color="cyan" text="Verified" icon="ri:verified-badge-fill" />}
{user.verifier && <BadgeSmall color="blue" text="Moderator" icon="ri:graduation-cap-fill" />}
{user.spammer && <BadgeSmall color="red" text="Spammer" icon="ri:alert-fill" />}
</div> </div>
<section <div class="flex justify-center gap-2">
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" <Button
> as="a"
<div class="mb-6 flex items-center gap-4"> href={`/u/${user.name}`}
icon="ri:global-line"
color="success"
shadow
size="sm"
label="View public profile"
/>
{ {
user.picture ? ( Astro.locals.user && user.id !== Astro.locals.user.id && !user.admin && (
<img src={user.picture} alt="" class="h-16 w-16 rounded-full ring-2 ring-green-500/30" /> <Button
) : ( as="a"
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10 ring-2 ring-green-500/30"> href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
<span class="font-title text-2xl text-green-500">{user.name.charAt(0) || 'A'}</span> data-astro-prefetch="tap"
</div> icon="ri:spy-line"
color="gray"
size="sm"
label="Impersonate"
/>
) )
} }
<div>
<h2 class="font-title text-lg text-green-400">{user.name}</h2>
<div class="mt-1 flex gap-2">
{
user.admin && (
<span class="font-title rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="font-title rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="font-title rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
)
}
</div>
</div> </div>
</div> </div>
<form <form
method="POST" method="POST"
action={actions.admin.user.update} action={actions.admin.user.update}
class="space-y-4 border-t border-green-500/30 pt-6"
enctype="multipart/form-data" enctype="multipart/form-data"
class="space-y-2"
data-astro-reload
> >
<h2 class="font-title text-center text-3xl leading-none font-bold">Edit profile</h2>
<input type="hidden" name="id" value={user.id} /> <input type="hidden" name="id" value={user.id} />
<div> <div class="grid grid-cols-2 gap-x-4 gap-y-2">
<label class="font-title mb-2 block text-sm text-green-500" for="name"> Name </label> <InputText
<input label="Name"
transition:persist
type="text"
name="name" name="name"
id="name" error={updateInputErrors.name}
value={user.name} inputProps={{ value: user.name, required: true }}
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/> />
{
updateInputErrors.name && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.name.join(', ')}</p>
)
}
</div>
<div> <InputText
<label class="font-title mb-2 block text-sm text-green-500" for="displayName"> Display Name </label> label="Display Name"
<input
transition:persist
type="text"
name="displayName" name="displayName"
maxlength={50} error={updateInputErrors.displayName}
id="displayName" inputProps={{ value: user.displayName ?? '', maxlength: 50 }}
value={user.displayName ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/> />
{
Array.isArray(updateInputErrors.displayName) && updateInputErrors.displayName.length > 0 && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.displayName.join(', ')}</p>
)
}
</div>
<div> <InputText
<label class="font-title mb-2 block text-sm text-green-500" for="link"> Link </label> label="Link"
<input
transition:persist
type="url"
name="link" name="link"
id="link" error={updateInputErrors.link}
value={user.link ?? ''} inputProps={{ value: user.link ?? '', type: 'url' }}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/> />
{
updateInputErrors.link && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.link.join(', ')}</p>
)
}
</div>
<div> <InputText
<label class="font-title mb-2 block text-sm text-green-500" for="picture"> label="Verified Link"
Picture url or path
</label>
<input
transition:persist
type="text"
name="picture"
id="picture"
value={user.picture ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.picture && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.picture.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="pictureFile">
Profile Picture Upload
</label>
<input
transition:persist
type="file"
name="pictureFile"
id="pictureFile"
accept="image/*"
class="font-title file:font-title block w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 file:mr-3 file:rounded-md file:border-0 file:bg-green-500/30 file:px-3 file:py-1 file:text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
<p class="font-title text-xs text-gray-400">
Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF, JXL. Max size:
5MB.
</p>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="admin"
checked={user.admin}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title text-sm text-green-500">Admin</span>
</label>
<Tooltip
as="label"
class="flex cursor-not-allowed items-center gap-2"
text="Automatically set based on verified link"
>
<input
type="checkbox"
name="verified"
checked={user.verified}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
disabled
/>
<span class="font-title text-sm text-green-500">Verified</span>
</Tooltip>
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="verifier"
checked={user.verifier}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title text-sm text-green-500">Verifier</span>
</label>
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="spammer"
checked={user.spammer}
class="rounded-sm border-red-500/30 bg-black/50 text-red-500 focus:ring-red-500 focus:ring-offset-black"
/>
<span class="font-title text-sm text-red-500">Spammer</span>
</label>
</div>
{
updateInputErrors.admin && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.admin.join(', ')}</p>
)
}
{
updateInputErrors.verifier && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.verifier.join(', ')}</p>
)
}
{
updateInputErrors.spammer && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.spammer.join(', ')}</p>
)
}
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verifiedLink">
Verified Link
</label>
<input
transition:persist
type="url"
name="verifiedLink" name="verifiedLink"
id="verifiedLink" error={updateInputErrors.verifiedLink}
value={user.verifiedLink} inputProps={{ value: user.verifiedLink, type: 'url' }}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/> />
{
updateInputErrors.verifiedLink && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.verifiedLink.join(', ')}</p>
)
}
</div> </div>
<div class="flex gap-4 pt-4"> <InputImageFile
<button label="Profile Picture Upload"
type="submit" name="pictureFile"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" value={user.picture}
> error={updateInputErrors.pictureFile}
<Icon name="ri:save-line" class="mr-2 size-4" /> square
Save description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF. Max size: 5MB."
</button> />
</div>
<InputCardGroup
name="type"
label="Type"
options={[
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' },
{ label: 'Moderator', value: 'verifier', icon: 'ri:graduation-cap-fill' },
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
{
label: 'Verified',
value: 'verified',
icon: 'ri:verified-badge-fill',
disabled: true,
noTransitionPersist: true,
},
]}
selectedValue={[
user.admin ? 'admin' : null,
user.verified ? 'verified' : null,
user.verifier ? 'verifier' : null,
user.spammer ? 'spammer' : null,
].filter((v) => v !== null)}
required
cardSize="sm"
iconSize="sm"
multiple
error={updateInputErrors.type}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form> </form>
{
Astro.locals.user && user.id !== Astro.locals.user.id && (
<a
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
class="font-title mt-4 inline-flex items-center justify-center rounded-md border border-yellow-500/30 bg-yellow-500/10 px-4 py-2 text-sm text-yellow-400 shadow-xs transition-colors duration-200 hover:bg-yellow-500/20 focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:spy-line" class="mr-2 size-4" />
Impersonate
</a>
)
}
</section>
<section <section class="space-y-2">
class="mt-8 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" <h2 class="font-title text-center text-3xl leading-none font-bold">Internal Notes</h2>
>
<h2 class="font-title mb-4 text-lg text-green-500">Internal Notes</h2>
{ {
user.internalNotes.length === 0 ? ( user.internalNotes.length === 0 ? (
<p class="text-gray-400">No internal notes yet.</p> <p class="text-day-300 text-center">No internal notes yet.</p>
) : ( ) : (
<div class="space-y-4"> <div class="space-y-4">
{user.internalNotes.map((note) => ( {user.internalNotes.map((note) => (
<div data-note-id={note.id} class="rounded-lg border border-green-500/30 bg-black/50 p-4"> <div data-note-id={note.id} class="border-night-400 bg-night-600 rounded-lg border p-4 shadow-sm">
<div class="mb-2 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between gap-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-1">
<span class="font-title text-xs text-gray-200"> {!!note.addedByUser?.picture && (
{note.addedByUser ? note.addedByUser.name : 'System'} <MyPicture
</span> src={note.addedByUser.picture}
<span class="font-title text-xs text-gray-500"> alt=""
{transformCase(timeAgo.format(note.createdAt, 'twitter-minute-now'), 'sentence')} width={12}
height={12}
class="size-6 rounded-full"
/>
)}
<span class="text-day-100 font-medium">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{note.addedByUser ? note.addedByUser.displayName || note.addedByUser.name : 'System'}
</span> </span>
<TimeFormatted date={note.createdAt} hourPrecision class="text-day-300 ms-1 text-sm" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center">
<label class="font-title inline-flex cursor-pointer items-center justify-center rounded-md border border-yellow-500/30 bg-yellow-500/10 px-2 py-1 text-xs text-yellow-400 shadow-xs transition-colors duration-200 hover:bg-yellow-500/20 focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"> <label class="text-day-300 hover:text-day-100 cursor-pointer p-1 transition-colors">
<input <input
type="checkbox" type="checkbox"
class="peer sr-only" class="peer sr-only"
data-edit-note-checkbox data-edit-note-checkbox
data-note-id={note.id} data-note-id={note.id}
/> />
<Icon name="ri:edit-line" class="size-3" /> <Icon name="ri:edit-line" class="size-5" />
</label> </label>
<form method="POST" action={actions.admin.user.internalNotes.delete} class="inline-flex"> <form
<input type="hidden" name="noteId" value={note.id} /> method="POST"
<button action={actions.admin.user.internalNotes.delete}
type="submit" class="contents"
class="font-title inline-flex items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" data-astro-reload
> >
<Icon name="ri:delete-bin-line" class="size-3" /> <input type="hidden" name="noteId" value={note.id} />
<button type="submit" class="text-day-300 p-1 transition-colors hover:text-red-400">
<Icon name="ri:delete-bin-line" class="size-5" />
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<div data-note-content> <div data-note-content>
<p class="font-title text-sm whitespace-pre-wrap text-gray-300">{note.content}</p> <p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
</div> </div>
<form <form
method="POST" method="POST"
action={actions.admin.user.internalNotes.update} action={actions.admin.user.internalNotes.update}
data-note-edit-form data-note-edit-form
class="mt-2 hidden" data-astro-reload
class="mt-4 hidden space-y-4"
> >
<input type="hidden" name="noteId" value={note.id} /> <input type="hidden" name="noteId" value={note.id} />
<textarea <InputTextArea
label="Note Content"
name="content" name="content"
rows="3" value={note.content}
class="font-title min-h-12 w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" inputProps={{ class: 'bg-night-700' }}
data-trim-content />
> <InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
{note.content}
</textarea>
<div class="mt-2 flex justify-end gap-2">
<button
type="button"
data-cancel-edit
class="font-title inline-flex items-center justify-center rounded-md border border-gray-500/30 bg-gray-500/10 px-3 py-1 text-xs text-gray-400 shadow-xs transition-colors duration-200 hover:bg-gray-500/20 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
Cancel
</button>
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-3 py-1 text-xs 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"
>
Save
</button>
</div>
</form> </form>
</div> </div>
))} ))}
@@ -450,83 +331,65 @@ if (!user) return Astro.rewrite('/404')
) )
} }
<form method="POST" action={actions.admin.user.internalNotes.add} class="mt-4 space-y-2"> <form
<input type="hidden" name="userId" value={user.id} /> method="POST"
<textarea action={actions.admin.user.internalNotes.add}
name="content" class="mt-10 space-y-2"
placeholder="Add a note..." data-astro-reload
rows="3"
class="font-title min-h-12 w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
></textarea>
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
> >
<Icon name="ri:add-line" class="mr-1 size-4" /> <h3 class="font-title mb-0 text-center text-xl leading-none font-bold">Add Note</h3>
Add
</button> <input type="hidden" name="userId" value={user.id} />
<InputTextArea
label="Note Content"
name="content"
inputProps={{ placeholder: 'Add a note...', rows: 3 }}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form> </form>
</section> </section>
<section <section class="space-y-2">
class="mt-8 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" <h2 class="font-title text-center text-3xl leading-none font-bold">Service Affiliations</h2>
>
<h2 class="font-title mb-4 text-lg text-green-500">Service Affiliations</h2>
{ {
user.serviceAffiliations.length === 0 ? ( user.serviceAffiliations.length === 0 ? (
<p class="text-gray-400">No service affiliations yet.</p> <p class="text-day-200 text-center">No service affiliations yet.</p>
) : ( ) : (
<div class="space-y-4"> <div class="grid grid-cols-2 gap-x-4">
{user.serviceAffiliations.map((affiliation) => ( {user.serviceAffiliations.map((affiliation) => {
<div class="flex items-center justify-between rounded-lg border border-green-500/30 bg-black/50 p-4"> const roleInfo = getServiceUserRoleInfo(affiliation.role)
<div> return (
<div class="flex items-center gap-2"> <div class="bg-night-600 border-night-400 flex items-center justify-start gap-2 rounded-lg border px-3 py-2">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-400 text-sm">at</span>
<a <a
href={`/service/${affiliation.service.slug}`} href={`/service/${affiliation.service.slug}`}
class="font-title text-sm text-green-400 hover:underline" class="text-day-100 hover:text-day-50 flex items-center gap-1 leading-none font-medium hover:underline"
> >
{affiliation.service.name} <span>{affiliation.service.name}</span>
<Icon name="ri:external-link-line" class="text-day-400 size-3.5" />
</a> </a>
<span <TimeFormatted
class={`font-title rounded-full px-2 py-0.5 text-xs ${ date={affiliation.createdAt}
affiliation.role === 'OWNER' hourPrecision
? 'border border-purple-500/50 bg-purple-500/20 text-purple-400' class="text-day-400 ms-auto me-2 text-sm"
: affiliation.role === 'ADMIN' />
? 'border border-red-500/50 bg-red-500/20 text-red-400'
: affiliation.role === 'MODERATOR'
? 'border border-orange-500/50 bg-orange-500/20 text-orange-400'
: affiliation.role === 'SUPPORT'
? 'border border-blue-500/50 bg-blue-500/20 text-blue-400'
: 'border border-green-500/50 bg-green-500/20 text-green-400'
}`}
>
{affiliation.role.toLowerCase()}
</span>
</div>
<div class="mt-1 flex items-center gap-2">
<span class="font-title text-xs text-gray-500">
{transformCase(timeAgo.format(affiliation.createdAt, 'twitter-minute-now'), 'sentence')}
</span>
</div>
</div>
<form <form
method="POST" method="POST"
action={actions.admin.user.serviceAffiliations.remove} action={actions.admin.user.serviceAffiliations.remove}
class="inline-flex"
data-astro-reload data-astro-reload
class="contents"
> >
<input type="hidden" name="id" value={affiliation.id} /> <input type="hidden" name="id" value={affiliation.id} />
<button <button type="submit" class="text-day-300 transition-colors hover:text-red-400">
type="submit" <Icon name="ri:delete-bin-line" class="size-5" />
class="font-title inline-flex items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:delete-bin-line" class="size-3" />
</button> </button>
</form> </form>
</div> </div>
))} )
})}
</div> </div>
) )
} }
@@ -534,114 +397,61 @@ if (!user) return Astro.rewrite('/404')
<form <form
method="POST" method="POST"
action={actions.admin.user.serviceAffiliations.add} action={actions.admin.user.serviceAffiliations.add}
class="mt-6 space-y-4 border-t border-green-500/30 pt-6"
data-astro-reload data-astro-reload
class="mt-10 space-y-2"
> >
<h3 class="font-title mb-0 text-center text-xl leading-none font-bold">Add Affiliation</h3>
<input type="hidden" name="userId" value={user.id} /> <input type="hidden" name="userId" value={user.id} />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <InputSelect
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="serviceId"> Service </label>
<select
name="serviceId" name="serviceId"
id="serviceId" label="Service"
required options={allServices.map((service) => ({
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500" label: service.name,
> value: service.id.toString(),
<option value="">Select a service</option> }))}
{allServices.map((service) => <option value={service.id}>{service.name}</option>)} selectProps={{ required: true }}
</select> />
</div>
<div> <InputCardGroup
<label class="font-title mb-2 block text-sm text-green-500" for="role"> Role </label>
<select
name="role" name="role"
id="role" label="Role"
options={serviceUserRoles.map((role) => ({
label: role.label,
value: role.value,
icon: role.icon,
}))}
required required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500" cardSize="sm"
> iconSize="sm"
<option value="OWNER">Owner</option> />
<option value="ADMIN">Admin</option>
<option value="MODERATOR">Moderator</option>
<option value="SUPPORT">Support</option>
<option value="TEAM_MEMBER">Team Member</option>
</select>
</div>
</div>
<div> <InputSubmitButton label="Add Affiliation" icon="ri:link" hideCancel />
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:link" class="mr-1 size-4" />
Add Service Affiliation
</button>
</div>
</form> </form>
</section> </section>
<section <form method="POST" action={actions.admin.user.karmaTransactions.add} data-astro-reload class="space-y-2">
class="mt-8 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" <h2 class="font-title text-center text-3xl leading-none font-bold">Grant/Remove Karma</h2>
>
<h2 class="font-title mb-4 text-lg text-green-500">Add Karma Transaction</h2>
<form
method="POST"
action={actions.admin.user.karmaTransactions.add}
class="mt-6 space-y-4 border-t border-green-500/30 pt-6"
data-astro-reload
>
<input type="hidden" name="userId" value={user.id} /> <input type="hidden" name="userId" value={user.id} />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <InputText
<div> label="Points"
<label class="font-title mb-2 block text-sm text-green-500" for="points"> Points </label>
<input
type="number"
name="points" name="points"
id="points" error={addKarmaTransactionResult?.error?.message}
required inputProps={{ type: 'number', required: true }}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500"
/> />
</div>
<div> <InputTextArea
<label class="font-title mb-2 block text-sm text-green-500" for="action"> Action </label> label="Description"
<input
type="text"
name="action"
id="action"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
</div>
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="description"> Description </label>
<textarea
name="description" name="description"
id="description" error={addKarmaTransactionResult?.error?.message}
required inputProps={{ required: true }}
rows="3" />
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500"
></textarea>
</div>
<div> <InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:add-line" class="mr-1 size-4" />
Add Karma Transaction
</button>
</div>
</form> </form>
</section>
</div>
</BaseLayout> </BaseLayout>
<script> <script>
@@ -656,7 +466,6 @@ if (!user) return Astro.rewrite('/404')
const noteContent = noteDiv.querySelector<HTMLDivElement>('[data-note-content]') const noteContent = noteDiv.querySelector<HTMLDivElement>('[data-note-content]')
const editForm = noteDiv.querySelector<HTMLFormElement>('[data-note-edit-form]') const editForm = noteDiv.querySelector<HTMLFormElement>('[data-note-edit-form]')
const cancelButton = noteDiv.querySelector<HTMLButtonElement>('[data-cancel-edit]')
if (noteContent && editForm) { if (noteContent && editForm) {
if (target.checked) { if (target.checked) {
@@ -667,23 +476,7 @@ if (!user) return Astro.rewrite('/404')
editForm.classList.add('hidden') editForm.classList.add('hidden')
} }
} }
if (cancelButton) {
cancelButton.addEventListener('click', () => {
target.checked = false
noteContent?.classList.remove('hidden')
editForm?.classList.add('hidden')
})
}
}) })
}) })
}) })
</script> </script>
<script>
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLTextAreaElement>('[data-trim-content]').forEach((textarea) => {
textarea.value = textarea.value.trim()
})
})
</script>

View File

@@ -6,11 +6,13 @@ import { orderBy as lodashOrderBy } from 'lodash-es'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro' import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro' import Tooltip from '../../../components/Tooltip.astro'
import UserBadge from '../../../components/UserBadge.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { pluralize } from '../../../lib/pluralize' import { pluralize } from '../../../lib/pluralize'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
import { formatDateShort } from '../../../lib/timeAgo' import { formatDateShort } from '../../../lib/timeAgo'
import { urlWithParams } from '../../../lib/urls'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -74,6 +76,8 @@ const dbUsers = await prisma.user.findMany({
select: { select: {
id: true, id: true,
name: true, name: true,
displayName: true,
picture: true,
verified: true, verified: true,
admin: true, admin: true,
verifier: true, verifier: true,
@@ -103,14 +107,11 @@ const users =
? lodashOrderBy(dbUsers, [(u) => (u.admin ? 'admin' : 'user')], [filters['sort-order']]) ? lodashOrderBy(dbUsers, [(u) => (u.admin ? 'admin' : 'user')], [filters['sort-order']])
: dbUsers : dbUsers
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => { const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
const currentSortBy = filters['sort-by'] return urlWithParams(Astro.url, {
const currentSortOrder = filters['sort-order'] 'sort-by': sortBy,
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc' 'sort-order': filters['sort-by'] === sortBy && filters['sort-order'] === 'asc' ? 'desc' : 'asc',
const searchParams = new URLSearchParams(Astro.url.search) })
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/users?${searchParams.toString()}`
} }
--- ---
@@ -241,10 +242,10 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
class={`group hover:bg-zinc-700/30 ${user.spammer ? 'bg-red-900/10' : ''}`} class={`group hover:bg-zinc-700/30 ${user.spammer ? 'bg-red-900/10' : ''}`}
> >
<td class="px-4 py-3 text-sm font-medium text-zinc-200"> <td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div>{user.name}</div> <UserBadge user={user} size="md" class="flex text-white" />
{user.internalNotes.length > 0 && ( {user.internalNotes.length > 0 && (
<Tooltip <Tooltip
class="text-2xs mt-1 text-yellow-400" class="text-2xs font-light text-yellow-200/40"
position="right" position="right"
text={user.internalNotes text={user.internalNotes
.map( .map(
@@ -257,7 +258,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
) )
.join('\n\n')} .join('\n\n')}
> >
<Icon name="ri:sticky-note-line" class="mr-1 inline-block size-3" /> <Icon name="ri:sticky-note-line" class="mr-0.5 inline-block size-3" />
{user.internalNotes.length} internal {pluralize('note', user.internalNotes.length)} {user.internalNotes.length} internal {pluralize('note', user.internalNotes.length)}
</Tooltip> </Tooltip>
)} )}
@@ -322,6 +323,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<Tooltip <Tooltip
as="a" as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`} href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
data-astro-prefetch="tap"
class="inline-flex items-center rounded-md border border-orange-500/50 bg-orange-500/20 px-1 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/30" class="inline-flex items-center rounded-md border border-orange-500/50 bg-orange-500/20 px-1 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/30"
text="Impersonate" text="Impersonate"
> >
@@ -335,6 +337,14 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
> >
<Icon name="ri:edit-line" class="size-4" /> <Icon name="ri:edit-line" class="size-4" />
</Tooltip> </Tooltip>
<Tooltip
as="a"
href={`/u/${user.name}`}
class="inline-flex items-center rounded-md border border-green-500/50 bg-green-500/20 px-1 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/30"
text="Public profile"
>
<Icon name="ri:global-line" class="size-4" />
</Tooltip>
</div> </div>
</td> </td>
</tr> </tr>

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