diff --git a/web/prisma/migrations/20250520083518_add_announcement_link/migration.sql b/web/prisma/migrations/20250520083518_add_announcement_link/migration.sql new file mode 100644 index 0000000..056afe7 --- /dev/null +++ b/web/prisma/migrations/20250520083518_add_announcement_link/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Announcement" ADD COLUMN "link" TEXT; diff --git a/web/prisma/migrations/20250520090825_announcement_link/migration.sql b/web/prisma/migrations/20250520090825_announcement_link/migration.sql new file mode 100644 index 0000000..55e3766 --- /dev/null +++ b/web/prisma/migrations/20250520090825_announcement_link/migration.sql @@ -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"; diff --git a/web/prisma/migrations/20250520092201_link_text/migration.sql b/web/prisma/migrations/20250520092201_link_text/migration.sql new file mode 100644 index 0000000..510cfce --- /dev/null +++ b/web/prisma/migrations/20250520092201_link_text/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Announcement" ADD COLUMN "linkText" TEXT; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 41a5fcf..abeec0d 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -613,9 +613,10 @@ model ServiceUser { model Announcement { id Int @id @default(autoincrement()) - title String content String type AnnouncementType + link String? + linkText String? startDate DateTime endDate DateTime? isActive Boolean @default(true) diff --git a/web/scripts/faker.ts b/web/scripts/faker.ts index 19803c0..cb082a0 100755 --- a/web/scripts/faker.ts +++ b/web/scripts/faker.ts @@ -14,6 +14,7 @@ import { EventType, type User, ServiceUserRole, + AnnouncementType, } from '@prisma/client' import { uniqBy } from 'lodash-es' import { generateUsername } from 'unique-username-generator' @@ -981,6 +982,22 @@ const generateFakeInternalNote = (userId: number, addedByUserId?: number) => addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined, }) 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() { await prisma.$transaction( async (tx) => { @@ -1004,6 +1021,7 @@ async function runFaker() { await tx.category.deleteMany() await tx.internalUserNote.deleteMany() await tx.user.deleteMany() + await tx.announcement.deleteMany() console.info('✅ Existing data cleaned up') } catch (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 diff --git a/web/src/actions/admin/announcement.ts b/web/src/actions/admin/announcement.ts index 6e2d4a6..3f9adbc 100644 --- a/web/src/actions/admin/announcement.ts +++ b/web/src/actions/admin/announcement.ts @@ -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 { z } from 'zod' @@ -9,9 +9,10 @@ const prisma = prismaInstance as PrismaClient const selectAnnouncementReturnFields = { id: true, - title: true, content: true, type: true, + link: true, + linkText: true, startDate: true, endDate: true, isActive: true, @@ -24,12 +25,18 @@ export const adminAnnouncementActions = { accept: 'form', permissions: 'admin', input: z.object({ - title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'), content: z .string() .min(1, 'Content is required') .max(1000, 'Content must be less than 1000 characters'), type: z.enum(['INFO', 'WARNING', 'ALERT']), + 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(), endDate: z.coerce.date().nullable().optional(), isActive: z.coerce.boolean().default(true), @@ -37,8 +44,13 @@ export const adminAnnouncementActions = { handler: async (input) => { const announcement = await prisma.announcement.create({ data: { - ...input, - endDate: input.endDate || null, + content: input.content, + type: input.type, + startDate: input.startDate, + isActive: input.isActive, + link: input.link ?? null, + linkText: input.linkText ?? null, + endDate: input.endDate ?? null, }, select: selectAnnouncementReturnFields, }) @@ -52,12 +64,18 @@ export const adminAnnouncementActions = { permissions: 'admin', input: z.object({ id: z.coerce.number().int().positive(), - title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'), content: z .string() .min(1, 'Content is required') .max(1000, 'Content must be less than 1000 characters'), type: z.enum(['INFO', 'WARNING', 'ALERT']), + 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(), endDate: z.coerce.date().nullable().optional(), isActive: z.coerce.boolean().default(true), @@ -82,8 +100,13 @@ export const adminAnnouncementActions = { const updatedAnnouncement = await prisma.announcement.update({ where: { id: announcement.id }, data: { - ...input, - endDate: input.endDate || null, + content: input.content, + type: input.type, + startDate: input.startDate, + isActive: input.isActive, + link: input.link ?? null, + linkText: input.linkText ?? null, + endDate: input.endDate ?? null, }, select: selectAnnouncementReturnFields, }) diff --git a/web/src/components/AnnouncementBanner.astro b/web/src/components/AnnouncementBanner.astro index edfa25e..a15108f 100644 --- a/web/src/components/AnnouncementBanner.astro +++ b/web/src/components/AnnouncementBanner.astro @@ -1,57 +1,89 @@ --- import { Icon } from 'astro-icon/components' -import { Markdown } from 'astro-remote' import { getAnnouncementTypeInfo } from '../constants/announcementTypes' import { cn } from '../lib/cn' import type { Prisma } from '@prisma/client' +import type { HTMLAttributes } from 'astro/types' -type Props = { - announcements: - | Prisma.AnnouncementGetPayload<{ - select: { - id: true - title: true - content: true - type: true - startDate: true - endDate: true - isActive: true - } - }>[] - | null - | undefined +type Props = HTMLAttributes<'div'> & { + announcement: Prisma.AnnouncementGetPayload<{ + select: { + id: true + content: true + type: true + link: true + linkText: true + startDate: true + endDate: true + isActive: true + } + }> } -const { announcements } = Astro.props +const { announcement, class: className, ...props } = Astro.props + +const typeInfo = getAnnouncementTypeInfo(announcement.type) + +const Tag = announcement.link ? 'a' : 'div' --- -{ - !!announcements && announcements.length > 0 && ( -
- {announcements.map((announcement) => { - const typeInfo = getAnnouncementTypeInfo(announcement.type) - - return ( -
- -
- - {announcement.title} - - - - -
-
- ) - })} + + + + +
+
+ + + {announcement.content} + +
+
+ +
+ {announcement.linkText} + +
+
diff --git a/web/src/components/Header.astro b/web/src/components/Header.astro index a56375e..09ef4d4 100644 --- a/web/src/components/Header.astro +++ b/web/src/components/Header.astro @@ -35,6 +35,7 @@ const splashText = showSplashText ? sample(splashTexts) : null 'border-red-900 bg-red-500/60': !!actualUser, } )} + transition:name="header-container" >