Compare commits

..

2 Commits

Author SHA1 Message Date
pluja
8bdbe8ea36 small updates 2025-05-20 10:29:03 +00:00
pluja
af7ebe813b announcements style 2025-05-20 10:20:09 +00:00
14 changed files with 462 additions and 337 deletions

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)

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

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

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:

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

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