Added notification for admin user (#106)

This commit is contained in:
Doh
2025-04-13 22:01:07 -04:00
committed by GitHub
parent e53e2f649a
commit 909bfa7c6f
14 changed files with 723 additions and 47 deletions

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react' // Import useEffect, useRef
import { useSearchParams } from 'next/navigation' // Import useSearchParams
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { FormattedNumber } from '@/components/FormattedNumber'
@@ -37,8 +38,32 @@ export default function CoinsManager() {
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const [pageSize, setPageSize] = useState(50)
const [currentPage, setCurrentPage] = useState(1)
const [note, setNote] = useState('')
const searchParams = useSearchParams()
const highlightId = searchParams.get('highlight')
const userIdFromQuery = searchParams.get('user') // Get user ID from query
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Effect to set selected user from query param if admin
useEffect(() => {
if (currentUser?.isAdmin && userIdFromQuery && userIdFromQuery !== selectedUser) {
// Check if the user ID from query exists in usersData
if (usersData.users.some(u => u.id === userIdFromQuery)) {
setSelectedUser(userIdFromQuery);
}
}
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
}, [userIdFromQuery, currentUser, usersData.users]);
// Effect to scroll to highlighted transaction
useEffect(() => {
if (highlightId && transactionRefs.current[highlightId]) {
transactionRefs.current[highlightId]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [highlightId, transactions]); // Re-run if highlightId or transactions change
const handleSaveNote = async (transactionId: string, note: string) => {
await updateNote(transactionId, note)
@@ -249,13 +274,17 @@ export default function CoinsManager() {
}
}
const isHighlighted = transaction.id === highlightId;
return (
<div
key={transaction.id}
className="flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
ref={(el) => { transactionRefs.current[transaction.id] = el; }} // Assign ref correctly
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
}`}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
{transaction.relatedItemId ? (
<Link
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
@@ -274,12 +303,13 @@ export default function CoinsManager() {
</span>
{transaction.userId && currentUser?.isAdmin && (
<Avatar className="h-6 w-6">
<AvatarImage
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath &&
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` || ""}
<AvatarImage
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` : undefined}
alt={usersData.users.find(u => u.id === transaction.userId)?.username}
/>
<AvatarFallback>
{usersData.users.find(u => u.id === transaction.userId)?.username[0]}
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
</AvatarFallback>
</Avatar>
)}
@@ -294,14 +324,16 @@ export default function CoinsManager() {
onDelete={handleDeleteNote}
/>
</div>
<span
className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span>
<div className="flex-shrink-0 text-right"> {/* Ensure amount stays on the right */}
<span
className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span>
</div>
</div>
)
})}

View File

@@ -1,13 +1,14 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/Logo'
import NotificationBell from './NotificationBell'
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,6 +20,7 @@ import AboutModal from './AboutModal'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { Profile } from './Profile'
import { useHelpers } from '@/lib/client-helpers'
interface HeaderProps {
className?: string
@@ -52,9 +54,7 @@ export default function Header({ className }: HeaderProps) {
</div>
</div>
</Link>
<Button variant="ghost" size="icon" aria-label="Notifications">
<Bell className="h-5 w-5" />
</Button>
<NotificationBell />
<Profile />
</div>
</div>

View File

@@ -0,0 +1,133 @@
'use client'
import { useMemo } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import NotificationDropdown from './NotificationDropdown';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
import { User, CoinTransaction } from '@/lib/types';
export default function NotificationBell() {
const { currentUser } = useHelpers();
const [coinsData] = useAtom(coinsAtom)
const [habitsData] = useAtom(habitsAtom)
const [wishlistData] = useAtom(wishlistAtom)
const [usersData] = useAtom(usersAtom);
// --- Calculate Unread and Read Notifications ---
const { unreadNotifications, displayedReadNotifications } = useMemo(() => {
const unread: CoinTransaction[] = [];
const read: CoinTransaction[] = [];
const MAX_READ_NOTIFICATIONS = 10; // Limit the number of past notifications shown
if (!currentUser || !currentUser.id) {
return { unreadNotifications: [], displayedReadNotifications: [] };
}
const lastReadTimestamp = currentUser.lastNotificationReadTimestamp
? t2d({ timestamp: currentUser.lastNotificationReadTimestamp, timezone: 'UTC' })
: null;
// Iterate through transactions (assuming they are sorted newest first)
for (const tx of coinsData.transactions) {
// Stop processing if we have enough read notifications
if (read.length >= MAX_READ_NOTIFICATIONS && (!lastReadTimestamp || t2d({ timestamp: tx.timestamp, timezone: 'UTC' }) <= lastReadTimestamp)) {
break; // Optimization: stop early if we have enough read and are past the unread ones
}
// Basic checks: must have a related item and be triggered by someone else
if (!tx.relatedItemId || tx.userId === currentUser.id) {
continue;
}
// Check if the transaction type indicates a notification-worthy event
const isRelevantType = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION' || tx.type === 'WISH_REDEMPTION';
if (!isRelevantType) {
continue;
}
// Check if the related item is shared with the current user
let isShared = false;
const isHabitCompletion = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION';
const isWishRedemption = tx.type === 'WISH_REDEMPTION';
if (isHabitCompletion) {
const habit = habitsData.habits.find(h => h.id === tx.relatedItemId);
if (habit?.userIds?.includes(currentUser.id) && tx.userId && habit.userIds.includes(tx.userId)) {
isShared = true;
}
} else if (isWishRedemption) {
const wish = wishlistData.items.find(w => w.id === tx.relatedItemId);
if (wish?.userIds?.includes(currentUser.id) && tx.userId && wish.userIds.includes(tx.userId)) {
isShared = true;
}
}
if (!isShared) {
continue; // Skip if not shared
}
// Transaction is relevant, determine if read or unread
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
if (!lastReadTimestamp || txTimestamp > lastReadTimestamp) {
unread.push(tx);
} else if (read.length < MAX_READ_NOTIFICATIONS) {
// Only add to read if we haven't hit the limit
read.push(tx);
}
}
// Transactions are assumed to be sorted newest first from the source
return { unreadNotifications: unread, displayedReadNotifications: read };
}, [coinsData.transactions, habitsData.habits, wishlistData.items, currentUser]);
// --- End Calculate Notifications ---
const unreadCount = unreadNotifications.length;
const handleNotificationClick = async () => {
if (!currentUser || !currentUser.id || unreadCount === 0) return; // Only update if there are unread notifications
try {
const nowTimestamp = d2t({ dateTime: getNow({}) });
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
} catch (error) {
console.error("Failed to update notification read timestamp:", error);
}
};
return (
<DropdownMenu onOpenChange={(open) => {
// Update timestamp only when opening the dropdown and there are unread notifications
if (open && unreadCount > 0) {
handleNotificationClick();
}
}}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 block h-2 w-2 rounded-full bg-red-500 ring-1 ring-white dark:ring-gray-800" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as useHelpers can return undefined initially
unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down
wishlistData={wishlistData}
usersData={usersData}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CoinsData, HabitsData, WishlistData, UserData, User, CoinTransaction } from '@/lib/types';
import { t2d } from '@/lib/utils';
import Link from 'next/link';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Info } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface NotificationDropdownProps {
currentUser: User | null;
unreadNotifications: CoinTransaction[];
displayedReadNotifications: CoinTransaction[];
habitsData: HabitsData; // Keep needed props
wishlistData: WishlistData;
usersData: UserData;
}
// Helper function to generate notification message
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
const username = triggeringUser?.username || 'Someone';
const itemName = relatedItemName || 'a shared item';
switch (tx.type) {
case 'HABIT_COMPLETION':
case 'TASK_COMPLETION':
return `${username} completed ${itemName}.`;
case 'WISH_REDEMPTION':
return `${username} redeemed ${itemName}.`;
// Add other relevant transaction types if needed
default:
return `Activity related to ${itemName} by ${username}.`; // Fallback message
}
};
// Helper function to get the name of the related item
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
if (!tx.relatedItemId) return undefined;
if (tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION') {
return habitsData.habits.find(h => h.id === tx.relatedItemId)?.name;
}
if (tx.type === 'WISH_REDEMPTION') {
return wishlistData.items.find(w => w.id === tx.relatedItemId)?.name;
}
return undefined;
};
export default function NotificationDropdown({
currentUser,
unreadNotifications, // Use props directly
displayedReadNotifications, // Use props directly
habitsData,
wishlistData,
usersData,
}: NotificationDropdownProps) {
if (!currentUser) {
return <div className="p-4 text-sm text-gray-500">Not logged in.</div>;
}
// Removed the useMemo block for calculating notifications
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
const message = getNotificationMessage(tx, triggeringUser, relatedItemName);
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
const timeAgo = txTimestamp.toRelative(); // e.g., "2 hours ago"
// Add the triggering user's ID to the query params if it exists
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
return (
// Wrap the Link with DropdownMenuItem and use asChild to pass props
<DropdownMenuItem key={tx.id} asChild className={`p-0 focus:bg-inherit dark:focus:bg-inherit cursor-pointer`}>
<Link href={linkHref} className={`block hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${isUnread ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} scroll={true}>
<div className="p-3 flex items-start gap-3">
<Avatar className="h-8 w-8 mt-1">
<AvatarImage src={triggeringUser?.avatarPath ? `/api/avatars/${triggeringUser.avatarPath.split('/').pop()}` : undefined} alt={triggeringUser?.username} />
<AvatarFallback>{triggeringUser?.username?.charAt(0).toUpperCase() || '?'}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className={`text-sm ${isUnread ? 'font-semibold' : ''}`}>{message}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</p>
</div>
</div>
</Link>
</DropdownMenuItem>
);
};
return (
<TooltipProvider>
{/* Removed the outer div as width is now set on DropdownMenuContent in NotificationBell */}
<>
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<h4 className="text-sm font-medium">Notifications</h4>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">
Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)
</p>
</TooltipContent>
</Tooltip>
</div>
<ScrollArea className="h-[400px]">
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
<div className="p-4 text-center text-sm text-gray-500">No notifications yet.</div>
)}
{unreadNotifications.length > 0 && (
<>
{unreadNotifications.map(tx => renderNotification(tx, true))}
{displayedReadNotifications.length > 0 && <Separator className="my-2" />}
</>
)}
{displayedReadNotifications.length > 0 && (
<>
{displayedReadNotifications.map(tx => renderNotification(tx, false))}
</>
)}
</ScrollArea>
</> {/* Close the fragment */}
</TooltipProvider>
);
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }