diff --git a/.cursorrules b/.cursorrules
index 41c5415..52ec9ea 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -199,7 +199,7 @@
label="Note for Moderators"
name="notes"
value={params.notes}
- rows={10}
+ inputProps={{ rows: 10 }}
error={inputErrors.notes}
/>
@@ -207,7 +207,7 @@
-
+
```
diff --git a/.gitignore b/.gitignore
index 54e225b..cb31a8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,4 +12,5 @@ dump*.sql
*.dump
*.log
*.bak
-migrate.py
\ No newline at end of file
+migrate.py
+sync-all.sh
\ No newline at end of file
diff --git a/justfile b/justfile
index 33a74f8..a94a3ad 100644
--- a/justfile
+++ b/justfile
@@ -15,14 +15,24 @@ import-triggers:
docker compose exec -T database psql -U ${DATABASE_USER:-kycnot} -d ${DATABASE_NAME:-kycnot} < "$sql_file"
done
+# Create a database backup that includes the Prisma migrations table (recommended)
dump-db:
#!/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_${TIMESTAMP}.dump
+ 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 > 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]
# If no filename is provided, it will use the most recent backup
import-db file="":
@@ -44,7 +54,96 @@ import-db file="":
echo "Restoring database from $BACKUP_FILE..."
# 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
- # 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!"
+
+ # 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
\ No newline at end of file
diff --git a/web/prisma/migrations/20250519115947_karma_transaction_enums/migration.sql b/web/prisma/migrations/20250519115947_karma_transaction_enums/migration.sql
new file mode 100644
index 0000000..b6755f7
--- /dev/null
+++ b/web/prisma/migrations/20250519115947_karma_transaction_enums/migration.sql
@@ -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;
diff --git a/web/prisma/migrations/20250519135045_announcements/migration.sql b/web/prisma/migrations/20250519135045_announcements/migration.sql
new file mode 100644
index 0000000..a8d4975
--- /dev/null
+++ b/web/prisma/migrations/20250519135045_announcements/migration.sql
@@ -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");
diff --git a/web/prisma/migrations/20250519145000_fix_karma_transactions/migration.sql b/web/prisma/migrations/20250519145000_fix_karma_transactions/migration.sql
new file mode 100644
index 0000000..0519ecb
--- /dev/null
+++ b/web/prisma/migrations/20250519145000_fix_karma_transactions/migration.sql
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma
index 946a589..41a5fcf 100644
--- a/web/prisma/schema.prisma
+++ b/web/prisma/schema.prisma
@@ -166,6 +166,24 @@ enum ServiceSuggestionStatusChange {
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 {
id Int @id @default(autoincrement())
userId Int
@@ -445,20 +463,21 @@ model User {
/// Computed via trigger. Do not update through prisma.
totalKarma Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @default(now()) @updatedAt
- lastLoginAt DateTime @default(now())
- comments Comment[]
- karmaTransactions KarmaTransaction[]
- commentVotes CommentVote[]
- suggestions ServiceSuggestion[]
- suggestionMessages ServiceSuggestionMessage[]
- internalNotes InternalUserNote[] @relation("UserRecievedNotes")
- addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
- verificationRequests ServiceVerificationRequest[]
- notifications Notification[] @relation("NotificationOwner")
- notificationPreferences NotificationPreferences?
- serviceAffiliations ServiceUser[] @relation("UserServices")
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+ lastLoginAt DateTime @default(now())
+ comments Comment[]
+ karmaTransactions KarmaTransaction[]
+ grantedKarmaTransactions KarmaTransaction[] @relation("KarmaGrantedBy")
+ commentVotes CommentVote[]
+ suggestions ServiceSuggestion[]
+ suggestionMessages ServiceSuggestionMessage[]
+ internalNotes InternalUserNote[] @relation("UserRecievedNotes")
+ addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
+ verificationRequests ServiceVerificationRequest[]
+ notifications Notification[] @relation("NotificationOwner")
+ notificationPreferences NotificationPreferences?
+ serviceAffiliations ServiceUser[] @relation("UserServices")
@@index([createdAt])
@@index([totalKarma])
@@ -489,24 +508,27 @@ model ServiceAttribute {
}
model KarmaTransaction {
- id Int @id @default(autoincrement())
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- userId Int
- action String
- points Int @default(0)
- comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
- commentId Int?
- suggestion ServiceSuggestion? @relation(fields: [suggestionId], references: [id], onDelete: Cascade)
- suggestionId Int?
- description String
- processed Boolean @default(false)
- createdAt DateTime @default(now())
+ id Int @id @default(autoincrement())
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId Int
+ action KarmaTransactionAction
+ points Int @default(0)
+ comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
+ commentId Int?
+ suggestion ServiceSuggestion? @relation(fields: [suggestionId], references: [id], onDelete: Cascade)
+ suggestionId Int?
+ description String
+ processed Boolean @default(false)
+ createdAt DateTime @default(now())
+ grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull)
+ grantedByUserId Int?
@@index([createdAt])
@@index([userId])
@@index([processed])
@@index([suggestionId])
@@index([commentId])
+ @@index([grantedByUserId])
}
enum VerificationStepStatus {
@@ -588,3 +610,17 @@ model ServiceUser {
@@index([serviceId])
@@index([role])
}
+
+model Announcement {
+ id Int @id @default(autoincrement())
+ title String
+ content String
+ type AnnouncementType
+ startDate DateTime
+ endDate DateTime?
+ isActive Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ @@index([isActive, startDate, endDate])
+}
diff --git a/web/prisma/triggers/01_karma_tx.sql b/web/prisma/triggers/01_karma_tx.sql
index dcd30d9..f562dac 100644
--- a/web/prisma/triggers/01_karma_tx.sql
+++ b/web/prisma/triggers/01_karma_tx.sql
@@ -24,7 +24,7 @@ DROP FUNCTION IF EXISTS handle_suggestion_status_change();
CREATE OR REPLACE FUNCTION insert_karma_transaction(
p_user_id INT,
p_points INT,
- p_action TEXT,
+ p_action "KarmaTransactionAction",
p_comment_id INT,
p_description TEXT,
p_suggestion_id INT DEFAULT NULL
@@ -65,7 +65,7 @@ BEGIN
PERFORM insert_karma_transaction(
NEW."authorId",
1,
- 'comment_approved',
+ 'COMMENT_APPROVED',
NEW.id,
format('Your comment #comment-%s in %s has been approved!',
NEW.id,
@@ -86,7 +86,7 @@ BEGIN
PERFORM insert_karma_transaction(
NEW."authorId",
5,
- 'comment_verified',
+ 'COMMENT_VERIFIED',
NEW.id,
format('Your comment #comment-%s in %s has been verified!',
NEW.id,
@@ -108,7 +108,7 @@ BEGIN
PERFORM insert_karma_transaction(
NEW."authorId",
-10,
- 'comment_spam',
+ 'COMMENT_SPAM',
NEW.id,
format('Your comment #comment-%s in %s has been marked as spam.',
NEW.id,
@@ -120,7 +120,7 @@ BEGIN
PERFORM insert_karma_transaction(
NEW."authorId",
10,
- 'comment_spam_reverted',
+ 'COMMENT_SPAM_REVERTED',
NEW.id,
format('Your comment #comment-%s in %s is no longer marked as spam.',
NEW.id,
@@ -136,7 +136,7 @@ CREATE OR REPLACE FUNCTION handle_comment_vote_change()
RETURNS TRIGGER AS $$
DECLARE
karma_points INT;
- vote_action TEXT;
+ vote_action "KarmaTransactionAction";
vote_description TEXT;
comment_author_id INT;
service_name TEXT;
@@ -151,7 +151,7 @@ BEGIN
IF TG_OP = 'INSERT' THEN
-- New vote
karma_points := CASE WHEN NEW.downvote THEN -1 ELSE 1 END;
- vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END;
+ vote_action := CASE WHEN NEW.downvote THEN 'COMMENT_DOWNVOTE' ELSE 'COMMENT_UPVOTE' END;
vote_description := format('Your comment #comment-%s in %s received %s',
NEW."commentId",
service_name,
@@ -160,7 +160,7 @@ BEGIN
ELSIF TG_OP = 'DELETE' THEN
-- Removed vote
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',
OLD."commentId",
service_name);
@@ -168,7 +168,7 @@ BEGIN
ELSIF TG_OP = 'UPDATE' THEN
-- Changed vote (from upvote to downvote or vice versa)
karma_points := CASE WHEN NEW.downvote THEN -2 ELSE 2 END;
- vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END;
+ vote_action := CASE WHEN NEW.downvote THEN 'COMMENT_DOWNVOTE' ELSE 'COMMENT_UPVOTE' END;
vote_description := format('Your comment #comment-%s in %s vote changed to %s',
NEW."commentId",
service_name,
@@ -243,7 +243,7 @@ BEGIN
PERFORM insert_karma_transaction(
NEW."userId",
10,
- 'suggestion_approved',
+ 'SUGGESTION_APPROVED',
NULL, -- p_comment_id (not applicable)
format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id
@@ -263,3 +263,24 @@ CREATE TRIGGER suggestion_status_change_trigger
ON "ServiceSuggestion"
FOR EACH ROW
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();
diff --git a/web/src/actions/admin/announcement.ts b/web/src/actions/admin/announcement.ts
new file mode 100644
index 0000000..6e2d4a6
--- /dev/null
+++ b/web/src/actions/admin/announcement.ts
@@ -0,0 +1,161 @@
+import { type Prisma, type PrismaClient, type AnnouncementType } 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,
+ title: true,
+ content: true,
+ type: 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({
+ title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
+ content: z
+ .string()
+ .min(1, 'Content is required')
+ .max(1000, 'Content must be less than 1000 characters'),
+ type: z.enum(['INFO', 'WARNING', 'ALERT']),
+ 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: {
+ ...input,
+ endDate: input.endDate || null,
+ },
+ select: selectAnnouncementReturnFields,
+ })
+
+ return { announcement }
+ },
+ }),
+
+ update: defineProtectedAction({
+ accept: 'form',
+ permissions: 'admin',
+ input: z.object({
+ id: z.coerce.number().int().positive(),
+ title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
+ content: z
+ .string()
+ .min(1, 'Content is required')
+ .max(1000, 'Content must be less than 1000 characters'),
+ type: z.enum(['INFO', 'WARNING', 'ALERT']),
+ 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: {
+ ...input,
+ 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 }
+ },
+ }),
+}
diff --git a/web/src/actions/admin/index.ts b/web/src/actions/admin/index.ts
index 14dff5c..68a14c9 100644
--- a/web/src/actions/admin/index.ts
+++ b/web/src/actions/admin/index.ts
@@ -1,3 +1,4 @@
+import { adminAnnouncementActions } from './announcement'
import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event'
import { adminServiceActions } from './service'
@@ -7,6 +8,7 @@ import { verificationStep } from './verificationStep'
export const adminActions = {
attribute: adminAttributeActions,
+ announcement: adminAnnouncementActions,
event: adminEventActions,
service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions,
diff --git a/web/src/actions/admin/user.ts b/web/src/actions/admin/user.ts
index 4ccd226..c81fb41 100644
--- a/web/src/actions/admin/user.ts
+++ b/web/src/actions/admin/user.ts
@@ -54,11 +54,8 @@ export const adminUserActions = {
.nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
- picture: z.string().max(255, 'Picture URL must be less than 255 characters').nullable().default(null),
pictureFile: z.instanceof(File).optional(),
- verifier: z.boolean().default(false),
- admin: z.boolean().default(false),
- spammer: z.boolean().default(false),
+ role: z.enum(['admin', 'verifier', 'spammer']),
verifiedLink: z
.string()
.url('Invalid URL')
@@ -72,7 +69,7 @@ export const adminUserActions = {
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
}),
- handler: async ({ id, picture, pictureFile, ...valuesToUpdate }) => {
+ handler: async ({ id, pictureFile, ...valuesToUpdate }) => {
const user = await prisma.user.findUnique({
where: {
id,
@@ -89,10 +86,10 @@ export const adminUserActions = {
})
}
- let pictureUrl = picture ?? null
- if (pictureFile && pictureFile.size > 0) {
- pictureUrl = await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/')
- }
+ const pictureUrl =
+ pictureFile && pictureFile.size > 0
+ ? await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/')
+ : null
const updatedUser = await prisma.user.update({
where: { id: user.id },
@@ -293,10 +290,9 @@ export const adminUserActions = {
input: z.object({
userId: z.coerce.number().int().positive(),
points: z.coerce.number().int(),
- action: z.string().min(1, 'Action is required'),
description: z.string().min(1, 'Description is required'),
}),
- handler: async (input) => {
+ handler: async (input, context) => {
// Check if the user exists
const user = await prisma.user.findUnique({
where: { id: input.userId },
@@ -314,9 +310,9 @@ export const adminUserActions = {
data: {
userId: input.userId,
points: input.points,
- action: input.action,
+ action: 'MANUAL_ADJUSTMENT',
description: input.description,
- processed: true,
+ grantedByUserId: context.locals.user.id,
},
})
},
diff --git a/web/src/components/AnnouncementBanner.astro b/web/src/components/AnnouncementBanner.astro
new file mode 100644
index 0000000..1634be7
--- /dev/null
+++ b/web/src/components/AnnouncementBanner.astro
@@ -0,0 +1,82 @@
+---
+import { Icon } from 'astro-icon/components'
+import { Markdown } from 'astro-remote'
+
+import type { AnnouncementType } from '@prisma/client'
+
+export type Announcement = {
+ id: number
+ title: string
+ content: string
+ type: AnnouncementType
+ startDate: Date
+ endDate: Date | null
+ isActive: boolean
+}
+
+export type Props = {
+ announcements: Announcement[]
+}
+
+const { announcements } = Astro.props
+
+// Get icon and class based on announcement type
+const getTypeInfo = (type: AnnouncementType) => {
+ switch (type) {
+ case 'INFO':
+ return {
+ icon: 'ri:information-line',
+ containerClass: 'bg-blue-900/40 border-blue-500/30',
+ titleClass: 'text-blue-400',
+ contentClass: 'text-blue-300',
+ }
+ case 'WARNING':
+ return {
+ icon: 'ri:alert-line',
+ containerClass: 'bg-yellow-900/40 border-yellow-500/30',
+ titleClass: 'text-yellow-400',
+ contentClass: 'text-yellow-300',
+ }
+ case 'ALERT':
+ return {
+ icon: 'ri:error-warning-line',
+ containerClass: 'bg-red-900/40 border-red-500/30',
+ titleClass: 'text-red-400',
+ contentClass: 'text-red-300',
+ }
+ default:
+ return {
+ icon: 'ri:information-line',
+ containerClass: 'bg-blue-900/40 border-blue-500/30',
+ titleClass: 'text-blue-400',
+ contentClass: 'text-blue-300',
+ }
+ }
+}
+---
+
+{
+ announcements.length > 0 && (
+
+ {announcements.map((announcement) => {
+ const typeInfo = getTypeInfo(announcement.type)
+
+ return (
+
+
+
+
+ {announcement.title}
+
+
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/web/src/components/InputCardGroup.astro b/web/src/components/InputCardGroup.astro
index bc579d3..46d0917 100644
--- a/web/src/components/InputCardGroup.astro
+++ b/web/src/components/InputCardGroup.astro
@@ -9,29 +9,33 @@ import InputWrapper from './InputWrapper.astro'
import type { MarkdownString } from '../lib/markdown'
import type { ComponentProps } from 'astro/types'
-type Props = Omit, 'children' | 'inputId'> & {
+type Props = Omit<
+ ComponentProps,
+ 'children' | 'inputId'
+> & {
options: {
label: string
value: string
icon?: string
iconClass?: string
description?: MarkdownString
+ disabled?: boolean
}[]
disabled?: boolean
- selectedValue?: string
+ selectedValue?: Multiple extends true ? string[] : string
cardSize?: 'lg' | 'md' | 'sm'
iconSize?: 'md' | 'sm'
- multiple?: boolean
+ multiple?: Multiple
}
const {
options,
disabled,
- selectedValue,
+ selectedValue = undefined as string[] | string | undefined,
cardSize = 'sm',
iconSize = 'sm',
class: className,
- multiple,
+ multiple = false as boolean,
...wrapperProps
} = Astro.props
@@ -40,6 +44,7 @@ const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
+{/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */}
0
'has-checked:border-green-700 has-checked:bg-green-700/20 has-checked:ring-1 has-checked:ring-green-700',
multiple &&
'has-focus-visible:border-day-300 has-focus-visible:ring-2 has-focus-visible:ring-green-700 has-focus-visible:ring-offset-1',
- disabled && 'cursor-not-allowed opacity-50'
+ 'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50'
)}
>
0
type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name}
value={option.value}
- checked={selectedValue === option.value}
+ checked={
+ Array.isArray(selectedValue)
+ ? selectedValue.includes(option.value)
+ : selectedValue === option.value
+ }
class="peer sr-only"
- disabled={disabled}
+ disabled={disabled || option.disabled}
/>
{option.icon && (
diff --git a/web/src/components/InputSelect.astro b/web/src/components/InputSelect.astro
new file mode 100644
index 0000000..6800c9e
--- /dev/null
+++ b/web/src/components/InputSelect.astro
@@ -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
, 'children' | 'inputId' | 'required'> & {
+ options: {
+ label: string
+ value: string
+ disabled?: boolean
+ }[]
+ selectProps?: Omit, '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
+---
+
+
+
+
diff --git a/web/src/components/InputTextArea.astro b/web/src/components/InputTextArea.astro
index 094e564..05fe4c2 100644
--- a/web/src/components/InputTextArea.astro
+++ b/web/src/components/InputTextArea.astro
@@ -1,44 +1,36 @@
---
+import { omit } from 'lodash-es'
+
import { cn } from '../lib/cn'
import { baseInputClassNames } from '../lib/formInputs'
import InputWrapper from './InputWrapper.astro'
-import type { ComponentProps } from 'astro/types'
+import type { ComponentProps, HTMLAttributes } from 'astro/types'
-type Props = Omit, 'children' | 'inputId'> & {
+type Props = Omit, 'children' | 'inputId' | 'required'> & {
+ inputProps?: Omit, 'name'>
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
---
-{/* eslint-disable astro/jsx-a11y/no-autofocus */}
-
-
+
{value}
diff --git a/web/src/components/InputWrapper.astro b/web/src/components/InputWrapper.astro
index ac092a4..a447fcd 100644
--- a/web/src/components/InputWrapper.astro
+++ b/web/src/components/InputWrapper.astro
@@ -66,7 +66,7 @@ const hasError = !!error && error.length > 0
{
!!description && (
-
+
)
diff --git a/web/src/constants/karmaTransactionActions.ts b/web/src/constants/karmaTransactionActions.ts
new file mode 100644
index 0000000..39d9b57
--- /dev/null
+++ b/web/src/constants/karmaTransactionActions.ts
@@ -0,0 +1,86 @@
+import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
+import { transformCase } from '../lib/strings'
+
+import type { KarmaTransactionAction } from '@prisma/client'
+
+type KarmaTransactionActionInfo
= {
+ 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 => ({
+ 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: 'manual-adjustment',
+ label: 'Manual adjustment',
+ icon: 'ri:gift-line',
+ },
+ ] as const satisfies KarmaTransactionActionInfo[]
+)
diff --git a/web/src/pages/account/index.astro b/web/src/pages/account/index.astro
index a8ab0b4..78c15c1 100644
--- a/web/src/pages/account/index.astro
+++ b/web/src/pages/account/index.astro
@@ -9,6 +9,7 @@ import BadgeSmall from '../../components/BadgeSmall.astro'
import Button from '../../components/Button.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro'
+import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
@@ -61,6 +62,12 @@ const user = await Astro.locals.banners.try('user', async () => {
action: true,
description: true,
createdAt: true,
+ grantedBy: {
+ select: {
+ name: true,
+ displayName: true,
+ },
+ },
comment: {
select: {
id: true,
@@ -864,24 +871,41 @@ if (!user) return Astro.rewrite('/404')
- {user.karmaTransactions.map((transaction) => (
-
- | {transaction.action} |
- {transaction.description} |
- = 0 ? 'text-green-400' : 'text-red-400'
- )}
- >
- {transaction.points >= 0 && '+'}
- {transaction.points}
- |
-
- {new Date(transaction.createdAt).toLocaleDateString()}
- |
-
- ))}
+ {user.karmaTransactions.map((transaction) => {
+ const actionInfo = getKarmaTransactionActionInfo(transaction.action)
+ return (
+
+ |
+
+
+ {actionInfo.label}
+ {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
+
+ {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
+ by {transaction.grantedBy.displayName || transaction.grantedBy.name}
+
+ )}
+
+ |
+ {transaction.description} |
+ = 0 ? 'text-green-400' : 'text-red-400'
+ )}
+ >
+ {transaction.points >= 0 && '+'}
+ {transaction.points}
+ |
+
+ {new Date(transaction.createdAt).toLocaleDateString()}
+ |
+
+ )
+ })}
diff --git a/web/src/pages/admin/announcements/index.astro b/web/src/pages/admin/announcements/index.astro
new file mode 100644
index 0000000..e8cdf58
--- /dev/null
+++ b/web/src/pages/admin/announcements/index.astro
@@ -0,0 +1,808 @@
+---
+import { Icon } from 'astro-icon/components'
+import { actions, isInputError } from 'astro:actions'
+import { z } from 'astro:schema'
+
+import { adminAnnouncementActions } from '../../../actions/admin/announcement'
+import SortArrowIcon from '../../../components/SortArrowIcon.astro'
+import TimeFormatted from '../../../components/TimeFormatted.astro'
+import Tooltip from '../../../components/Tooltip.astro'
+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: z.enum(['INFO', 'WARNING', 'ALERT']).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 = [
+ { title: { contains: filters.search, mode: 'insensitive' } },
+ { 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()}`
+}
+
+// Get type badge class based on announcement type
+const getTypeBadgeClass = (type: AnnouncementType) => {
+ switch (type) {
+ case 'INFO':
+ return 'bg-blue-900/30 text-blue-400'
+ case 'WARNING':
+ return 'bg-yellow-900/30 text-yellow-400'
+ case 'ALERT':
+ return 'bg-red-900/30 text-red-400'
+ default:
+ return 'bg-zinc-900/30 text-zinc-400'
+ }
+}
+
+// Current date for form min values
+const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm
+
+// Default new announcement
+const newAnnouncement = {
+ title: '',
+ content: '',
+ type: 'INFO' as const,
+ startDate: currentDate,
+ endDate: '',
+ isActive: true,
+}
+
+// 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)
+
+// 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,
+ })
+}
+---
+
+
+
+
Announcement Management
+
+ {announcements.length} announcements
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Announcements List
+
+ Scroll horizontally to see more →
+
+
+
+
+
+
+
+
+
diff --git a/web/src/pages/admin/index.astro b/web/src/pages/admin/index.astro
index bb2124b..49592dc 100644
--- a/web/src/pages/admin/index.astro
+++ b/web/src/pages/admin/index.astro
@@ -43,6 +43,12 @@ const adminLinks: AdminLink[] = [
href: '/admin/service-suggestions',
description: 'Review and manage service suggestions',
},
+ {
+ icon: 'ri:megaphone-line',
+ title: 'Announcements',
+ href: '/admin/announcements',
+ description: 'Manage site announcements',
+ },
]
---
diff --git a/web/src/pages/admin/users/[username].astro b/web/src/pages/admin/users/[username].astro
index 9008650..82da00c 100644
--- a/web/src/pages/admin/users/[username].astro
+++ b/web/src/pages/admin/users/[username].astro
@@ -1,12 +1,20 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
+import { Image } from 'astro:assets'
-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 TimeFormatted from '../../../components/TimeFormatted.astro'
+import { getServiceUserRoleInfo, serviceUserRoles } from '../../../constants/serviceUserRoles'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { prisma } from '../../../lib/prisma'
-import { transformCase } from '../../../lib/strings'
-import { timeAgo } from '../../../lib/timeAgo'
const { username } = Astro.params
@@ -56,6 +64,8 @@ const [user, allServices] = await Astro.locals.banners.tryMany([
select: {
id: true,
name: true,
+ displayName: true,
+ picture: true,
},
},
},
@@ -105,543 +115,321 @@ const [user, allServices] = await Astro.locals.banners.tryMany([
if (!user) return Astro.rewrite('/404')
---
-
-
-
-
User Profile: {user.name}
-
-
- Back to Users
-
+
+
+ {
+ !!user.picture && (
+
+ )
+ }
+
+ {user.displayName ? `${user.displayName} (${user.name})` : user.name}
+
+
+
+ {user.admin && }
+ {user.verified && }
+ {user.verifier && }
+ {user.spammer && }
-
-
- {
- user.picture ? (
-

- ) : (
-
- {user.name.charAt(0) || 'A'}
-
- )
- }
-
-
{user.name}
-
- {
- user.admin && (
-
- admin
-
- )
- }
- {
- user.verified && (
-
- verified
-
- )
- }
- {
- user.verifier && (
-
- verifier
-
- )
- }
-
-
-
-
-
+
+
{
- Astro.locals.user && user.id !== Astro.locals.user.id && (
-
-
- Impersonate
-
+ data-astro-prefetch="tap"
+ icon="ri:spy-line"
+ color="gray"
+ size="sm"
+ label="Impersonate"
+ />
)
}
-
+
+
-
- Internal Notes
+