Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useEffect, useState, type ComponentProps } from "react"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { NotificationItem, type NotificationItemType } from "./NotificationItem"
import { IconBell, IconLoader2 } from "@tabler/icons-react"
import { cn } from "@dotkomonline/ui"

interface NotificationDropdownProps extends ComponentProps<typeof DropdownMenu.Root> {
items?: NotificationItemType[]
hasMoreItems?: boolean
isLoading?: boolean
onReadMore?: () => void
}

export const NotificationDropdown = ({
items = [],
hasMoreItems = false,
isLoading,
onReadMore,
open,
...props
}: NotificationDropdownProps) => {
const handleMarkAllAsRead = () => {
console.log("Mark all as read clicked")
}

const handleItemClick = (item: NotificationItemType) => {
// Use to set readAt. The redirection to the item should not happen in this callback.
console.log("Notification clicked:", item)
}

const [infiniteScrollEl, setInfiniteScrollEl] = useState<HTMLDivElement | null>(null)

// If the bottom div is visible within the scroll container, we attempt to fetch more items.
// Using a state-based callback ref so the effect re-fires when the portal mounts the element.
useEffect(() => {
if (infiniteScrollEl === null) {
return
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting) && hasMoreItems) {
onReadMore?.()
}
})
observer.observe(infiniteScrollEl)
return () => {
observer.disconnect()
}
}, [infiniteScrollEl, hasMoreItems, onReadMore])

return (
<DropdownMenu.Root open={open} {...props}>
<DropdownMenu.Trigger asChild>
<button
aria-label="Varslinger"
className="flex items-center justify-center w-10 h-10 rounded-full hover:bg-blue-200 dark:hover:bg-stone-700 transition-colors"
type="button"
>
<IconBell width={24} height={24} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
className={cn(
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2",
"data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50",
"w-102 overflow-hidden",
"rounded-lg shadow-lg",
"bg-white border border-black/10",
"dark:border-white/10 dark:bg-stone-800"
)}
>
<div className="p-5 border-b border-black/10 dark:border-white/10">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-bold text-black dark:text-white">Varslinger</h3>
<button
type="button"
className="text-brand dark:text-blue-300/80 hover:text-brand/80 dark:hover:text-blue-300/60 text-sm font-semibold transition-colors"
onClick={handleMarkAllAsRead}
>
Marker alle som lest
</button>
</div>
<p className="text-black/60 dark:text-white/60 text-xs tracking-wider font-semibold">SISTE OPPDATERINGER</p>
</div>

<div className="max-h-[min(32rem,50vh)] overflow-y-auto">
{items.length ? (
<>
{items.map((item) => (
<NotificationItem key={item.id} notification={item} onItemClick={() => handleItemClick(item)} />
))}
{hasMoreItems && <div ref={setInfiniteScrollEl} className="h-px -mt-px" />}
</>
) : (
!isLoading && <p className="p-6 text-sm text-center text-black/60 dark:text-white/60">Ingen varslinger</p>
)}
{isLoading && <IconLoader2 className="animate-spin size-4 mx-auto my-6 text-black/70 dark:text-white/70" />}
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}
120 changes: 120 additions & 0 deletions apps/web/src/components/Navbar/Notifications/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
IconBookFilled,
IconBriefcaseFilled,
IconCalendarExclamation,
IconCalendarPlus,
IconCalendarTime,
IconCalendarUp,
IconMessageCircleFilled,
type IconProps,
IconSpeakerphone,
IconUsers,
IconVocabulary,
} from "@tabler/icons-react"
import type { ComponentProps, ForwardRefExoticComponent, RefAttributes } from "react"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { Badge, cn } from "@dotkomonline/ui"
import type { NotificationPayloadType, NotificationType } from "@dotkomonline/types"

export interface NotificationItemType {
id: string
createdAt: Date
updatedAt: Date
title: string
shortDescription: string
content: string
type: NotificationType
payload?: string
payloadType: NotificationPayloadType
actorGroupId: string
createdById?: string
lastUpdatedById?: string
taskId?: string
readAt?: Date
}

export const NotificationIconMap: Record<
NotificationType,
ForwardRefExoticComponent<IconProps & RefAttributes<SVGSVGElement>>
> = {
BROADCAST: IconMessageCircleFilled,
BROADCAST_IMPORTANT: IconSpeakerphone,
EVENT_REGISTRATION: IconCalendarPlus,
EVENT_REMINDER: IconCalendarTime,
EVENT_UPDATE: IconCalendarExclamation,
JOB_LISTING_REMINDER: IconBriefcaseFilled,
NEW_ARTICLE: IconBookFilled,
NEW_EVENT: IconCalendarUp,
NEW_INTEREST_GROUP: IconUsers,
NEW_JOB_LISTING: IconBriefcaseFilled,
NEW_OFFLINE: IconVocabulary,
NEW_MARK: IconSpeakerphone,
NEW_FEEDBACK_FORM: IconSpeakerphone,
}

interface NotificationItem extends ComponentProps<typeof DropdownMenu.Item> {
notification: NotificationItemType
onItemClick?: () => void
}

