Compare commits

...

5 Commits

Author SHA1 Message Date
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
24 changed files with 542 additions and 377 deletions

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

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

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

@@ -613,9 +613,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)

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'
@@ -981,6 +982,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 +1021,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 +1325,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

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

@@ -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 line-clamp-3 bg-[linear-gradient(90deg,var(--gradient-edge,#FFEBF9)_0%,var(--gradient-center,#8a56cc)_50%,var(--gradient-edge,#FFEBF9)_100%)] 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

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

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

@@ -35,6 +35,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">
@@ -158,17 +159,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

@@ -9,56 +9,66 @@ 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:information-fill',
classNames: { classNames: {
container: 'bg-blue-900/40 border-blue-500/30', container: 'bg-blue-950',
title: 'text-blue-400', bg: 'from-blue-400 to-blue-700',
content: 'text-blue-300', content: '[--gradient-edge:var(--color-blue-100)] [--gradient-center:var(--color-blue-200)]',
icon: 'text-blue-400/80',
badge: 'bg-blue-900/30 text-blue-400',
}, },
}), }),
[ [
{ {
value: 'INFO', value: 'INFO',
label: 'Info', label: 'Info',
icon: 'ri:information-line', icon: 'ri:information-fill',
classNames: { classNames: {
container: 'bg-blue-900/40 border-blue-500/30', container: 'bg-green-950',
title: 'text-blue-400', bg: 'from-green-400 to-green-700',
content: 'text-blue-300', content: '[--gradient-edge:var(--color-green-100)] [--gradient-center:var(--color-lime-200)]',
icon: 'text-green-400/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-yellow-100)] [--gradient-center:var(--color-amber-200)]',
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-200)]',
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

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

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

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

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

@@ -336,6 +336,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

@@ -4,7 +4,6 @@ import { z } from 'astro:schema'
import { groupBy, orderBy } from 'lodash-es' import { groupBy, orderBy } 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 Pagination from '../components/Pagination.astro' import Pagination from '../components/Pagination.astro'
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' import ServiceFiltersPill from '../components/ServiceFiltersPill.astro'
@@ -186,8 +185,7 @@ if (redirectUrl) return Astro.redirect(redirectUrl.toString())
export type ServicesFiltersObject = typeof filters export type ServicesFiltersObject = typeof filters
const currentDate = new Date() const [categories, [services, totalServices, hadToIncludeCommunityContributed]] =
const [categories, [services, totalServices, hadToIncludeCommunityContributed], activeAnnouncements] =
await Astro.locals.banners.tryMany([ await Astro.locals.banners.tryMany([
[ [
'Unable to load category filters.', 'Unable to load category filters.',
@@ -411,28 +409,6 @@ const [categories, [services, totalServices, hadToIncludeCommunityContributed],
}, },
[[] as [], 0, false] as const, [[] as [], 0, false] as const,
], ],
[
'Unable to load announcements.',
() =>
prisma.announcement.findMany({
where: {
isActive: true,
startDate: { lte: currentDate },
OR: [{ endDate: null }, { endDate: { gt: currentDate } }],
},
select: {
id: true,
title: true,
content: true,
type: true,
startDate: true,
endDate: true,
isActive: true,
},
orderBy: [{ type: 'desc' }, { createdAt: 'desc' }],
}),
[],
],
]) ])
const attributes = await Astro.locals.banners.try( const attributes = await Astro.locals.banners.try(
@@ -528,8 +504,6 @@ 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'