Compare commits

...

14 Commits

Author SHA1 Message Date
pluja
cdfdcfc122 Release 2025-05-23-R3WZ 2025-05-23 11:52:16 +00:00
pluja
f4525e3d32 Release 2025-05-22-SwZ1 2025-05-22 23:07:55 +00:00
pluja
ecc8f67fc4 Release 2025-05-22-XDxe 2025-05-22 22:58:18 +00:00
pluja
72c238a4dc Release 2025-05-22-16vM 2025-05-22 22:38:41 +00:00
pluja
d79bedf219 Release 2025-05-22-5X5Q 2025-05-22 19:43:20 +00:00
pluja
2362d2cc73 Release 2025-05-22-Uvv4 2025-05-22 19:19:07 +00:00
pluja
a69c0aeed4 Release 2025-05-22-GmO6 2025-05-22 11:10:18 +00:00
pluja
ed86f863e3 Release 2025-05-21-MXjT 2025-05-21 14:31:33 +00:00
pluja
845aa1185c Release 2025-05-21-AQ5C 2025-05-21 07:03:39 +00:00
pluja
17b3642f7e Update favicon 2025-05-20 11:27:55 +00:00
pluja
d64268d396 fix logout issue 2025-05-20 11:12:55 +00:00
pluja
9c289753dd fix generate 2025-05-20 11:00:28 +00:00
pluja
8bdbe8ea36 small updates 2025-05-20 10:29:03 +00:00
pluja
af7ebe813b announcements style 2025-05-20 10:20:09 +00:00
79 changed files with 2400 additions and 1765 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,18 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
I2P_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
ONION_ADDRESS: envField.string({
context: 'server',
access: 'public',
url: true,
optional: false,
}),
REDIS_URL: envField.string({ REDIS_URL: envField.string({
context: 'server', context: 'server',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -135,6 +135,7 @@ enum NotificationType {
SUGGESTION_MESSAGE SUGGESTION_MESSAGE
SUGGESTION_STATUS_CHANGE SUGGESTION_STATUS_CHANGE
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code. // KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
KARMA_CHANGE
/// Marked as spammer, promoted to admin, etc. /// Marked as spammer, promoted to admin, etc.
ACCOUNT_STATUS_CHANGE ACCOUNT_STATUS_CHANGE
EVENT_CREATED EVENT_CREATED
@@ -207,6 +208,8 @@ model Notification {
aboutCommentStatusChange CommentStatusChange? aboutCommentStatusChange CommentStatusChange?
aboutServiceVerificationStatusChange ServiceVerificationStatusChange? aboutServiceVerificationStatusChange ServiceVerificationStatusChange?
aboutSuggestionStatusChange ServiceSuggestionStatusChange? aboutSuggestionStatusChange ServiceSuggestionStatusChange?
aboutKarmaTransaction KarmaTransaction? @relation(fields: [aboutKarmaTransactionId], references: [id])
aboutKarmaTransactionId Int?
@@index([userId]) @@index([userId])
@@index([read]) @@index([read])
@@ -229,6 +232,7 @@ model NotificationPreferences {
enableOnMyCommentStatusChange Boolean @default(true) enableOnMyCommentStatusChange Boolean @default(true)
enableAutowatchMyComments Boolean @default(true) enableAutowatchMyComments Boolean @default(true)
enableNotifyPendingRepliesOnWatch Boolean @default(false) enableNotifyPendingRepliesOnWatch Boolean @default(false)
karmaNotificationThreshold Int @default(10)
onEventCreatedForServices Service[] @relation("onEventCreatedForServices") onEventCreatedForServices Service[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices") onRootCommentCreatedForServices Service[] @relation("onRootCommentCreatedForServices")
@@ -393,12 +397,15 @@ model Service {
} }
model ServiceContactMethod { model ServiceContactMethod {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
label String /// Only include it if you want to override the formatted value.
label String?
/// Including the protocol (e.g. "mailto:", "tel:", "https://") /// Including the protocol (e.g. "mailto:", "tel:", "https://")
value String value String
iconId String
info String createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade) services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade)
serviceId Int serviceId Int
} }
@@ -522,6 +529,7 @@ model KarmaTransaction {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull) grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull)
grantedByUserId Int? grantedByUserId Int?
Notification Notification[]
@@index([createdAt]) @@index([createdAt])
@@index([userId]) @@index([userId])
@@ -613,9 +621,10 @@ model ServiceUser {
model Announcement { model Announcement {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String
content String content String
type AnnouncementType type AnnouncementType
link String?
linkText String?
startDate DateTime startDate DateTime
endDate DateTime? endDate DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 692 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 566 B

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { type Prisma, type PrismaClient, type AnnouncementType } from '@prisma/client' import { type Prisma, type PrismaClient } from '@prisma/client'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { z } from 'zod' import { z } from 'zod'
@@ -9,9 +9,10 @@ const prisma = prismaInstance as PrismaClient
const selectAnnouncementReturnFields = { const selectAnnouncementReturnFields = {
id: true, id: true,
title: true,
content: true, content: true,
type: true, type: true,
link: true,
linkText: true,
startDate: true, startDate: true,
endDate: true, endDate: true,
isActive: true, isActive: true,
@@ -24,12 +25,18 @@ export const adminAnnouncementActions = {
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
content: z content: z
.string() .string()
.min(1, 'Content is required') .min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'), .max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']), type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(), endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true), isActive: z.coerce.boolean().default(true),
@@ -37,8 +44,13 @@ export const adminAnnouncementActions = {
handler: async (input) => { handler: async (input) => {
const announcement = await prisma.announcement.create({ const announcement = await prisma.announcement.create({
data: { data: {
...input, content: input.content,
endDate: input.endDate || null, type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
}, },
select: selectAnnouncementReturnFields, select: selectAnnouncementReturnFields,
}) })
@@ -52,12 +64,18 @@ export const adminAnnouncementActions = {
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
id: z.coerce.number().int().positive(), 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 content: z
.string() .string()
.min(1, 'Content is required') .min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'), .max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']), type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(), endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true), isActive: z.coerce.boolean().default(true),
@@ -82,8 +100,13 @@ export const adminAnnouncementActions = {
const updatedAnnouncement = await prisma.announcement.update({ const updatedAnnouncement = await prisma.announcement.update({
where: { id: announcement.id }, where: { id: announcement.id },
data: { data: {
...input, content: input.content,
endDate: input.endDate || null, type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
}, },
select: selectAnnouncementReturnFields, select: selectAnnouncementReturnFields,
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions' import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions'
import { Image } from 'astro:assets'
import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha' import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'

View File

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

View File

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

View File

@@ -110,16 +110,18 @@ if (!user || !user.admin || !user.verifier) return null
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING' comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400' ? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400' : 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)} )}
data-action="status" data-action="status"
data-value={comment.status === 'PENDING' ? 'APPROVED' : 'PENDING'} data-value={comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'APPROVED'
: 'PENDING'}
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.status === 'PENDING' ? 'Approve' : 'Pending'} {comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</button> </button>
<button <button

View File

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

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import QRCode from 'qrcode' import * as QRCode from 'qrcode'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -26,7 +26,7 @@ function getAddressURI(address: string, cryptoName: string) {
} }
const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName), { const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName), {
width: 128, width: 256,
margin: 1, margin: 1,
color: { color: {
dark: '#ffffff', dark: '#ffffff',
@@ -41,13 +41,18 @@ const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName),
<Icon name={cryptoIcon} class="size-6 text-white" /> <Icon name={cryptoIcon} class="size-6 text-white" />
<span class="font-title text-base font-semibold text-white">{cryptoName}</span> <span class="font-title text-base font-semibold text-white">{cryptoName}</span>
</div> </div>
<p class="px-7 font-mono text-base leading-snug tracking-wide break-all text-white"> <p
<span class="cursor-pointer px-7 font-mono text-base leading-snug tracking-wide break-all text-white select-all"
class="cursor-pointer select-all" >
set:html={address.length > 12 {
? `<span class="font-bold mr-0.5 text-green-500">${address.substring(0, 6)}</span>${address.substring(6, address.length - 6)}<span class="font-bold ml-0.5 text-green-500">${address.substring(address.length - 6)}</span>` address.length > 12
: `<span class="font-bold">${address}</span>`} ? [
/> <span class="mr-0.5 font-bold text-green-500">{address.substring(0, 6)}</span>,
address.substring(6, address.length - 6),
<span class="ml-0.5 font-bold text-green-500">{address.substring(address.length - 6)}</span>,
]
: address
}
</p> </p>
</div> </div>
<img <img
@@ -55,6 +60,6 @@ const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName),
alt={`${cryptoName} QR code`} alt={`${cryptoName} QR code`}
width="128" width="128"
height="128" height="128"
class="mr-4 hidden size-36 rounded sm:block" class="mr-4 hidden size-32 rounded sm:block"
/> />
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
hasError && baseInputClassNames.error, hasError && baseInputClassNames.error,
!!inputProps?.disabled && baseInputClassNames.disabled !!inputProps?.disabled && baseInputClassNames.disabled
)} )}
name={wrapperProps.name}>{value}</textarea name={wrapperProps.name}
> set:text={value}
/>
</InputWrapper> </InputWrapper>

View File

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

View File

@@ -4,6 +4,7 @@ import type { ComponentProps } from 'react'
import { Picture } from 'astro:assets' import { Picture } from 'astro:assets'
import defaultServiceImage from '../assets/fallback-service-image.jpg' import defaultServiceImage from '../assets/fallback-service-image.jpg'
import { cn } from '../lib/cn'
const fallbackImages = { const fallbackImages = {
service: defaultServiceImage, service: defaultServiceImage,
@@ -20,6 +21,7 @@ const {
fallback = undefined as keyof typeof fallbackImages | undefined, fallback = undefined as keyof typeof fallbackImages | undefined,
height, height,
width, width,
pictureAttributes,
...props ...props
} = Astro.props } = Astro.props
@@ -36,6 +38,10 @@ const fallbackImage = fallback ? fallbackImages[fallback] : undefined
formats={formats} formats={formats}
height={height ? Number(height) * 2 : undefined} height={height ? Number(height) * 2 : undefined}
width={width ? Number(width) * 2 : undefined} width={width ? Number(width) * 2 : undefined}
pictureAttributes={{
...pictureAttributes,
class: cn('shrink-0', pictureAttributes?.class),
}}
{...(props as any)} {...(props as any)}
/> />
) )

View File

@@ -9,10 +9,11 @@ type Props = HTMLAttributes<'div'> & {
value: HTMLAttributes<'input'>['value'] value: HTMLAttributes<'input'>['value']
label: string label: string
}[] }[]
inputProps?: Omit<HTMLAttributes<'input'>, 'checked' | 'class' | 'name' | 'type' | 'value'>
selectedValue?: string | null selectedValue?: string | null
} }
const { name, options, selectedValue, class: className, ...rest } = Astro.props const { name, options, selectedValue, inputProps, class: className, ...rest } = Astro.props
--- ---
<div <div
@@ -31,6 +32,7 @@ const { name, options, selectedValue, class: className, ...rest } = Astro.props
value={option.value} value={option.value}
checked={selectedValue === option.value} checked={selectedValue === option.value}
class="peer sr-only" class="peer sr-only"
{...inputProps}
/> />
<span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500"> <span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500">
{option.label} {option.label}

View File

@@ -88,12 +88,26 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white"> <h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
{name}{ {name}{
statusIcon && ( statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-my-2 shrink-0"> <Tooltip
<Icon text={statusIcon.label}
is:inline={inlineIcons} position="right"
name={statusIcon.icon} class="-my-2 shrink-0 whitespace-nowrap"
class={cn('inline-block size-6 shrink-0 rounded-lg p-1', statusIcon.classNames.icon)} enabled={verificationStatus !== 'VERIFICATION_FAILED'}
/> >
{[
<Icon
is:inline={inlineIcons}
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
statusIcon.classNames.icon
)}
/>,
verificationStatus === 'VERIFICATION_FAILED' && (
<span class="text-sm font-bold text-red-500">SCAM</span>
),
]}
</Tooltip> </Tooltip>
) )
} }

View File

@@ -34,7 +34,8 @@ const {
<form <form
method="GET" method="GET"
hx-get={Astro.url.pathname} hx-get={Astro.url.pathname}
hx-trigger={`input delay:500ms from:input[type='text'], keyup[key=='Enter'], change from:input:not([data-show-more-input], #${showFiltersId}), change from:select`} hx-trigger={// NOTE: I need to do the [data-trigger-on-change] hack, because HTMX doesnt suport the :not() selector, and I need to exclude the Show more buttons, and not trigger for inputs outside the form
"input delay:500ms from:([data-services-filters-form] input[type='text']), keyup[key=='Enter'], change from:([data-services-filters-form] [data-trigger-on-change])"}
hx-target={`#${searchResultsId}`} hx-target={`#${searchResultsId}`}
hx-select={`#${searchResultsId}`} hx-select={`#${searchResultsId}`}
hx-push-url="true" hx-push-url="true"
@@ -44,7 +45,11 @@ const {
.filter((verification) => verification.default) .filter((verification) => verification.default)
.map((verification) => verification.slug)} .map((verification) => verification.slug)}
{...formProps} {...formProps}
class={cn('', className)} class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
className
)}
> >
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="font-title text-xl text-green-500">FILTERS</h2> <h2 class="font-title text-xl text-green-500">FILTERS</h2>
@@ -64,6 +69,7 @@ const {
name="sort" name="sort"
id="sort" id="sort"
class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden" class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
data-trigger-on-change
> >
{ {
options.sort.map((option) => ( options.sort.map((option) => (
@@ -108,6 +114,7 @@ const {
name="categories" name="categories"
value={category.slug} value={category.slug}
checked={category.checked} checked={category.checked}
data-trigger-on-change
/> />
<span class="peer-checked:font-bold"> <span class="peer-checked:font-bold">
{category.name} {category.name}
@@ -121,13 +128,7 @@ const {
{ {
options.categories.filter((category) => category.showAlways).length < options.categories.length && ( options.categories.filter((category) => category.showAlways).length < options.categories.length && (
<> <>
<input <input type="checkbox" id="show-more-categories" class="peer sr-only" hx-preserve />
type="checkbox"
id="show-more-categories"
class="peer sr-only"
hx-preserve
data-show-more-input
/>
<label <label
for="show-more-categories" for="show-more-categories"
class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2" class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
@@ -158,6 +159,7 @@ const {
name="verification" name="verification"
value={verification.slug} value={verification.slug}
checked={filters.verification.includes(verification.value)} checked={filters.verification.includes(verification.value)}
data-trigger-on-change
/> />
<Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} /> <Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} />
<span class="peer-checked:font-bold">{verification.labelShort}</span> <span class="peer-checked:font-bold">{verification.labelShort}</span>
@@ -176,6 +178,9 @@ const {
options={options.modeOptions} options={options.modeOptions}
selectedValue={filters['currency-mode']} selectedValue={filters['currency-mode']}
class="-my-2" class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/> />
</div> </div>
<div> <div>
@@ -188,6 +193,7 @@ const {
name="currencies" name="currencies"
value={currency.slug} value={currency.slug}
checked={filters.currencies?.some((id) => id === currency.id)} checked={filters.currencies?.some((id) => id === currency.id)}
data-trigger-on-change
/> />
<Icon name={currency.icon} class="size-4" /> <Icon name={currency.icon} class="size-4" />
<span class="peer-checked:font-bold">{currency.name}</span> <span class="peer-checked:font-bold">{currency.name}</span>
@@ -210,6 +216,7 @@ const {
name="networks" name="networks"
value={network.slug} value={network.slug}
checked={filters.networks?.some((slug) => slug === network.slug)} checked={filters.networks?.some((slug) => slug === network.slug)}
data-trigger-on-change
/> />
<Icon name={network.icon} class="size-4" /> <Icon name={network.icon} class="size-4" />
<span class="peer-checked:font-bold">{network.name}</span> <span class="peer-checked:font-bold">{network.name}</span>
@@ -233,6 +240,7 @@ const {
id="max-kyc" id="max-kyc"
value={filters['max-kyc'] ?? 4} value={filters['max-kyc'] ?? 4}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs"> <div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
@@ -261,6 +269,7 @@ const {
id="user-rating" id="user-rating"
value={filters['user-rating']} value={filters['user-rating']}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-2 text-xs"> <div class="text-day-400 mt-1 flex justify-between px-2 text-xs">
@@ -289,12 +298,22 @@ const {
options={options.modeOptions} options={options.modeOptions}
selectedValue={filters['attribute-mode']} selectedValue={filters['attribute-mode']}
class="-my-2" class="-my-2"
inputProps={{
'data-trigger-on-change': true,
}}
/> />
</div> </div>
{ {
options.attributesByCategory.map(({ category, attributes }) => ( options.attributesByCategory.map(({ categoryInfo, attributes }) => (
<fieldset class="min-w-0"> <fieldset class="min-w-0">
<legend class="font-title mb-0.5 text-xs tracking-wide text-white">{category}</legend> <legend class="font-title mb-0.5 inline-flex items-center gap-1 text-[0.8125rem] tracking-wide text-white uppercase">
<Icon
name={categoryInfo.icon}
class={cn('size-4 shrink-0 opacity-80', categoryInfo.classNames.icon)}
aria-hidden="true"
/>
{categoryInfo.label}
</legend>
<ul class="[:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden"> <ul class="[:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden">
{attributes.map((attribute) => { {attributes.map((attribute) => {
@@ -318,6 +337,7 @@ const {
value="" value=""
checked={!attribute.value} checked={!attribute.value}
aria-label="Ignore" aria-label="Ignore"
data-trigger-on-change
/> />
<input <input
type="radio" type="radio"
@@ -327,6 +347,7 @@ const {
class="peer/yes sr-only" class="peer/yes sr-only"
checked={attribute.value === 'yes'} checked={attribute.value === 'yes'}
aria-label="Include" aria-label="Include"
data-trigger-on-change
/> />
<input <input
type="radio" type="radio"
@@ -336,6 +357,7 @@ const {
class="peer/no sr-only" class="peer/no sr-only"
checked={attribute.value === 'no'} checked={attribute.value === 'no'}
aria-label="Exclude" aria-label="Exclude"
data-trigger-on-change
/> />
<div class="pointer-events-none absolute inset-y-0 -left-[2px] hidden w-[calc(var(--spacing)*4.5*2+1px)] rounded-md border-2 border-blue-500 peer-focus-visible/empty:block peer-focus-visible/no:block peer-focus-visible/yes:block" /> <div class="pointer-events-none absolute inset-y-0 -left-[2px] hidden w-[calc(var(--spacing)*4.5*2+1px)] rounded-md border-2 border-blue-500 peer-focus-visible/empty:block peer-focus-visible/no:block peer-focus-visible/yes:block" />
@@ -356,11 +378,9 @@ const {
</label> </label>
<span <span
class="bg-night-400 border-night-500 pointer-events-none block h-4 w-px border-y peer-checked/no:w-[0.5px] peer-checked/yes:w-[0.5px]" class="bg-night-400 border-night-500 before:bg-night-400 before:border-night-600 pointer-events-none block h-4 w-px border-y peer-checked/no:w-[0.5px] peer-checked/yes:w-[0.5px] before:h-full before:w-px before:border-y-2"
aria-hidden="true" aria-hidden="true"
> />
<span class="bg-night-400 border-night-600 block h-full w-px border-y-2" />
</span>
<label <label
for={noId} for={noId}
@@ -383,8 +403,8 @@ const {
aria-hidden="true" aria-hidden="true"
> >
<Icon <Icon
name={attribute.info.icon} name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.info.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -398,8 +418,8 @@ const {
aria-hidden="true" aria-hidden="true"
> >
<Icon <Icon
name={attribute.info.icon} name={attribute.typeInfo.icon}
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.info.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -417,19 +437,18 @@ const {
<> <>
<input <input
type="checkbox" type="checkbox"
id={`show-more-attributes-${category}`} id={`show-more-attributes-${categoryInfo.slug}`}
class="peer sr-only" class="peer sr-only"
hx-preserve hx-preserve
data-show-more-input
/> />
<label <label
for={`show-more-attributes-${category}`} for={`show-more-attributes-${categoryInfo.slug}`}
class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2" class="peer-focus-visible:ring-offset-night-700 mt-2 block cursor-pointer rounded-sm text-sm text-green-500 peer-checked:hidden peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
+ Show more + Show more
</label> </label>
<label <label
for={`show-more-attributes-${category}`} for={`show-more-attributes-${categoryInfo.slug}`}
class="peer-focus-visible:ring-offset-night-700 mt-2 hidden cursor-pointer rounded-sm text-sm text-green-500 peer-checked:block peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2" class="peer-focus-visible:ring-offset-night-700 mt-2 hidden cursor-pointer rounded-sm text-sm text-green-500 peer-checked:block peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2"
> >
- Show less - Show less
@@ -455,6 +474,7 @@ const {
id="min-score" id="min-score"
value={filters['min-score']} value={filters['min-score']}
class="w-full accent-green-500" class="w-full accent-green-500"
data-trigger-on-change
/> />
</div> </div>
<div class="-mx-1.5 mt-2 flex justify-between px-1"> <div class="-mx-1.5 mt-2 flex justify-between px-1">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,8 +78,8 @@ export const {
}, },
{ {
value: 'MANUAL_ADJUSTMENT', value: 'MANUAL_ADJUSTMENT',
slug: 'manual-adjustment', slug: 'gift',
label: 'Manual adjustment', label: 'Gift',
icon: 'ri:gift-line', icon: 'ri:gift-line',
}, },
] as const satisfies KarmaTransactionActionInfo<KarmaTransactionAction>[] ] as const satisfies KarmaTransactionActionInfo<KarmaTransactionAction>[]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -190,7 +190,7 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## Support the project ## Support
If you like this project, you can **support** it through these methods: If you like this project, you can **support** it through these methods:
@@ -202,12 +202,10 @@ If you like this project, you can **support** it through these methods:
## Contact ## Contact
You can contact via direct chat or via email. You can contact via direct chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion) - [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
- If you use ProtonMail or Tutanota, you can have E2E encrypted communications with us directly. We also offer a [PGP Key](/pgp). Otherwise, we recommend reaching out via SimpleX chat for encrypted communications.
## Disclaimer ## Disclaimer

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro' import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions' import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks' import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project' import { SUPPORT_EMAIL } from '../../constants/project'
@@ -65,6 +66,7 @@ const user = await Astro.locals.banners.try('user', async () => {
select: { select: {
name: true, name: true,
displayName: true, displayName: true,
picture: true,
}, },
}, },
comment: { comment: {
@@ -158,11 +160,11 @@ if (!user) return Astro.rewrite('/404')
--- ---
<BaseLayout <BaseLayout
pageTitle={`${user.name} - Account`} pageTitle={`${user.displayName ?? user.name} - Account`}
description="Manage your user profile" description="Manage your user profile"
ogImage={{ ogImage={{
template: 'generic', template: 'generic',
title: `${user.name} | Account`, title: `${user.displayName ?? user.name} | Account`,
description: 'Manage your user profile', description: 'Manage your user profile',
icon: 'ri:user-3-line', icon: 'ri:user-3-line',
}} }}
@@ -181,50 +183,52 @@ if (!user) return Astro.rewrite('/404')
]} ]}
> >
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center gap-4"> <header class="flex flex-wrap items-center justify-center gap-4">
{ <div class="flex grow flex-wrap items-center justify-center gap-4">
user.picture ? ( {
<MyPicture user.picture ? (
src={user.picture} <MyPicture
alt="" src={user.picture}
class="ring-day-500/30 size-16 rounded-full ring-2" alt=""
width={64} class="ring-day-500/30 xs:size-14 size-12 rounded-full ring-2 sm:size-16"
height={64} width={64}
/> height={64}
) : ( />
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2"> ) : (
<Icon name="ri:user-3-line" class="size-8" /> <div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
</div> <Icon name="ri:user-3-line" class="size-8" />
) </div>
} )
<div> }
<h1 class="font-title text-lg font-bold tracking-wider text-white">{user.name}</h1> <div class="grow">
{user.displayName && <p class="text-day-200">{user.displayName}</p>} <h1 class="font-title text-lg font-bold tracking-wider text-white">
<div class="mt-1 flex gap-2"> {user.displayName ?? user.name}
</h1>
{user.displayName && <p class="text-day-200 font-title">{user.name}</p>}
{ {
user.admin && ( (user.admin || user.verified || user.verifier) && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400"> <div class="mt-1 flex gap-2">
admin {user.admin && (
</span> <span class="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 && ( {user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400"> <span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified verified
</span> </span>
) )}
} {user.verifier && (
{ <span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
user.verifier && ( verifier
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400"> </span>
verifier )}
</span> </div>
) )
} }
</div> </div>
</div> </div>
<nav class="ml-auto flex items-center gap-2"> <nav class="flex flex-wrap items-center justify-center gap-2">
<Tooltip <Tooltip
as="a" as="a"
href={`/u/${user.name}`} href={`/u/${user.name}`}
@@ -429,7 +433,7 @@ if (!user) return Astro.rewrite('/404')
<li> <li>
<Button <Button
as="a" as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=User verification request - ${user.name}&body=I would like to be verified as related to https://www.example.com`} href={`mailto:${SUPPORT_EMAIL}?subject=User verification request - ${user.displayName ?? user.name}&body=I would like to be verified as related to https://www.example.com`}
label="Request verification" label="Request verification"
size="sm" size="sm"
/> />
@@ -448,7 +452,7 @@ if (!user) return Astro.rewrite('/404')
</h2> </h2>
<Button <Button
as="a" as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=Service Affiliation Verification Request - ${user.name}&body=I would like to be verified as related to the services ACME as Admin and XYZ as Team Member. Here is the proof...`} href={`mailto:${SUPPORT_EMAIL}?subject=Service Affiliation Verification Request - ${user.displayName ?? user.name}&body=I would like to be verified as related to the services ACME as Admin and XYZ as Team Member. Here is the proof...`}
label="Request" label="Request"
size="md" size="md"
/> />
@@ -534,77 +538,38 @@ if (!user) return Astro.rewrite('/404')
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3"> <div class="space-y-3">
<h3 class="font-title border-day-700/20 text-day-200 border-b pb-2 text-sm">Positive unlocks</h3> <input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
<label
for="positive-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
{ <div class="mt-3 hidden space-y-3 peer-checked:block md:block">
sortBy( {
karmaUnlocks.filter((unlock) => unlock.karma >= 0), sortBy(
'karma' karmaUnlocks.filter((unlock) => unlock.karma >= 0),
).map((unlock) => ( 'karma'
<div ).map((unlock) => (
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn('font-medium', user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400')}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
</div>
<div class="space-y-3">
<h3 class="font-title border-b border-red-500/20 pb-2 text-sm text-red-400">Negative unlocks</h3>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div <div
class={cn( class={cn(
'flex items-center justify-between rounded-md border p-3', 'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id] user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10' ? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800' : 'border-night-500 bg-night-800'
)} )}
> >
<div class="flex items-center"> <div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}> <span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" /> <Icon name={unlock.icon} class="size-5" />
</span> </span>
<div> <div>
<p <p
class={cn( class={cn(
'font-medium', 'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400' user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)} )}
> >
{unlock.name} {unlock.name}
@@ -614,24 +579,85 @@ if (!user) return Astro.rewrite('/404')
</div> </div>
<div> <div>
{user.karmaUnlocks[unlock.id] ? ( {user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400"> <span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active <Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span> </span>
) : ( ) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs"> <span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided <Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span> </span>
)} )}
</div> </div>
</div> </div>
)) ))
} }
</div>
</div>
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs"> <div class="space-y-3">
<Icon name="ri:information-line" class="inline-block size-4" /> <input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties. <label
</p> for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
</span>
)}
</div>
</div>
))
}
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties.
</p>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -858,7 +884,10 @@ if (!user) return Astro.rewrite('/404')
} }
</section> </section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> <section
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
id="karma-transactions"
>
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" /> <Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
@@ -891,13 +920,10 @@ if (!user) return Astro.rewrite('/404')
<Icon name={actionInfo.icon} class="size-4" /> <Icon name={actionInfo.icon} class="size-4" />
{actionInfo.label} {actionInfo.label}
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && ( {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
<a <>
href={`/u/${transaction.grantedBy.name}`} <span class="text-day-500">from</span>
class="text-day-500 ml-1 hover:underline" <UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
> </>
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
by {transaction.grantedBy.displayName || transaction.grantedBy.name}
</a>
)} )}
</span> </span>
</td> </td>
@@ -912,7 +938,12 @@ if (!user) return Astro.rewrite('/404')
{transaction.points} {transaction.points}
</td> </td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap"> <td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
{new Date(transaction.createdAt).toLocaleDateString()} <TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td> </td>
</tr> </tr>
) )

View File

@@ -6,13 +6,17 @@ import { z } from 'astro:schema'
import { adminAnnouncementActions } from '../../../actions/admin/announcement' import { adminAnnouncementActions } from '../../../actions/admin/announcement'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
import InputCardGroup from '../../../components/InputCardGroup.astro' import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputSelect from '../../../components/InputSelect.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro' import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro' import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro' import InputTextArea from '../../../components/InputTextArea.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro' import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro' import Tooltip from '../../../components/Tooltip.astro'
import {
announcementTypes,
getAnnouncementTypeInfo,
zodAnnouncementTypesById,
} from '../../../constants/announcementTypes'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
@@ -26,7 +30,7 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
.default('createdAt'), .default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(), search: z.string().optional(),
type: z.enum(['INFO', 'WARNING', 'ALERT']).optional(), type: zodAnnouncementTypesById.optional(),
status: z.enum(['active', 'inactive']).optional(), status: z.enum(['active', 'inactive']).optional(),
}, },
Astro Astro
@@ -41,10 +45,7 @@ const prismaOrderBy = {
const whereClause: Prisma.AnnouncementWhereInput = {} const whereClause: Prisma.AnnouncementWhereInput = {}
if (filters.search) { if (filters.search) {
whereClause.OR = [ whereClause.OR = [{ content: { contains: filters.search, mode: 'insensitive' } }]
{ title: { contains: filters.search, mode: 'insensitive' } },
{ content: { contains: filters.search, mode: 'insensitive' } },
]
} }
if (filters.type) { if (filters.type) {
@@ -72,32 +73,19 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
return `/admin/announcements?${searchParams.toString()}` 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 // Current date for form min values
const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm
// Default new announcement // Default new announcement
const newAnnouncement = { const newAnnouncement = {
title: '',
content: '', content: '',
type: 'INFO' as const, type: 'INFO' as const,
link: null,
linkText: null,
startDate: currentDate, startDate: currentDate,
endDate: '', endDate: '',
isActive: true, isActive: true as boolean,
} } satisfies Prisma.AnnouncementCreateInput
// Get action results // Get action results
const createResult = Astro.getActionResult(adminAnnouncementActions.create) const createResult = Astro.getActionResult(adminAnnouncementActions.create)
@@ -184,9 +172,13 @@ if (toggleResult?.error) {
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none" class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> >
<option value="" selected={!filters.type}>All Types</option> <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> announcementTypes.map((type) => (
<option value="ALERT" selected={filters.type === 'ALERT'}>Alert</option> <option value={type.value} selected={filters.type === type.value}>
{type.label}
</option>
))
}
</select> </select>
</div> </div>
<div> <div>
@@ -229,20 +221,8 @@ if (toggleResult?.error) {
<h2 class="font-title mb-4 text-lg font-semibold text-blue-400">Create New Announcement</h2> <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"> <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 class="space-y-3 md:col-span-2">
<InputText
label="Title*"
name="title"
error={createInputErrors.title}
inputProps={{
required: true,
maxlength: 255,
placeholder: 'Announcement Title',
value: newAnnouncement.title,
}}
/>
<InputTextArea <InputTextArea
label="Content*" label="Content"
name="content" name="content"
error={createInputErrors.content} error={createInputErrors.content}
value={newAnnouncement.content} value={newAnnouncement.content}
@@ -256,23 +236,41 @@ if (toggleResult?.error) {
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<InputSelect <InputText
label="Type*" label="Link"
name="type" name="link"
error={createInputErrors.type} error={createInputErrors.link}
options={[ inputProps={{
{ label: 'Info', value: 'INFO' }, type: 'url',
{ label: 'Warning', value: 'WARNING' }, placeholder: 'https://example.com',
{ label: 'Alert', value: 'ALERT' },
]}
selectProps={{
required: true,
value: newAnnouncement.type,
}} }}
/> />
<InputText
label="Link Text "
name="linkText"
error={createInputErrors.linkText}
inputProps={{
placeholder: 'Link Text',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
label="Type"
name="type"
options={announcementTypes.map((type) => ({
label: type.label,
value: type.value,
icon: type.icon,
}))}
cardSize="sm"
required
selectedValue={newAnnouncement.type}
/>
<InputText <InputText
label="Start Date & Time*" label="Start Date & Time"
name="startDate" name="startDate"
error={createInputErrors.startDate} error={createInputErrors.startDate}
inputProps={{ inputProps={{
@@ -283,7 +281,7 @@ if (toggleResult?.error) {
/> />
<InputText <InputText
label="End Date & Time (Optional)" label="End Date & Time"
name="endDate" name="endDate"
error={createInputErrors.endDate} error={createInputErrors.endDate}
inputProps={{ inputProps={{
@@ -307,7 +305,7 @@ if (toggleResult?.error) {
/> />
<div class="pt-4"> <div class="pt-4">
<InputSubmitButton label="Create Announcement" icon="ri:save-line" /> <InputSubmitButton label="Create Announcement" icon="ri:save-line" hideCancel />
<button <button
type="button" type="button"
id="cancel-create" id="cancel-create"
@@ -455,198 +453,215 @@ if (toggleResult?.error) {
) )
} }
{ {
announcements.map((announcement) => ( announcements.map((announcement) => {
<> const announcementTypeInfo = getAnnouncementTypeInfo(announcement.type)
<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"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
return (
<>
<tr class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm">
<div class="line-clamp-2 text-zinc-400">{announcement.content}</div>
</td>
<td class="px-4 py-3 text-left text-sm">
<span
class={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-medium ${announcementTypeInfo.classNames.badge}`}
>
<Icon name={announcementTypeInfo.icon} class="me-1 size-3" />
{announcementTypeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
<TimeFormatted date={announcement.startDate} hourPrecision={false} prefix={false} />
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
{announcement.endDate ? (
<TimeFormatted date={announcement.endDate} hourPrecision={false} prefix={false} />
) : (
<span class="text-zinc-500">—</span>
)}
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${announcement.isActive ? 'bg-green-900/30 text-green-400' : 'bg-zinc-700/50 text-zinc-400'}`}
>
{announcement.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-400">
<TimeFormatted
date={announcement.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="button"
type="button"
data-id={announcement.id}
class="edit-button inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
<form
method="POST"
action={actions.admin.announcement.toggleActive}
class="inline-block"
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
>
<input type="hidden" name="id" value={announcement.id} />
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
<button
type="submit"
class={`rounded-md border px-1 py-1 text-xs transition-colors ${
announcement.isActive
? 'border-yellow-500/50 bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'border-green-500/50 bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
>
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
<Icon
name={
announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'
}
class="size-4"
/>
</Tooltip>
</button>
</form>
<form
method="POST"
action={actions.admin.announcement.delete}
class="inline-block"
data-confirm="Are you sure you want to delete this announcement?"
>
<input type="hidden" name="id" value={announcement.id} />
<button
type="submit"
class="rounded-md border border-red-500/50 bg-red-500/20 px-1 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/30"
>
<Tooltip text="Delete">
<Icon name="ri:delete-bin-line" class="size-4" />
</Tooltip>
</button>
</form>
</div>
</td>
</tr>
<tr id={`edit-announcement-form-${announcement.id}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-3 font-semibold text-blue-300">
Edit: {announcement.content}
</h3>
<form <form
method="POST" method="POST"
action={actions.admin.announcement.toggleActive} action={actions.admin.announcement.update}
class="inline-block" class="grid gap-4 md:grid-cols-2"
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="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 <div class="space-y-3 md:col-span-2">
method="POST" <InputTextArea
action={actions.admin.announcement.delete} label="Content"
class="inline-block" name="content"
data-confirm="Are you sure you want to delete this announcement?" value={announcement.content}
> inputProps={{
<input type="hidden" name="id" value={announcement.id} /> required: true,
<button maxlength: 1000,
type="submit" rows: 3,
class="rounded-md border border-red-500/50 bg-red-500/20 px-1 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/30" }}
>
<Tooltip text="Delete">
<Icon name="ri:delete-bin-line" class="size-4" />
</Tooltip>
</button>
</form>
</div>
</td>
</tr>
<tr id={`edit-announcement-form-${announcement.id}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-3 font-semibold text-blue-300">
Edit: {announcement.title}
</h3>
<form
method="POST"
action={actions.admin.announcement.update}
class="grid gap-4 md:grid-cols-2"
>
<input type="hidden" name="id" value={announcement.id} />
<div class="space-y-3 md:col-span-2">
<InputText
label="Title*"
name="title"
inputProps={{
id: `edit-title-${announcement.id}`,
required: true,
maxlength: 255,
value: announcement.title,
}}
/>
<InputTextArea
label="Content*"
name="content"
value={announcement.content}
inputProps={{
id: `edit-content-${announcement.id}`,
required: true,
maxlength: 1000,
rows: 3,
}}
/>
</div>
<div class="space-y-3">
<InputSelect
label="Type*"
name="type"
options={[
{ label: 'Info', value: 'INFO' },
{ label: 'Warning', value: 'WARNING' },
{ label: 'Alert', value: 'ALERT' },
]}
selectProps={{
id: `edit-type-${announcement.id}`,
required: true,
value: announcement.type,
}}
/>
<InputText
label="Start Date & Time*"
name="startDate"
inputProps={{
id: `edit-startDate-${announcement.id}`,
type: 'datetime-local',
required: true,
value: new Date(announcement.startDate).toISOString().slice(0, 16),
}}
/>
<InputText
label="End Date & Time (Optional)"
name="endDate"
inputProps={{
id: `edit-endDate-${announcement.id}`,
type: 'datetime-local',
value: announcement.endDate
? new Date(announcement.endDate).toISOString().slice(0, 16)
: '',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4">
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel={true} />
<Button
type="button"
label="Cancel"
color="gray"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
class="ml-2"
/> />
</div> </div>
</div>
</form> <div class="space-y-3">
</td> <InputText
</tr> label="Link"
</> name="link"
)) inputProps={{
type: 'url',
placeholder: 'https://example.com',
value: announcement.link,
}}
/>
<InputText
label="Link Text"
name="linkText"
inputProps={{
placeholder: 'Link Text',
value: announcement.linkText,
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
label="Type"
name="type"
options={announcementTypes.map((type) => ({
label: type.label,
value: type.value,
icon: type.icon,
}))}
cardSize="sm"
required
selectedValue={announcement.type}
/>
<InputText
label="Start Date & Time"
name="startDate"
inputProps={{
type: 'datetime-local',
required: true,
value: new Date(announcement.startDate).toISOString().slice(0, 16),
}}
/>
<InputText
label="End Date & Time"
name="endDate"
inputProps={{
type: 'datetime-local',
value: announcement.endDate
? new Date(announcement.endDate).toISOString().slice(0, 16)
: '',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4">
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel />
<Button
type="button"
label="Cancel"
color="gray"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
class="ml-2"
/>
</div>
</div>
</form>
</td>
</tr>
</>
)
})
} }
</tbody> </tbody>
</table> </table>

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -116,7 +116,7 @@ if (!user) return Astro.rewrite('/404')
--- ---
<BaseLayout <BaseLayout
pageTitle={`User: ${user.name}`} pageTitle={`${user.displayName ?? user.name} - User`}
widthClassName="max-w-screen-lg" widthClassName="max-w-screen-lg"
className={{ main: 'space-y-24' }} className={{ main: 'space-y-24' }}
> >
@@ -306,7 +306,7 @@ if (!user) return Astro.rewrite('/404')
</div> </div>
<div data-note-content> <div data-note-content>
<p class="text-day-200 whitespace-pre-wrap">{note.content}</p> <p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
</div> </div>
<form <form

View File

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

View File

@@ -169,7 +169,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
'[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden' '[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden'
)} )}
hx-get={Astro.url.pathname} hx-get={Astro.url.pathname}
hx-trigger="input from:input, keyup[key=='Enter'], change from:select" hx-trigger="input from:find input, keyup[key=='Enter'], change from:find select"
hx-target="#events-list-container" hx-target="#events-list-container"
hx-select="#events-list-container" hx-select="#events-list-container"
hx-swap="outerHTML" hx-swap="outerHTML"

16
web/src/pages/health.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { APIRoute } from 'astro'
export const GET: APIRoute = () => {
return new Response(
JSON.stringify({
message: 'OK',
timestamp: new Date().toISOString(),
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
)
}

View File

@@ -1,15 +1,16 @@
--- ---
import { ServiceVisibility } from '@prisma/client' import { ServiceVisibility } from '@prisma/client'
import { z } from 'astro:schema' import { z } from 'astro:schema'
import { groupBy, orderBy } from 'lodash-es' import { groupBy, omit, orderBy, uniq } from 'lodash-es'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import InputText from '../components/InputText.astro'
import Pagination from '../components/Pagination.astro' import Pagination from '../components/Pagination.astro'
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' import ServiceFiltersPill from '../components/ServiceFiltersPill.astro'
import ServicesFilters from '../components/ServicesFilters.astro' import ServicesFilters from '../components/ServicesFilters.astro'
import ServicesSearchResults from '../components/ServicesSearchResults.astro' import ServicesSearchResults from '../components/ServicesSearchResults.astro'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { import {
currencies, currencies,
@@ -27,6 +28,7 @@ import {
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays' import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { parseIntWithFallback } from '../lib/numbers' import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { makeSortSeed } from '../lib/sortSeed' import { makeSortSeed } from '../lib/sortSeed'
@@ -131,9 +133,12 @@ const attributeOptions = [
prefix: string prefix: string
}[] }[]
const ignoredKeysForDefaultData = ['sort-seed']
const { const {
data: filters, data: filters,
hasDefaultData: hasDefaultFilters, hasDefaultData: hasDefaultFilters,
defaultData: defaultFilters,
redirectUrl, redirectUrl,
} = zodParseQueryParamsStoringErrors( } = zodParseQueryParamsStoringErrors(
{ {
@@ -165,7 +170,7 @@ const {
}, },
Astro, Astro,
{ {
ignoredKeysForDefaultData: ['sort-seed'], ignoredKeysForDefaultData,
cleanUrl: { cleanUrl: {
removeUneededObjectParams: true, removeUneededObjectParams: true,
removeParams: { removeParams: {
@@ -182,12 +187,130 @@ const {
} }
) )
const hasDefaultFiltersIgnoringQ = areEqualObjectsWithoutOrder(
omit(filters, [...ignoredKeysForDefaultData, 'q']),
omit(defaultFilters, [...ignoredKeysForDefaultData, 'q'])
)
if (redirectUrl) return Astro.redirect(redirectUrl.toString()) if (redirectUrl) return Astro.redirect(redirectUrl.toString())
const includeScams =
!!filters.q &&
(areEqualArraysWithoutOrder(filters.verification, ['VERIFICATION_SUCCESS', 'APPROVED']) ||
areEqualArraysWithoutOrder(filters.verification, [
'VERIFICATION_SUCCESS',
'APPROVED',
'COMMUNITY_CONTRIBUTED',
]))
export type ServicesFiltersObject = typeof filters export type ServicesFiltersObject = typeof filters
const currentDate = new Date() const groupedAttributes = groupBy(
const [categories, [services, totalServices, hadToIncludeCommunityContributed], activeAnnouncements] = Object.entries(filters.attr ?? {}).flatMap(([key, value]) => {
const id = parseIntWithFallback(key)
if (id === null) return []
return [{ id, value }]
}),
'value'
)
const where = {
listedAt: {
lte: new Date(),
},
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
},
serviceVisibility: ServiceVisibility.PUBLIC,
overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and'
? { hasEvery: filters.currencies }
: { hasSome: filters.currencies }
: undefined,
kycLevel: {
lte: filters['max-kyc'],
},
AND: [
...(filters['user-rating'] > 0
? [
{
averageUserRating: {
gte: filters['user-rating'],
},
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.q
? [
{
OR: [
{ name: { contains: filters.q, mode: 'insensitive' as const } },
{ description: { contains: filters.q, mode: 'insensitive' as const } },
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.networks.length
? [
{
OR: [
...(filters.networks.includes('onion') ? [{ onionUrls: { isEmpty: false } }] : []),
...(filters.networks.includes('i2p') ? [{ i2pUrls: { isEmpty: false } }] : []),
...(filters.networks.includes('clearnet') ? [{ serviceUrls: { isEmpty: false } }] : []),
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.attr && (groupedAttributes.yes?.length ?? 0) + (groupedAttributes.no?.length ?? 0) > 0
? [
{
AND: [
...(groupedAttributes.yes && groupedAttributes.yes.length > 0
? [
{
[filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.yes.map(
({ id }) =>
({
attributes: {
some: {
attribute: {
id,
},
},
},
}) satisfies Prisma.ServiceWhereInput
),
},
]
: []),
...(groupedAttributes.no && groupedAttributes.no.length > 0
? [
{
[filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.no.map(
({ id }) =>
({
attributes: {
none: {
attribute: {
id,
},
},
},
}) satisfies Prisma.ServiceWhereInput
),
},
]
: []),
],
},
]
: []),
],
} as const satisfies Prisma.ServiceWhereInput
const [categories, [services, totalServices], countCommunityOnly, attributes] =
await Astro.locals.banners.tryMany([ await Astro.locals.banners.tryMany([
[ [
'Unable to load category filters.', 'Unable to load category filters.',
@@ -208,146 +331,16 @@ const [categories, [services, totalServices, hadToIncludeCommunityContributed],
[ [
'Unable to load services.', 'Unable to load services.',
async () => { async () => {
const groupedAttributes = groupBy( const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
Object.entries(filters.attr ?? {}).flatMap(([key, value]) => {
const id = parseIntWithFallback(key)
if (id === null) return []
return [{ id, value }]
}),
'value'
)
const where = {
listedAt: {
lte: new Date(),
},
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: {
in: filters.verification,
},
serviceVisibility: ServiceVisibility.PUBLIC,
overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and'
? { hasEvery: filters.currencies }
: { hasSome: filters.currencies }
: undefined,
kycLevel: {
lte: filters['max-kyc'],
},
AND: [
...(filters['user-rating'] > 0
? [
{
averageUserRating: {
gte: filters['user-rating'],
},
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.q
? [
{
OR: [
{ name: { contains: filters.q, mode: 'insensitive' as const } },
{ description: { contains: filters.q, mode: 'insensitive' as const } },
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.networks.length
? [
{
OR: [
...(filters.networks.includes('onion') ? [{ onionUrls: { isEmpty: false } }] : []),
...(filters.networks.includes('i2p') ? [{ i2pUrls: { isEmpty: false } }] : []),
...(filters.networks.includes('clearnet') ? [{ serviceUrls: { isEmpty: false } }] : []),
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.attr && (groupedAttributes.yes?.length ?? 0) + (groupedAttributes.no?.length ?? 0) > 0
? [
{
AND: [
...(groupedAttributes.yes && groupedAttributes.yes.length > 0
? [
{
[filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.yes.map(
({ id }) =>
({
attributes: {
some: {
attribute: {
id,
},
},
},
}) satisfies Prisma.ServiceWhereInput
),
},
]
: []),
...(groupedAttributes.no && groupedAttributes.no.length > 0
? [
{
[filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.no.map(
({ id }) =>
({
attributes: {
none: {
attribute: {
id,
},
},
},
}) satisfies Prisma.ServiceWhereInput
),
},
]
: []),
],
},
]
: []),
],
} as const satisfies Prisma.ServiceWhereInput
const select = {
id: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(typeof sortOptions)[number]['orderBy']['key'],
true
>),
} as const satisfies Prisma.ServiceSelect
let [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
where, where,
select, select: {
id: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(typeof sortOptions)[number]['orderBy']['key'],
true
>),
},
}) })
let hadToIncludeCommunityContributed = false
if (
totalServices === 0 &&
areEqualArraysWithoutOrder(where.verificationStatus.in, ['VERIFICATION_FAILED', 'APPROVED'])
) {
const [unsortedServiceCommunityServices, totalCommunityServices] =
await prisma.service.findManyAndCount({
where: {
...where,
verificationStatus: {
...where.verificationStatus,
in: [...where.verificationStatus.in, 'COMMUNITY_CONTRIBUTED'],
},
},
select,
})
if (totalCommunityServices !== 0) {
hadToIncludeCommunityContributed = true
unsortedServices = unsortedServiceCommunityServices
totalServices = totalCommunityServices
}
}
const rng = seedrandom(filters['sort-seed']) const rng = seedrandom(filters['sort-seed'])
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
@@ -407,61 +400,56 @@ const [categories, [services, totalServices, hadToIncludeCommunityContributed],
[selectedSort.orderBy.direction, 'asc'] [selectedSort.orderBy.direction, 'asc']
) )
return [sortedServicesWithInfo, totalServices, hadToIncludeCommunityContributed] as const return [sortedServicesWithInfo, totalServices] as const
}, },
[[] as [], 0, false] as const, [[] as [], 0, false] as const,
], ],
[ [
'Unable to load announcements.', 'Unable to load count if including community.',
() => () =>
prisma.announcement.findMany({ areEqualArraysWithoutOrder(filters.verification, ['VERIFICATION_SUCCESS', 'APPROVED']) ||
where: { areEqualArraysWithoutOrder(filters.verification, [
isActive: true, 'VERIFICATION_SUCCESS',
startDate: { lte: currentDate }, 'APPROVED',
OR: [{ endDate: null }, { endDate: { gt: currentDate } }], 'VERIFICATION_FAILED',
}, ])
? prisma.service.count({
where: {
...where,
verificationStatus: 'COMMUNITY_CONTRIBUTED',
},
})
: null,
null,
],
[
'Unable to load attribute filters.',
() =>
prisma.attribute.findMany({
select: { select: {
id: true, id: true,
slug: true,
title: true, title: true,
content: true, category: true,
type: true, type: true,
startDate: true, _count: {
endDate: true, select: {
isActive: true, services: true,
},
},
}, },
orderBy: [{ type: 'desc' }, { createdAt: 'desc' }], orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
}), }),
[], [],
], ],
]) ])
const attributes = await Astro.locals.banners.try(
'Unable to load attribute filters.',
() =>
prisma.attribute.findMany({
select: {
id: true,
slug: true,
title: true,
category: true,
type: true,
_count: {
select: {
services: true,
},
},
},
orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
}),
[]
)
const attributesByCategory = orderBy( const attributesByCategory = orderBy(
Object.entries( Object.entries(
groupBy( groupBy(
attributes.map((attr) => { attributes.map((attr) => {
return { return {
info: getAttributeTypeInfo(attr.type), typeInfo: getAttributeTypeInfo(attr.type),
...attr, ...attr,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
value: filters.attr?.[attr.id] || undefined, value: filters.attr?.[attr.id] || undefined,
@@ -471,6 +459,7 @@ const attributesByCategory = orderBy(
) )
).map(([category, attributes]) => ({ ).map(([category, attributes]) => ({
category, category,
categoryInfo: getAttributeCategoryInfo(category),
attributes: orderBy( attributes: orderBy(
attributes, attributes,
['value', 'type', '_count.services', 'title'], ['value', 'type', '_count.services', 'title'],
@@ -512,7 +501,8 @@ const filtersOptions = {
export type ServicesFiltersOptions = typeof filtersOptions export type ServicesFiltersOptions = typeof filtersOptions
// const searchResultsId = 'search-results'
const showFiltersId = 'show-filters'
--- ---
<BaseLayout <BaseLayout
@@ -528,14 +518,38 @@ export type ServicesFiltersOptions = typeof filtersOptions
}, },
]} ]}
> >
<AnnouncementBanner announcements={activeAnnouncements} />
<div class="flex flex-col gap-4 sm:flex-row sm:gap-8"> <div class="flex flex-col gap-4 sm:flex-row sm:gap-8">
<div <div
class='[&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-night-700 flex items-stretch sm:hidden [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-2 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-green-500 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-2' class='[&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-night-700 flex items-stretch sm:hidden [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-2 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-green-500 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-2'
> >
{ {
!hasDefaultFilters ? ( hasDefaultFilters || hasDefaultFiltersIgnoringQ ? (
<form
method="GET"
hx-get={Astro.url.pathname}
hx-trigger="input delay:500ms, keyup[key=='Enter']"
hx-target={`#${searchResultsId}`}
hx-select={`#${searchResultsId}`}
hx-push-url="true"
hx-indicator="#search-indicator"
class="contents"
>
<InputText
name="q"
label="Search..."
hideLabel
inputIcon="ri:search-line"
inputIconClass="text-day-500 size-4.5"
inputProps={{
placeholder: 'Search',
value: filters.q,
class: 'bg-night-800 border-night-500',
'transition:persist': false,
}}
class="mr-4 flex-1"
/>
</form>
) : (
<div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4"> <div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4">
{filters.q && ( {filters.q && (
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} /> <ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
@@ -644,12 +658,19 @@ export type ServicesFiltersOptions = typeof filtersOptions
) )
})} })}
</div> </div>
) : (
<div class="text-day-500 flex flex-1 items-center">No filters</div>
) )
} }
<Button as="label" for="show-filters" label="Filters" icon="ri:filter-3-line" /> <Button
as="label"
for="show-filters"
label="Filters"
icon="ri:filter-3-line"
class="max-2xs:w-9 max-2xs:px-0"
classNames={{
label: 'max-2xs:hidden',
}}
/>
</div> </div>
<input <input
@@ -663,8 +684,8 @@ export type ServicesFiltersOptions = typeof filtersOptions
class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0" class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0"
> >
<ServicesFilters <ServicesFilters
searchResultsId="search-results" searchResultsId={searchResultsId}
showFiltersId="show-filters" showFiltersId={showFiltersId}
filters={{ filters={{
...filters, ...filters,
'sort-seed': makeSortSeed(), 'sort-seed': makeSortSeed(),
@@ -682,7 +703,9 @@ export type ServicesFiltersOptions = typeof filtersOptions
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']} sortSeed={filters['sort-seed']}
filters={filters} filters={filters}
hadToIncludeCommunityContributed={hadToIncludeCommunityContributed} includeScams={includeScams}
countCommunityOnly={countCommunityOnly}
inlineIcons
/> />
</div> </div>
{ {

View File

@@ -124,6 +124,13 @@ const [dbNotifications, notificationPreferences, totalNotifications] = await Ast
verificationStatus: true, verificationStatus: true,
}, },
}, },
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
}, },
}), }),
[], [],
@@ -136,6 +143,7 @@ const [dbNotifications, notificationPreferences, totalNotifications] = await Ast
enableOnMyCommentStatusChange: true, enableOnMyCommentStatusChange: true,
enableAutowatchMyComments: true, enableAutowatchMyComments: true,
enableNotifyPendingRepliesOnWatch: true, enableNotifyPendingRepliesOnWatch: true,
karmaNotificationThreshold: true,
}), }),
null, null,
], ],
@@ -232,7 +240,7 @@ const notifications = dbNotifications.map((notification) => ({
<div> <div>
<div class="font-medium text-zinc-200">{notification.title}</div> <div class="font-medium text-zinc-200">{notification.title}</div>
<p class="text-sm text-zinc-400">{notification.content}</p> <p class="text-sm wrap-anywhere text-zinc-400">{notification.content}</p>
<div class="mt-1 text-xs text-zinc-500"> <div class="mt-1 text-xs text-zinc-500">
<TimeFormatted date={notification.createdAt} prefix={false} caseType="sentence" /> <TimeFormatted date={notification.createdAt} prefix={false} caseType="sentence" />
</div> </div>
@@ -326,6 +334,23 @@ const notifications = dbNotifications.map((notification) => ({
</label> </label>
))} ))}
<div class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
<span class="flex items-center text-zinc-300">
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
Notify me when my karma changes by at least
</span>
<div class="flex items-center gap-2">
<input
type="number"
name="karmaNotificationThreshold"
value={notificationPreferences.karmaNotificationThreshold}
min="1"
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
/>
<span class="text-zinc-400">points</span>
</div>
</div>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<Button type="submit" label="Save" icon="ri:save-line" color="success" /> <Button type="submit" label="Save" icon="ri:save-line" color="success" />
</div> </div>

View File

@@ -149,9 +149,13 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<div class="mt-6"> <div class="mt-6">
<div class="text-day-200 mb-2 text-sm">Notes for moderators:</div> <div class="text-day-200 mb-2 text-sm">Notes for moderators:</div>
<div class="text-sm whitespace-pre-wrap"> {
{serviceSuggestion.notes ?? <span class="italic">Empty</span>} serviceSuggestion.notes ? (
</div> <div class="text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
) : (
<span class="text-sm italic">Empty</span>
)
}
</div> </div>
</section> </section>

View File

@@ -26,6 +26,7 @@ import Tooltip from '../../components/Tooltip.astro'
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro' import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories' import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes' import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies' import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes' import { getEventTypeInfo } from '../../constants/eventTypes'
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels' import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
@@ -37,7 +38,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import { someButNotAll, undefinedIfEmpty } from '../../lib/arrays' import { someButNotAll, undefinedIfEmpty } from '../../lib/arrays'
import { makeNonDbAttributes, sortAttributes } from '../../lib/attributes' import { makeNonDbAttributes, sortAttributes } from '../../lib/attributes'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
import { formatContactMethod } from '../../lib/contactMethods'
import { getOrCreateNotificationPreferences } from '../../lib/notificationPreferences' import { getOrCreateNotificationPreferences } from '../../lib/notificationPreferences'
import { formatNumber, type FormatNumberOptions } from '../../lib/numbers' import { formatNumber, type FormatNumberOptions } from '../../lib/numbers'
import { pluralize } from '../../lib/pluralize' import { pluralize } from '../../lib/pluralize'
@@ -95,8 +95,8 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
isRecentlyListed: true, isRecentlyListed: true,
contactMethods: { contactMethods: {
select: { select: {
iconId: true,
value: true, value: true,
label: true,
}, },
}, },
attributes: { attributes: {
@@ -379,20 +379,22 @@ const ogImageTemplateData = {
service.contactMethods service.contactMethods
.map((method) => ({ .map((method) => ({
...method, ...method,
...(formatContactMethod(method.value) ?? { type: 'unknown', formattedValue: method.value }), info: formatContactMethod(method.value),
})) }))
.map<ContactPoint | null>(({ type, formattedValue }) => { .map<ContactPoint | null>(({ info, label }) => {
switch (type) { switch (info.type) {
case 'telephone': { case 'telephone': {
return { return {
'@type': 'ContactPoint', '@type': 'ContactPoint',
telephone: formattedValue, telephone: info.formattedValue,
name: label ?? info.label,
} }
} }
case 'email': { case 'email': {
return { return {
'@type': 'ContactPoint', '@type': 'ContactPoint',
email: formattedValue, email: info.formattedValue,
name: label ?? info.label,
} }
} }
default: { default: {
@@ -467,17 +469,6 @@ const ogImageTemplateData = {
} }
<VerificationWarningBanner service={service} /> <VerificationWarningBanner service={service} />
{
service.verificationSteps.some((step) => step.status === VerificationStepStatus.FAILED) && (
<div class="mb-4 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-300">
<Icon name="ri:error-warning-line" class="inline-block size-4 shrink-0" />
<span>
This service has failed one or more verification steps. Review the verification details carefully.
</span>
</div>
)
}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{ {
!!service.imageUrl && ( !!service.imageUrl && (
@@ -741,8 +732,8 @@ const ogImageTemplateData = {
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-day-300 hover:text-day-200 flex items-center gap-1 px-2 py-1 hover:underline" class="text-day-300 hover:text-day-200 flex items-center gap-1 px-2 py-1 hover:underline"
> >
<Icon name={method.iconId} class="text-day-200 h-5 w-5" /> <Icon name={methodInfo.icon} class="text-day-200 h-5 w-5" />
<span>{methodInfo?.formattedValue ?? method.value}</span> <span>{method.label ?? methodInfo.formattedValue}</span>
</a> </a>
</li> </li>
) )

View File

@@ -10,6 +10,7 @@ import InputTextArea from '../../components/InputTextArea.astro'
import MyPicture from '../../components/MyPicture.astro' import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions' import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
import { karmaUnlocks } from '../../constants/karmaUnlocks' import { karmaUnlocks } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project' import { SUPPORT_EMAIL } from '../../constants/project'
@@ -64,6 +65,7 @@ const user = await Astro.locals.banners.try('user', async () => {
select: { select: {
name: true, name: true,
displayName: true, displayName: true,
picture: true,
}, },
}, },
comment: { comment: {
@@ -175,8 +177,8 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
--- ---
<BaseLayout <BaseLayout
pageTitle={`${user.name} - Account`} pageTitle={`${user.displayName ?? user.name} - User Profile`}
description="Manage your user profile" description={`User profile page of ${user.displayName ?? user.name} in KYCnot.me`}
ogImage={{ ogImage={{
template: 'generic', template: 'generic',
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
@@ -204,7 +206,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
image: user.picture ?? undefined, image: user.picture ?? undefined,
url: new URL(`/u/${user.name}`, Astro.url).href, url: new URL(`/u/${user.name}`, Astro.url).href,
sameAs: user.link ? [user.link] : undefined, sameAs: user.link ? [user.link] : undefined,
description: `User account for ${user.displayName ?? user.name} on KYCnot.me`, description: `User profile page for ${user.displayName ?? user.name} on KYCnot.me`,
identifier: [user.name, user.id.toString()], identifier: [user.name, user.id.toString()],
jobTitle: user.admin ? 'Administrator' : user.verifier ? 'Moderator' : undefined, jobTitle: user.admin ? 'Administrator' : user.verifier ? 'Moderator' : undefined,
memberOf: KYCNOTME_SCHEMA_MINI, memberOf: KYCNOTME_SCHEMA_MINI,
@@ -240,53 +242,55 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
]} ]}
> >
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center gap-4"> <header class="flex flex-wrap items-center justify-center gap-4">
{ <div class="flex grow flex-wrap items-center justify-center gap-4">
user.picture ? ( {
<MyPicture user.picture ? (
src={user.picture} <MyPicture
alt="" src={user.picture}
class="ring-day-500/30 size-16 rounded-full ring-2" alt=""
width={64} class="ring-day-500/30 xs:size-14 size-12 rounded-full ring-2 sm:size-16"
height={64} width={64}
/> height={64}
) : ( />
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2"> ) : (
<Icon name="ri:user-3-line" class="size-8" /> <div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" />
</div>
)
}
<div class="grow">
<h1 class="font-title text-lg font-bold tracking-wider text-white">
{user.displayName ?? user.name}
{isCurrentUser && <span class="text-day-500 font-normal">(You)</span>}
</h1>
{user.displayName && <p class="text-day-200 font-title">{user.name}</p>}
<div class="mt-1 flex gap-2">
{
user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
)
}
</div> </div>
)
}
<div>
<h1 class="font-title text-lg font-bold tracking-wider text-white">
{user.name}
{isCurrentUser && <span class="text-day-500 font-normal">(You)</span>}
</h1>
{user.displayName && <p class="text-day-200">{user.displayName}</p>}
<div class="mt-1 flex gap-2">
{
user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
)
}
</div> </div>
</div> </div>
<nav class="ml-auto flex items-center gap-2"> <nav class="flex items-center gap-2">
<AdminOnly> <AdminOnly>
<Tooltip <Tooltip
as="a" as="a"
@@ -321,7 +325,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
) )
} }
<a <a
href={`mailto:${SUPPORT_EMAIL}`} href={`mailto:${SUPPORT_EMAIL}?subject=User report - ${user.displayName ?? user.name}&body=${Astro.locals.user ? `I'm ${Astro.locals.user.displayName ? `${Astro.locals.user.displayName} (${Astro.locals.user.name})` : user.name}. ` : ''}I'm reporting the user ${user.displayName ? `"${user.displayName}" (${user.name})` : `"${user.name}"`} because...`}
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm 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" class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm 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:alert-line" class="size-4" /> Report <Icon name="ri:alert-line" class="size-4" /> Report
@@ -521,7 +525,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<TimeFormatted date={note.createdAt} hourPrecision /> <TimeFormatted date={note.createdAt} hourPrecision />
</span> </span>
</div> </div>
<div class="text-day-200 whitespace-pre-wrap">{note.content}</div> <div class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
</div> </div>
))} ))}
</div> </div>
@@ -630,77 +634,38 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3"> <div class="space-y-3">
<h3 class="font-title border-day-700/20 text-day-200 border-b pb-2 text-sm">Positive unlocks</h3> <input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
<label
for="positive-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
{ <div class="mt-3 hidden space-y-3 peer-checked:block md:block">
sortBy( {
karmaUnlocks.filter((unlock) => unlock.karma >= 0), sortBy(
'karma' karmaUnlocks.filter((unlock) => unlock.karma >= 0),
).map((unlock) => ( 'karma'
<div ).map((unlock) => (
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn('font-medium', user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400')}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
</div>
<div class="space-y-3">
<h3 class="font-title border-b border-red-500/20 pb-2 text-sm text-red-400">Negative unlocks</h3>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div <div
class={cn( class={cn(
'flex items-center justify-between rounded-md border p-3', 'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id] user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10' ? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800' : 'border-night-500 bg-night-800'
)} )}
> >
<div class="flex items-center"> <div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}> <span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" /> <Icon name={unlock.icon} class="size-5" />
</span> </span>
<div> <div>
<p <p
class={cn( class={cn(
'font-medium', 'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400' user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)} )}
> >
{unlock.name} {unlock.name}
@@ -710,24 +675,85 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
<div> <div>
{user.karmaUnlocks[unlock.id] ? ( {user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400"> <span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active <Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span> </span>
) : ( ) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs"> <span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided <Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span> </span>
)} )}
</div> </div>
</div> </div>
)) ))
} }
</div>
</div>
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs"> <div class="space-y-3">
<Icon name="ri:information-line" class="inline-block size-4" /> <input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties. <label
</p> for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
</span>
)}
</div>
</div>
))
}
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties.
</p>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -978,13 +1004,10 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<Icon name={actionInfo.icon} class="size-4" /> <Icon name={actionInfo.icon} class="size-4" />
{actionInfo.label} {actionInfo.label}
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && ( {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
<a <>
href={`/u/${transaction.grantedBy.name}`} <span class="text-day-500">from</span>
class="text-day-500 ml-1 hover:underline" <UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
> </>
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
by {transaction.grantedBy.displayName || transaction.grantedBy.name}
</a>
)} )}
</span> </span>
</td> </td>

View File

@@ -93,15 +93,30 @@
--color-night-950: oklch(11.97% 0.004 145.32); --color-night-950: oklch(11.97% 0.004 145.32);
} }
@layer utilities { @utility text-shadow-glow {
.text-shadow-glow { text-shadow:
text-shadow: 0 0 16px color-mix(in oklab, currentColor 30%, transparent),
0 0 16px color-mix(in oklab, currentColor 30%, transparent), 0 0 4px color-mix(in oklab, currentColor 60%, transparent);
0 0 4px color-mix(in oklab, currentColor 60%, transparent); }
@utility drop-shadow-glow {
filter: drop-shadow(0 0 16px color-mix(in oklab, currentColor 30%, transparent))
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
}
@utility checkbox-force-checked {
&:not(:checked) {
@apply border-transparent! bg-current/50!;
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e") !important;
} }
.drop-shadow-glow { }
filter: drop-shadow(0 0 16px color-mix(in oklab, currentColor 30%, transparent))
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent)); @theme {
--animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running;
@keyframes text-gradient {
to {
background-position: -200%;
}
} }
} }