export const NotificationItem = ({ notification, onItemClick, className, ...props }: NotificationItem) => {
const Icon = NotificationIconMap[notification.type]
const isRead = !!notification.readAt
const isImportant = notification.type === "BROADCAST_IMPORTANT"

const unreadItemColor = isImportant
? "border-l-red-500 bg-red-500/5 hover:bg-red-500/10 focus-visible:bg-red-500/10 dark:focus-visible:bg-red-500/15"
: "border-l-blue-500 bg-blue-500/5 hover:bg-blue-500/10 focus-visible:bg-blue-500/10 dark:focus-visible:bg-blue-500/15"
const unreadDotColor = isImportant ? "bg-red-500" : "bg-blue-500"
const unreadTextColor = isImportant ? "text-red-600" : "text-blue-600"
const unreadIconBgColor = isImportant ? "bg-red-500/20" : "bg-blue-500/20"

return (
<DropdownMenu.Item
{...props}
className={cn(
"flex gap-4 px-5 py-4 cursor-pointer border-l-4 focus-visible:outline-none not-last:border-b not-last:border-b-white/10",
{ [unreadItemColor]: !isRead },
{
"opacity-80 border-transparent hover:bg-black/5 focus-visible:bg-black/10 dark:hover:bg-white/5 dark:focus-visible:bg-white/10":
isRead,
},
className
)}
onClick={onItemClick}
>
<div className="flex flex-row gap-3 w-full">
<div>
<div
className={cn(
"rounded-lg flex items-center justify-center h-10 w-10",
{ [unreadIconBgColor]: !isRead },
{ "bg-black/20": isRead }
)}
>
<Icon
className={cn("w-6 h-6", { [unreadTextColor]: !isRead }, { "text-stone-800 dark:text-white": isRead })}
/>
</div>
{!isRead && <div className={cn("h-2 w-2 rounded-full mx-auto mt-2", unreadDotColor)} />}
</div>
<div className="flex flex-col p-0.5 w-full relative">
<p className="text-black/70 dark:text-white/70 text-xs ml-auto absolute top-0 right-0">5 timer siden</p>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded timestamp string will display "5 timer siden" for all notifications regardless of their actual creation time. This should calculate the relative time from notification.createdAt or notification.updatedAt.

// Fix: Replace with dynamic time calculation
<p className="text-black/70 dark:text-white/70 text-xs ml-auto absolute top-0 right-0">
  {formatRelativeTime(notification.createdAt)}
</p>
Suggested change
<p className="text-black/70 dark:text-white/70 text-xs ml-auto absolute top-0 right-0">5 timer siden</p>
<p className="text-black/70 dark:text-white/70 text-xs ml-auto absolute top-0 right-0">{formatRelativeTime(notification.createdAt)}</p>

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


{isImportant && (
<Badge
variant="solid"
color="red"
className={cn("bg-red-500 text-white text-xs py-1 px-2", { "mb-1": isImportant })}
>
Viktig melding
</Badge>
)}

<p className="font-semibold text-black dark:text-white text-sm">{notification.title}</p>
<p className="text-black dark:text-white/80 text-sm">{notification.shortDescription}</p>
</div>
</div>
</DropdownMenu.Item>
)
}
6 changes: 4 additions & 2 deletions apps/web/src/components/Navbar/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { useTheme } from "next-themes"
import Link from "next/link"
import { type FC, Fragment, useEffect, useState } from "react"
import { ThemeToggle } from "./ThemeToggle"
import { NotificationDropdown } from "./Notifications/NotificationDropdown"

const DEBUG_CONTACT_URL =
"https://docs.google.com/forms/d/e/1FAIpQLScvjEqVsiRIYnVqCNqbH_-nmYk3Ux6la8a7KZzsY3sJDbW-iA/viewform"
Expand Down Expand Up @@ -235,8 +236,9 @@ export const ProfileMenu: FC = () => {
}

return (
<div className="flex gap-2 mr-2 lg:mr-0">
<div className="flex gap-1 mr-2 lg:mr-0">
<ContactDebugDropdown />
<NotificationDropdown />
<AvatarDropdown dbUser={dbUser} dbUserIsLoading={dbUserQuery.isLoading} />
</div>
)
Expand Down Expand Up @@ -278,7 +280,7 @@ export const AvatarDropdown: FC<AvatarDropdownProps> = ({ dbUser, dbUserIsLoadin
<button
type="button"
aria-label="Åpne profilmeny"
className="relative rounded-full transition-all duration-200 focus:outline-none"
className="relative rounded-full transition-all duration-200 focus:outline-none ml-1"
>
<Avatar className="h-10 w-10">
<AvatarImage src={user?.imageUrl ?? undefined} alt={user?.name ?? "Profilbilde"} />
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from "./task"
export * from "./user"
export * from "./audit-log"
export * from "./workspace-sync"
export * from "./notifications"
14 changes: 14 additions & 0 deletions packages/types/src/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { schemas } from "@dotkomonline/db/schemas"
import type z from "zod"

export const NotificationSchema = schemas.NotificationSchema.extend({})

export type Notification = z.infer<typeof NotificationSchema>

export const NotificationPayloadTypeSchema = schemas.NotificationPayloadTypeSchema

export type NotificationPayloadType = z.infer<typeof NotificationPayloadTypeSchema>

export const NotificationTypeSchema = schemas.NotificationTypeSchema

export type NotificationType = z.infer<typeof NotificationTypeSchema>
Loading