Skip to content

Commit

Permalink
Merge pull request #207 from crux-bphc/unread-announcements-popup
Browse files Browse the repository at this point in the history
Popup toast for unread announcements, can mark announcements as read
  • Loading branch information
skoriop authored Jul 23, 2024
2 parents d6b4246 + 90f4db7 commit 3395bcc
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 17 deletions.
2 changes: 1 addition & 1 deletion frontend/src/About.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function About() {
</a>
. We thank Arunachala AM, Jason Goveas, Anurav Garg, Dharanikanth
Reddy, Karthik Prakash, Shreyash Dash, Kovid Lakhera, Palaniappan R,
Meghraj Goswami, Soumitra Shewale, Kishan Abijay, Namit Bhutani,
Meghraj Goswami, Soumitra Shewale, Kishan Abijay, Namit Bhutani,
Chaitanya Keyal and Samir Khairati for this rewrite of ChronoFactorem.
</p>
</div>
Expand Down
82 changes: 71 additions & 11 deletions frontend/src/components/announcements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";

import { DialogTrigger } from "@radix-ui/react-dialog";
import { Megaphone } from "lucide-react";
import { useEffect, useState } from "react";
import type { z } from "zod";
import { announcementType } from "../../../lib/src";
import { announcementWithIDType } from "../../../lib/src";
import { Button } from "./ui/button";

const fetchAnnouncements = async (): Promise<
z.infer<typeof announcementType>[]
z.infer<typeof announcementWithIDType>[]
> => {
const response = await axios.get<z.infer<typeof announcementType>[]>(
const response = await axios.get<z.infer<typeof announcementWithIDType>[]>(
"/api/user/announcements",
);
return response.data;
Expand All @@ -29,6 +30,39 @@ function Announcements() {
queryFn: fetchAnnouncements,
});

const { toast } = useToast();

const [readAnnouncements, setReadAnnouncements] = useState<string[]>(() => {
const storedReadAnnouncements = localStorage.getItem("readAnnouncements");
return storedReadAnnouncements ? JSON.parse(storedReadAnnouncements) : [];
});

useEffect(() => {
localStorage.setItem(
"readAnnouncements",
JSON.stringify(readAnnouncements),
);
}, [readAnnouncements]);

useEffect(() => {
const unreadAnnouncements = announcements?.filter(
(announcement) => !readAnnouncements.includes(announcement.id),
);

if (unreadAnnouncements && unreadAnnouncements.length > 0) {
toast({
title: "New Announcements",
description: `You have ${
unreadAnnouncements.length
} unread announcement${unreadAnnouncements.length > 1 ? "s" : ""}.`,
});
}
}, [announcements, readAnnouncements, toast]);

const markAsRead = (id: string) => {
setReadAnnouncements((prev) => [...prev, id]);
};

return (
<Dialog>
<DialogTrigger asChild>
Expand All @@ -44,13 +78,29 @@ function Announcements() {
<div className="flex flex-col-reverse mx-3 mt-1 gap-3 divide-y divide-y-reverse">
{Array.isArray(announcements) && announcements?.length ? (
announcements
?.sort(
(a, b) =>
new Date(a.createdAt as string).getTime() -
new Date(b.createdAt as string).getTime(),
)
?.sort((a, b) => {
const isUnreadA = !readAnnouncements.includes(a.id);
const isUnreadB = !readAnnouncements.includes(b.id);

if (isUnreadA !== isUnreadB) {
return isUnreadA ? -1 : 1;
}

return (
new Date(b.createdAt as string).getTime() -
new Date(a.createdAt as string).getTime()
);
})
.reverse()
.map((announcement) => (
<div className="flex gap-1 flex-col">
<div
key={announcement.id}
className={`flex gap-1 flex-col ${
readAnnouncements.includes(announcement.id)
? "opacity-50"
: ""
}`}
>
<h1 className="font-bold text-base">
{announcement.title}
</h1>
Expand All @@ -60,6 +110,16 @@ function Announcements() {
).toLocaleString()}
</p>
<p className="opacity-90 mb-3">{announcement.message}</p>
{!readAnnouncements.includes(announcement.id) && (
<Button
onClick={() => markAsRead(announcement.id)}
className="mb-3"
size="sm"
variant="outline"
>
Mark as Read
</Button>
)}
</div>
))
) : (
Expand Down
36 changes: 31 additions & 5 deletions lib/src/zodEntityTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,40 @@ export const namedCourseWithSectionsType = (name?: string) =>
export const namedAnnouncementType = (name?: string) =>
z
.object({
title: namedNonEmptyStringType(addNameToString("announcement title", name)),
message: namedNonEmptyStringType(addNameToString("announcement message", name)),
createdAt: namedISOTimestampType(addNameToString("announcement createdAt", name)).optional(),
title: namedNonEmptyStringType(
addNameToString("announcement title", name),
),
message: namedNonEmptyStringType(
addNameToString("announcement message", name),
),
createdAt: namedISOTimestampType(
addNameToString("announcement createdAt", name),
).optional(),
})
.strict({ message: addNameToString("announcement has extra fields", name) });
.strict({
message: addNameToString("announcement has extra fields", name),
});

export const announcementType = namedAnnouncementType();
export const namedAnnouncementWithIDType = (name?: string) =>
z
.object({
id: namedUUIDType(addNameToString("announcement id", name)),
title: namedNonEmptyStringType(
addNameToString("announcement title", name),
),
message: namedNonEmptyStringType(
addNameToString("announcement message", name),
),
createdAt: namedISOTimestampType(
addNameToString("announcement createdAt", name),
).optional(),
})
.strict({
message: addNameToString("announcement has extra fields", name),
});

export const announcementWithIDType = namedAnnouncementWithIDType();
export const announcementType = namedAnnouncementType();
export const userType = namedUserType();
export const timetableType = namedTimetableType();
export const sectionType = namedSectionType();
Expand Down

0 comments on commit 3395bcc

Please sign in to comment.