mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Added notification for admin user (#106)
This commit is contained in:
@@ -1,7 +1 @@
|
|||||||
if git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚'; then
|
npm run typecheck && npm run lint && npm run test
|
||||||
echo "Error: Found debug marker 🪚 in these files:"
|
|
||||||
git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚' | awk -F: '{print " " $1 ":" $2}'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
npm run typecheck && npm run test
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.2.8
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* notification for admin users on shared habit / wishlist completion (#92)
|
||||||
|
|
||||||
## Version 0.2.7
|
## Version 0.2.7
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export async function addCoins({
|
|||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -238,7 +239,7 @@ export async function addCoins({
|
|||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId }),
|
...(relatedItemId && { relatedItemId }),
|
||||||
...(note && note.trim() !== '' && { note }),
|
...(note && note.trim() !== '' && { note }),
|
||||||
userId: userId || await getCurrentUserId()
|
userId: userId || currentUser?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
@@ -283,6 +284,7 @@ export async function removeCoins({
|
|||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -292,7 +294,7 @@ export async function removeCoins({
|
|||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId }),
|
...(relatedItemId && { relatedItemId }),
|
||||||
...(note && note.trim() !== '' && { note }),
|
...(note && note.trim() !== '' && { note }),
|
||||||
userId: userId || await getCurrentUserId()
|
userId: userId || currentUser?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
@@ -383,7 +385,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
throw new Error('Username already exists');
|
throw new Error('Username already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = password ? saltAndHashPassword(password) : '';
|
const hashedPassword = password ? saltAndHashPassword(password) : undefined;
|
||||||
|
|
||||||
|
|
||||||
const newUser: User = {
|
const newUser: User = {
|
||||||
@@ -392,6 +394,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
permissions,
|
permissions,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
lastNotificationReadTimestamp: undefined,
|
||||||
...(avatarPath && { avatarPath })
|
...(avatarPath && { avatarPath })
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -482,6 +485,31 @@ export async function deleteUser(userId: string): Promise<void> {
|
|||||||
await saveUsersData(newData)
|
await saveUsersData(newData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
|
||||||
|
const data = await loadUsersData()
|
||||||
|
const userIndex = data.users.findIndex(user => user.id === userId)
|
||||||
|
|
||||||
|
if (userIndex === -1) {
|
||||||
|
throw new Error('User not found for updating notification timestamp')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = {
|
||||||
|
...data.users[userIndex],
|
||||||
|
lastNotificationReadTimestamp: timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData: UserData = {
|
||||||
|
users: [
|
||||||
|
...data.users.slice(0, userIndex),
|
||||||
|
updatedUser,
|
||||||
|
...data.users.slice(userIndex + 1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUsersData(newData)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function loadServerSettings(): Promise<ServerSettings> {
|
export async function loadServerSettings(): Promise<ServerSettings> {
|
||||||
return {
|
return {
|
||||||
isDemo: !!process.env.DEMO,
|
isDemo: !!process.env.DEMO,
|
||||||
|
|||||||
0
app/actions/wishlist.ts
Normal file
0
app/actions/wishlist.ts
Normal file
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'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 { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
@@ -37,8 +38,32 @@ export default function CoinsManager() {
|
|||||||
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
|
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
|
||||||
const [pageSize, setPageSize] = useState(50)
|
const [pageSize, setPageSize] = useState(50)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
const [note, setNote] = useState('')
|
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) => {
|
const handleSaveNote = async (transactionId: string, note: string) => {
|
||||||
await updateNote(transactionId, note)
|
await updateNote(transactionId, note)
|
||||||
@@ -249,13 +274,17 @@ export default function CoinsManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHighlighted = transaction.id === highlightId;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={transaction.id}
|
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="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
|
||||||
{transaction.relatedItemId ? (
|
{transaction.relatedItemId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
|
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
|
||||||
@@ -275,11 +304,12 @@ export default function CoinsManager() {
|
|||||||
{transaction.userId && currentUser?.isAdmin && (
|
{transaction.userId && currentUser?.isAdmin && (
|
||||||
<Avatar className="h-6 w-6">
|
<Avatar className="h-6 w-6">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath &&
|
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
|
||||||
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` || ""}
|
`/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>
|
<AvatarFallback>
|
||||||
{usersData.users.find(u => u.id === transaction.userId)?.username[0]}
|
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
@@ -294,14 +324,16 @@ export default function CoinsManager() {
|
|||||||
onDelete={handleDeleteNote}
|
onDelete={handleDeleteNote}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div className="flex-shrink-0 text-right"> {/* Ensure amount stays on the right */}
|
||||||
className={`font-mono ${transaction.amount >= 0
|
<span
|
||||||
? 'text-green-600 dark:text-green-400'
|
className={`font-mono ${transaction.amount >= 0
|
||||||
: 'text-red-600 dark:text-red-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
}`}
|
: 'text-red-600 dark:text-red-400'
|
||||||
>
|
}`}
|
||||||
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
|
>
|
||||||
</span>
|
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
|
import NotificationBell from './NotificationBell'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -19,6 +20,7 @@ import AboutModal from './AboutModal'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { Profile } from './Profile'
|
import { Profile } from './Profile'
|
||||||
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -52,9 +54,7 @@ export default function Header({ className }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
<NotificationBell />
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<Profile />
|
<Profile />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
133
components/NotificationBell.tsx
Normal file
133
components/NotificationBell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
components/NotificationDropdown.tsx
Normal file
135
components/NotificationDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal 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 }
|
||||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal 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 }
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getDefaultUsersData,
|
getDefaultUsersData,
|
||||||
CompletionCache,
|
CompletionCache,
|
||||||
getDefaultServerSettings,
|
getDefaultServerSettings,
|
||||||
|
User,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export type SafeUser = SessionUser & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type User = SafeUser & {
|
export type User = SafeUser & {
|
||||||
password: string
|
password?: string // Optional: Allow users without passwords (e.g., initial setup)
|
||||||
|
lastNotificationReadTimestamp?: string // UTC ISO date string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Habit = {
|
export type Habit = {
|
||||||
@@ -101,8 +102,9 @@ export const getDefaultUsersData = (): UserData => ({
|
|||||||
{
|
{
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
password: '',
|
// password: '', // No default password for admin initially? Or set a secure default?
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
268
package-lock.json
generated
268
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.4",
|
"version": "0.2.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.4",
|
"version": "0.2.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-separator": "^1.1.3",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
@@ -1527,6 +1529,189 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-G9rdWTQjOR4sk76HwSdROhPU0jZWpfozn9skU1v4N0/g9k7TmswrJn8W8WMU+aYktnLLpk5LX6fofj2bGe5NFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.2",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.0.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-select": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
|
||||||
@@ -1569,6 +1754,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-2omrWKJvxR0U/tkIXezcc1nFMwtLU0+b/rDK40gnzJqTLWQ/TD/D5IYVefp9sC3QWfeQbpSbEA6op9MQKyaALQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.7",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -25,7 +25,9 @@
|
|||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-separator": "^1.1.3",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user