announcements

This commit is contained in:
pluja
2025-05-19 16:57:10 +00:00
parent 205b6e8ea0
commit 636057f8e0
26 changed files with 1966 additions and 659 deletions

View File

@@ -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 @@
<InputHoneypotTrap name="message" />
<InputSubmitButton />
<InputSubmitButton hideCancel />
</form>
</BaseLayout>
```

3
.gitignore vendored
View File

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

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"
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

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

@@ -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])
}

View File

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

View File

@@ -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 }
},
}),
}

View File

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

View File

@@ -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,
},
})
},

View File

@@ -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 && (
<div class="mb-4 flex flex-col items-center space-y-1">
{announcements.map((announcement) => {
const typeInfo = getTypeInfo(announcement.type)
return (
<div
class={`flex flex-row items-center rounded border ${typeInfo.containerClass} mx-auto w-auto max-w-full px-3 py-2`}
>
<Icon name={typeInfo.icon} class={`size-4 flex-shrink-0 ${typeInfo.titleClass} mr-2`} />
<div class="flex min-w-0 flex-col">
<span class={`text-sm leading-tight font-bold ${typeInfo.titleClass} truncate`}>
{announcement.title}
</span>
<span class={`text-xs ${typeInfo.contentClass} truncate leading-snug [&_a]:underline`}>
<Markdown content={announcement.content} />
</span>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -9,29 +9,33 @@ import InputWrapper from './InputWrapper.astro'
import type { MarkdownString } from '../lib/markdown'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
type Props<Multiple extends boolean = false> = Omit<
ComponentProps<typeof InputWrapper>,
'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 */}
<InputWrapper inputId={inputId} class={cn('@container', className)} {...wrapperProps}>
<div
class={cn(
@@ -62,7 +67,7 @@ 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',
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'
)}
>
<input
@@ -70,9 +75,13 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 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}
/>
<div class="flex items-center gap-1.5">
{option.icon && (

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

@@ -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<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
inputProps?: Omit<HTMLAttributes<'textarea'>, '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 */}
<InputWrapper inputId={inputId} {...wrapperProps}>
<InputWrapper inputId={inputId} required={inputProps?.required} {...wrapperProps}>
<textarea
transition:persist
{...omit(inputProps, ['class', 'id', 'name'])}
id={inputId}
class={cn(
baseInputClassNames.input,
baseInputClassNames.textarea,
inputProps?.class,
hasError && baseInputClassNames.error,
disabled && baseInputClassNames.disabled
!!inputProps?.disabled && baseInputClassNames.disabled
)}
placeholder={placeholder}
required={wrapperProps.required}
disabled={disabled}
name={wrapperProps.name}
autofocus={autofocus}
maxlength={maxlength}
rows={rows}>{value}</textarea
name={wrapperProps.name}>{value}</textarea
>
</InputWrapper>

View File

@@ -66,7 +66,7 @@ const hasError = !!error && error.length > 0
{
!!description && (
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 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} />
</div>
)

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: 'manual-adjustment',
label: 'Manual adjustment',
icon: 'ri:gift-line',
},
] as const satisfies KarmaTransactionActionInfo<KarmaTransactionAction>[]
)

View File

@@ -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')
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.karmaTransactions.map((transaction) => (
<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">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
{new Date(transaction.createdAt).toLocaleDateString()}
</td>
</tr>
))}
{user.karmaTransactions.map((transaction) => {
const actionInfo = getKarmaTransactionActionInfo(transaction.action)
return (
<tr class="hover:bg-night-500/5">
<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 && (
<a
href={`/u/${transaction.grantedBy.name}`}
class="text-day-500 ml-1 hover:underline"
>
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
by {transaction.grantedBy.displayName || transaction.grantedBy.name}
</a>
)}
</span>
</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
{new Date(transaction.createdAt).toLocaleDateString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>

View File

@@ -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,
})
}
---
<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>
<option value="INFO" selected={filters.type === 'INFO'}>Info</option>
<option value="WARNING" selected={filters.type === 'WARNING'}>Warning</option>
<option value="ALERT" selected={filters.type === 'ALERT'}>Alert</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">
<div>
<label for="title" class="block text-xs font-medium text-zinc-400">Title*</label>
<input
type="text"
name="title"
id="title"
required
maxlength="255"
placeholder="Announcement Title"
value={newAnnouncement.title}
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="content" class="block text-xs font-medium text-zinc-400">Content*</label>
<textarea
name="content"
id="content"
required
maxlength="1000"
rows="3"
placeholder="Announcement 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"
>{newAnnouncement.content}</textarea
>
</div>
</div>
<div class="space-y-3">
<div>
<label for="type" class="block text-xs font-medium text-zinc-400">Type*</label>
<select
name="type"
id="type"
required
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="INFO" selected={true}>Info</option>
<option value="WARNING" selected={false}>Warning</option>
<option value="ALERT" selected={false}>Alert</option>
</select>
</div>
<div>
<label for="startDate" class="block text-xs font-medium text-zinc-400">Start Date & Time*</label>
<input
type="datetime-local"
name="startDate"
id="startDate"
required
min={currentDate}
value={newAnnouncement.startDate}
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"
/>
</div>
<div>
<label for="endDate" class="block text-xs font-medium text-zinc-400"
>End Date & Time (Optional)</label
>
<input
type="datetime-local"
name="endDate"
id="endDate"
min={currentDate}
value={newAnnouncement.endDate}
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"
/>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center">
<input
type="checkbox"
name="isActive"
id="isActive"
value="true"
checked={newAnnouncement.isActive}
class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-blue-600 focus:ring-blue-500"
/>
<label for="isActive" class="ml-2 block text-sm text-zinc-400">Active</label>
</div>
<div class="pt-4">
<button
type="submit"
class="inline-flex items-center rounded-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:save-line" class="mr-1 h-4 w-4" />
Create Announcement
</button>
<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>
<!-- Edit Announcement Modal -->
<dialog
id="edit-announcement-modal"
class="m-auto w-full max-w-2xl 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-blue-400">Edit Announcement</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>
<form
method="POST"
action={actions.admin.announcement.update}
id="edit-form"
class="grid gap-4 md:grid-cols-2"
>
<input type="hidden" name="id" id="edit-id" />
<div class="space-y-3 md:col-span-2">
<div>
<label for="edit-title" class="block text-xs font-medium text-zinc-400">Title*</label>
<input
type="text"
name="title"
id="edit-title"
required
maxlength="255"
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"
/>
</div>
<div>
<label for="edit-content" class="block text-xs font-medium text-zinc-400">Content*</label>
<textarea
name="content"
id="edit-content"
required
maxlength="1000"
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 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
></textarea>
</div>
</div>
<div class="space-y-3">
<div>
<label for="edit-type" class="block text-xs font-medium text-zinc-400">Type*</label>
<select
name="type"
id="edit-type"
required
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="INFO" selected={true}>Info</option>
<option value="WARNING" selected={false}>Warning</option>
<option value="ALERT" selected={false}>Alert</option>
</select>
</div>
<div>
<label for="edit-startDate" class="block text-xs font-medium text-zinc-400"
>Start Date & Time*</label
>
<input
type="datetime-local"
name="startDate"
id="edit-startDate"
required
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"
/>
</div>
<div>
<label for="edit-endDate" class="block text-xs font-medium text-zinc-400"
>End Date & Time (Optional)</label
>
<input
type="datetime-local"
name="endDate"
id="edit-endDate"
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"
/>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center">
<input
type="checkbox"
name="isActive"
id="edit-isActive"
value="true"
class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-blue-600 focus:ring-blue-500"
/>
<label for="edit-isActive" class="ml-2 block text-sm text-zinc-400">Active</label>
</div>
<div class="pt-4">
<button
type="submit"
class="inline-flex items-center rounded-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:save-line" class="mr-1 h-4 w-4" />
Update Announcement
</button>
<button
type="button"
class="close-modal 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>
</dialog>
<!-- 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) => (
<tr class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm">
<div class="font-medium text-zinc-200">{announcement.title}</div>
<div class="mt-1 line-clamp-1 text-xs 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 ${getTypeBadgeClass(announcement.type)}`}
>
{announcement.type}
</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"
data-announcement={JSON.stringify(announcement)}
>
<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>
))
}
</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;
}
</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')
})
// Edit Modal functionality
const editModal = document.getElementById('edit-announcement-modal') as HTMLDialogElement
const editButtons = document.querySelectorAll('.edit-button')
const editForm = document.getElementById('edit-form') as HTMLFormElement
editButtons.forEach((button) => {
button.addEventListener('click', () => {
const announcementData = JSON.parse(button.getAttribute('data-announcement') || '{}')
const idInput = document.getElementById('edit-id') as HTMLInputElement
const titleInput = document.getElementById('edit-title') as HTMLInputElement
const contentInput = document.getElementById('edit-content') as HTMLTextAreaElement
const typeSelect = document.getElementById('edit-type') as HTMLSelectElement
const startDateInput = document.getElementById('edit-startDate') as HTMLInputElement
const endDateInput = document.getElementById('edit-endDate') as HTMLInputElement
const isActiveCheckbox = document.getElementById('edit-isActive') as HTMLInputElement
idInput.value = announcementData.id.toString()
titleInput.value = announcementData.title
contentInput.value = announcementData.content
typeSelect.value = announcementData.type
// Format dates for the date inputs (YYYY-MM-DDThh:mm)
const formatDateForInput = (dateString: string | null) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toISOString().slice(0, 16)
}
startDateInput.value = formatDateForInput(announcementData.startDate) ?? ''
endDateInput.value = formatDateForInput(announcementData.endDate) ?? ''
isActiveCheckbox.checked = announcementData.isActive
editModal?.showModal()
})
})
// 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
deleteButtons.forEach((button) => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-id')
const deleteIdInput = document.getElementById('delete-id') as HTMLInputElement
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

@@ -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',
},
]
---

View File

@@ -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')
---
<BaseLayout pageTitle={`User: ${user.name}`} htmx>
<div class="container mx-auto max-w-2xl py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="font-title text-2xl text-green-400">User Profile: {user.name}</h1>
<a
href="/admin/users"
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:arrow-left-line" class="mr-1 size-4" />
Back to Users
</a>
<BaseLayout
pageTitle={`User: ${user.name}`}
htmx
widthClassName="max-w-screen-lg"
className={{ main: 'space-y-24' }}
>
<div class="mt-12">
{
!!user.picture && (
<Image
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"
>
{user.displayName ? `${user.displayName} (${user.name})` : user.name}
</h1>
<div class="mb-4 flex flex-wrap justify-center gap-2">
{user.admin && <BadgeSmall color="green" text="Admin" icon="ri:shield-check-fill" />}
{user.verified && <BadgeSmall color="cyan" text="Verified" icon="ri:verified-badge-fill" />}
{user.verifier && <BadgeSmall color="blue" text="Verifier" icon="ri:check-fill" />}
{user.spammer && <BadgeSmall color="red" text="Spammer" icon="ri:alert-fill" />}
</div>
<section
class="rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<div class="mb-6 flex items-center gap-4">
{
user.picture ? (
<img src={user.picture} alt="" class="h-16 w-16 rounded-full ring-2 ring-green-500/30" />
) : (
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10 ring-2 ring-green-500/30">
<span class="font-title text-2xl text-green-500">{user.name.charAt(0) || 'A'}</span>
</div>
)
}
<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>
<form
method="POST"
action={actions.admin.user.update}
class="space-y-4 border-t border-green-500/30 pt-6"
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={user.id} />
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="name"> Name </label>
<input
transition:persist
type="text"
name="name"
id="name"
value={user.name}
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>
<label class="font-title mb-2 block text-sm text-green-500" for="displayName"> Display Name </label>
<input
transition:persist
type="text"
name="displayName"
maxlength={50}
id="displayName"
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>
<label class="font-title mb-2 block text-sm text-green-500" for="link"> Link </label>
<input
transition:persist
type="url"
name="link"
id="link"
value={user.link ?? ''}
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>
<label class="font-title mb-2 block text-sm text-green-500" for="picture">
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"
id="verifiedLink"
value={user.verifiedLink}
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 class="flex gap-4 pt-4">
<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:save-line" class="mr-2 size-4" />
Save
</button>
</div>
</form>
<div class="flex justify-center gap-2">
<Button
as="a"
href={`/u/${user.name}`}
icon="ri:global-line"
color="success"
shadow
size="sm"
label="View public profile"
/>
{
Astro.locals.user && user.id !== Astro.locals.user.id && (
<a
Astro.locals.user && user.id !== Astro.locals.user.id && !user.admin && (
<Button
as="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>
data-astro-prefetch="tap"
icon="ri:spy-line"
color="gray"
size="sm"
label="Impersonate"
/>
)
}
</section>
</div>
</div>
<section
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 mb-4 text-lg text-green-500">Internal Notes</h2>
<form method="POST" action={actions.admin.user.update} enctype="multipart/form-data" class="space-y-2">
<h2 class="font-title text-center text-3xl leading-none font-bold">Edit profile</h2>
{
user.internalNotes.length === 0 ? (
<p class="text-gray-400">No internal notes yet.</p>
) : (
<div class="space-y-4">
{user.internalNotes.map((note) => (
<div data-note-id={note.id} class="rounded-lg border border-green-500/30 bg-black/50 p-4">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-title text-xs text-gray-200">
{note.addedByUser ? note.addedByUser.name : 'System'}
</span>
<span class="font-title text-xs text-gray-500">
{transformCase(timeAgo.format(note.createdAt, 'twitter-minute-now'), 'sentence')}
</span>
</div>
<input type="hidden" name="id" value={user.id} />
<div class="flex items-center gap-2">
<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">
<input
type="checkbox"
class="peer sr-only"
data-edit-note-checkbox
data-note-id={note.id}
/>
<Icon name="ri:edit-line" class="size-3" />
</label>
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<InputText
label="Name"
name="name"
error={updateInputErrors.name}
inputProps={{ value: user.name, required: true }}
/>
<form method="POST" action={actions.admin.user.internalNotes.delete} class="inline-flex">
<input type="hidden" name="noteId" value={note.id} />
<button
type="submit"
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>
</form>
</div>
<InputText
label="Display Name"
name="displayName"
error={updateInputErrors.displayName}
inputProps={{ value: user.displayName ?? '', maxlength: 50 }}
/>
<InputText
label="Link"
name="link"
error={updateInputErrors.link}
inputProps={{ value: user.link ?? '', type: 'url' }}
/>
<InputText
label="Verified Link"
name="verifiedLink"
error={updateInputErrors.verifiedLink}
inputProps={{ value: user.verifiedLink, type: 'url' }}
/>
</div>
<InputImageFile
label="Profile Picture Upload"
name="pictureFile"
value={user.picture}
error={updateInputErrors.pictureFile}
square
description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF, JXL. Max size: 5MB."
/>
<InputCardGroup
name="role"
label="Role"
options={[
{ label: 'Admin', value: 'admin', icon: 'ri:shield-check-fill' },
{ label: 'Verified', value: 'verified', icon: 'ri:verified-badge-fill', disabled: true },
{ label: 'Verifier', value: 'verifier', icon: 'ri:check-fill' },
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
]}
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.role}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
<section class="space-y-2">
<h2 class="font-title text-center text-3xl leading-none font-bold">Internal Notes</h2>
{
user.internalNotes.length === 0 ? (
<p class="text-day-300 text-center">No internal notes yet.</p>
) : (
<div class="space-y-4">
{user.internalNotes.map((note) => (
<div data-note-id={note.id} class="border-night-400 bg-night-600 rounded-lg border p-4 shadow-sm">
<div class="mb-1 flex items-center justify-between gap-4">
<div class="flex items-center gap-1">
{!!note.addedByUser?.picture && (
<Image
src={note.addedByUser.picture}
alt=""
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>
<TimeFormatted date={note.createdAt} hourPrecision class="text-day-300 ms-1 text-sm" />
</div>
<div data-note-content>
<p class="font-title text-sm whitespace-pre-wrap text-gray-300">{note.content}</p>
</div>
<form
method="POST"
action={actions.admin.user.internalNotes.update}
data-note-edit-form
class="mt-2 hidden"
>
<input type="hidden" name="noteId" value={note.id} />
<textarea
name="content"
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"
data-trim-content
>
{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
<div class="flex items-center">
<label class="text-day-300 hover:text-day-100 cursor-pointer p-1 transition-colors">
<input
type="checkbox"
class="peer sr-only"
data-edit-note-checkbox
data-note-id={note.id}
/>
<Icon name="ri:edit-line" class="size-5" />
</label>
<form method="POST" action={actions.admin.user.internalNotes.delete} class="contents">
<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
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>
))}
</div>
)
}
<form method="POST" action={actions.admin.user.internalNotes.add} class="mt-4 space-y-2">
<input type="hidden" name="userId" value={user.id} />
<textarea
name="content"
placeholder="Add a note..."
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" />
Add
</button>
</form>
</section>
<div data-note-content>
<p class="text-day-200 whitespace-pre-wrap">{note.content}</p>
</div>
<section
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 mb-4 text-lg text-green-500">Service Affiliations</h2>
<form
method="POST"
action={actions.admin.user.internalNotes.update}
data-note-edit-form
class="mt-4 hidden space-y-4"
>
<input type="hidden" name="noteId" value={note.id} />
<InputTextArea
label="Note Content"
name="content"
value={note.content}
inputProps={{ class: 'bg-night-700' }}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</div>
))}
</div>
)
}
{
user.serviceAffiliations.length === 0 ? (
<p class="text-gray-400">No service affiliations yet.</p>
) : (
<div class="space-y-4">
{user.serviceAffiliations.map((affiliation) => (
<div class="flex items-center justify-between rounded-lg border border-green-500/30 bg-black/50 p-4">
<div>
<div class="flex items-center gap-2">
<a
href={`/service/${affiliation.service.slug}`}
class="font-title text-sm text-green-400 hover:underline"
>
{affiliation.service.name}
</a>
<span
class={`font-title rounded-full px-2 py-0.5 text-xs ${
affiliation.role === 'OWNER'
? 'border border-purple-500/50 bg-purple-500/20 text-purple-400'
: 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 method="POST" action={actions.admin.user.internalNotes.add} class="mt-10 space-y-2">
<h3 class="font-title mb-0 text-center text-xl leading-none font-bold">Add Note</h3>
<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>
</section>
<section class="space-y-2">
<h2 class="font-title text-center text-3xl leading-none font-bold">Service Affiliations</h2>
{
user.serviceAffiliations.length === 0 ? (
<p class="text-day-200 text-center">No service affiliations yet.</p>
) : (
<div class="grid grid-cols-2 gap-x-4">
{user.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<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
href={`/service/${affiliation.service.slug}`}
class="text-day-100 hover:text-day-50 flex items-center gap-1 leading-none font-medium hover:underline"
>
<span>{affiliation.service.name}</span>
<Icon name="ri:external-link-line" class="text-day-400 size-3.5" />
</a>
<TimeFormatted
date={affiliation.createdAt}
hourPrecision
class="text-day-400 ms-auto me-2 text-sm"
/>
<form
method="POST"
action={actions.admin.user.serviceAffiliations.remove}
class="inline-flex"
data-astro-reload
class="contents"
>
<input type="hidden" name="id" value={affiliation.id} />
<button
type="submit"
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 type="submit" class="text-day-300 transition-colors hover:text-red-400">
<Icon name="ri:delete-bin-line" class="size-5" />
</button>
</form>
</div>
))}
</div>
)
}
<form
method="POST"
action={actions.admin.user.serviceAffiliations.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} />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="serviceId"> Service </label>
<select
name="serviceId"
id="serviceId"
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"
>
<option value="">Select a service</option>
{allServices.map((service) => <option value={service.id}>{service.name}</option>)}
</select>
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="role"> Role </label>
<select
name="role"
id="role"
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"
>
<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>
<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>
</section>
<section
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"
<form
method="POST"
action={actions.admin.user.serviceAffiliations.add}
data-astro-reload
class="mt-10 space-y-2"
>
<h2 class="font-title mb-4 text-lg text-green-500">Add Karma Transaction</h2>
<h3 class="font-title mb-0 text-center text-xl leading-none font-bold">Add Affiliation</h3>
<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">
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="points"> Points </label>
<input
type="number"
name="points"
id="points"
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>
<InputSelect
name="serviceId"
label="Service"
options={allServices.map((service) => ({
label: service.name,
value: service.id.toString(),
}))}
selectProps={{ required: true }}
/>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="action"> Action </label>
<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>
<InputCardGroup
name="role"
label="Role"
options={serviceUserRoles.map((role) => ({
label: role.label,
value: role.value,
icon: role.icon,
}))}
required
cardSize="sm"
iconSize="sm"
/>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="description"> Description </label>
<textarea
name="description"
id="description"
required
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>
<InputSubmitButton label="Add Affiliation" icon="ri:link" hideCancel />
</form>
</section>
<div>
<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>
</section>
</div>
<form method="POST" action={actions.admin.user.karmaTransactions.add} data-astro-reload class="space-y-2">
<h2 class="font-title text-center text-3xl leading-none font-bold">Grant/Remove Karma</h2>
<input type="hidden" name="userId" value={user.id} />
<InputText
label="Points"
name="points"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ type: 'number', required: true }}
/>
<InputTextArea
label="Description"
name="description"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ required: true }}
/>
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
</form>
</BaseLayout>
<script>
@@ -656,7 +444,6 @@ if (!user) return Astro.rewrite('/404')
const noteContent = noteDiv.querySelector<HTMLDivElement>('[data-note-content]')
const editForm = noteDiv.querySelector<HTMLFormElement>('[data-note-edit-form]')
const cancelButton = noteDiv.querySelector<HTMLButtonElement>('[data-cancel-edit]')
if (noteContent && editForm) {
if (target.checked) {
@@ -667,23 +454,7 @@ if (!user) return Astro.rewrite('/404')
editForm.classList.add('hidden')
}
}
if (cancelButton) {
cancelButton.addEventListener('click', () => {
target.checked = false
noteContent?.classList.remove('hidden')
editForm?.classList.add('hidden')
})
}
})
})
})
</script>
<script>
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLTextAreaElement>('[data-trim-content]').forEach((textarea) => {
textarea.value = textarea.value.trim()
})
})
</script>

View File

@@ -322,6 +322,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<Tooltip
as="a"
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"
text="Impersonate"
>

View File

@@ -4,6 +4,7 @@ import { z } from 'astro:schema'
import { groupBy, orderBy } from 'lodash-es'
import seedrandom from 'seedrandom'
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import Button from '../components/Button.astro'
import Pagination from '../components/Pagination.astro'
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro'
@@ -501,7 +502,18 @@ const filtersOptions = {
export type ServicesFiltersOptions = typeof filtersOptions
//
const currentDate = new Date()
const activeAnnouncements = await prisma.announcement.findMany({
where: {
isActive: true,
startDate: { lte: currentDate },
OR: [{ endDate: null }, { endDate: { gt: currentDate } }],
},
orderBy: [
{ type: 'desc' }, // ALERT first, then WARNING, then INFO
{ createdAt: 'desc' },
],
})
---
<BaseLayout
@@ -517,6 +529,9 @@ export type ServicesFiltersOptions = typeof filtersOptions
},
]}
>
<!-- Display announcements at the top of the page -->
<AnnouncementBanner announcements={activeAnnouncements} />
<div class="flex flex-col gap-4 sm:flex-row sm:gap-8">
<div class="flex items-stretch sm:hidden">
{

View File

@@ -88,8 +88,11 @@ if (!service) return Astro.rewrite('/404')
label="Note for Moderators"
name="notes"
value={params.notes}
rows={10}
placeholder="List the changes you want us to make to the service. Example: 'Add X, Y and Z attributes' 'Monero is accepted'. Provide supporting evidence."
inputProps={{
rows: 10,
placeholder:
'List the changes you want us to make to the service. Example: "Add X, Y and Z attributes" "Monero is accepted". Provide supporting evidence.',
}}
error={inputErrors.notes}
/>

View File

@@ -184,31 +184,40 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
label="Description"
name="description"
id="description"
required
maxlength={SUGGESTION_DESCRIPTION_MAX_LENGTH}
inputProps={{
required: true,
maxlength: SUGGESTION_DESCRIPTION_MAX_LENGTH,
}}
error={inputErrors.description}
/>
<InputTextArea
label="Service URLs"
name="serviceUrls"
required
placeholder="https://example1.com\nhttps://example2.org"
inputProps={{
required: true,
placeholder: 'https://example1.com\nhttps://example2.org',
}}
error={inputErrors.serviceUrls}
/>
<InputTextArea
label="Terms of Service URLs"
name="tosUrls"
required
placeholder="https://example1.com/tos\nhttps://example2.org/terms"
inputProps={{
required: true,
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms',
}}
error={inputErrors.tosUrls}
/>
<InputTextArea
label="Onion URLs"
name="onionUrls"
placeholder="http://example1.onion\nhttp://example2.onion"
inputProps={{
required: true,
placeholder: 'http://example1.onion\nhttp://example2.onion',
}}
error={inputErrors.onionUrls}
/>
@@ -276,7 +285,9 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
name="notes"
id="notes"
error={inputErrors.notes}
maxlength={SUGGESTION_NOTES_MAX_LENGTH}
inputProps={{
maxlength: SUGGESTION_NOTES_MAX_LENGTH,
}}
/>
<Captcha action={actions.serviceSuggestion.createService} />

View File

@@ -1,13 +1,17 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { Picture } from 'astro:assets'
import { sortBy } from 'lodash-es'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
import { karmaUnlocks } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
@@ -57,6 +61,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,
@@ -140,6 +150,21 @@ const user = await Astro.locals.banners.try('user', async () => {
},
orderBy: [{ role: 'asc' }, { service: { name: 'asc' } }],
},
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
addedByUser: {
select: {
name: true,
displayName: true,
picture: true,
},
},
},
orderBy: { createdAt: 'desc' },
},
},
})
)
@@ -255,6 +280,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<Tooltip
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=${encodeURIComponent(Astro.url.href)}`}
data-astro-prefetch="tap"
class="inline-flex items-center gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-sm text-amber-400 shadow-xs transition-colors duration-200 hover:bg-amber-500/20 focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
text="Impersonate (admin)"
>
@@ -453,6 +479,58 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div>
</section>
<AdminOnly>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header>
<h2 class="font-title text-day-200 mb-6 text-center text-2xl font-bold">
Internal Notes <span class="text-day-400 text-sm font-normal">(Admin only)</span>
</h2>
</header>
{
user.internalNotes.length === 0 ? (
<p class="text-day-400 text-center">No internal notes yet.</p>
) : (
<div class="space-y-4">
{user.internalNotes.map((note) => (
<div class="border-night-400 bg-night-600 rounded-lg border p-4">
<div class="mb-2 flex items-center gap-2">
{!!note.addedByUser?.picture && (
<img
src={note.addedByUser.picture}
alt=""
width={24}
height={24}
class="size-6 rounded-full"
/>
)}
<span class="text-day-100 font-bold">
{note.addedByUser ? (note.addedByUser.displayName ?? note.addedByUser.name) : 'System'}
</span>
<span class="text-day-400 text-sm">
<TimeFormatted date={note.createdAt} hourPrecision />
</span>
</div>
<div class="text-day-200 whitespace-pre-wrap">{note.content}</div>
</div>
))}
</div>
)
}
<form
method="POST"
action={actions.admin.user.internalNotes.add}
data-note-edit-form
class="mt-4 space-y-4"
>
<input type="hidden" name="userId" value={user.id} />
<InputTextArea label="Add a note" name="content" inputProps={{ class: 'bg-night-700' }} />
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</section>
</AdminOnly>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
@@ -879,29 +957,46 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.karmaTransactions.map((transaction) => (
<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">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
))}
{user.karmaTransactions.map((transaction) => {
const actionInfo = getKarmaTransactionActionInfo(transaction.action)
return (
<tr class="hover:bg-night-500/5">
<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 && (
<a
href={`/u/${transaction.grantedBy.name}`}
class="text-day-500 ml-1 hover:underline"
>
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
by {transaction.grantedBy.displayName || transaction.grantedBy.name}
</a>
)}
</span>
</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>