Multiuser support (#60)

This commit is contained in:
Doh
2025-02-18 23:43:23 -05:00
committed by GitHub
parent 363b31e934
commit 8ac2ec053d
48 changed files with 2678 additions and 271 deletions

View File

@@ -1,42 +1,56 @@
'use client'
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Info, SmilePlus } from 'lucide-react'
import { Info, SmilePlus, Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Habit } from '@/lib/types'
import { Habit, SafeUser } from '@/lib/types'
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants'
import * as chrono from 'chrono-node';
import { DateTime } from 'luxon'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useHelpers } from '@/lib/client-helpers'
interface AddEditHabitModalProps {
onClose: () => void
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
habit?: Habit | null
isTask: boolean
}
export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) {
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
const [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const [name, setName] = useState(habit?.name || '')
const [description, setDescription] = useState(habit?.description || '')
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const isRecurRule = !isTasksView
const isRecurRule = !isTask
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE
const [ruleText, setRuleText] = useState<string>(origRuleText)
const now = getNow({ timezone: settings.system.timezone })
const { currentUser } = useHelpers()
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom)
const users = usersData.users
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -47,7 +61,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [],
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }),
isTask: isTasksView ? true : undefined
isTask: isTask || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
})
}
@@ -55,7 +70,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{habit ? `Edit ${isTasksView ? 'Task' : 'Habit'}` : `Add New ${isTasksView ? 'Task' : 'Habit'}`}</DialogTitle>
<DialogTitle>{habit ? `Edit ${isTask ? 'Task' : 'Habit'}` : `Add New ${isTask ? 'Task' : 'Habit'}`}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
@@ -115,13 +130,47 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
When *
</Label>
<div className="col-span-3 space-y-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/>
<div className="flex gap-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
/>
{isTask && (
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
<span>
@@ -216,9 +265,41 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</div>
</div>
</div>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">Share</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTasksView ? 'Task' : 'Habit'}`}</Button>
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTask ? 'Task' : 'Habit'}`}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -1,4 +1,8 @@
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -33,7 +37,10 @@ export default function AddEditWishlistItemModal({
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '')
const { currentUser } = useHelpers()
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
useEffect(() => {
if (editingItem) {
@@ -93,7 +100,8 @@ export default function AddEditWishlistItemModal({
description,
coinCost,
targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined
link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
}
if (editingItem) {
@@ -268,6 +276,38 @@ export default function AddEditWishlistItemModal({
)}
</div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">Share</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button>

View File

@@ -1,12 +1,24 @@
'use client'
import { ReactNode } from 'react'
import { ReactNode, useEffect } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom } from '@/lib/atoms'
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
import PomodoroTimer from './PomodoroTimer'
import UserSelectModal from './UserSelectModal'
import { useSession } from 'next-auth/react'
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const { data: session, status } = useSession()
const currentUserId = session?.user.id
useEffect(() => {
if (status === 'loading') return
if (!currentUserId && !userSelect) {
setUserSelect(true)
}
}, [currentUserId, status, userSelect])
return (
<>
@@ -14,6 +26,9 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
{pomo.show && (
<PomodoroTimer />
)}
{userSelect && (
<UserSelectModal onClose={() => setUserSelect(false)}/>
)}
</>
)
}

View File

@@ -5,14 +5,16 @@ import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { FormattedNumber } from '@/components/FormattedNumber'
import { History, Pencil } from 'lucide-react'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { settingsAtom } from '@/lib/atoms'
import { settingsAtom, usersAtom } from '@/lib/atoms'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { useCoins } from '@/hooks/useCoins'
import { TransactionNoteEditor } from './TransactionNoteEditor'
import { useHelpers } from '@/lib/client-helpers'
export default function CoinsManager() {
const {
@@ -28,10 +30,12 @@ export default function CoinsManager() {
transactionsToday
} = useCoins()
const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom)
const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const [pageSize, setPageSize] = useState(50)
const [currentPage, setCurrentPage] = useState(1)
const { currentUser } = useHelpers()
const [note, setNote] = useState('')
@@ -252,6 +256,17 @@ export default function CoinsManager() {
>
{transaction.type.split('_').join(' ')}
</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()}` || ""}
/>
<AvatarFallback>
{usersData.users.find(u => u.id === transaction.userId)?.username[0]}
</AvatarFallback>
</Avatar>
)}
</div>
<p className="text-sm text-gray-500">
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}

View File

@@ -0,0 +1,40 @@
import { Badge } from '@/components/ui/badge'
import { Habit } from '@/lib/types'
import { isHabitDue, getCompletionsForDate } from '@/lib/utils'
interface CompletionCountBadgeProps {
habits: Habit[]
selectedDate: luxon.DateTime
timezone: string
type: 'tasks' | 'habits'
}
export function CompletionCountBadge({ habits, selectedDate, timezone, type }: CompletionCountBadgeProps) {
const filteredHabits = habits.filter(habit => {
const isTask = type === 'tasks'
if ((habit.isTask === isTask) && isHabitDue({
habit,
timezone,
date: selectedDate
})) {
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone })
return completions >= (habit.targetCompletions || 1)
}
return false
}).length
const totalHabits = habits.filter(habit =>
(habit.isTask === (type === 'tasks')) &&
isHabitDue({
habit,
timezone,
date: selectedDate
})
).length
return (
<Badge variant="secondary">
{`${filteredHabits}/${totalHabits} Completed`}
</Badge>
)
}

View File

@@ -1,4 +1,4 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer } from 'lucide-react'
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
@@ -9,7 +9,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom } from '@/lib/atoms'
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -18,6 +18,8 @@ import { WishlistItemType } from '@/lib/types'
import { Habit } from '@/lib/types'
import Linkify from './linkify'
import { useHabits } from '@/hooks/useHabits'
import AddEditHabitModal from './AddEditHabitModal'
import { Button } from './ui/button'
interface UpcomingItemsProps {
habits: Habit[]
@@ -32,24 +34,33 @@ export default function DailyOverview({
}: UpcomingItemsProps) {
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
const [dailyTasks, setDailyTasks] = useState<Habit[]>([])
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = completedHabitsMap.get(today) || []
const isTasksView = browserSettings.viewType === 'tasks'
const { saveHabit } = useHabits()
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
useEffect(() => {
// Filter habits that are due today based on their recurrence rule
// Filter habits and tasks that are due today and not archived
const filteredHabits = habits.filter(habit =>
(isTasksView ? habit.isTask : !habit.isTask) &&
!habit.isTask &&
!habit.archived &&
isHabitDueToday({ habit, timezone: settings.system.timezone })
)
const filteredTasks = habits.filter(habit =>
habit.isTask &&
isHabitDueToday({ habit, timezone: settings.system.timezone })
)
setDailyHabits(filteredHabits)
}, [habits, isTasksView])
setDailyTasks(filteredTasks)
}, [habits])
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
// Filter out archived wishlist items
const sortedWishlistItems = wishlistItems
.filter(item => !item.archived)
.sort((a, b) => {
const aRedeemable = a.coinCost <= coinBalance
const bRedeemable = b.coinCost <= coinBalance
@@ -64,8 +75,17 @@ export default function DailyOverview({
})
const [expandedHabits, setExpandedHabits] = useState(false)
const [expandedTasks, setExpandedTasks] = useState(false)
const [expandedWishlist, setExpandedWishlist] = useState(false)
const [hasTasks] = useAtom(hasTasksAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
}>({
isOpen: false,
isTask: false
});
return (
<>
@@ -74,17 +94,271 @@ export default function DailyOverview({
<CardTitle>Today's Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{isTasksView ? 'Daily Tasks' : 'Daily Habits'}</h3>
<Badge variant="secondary">
{`${dailyHabits.filter(habit => {
const completions = (completedHabitsMap.get(today) || [])
.filter(h => h.id === habit.id).length;
return completions >= (habit.targetCompletions || 1);
}).length}/${dailyHabits.length} Completed`}
</Badge>
<div className="space-y-6">
{/* Tasks Section */}
{hasTasks && dailyTasks.length === 0 ? (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Tasks</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: true
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Task</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
No tasks due today. Add some tasks to get started!
</div>
</div>
) : hasTasks && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Daily Tasks</h3>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{`${dailyTasks.filter(task => {
const completions = (completedHabitsMap.get(today) || [])
.filter(h => h.id === task.id).length;
return completions >= (task.targetCompletions || 1);
}).length}/${dailyTasks.length} Completed`}
</Badge>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: true
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Task</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedTasks ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyTasks
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, expandedTasks ? undefined : 5)
.map((habit) => {
const completionsToday = habit.completions.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target || (habit.isTask && habit.archived)
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-none">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</ContextMenuTrigger>
<span className={isCompleted ? 'line-through' : ''}>
<Linkify>
{habit.name}
</Linkify>
</span>
<ContextMenuContent className="w-64">
<ContextMenuItem onClick={() => {
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedTasks(!expandedTasks)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedTasks ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits?view=tasks"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' }))}
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
)}
{/* Habits Section */}
{dailyHabits.length === 0 ? (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: false
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Habit</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
No habits due today. Add some habits to get started!
</div>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Daily Habits</h3>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{`${dailyHabits.filter(habit => {
const completions = (completedHabitsMap.get(today) || [])
.filter(h => h.id === habit.id).length;
return completions >= (habit.targetCompletions || 1);
}).length}/${dailyHabits.length} Completed`}
</Badge>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: false
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Habit</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyHabits
@@ -234,12 +508,14 @@ export default function DailyOverview({
<Link
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'habits' }))}
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
@@ -339,6 +615,17 @@ export default function DailyOverview({
</div>
</CardContent>
</Card>
{modalConfig.isOpen && (
<AddEditHabitModal
onClose={() => setModalConfig({ isOpen: false, isTask: false })}
onSave={async (habit) => {
await saveHabit({ ...habit, isTask: modalConfig.isTask })
setModalConfig({ isOpen: false, isTask: false });
}}
habit={null}
isTask={modalConfig.isTask}
/>
)}
</>
)
}

View File

@@ -1,19 +1,18 @@
'use client'
import { useAtom } from 'jotai'
import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits'
import { ViewToggle } from './ViewToggle'
import { useCoins } from '@/hooks/useCoins'
export default function Dashboard() {
const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const [coins] = useAtom(coinsAtom)
const coinBalance = coins.balance
const { balance } = useCoins()
const [wishlist] = useAtom(wishlistAtom)
const wishlistItems = wishlist.items
@@ -21,15 +20,14 @@ export default function Dashboard() {
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<ViewToggle />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CoinBalance coinBalance={coinBalance} />
<CoinBalance coinBalance={balance} />
<HabitStreak habits={habits} />
<DailyOverview
wishlistItems={wishlistItems}
habits={habits}
coinBalance={coinBalance}
coinBalance={balance}
/>
{/* <HabitHeatmap habits={habits} /> */}

View File

@@ -3,13 +3,13 @@
import { useState, useMemo, useCallback } from 'react'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { CompletionCountBadge } from '@/components/CompletionCountBadge'
import { Button } from '@/components/ui/button'
import { Check, Circle, CircleCheck } from 'lucide-react'
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms'
import { DateTime } from 'luxon'
import Linkify from './linkify'
import { Habit } from '@/lib/types'
@@ -27,6 +27,7 @@ export default function HabitCalendar() {
const [settings] = useAtom(settingsAtom)
const [selectedDate, setSelectedDate] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const [habitsData] = useAtom(habitsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const habits = habitsData.habits
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
@@ -39,9 +40,9 @@ export default function HabitCalendar() {
}, [completedHabitsMap, settings.system.timezone])
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="container mx-auto px-4 py-6">
<h1 className="text-2xl font-semibold mb-6">Habit Calendar</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Calendar</CardTitle>
@@ -62,7 +63,7 @@ export default function HabitCalendar() {
)
}}
modifiersClassNames={{
completed: 'bg-green-100 text-green-800 font-bold',
completed: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 font-medium rounded-md',
}}
/>
</CardContent>
@@ -71,7 +72,7 @@ export default function HabitCalendar() {
<CardHeader>
<CardTitle>
{selectedDate ? (
<>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: "yyyy-MM-dd" })}</>
<>{d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
) : (
'Select a date'
)}
@@ -79,19 +80,95 @@ export default function HabitCalendar() {
</CardHeader>
<CardContent>
{selectedDate && (
<ul className="space-y-2">
{habits
.filter(habit => isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDate
}))
.map((habit) => {
<div className="space-y-8">
{hasTasks && (
<div className="pt-2 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3>
<CompletionCountBadge
habits={habits}
selectedDate={selectedDate}
timezone={settings.system.timezone}
type="tasks"
/>
</div>
<ul className="space-y-3">
{habits
.filter(habit => habit.isTask && isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDate
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDate)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
</ul>
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3>
<CompletionCountBadge
habits={habits}
selectedDate={selectedDate}
timezone={settings.system.timezone}
type="habits"
/>
</div>
<ul className="space-y-3">
{habits
.filter(habit => !habit.isTask && !habit.archived && isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDate
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2">
<span>
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
@@ -129,8 +206,10 @@ export default function HabitCalendar() {
</div>
</li>
)
})}
</ul>
})}
</ul>
</div>
</div>
)}
</CardContent>
</Card>

View File

@@ -1,10 +1,10 @@
import { Habit } from '@/lib/types'
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore } from 'lucide-react'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,6 +16,8 @@ import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
import { DateTime } from 'luxon'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
interface HabitItemProps {
habit: Habit
@@ -23,16 +25,38 @@ interface HabitItemProps {
onDelete: () => void
}
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return null;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return null
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits()
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
const [settings] = useAtom(settingsAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
const completionsToday = habit.completions?.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
).length || 0
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false)
const [usersData] = useAtom(usersAtom)
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('habit', 'write')
const canInteract = hasPermission('habit', 'interact')
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const isRecurRule = !isTasksView
@@ -62,9 +86,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.name}</CardTitle>
<div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
<span>{habit.name}</span>
{isTaskOverdue(habit, settings.system.timezone) && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
Overdue
</span>
)}
</CardTitle>
{renderUserAvatars(habit, currentUser as User, usersData)}
</div>
{habit.description && (
<CardDescription className={`whitespace-pre-line ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
)}
@@ -83,7 +117,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant={isCompletedToday ? "secondary" : "default"}
size="sm"
onClick={async () => await completeHabit(habit)}
disabled={habit.archived || (isCompletedToday && completionsToday >= target)}
disabled={!canInteract || habit.archived || (isCompletedToday && completionsToday >= target)}
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
>
<Check className="h-4 w-4 sm:mr-2" />
@@ -121,6 +155,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant="outline"
size="sm"
onClick={async () => await undoComplete(habit)}
disabled={!canWrite}
className="w-10 sm:w-auto"
>
<Undo2 className="h-4 w-4" />
@@ -134,6 +169,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
@@ -149,6 +185,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<DropdownMenuContent align="end">
{!habit.archived && (
<DropdownMenuItem onClick={() => {
if (!canInteract) return
setPomo((prev) => ({
...prev,
show: true,
@@ -160,13 +197,23 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</DropdownMenuItem>
)}
{!habit.archived && (
<DropdownMenuItem onClick={() => archiveHabit(habit.id)}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
<>
{habit.isTask && (
<DropdownMenuItem disabled={!canWrite} onClick={() => {
saveHabit({...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone })})})
}}>
<Calendar className="mr-2 h-4 w-4" />
<span>Move to Today</span>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!canWrite} onClick={() => archiveHabit(habit.id)}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
</>
)}
{habit.archived && (
<DropdownMenuItem onClick={() => unarchiveHabit(habit.id)}>
<DropdownMenuItem disabled={!canWrite} onClick={() => unarchiveHabit(habit.id)}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>

View File

@@ -12,6 +12,7 @@ import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types'
import { useHabits } from '@/hooks/useHabits'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { ViewToggle } from './ViewToggle'
export default function HabitList() {
const { saveHabit, deleteHabit } = useHabits()
@@ -24,7 +25,13 @@ export default function HabitList() {
const activeHabits = habits.filter(h => !h.archived)
const archivedHabits = habits.filter(h => h.archived)
const [settings] = useAtom(settingsAtom)
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
}>({
isOpen: false,
isTask: false
})
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
isOpen: false,
@@ -34,14 +41,17 @@ export default function HabitList() {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
</Button>
</div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
</h1>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
</Button>
</div>
<div className='py-4'>
<ViewToggle />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{activeHabits.length === 0 ? (
<div className="col-span-2">
@@ -58,7 +68,7 @@ export default function HabitList() {
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setIsModalOpen(true)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
@@ -78,7 +88,7 @@ export default function HabitList() {
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setIsModalOpen(true)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
@@ -86,18 +96,19 @@ export default function HabitList() {
</>
)}
</div>
{isModalOpen &&
{modalConfig.isOpen &&
<AddEditHabitModal
onClose={() => {
setIsModalOpen(false)
setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null)
}}
onSave={async (habit) => {
await saveHabit({ ...habit, id: editingHabit?.id })
setIsModalOpen(false)
await saveHabit({ ...habit, id: editingHabit?.id, isTask: modalConfig.isTask })
setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null)
}}
habit={editingHabit}
isTask={modalConfig.isTask}
/>
}
<ConfirmDialog

View File

@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { settingsAtom, hasTasksAtom } from '@/lib/atoms'
interface HabitStreakProps {
habits: Habit[]
@@ -13,6 +13,7 @@ interface HabitStreakProps {
export default function HabitStreak({ habits }: HabitStreakProps) {
const [settings] = useAtom(settingsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
// Get the last 7 days of data
const dates = Array.from({ length: 7 }, (_, i) => {
const d = getNow({ timezone: settings.system.timezone });
@@ -20,21 +21,27 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
}).reverse()
const completions = dates.map(date => {
const completedCount = getCompletedHabitsForDate({
habits,
const completedHabits = getCompletedHabitsForDate({
habits: habits.filter(h => !h.isTask),
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
timezone: settings.system.timezone
}).length;
});
const completedTasks = getCompletedHabitsForDate({
habits: habits.filter(h => h.isTask),
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
timezone: settings.system.timezone
});
return {
date,
completed: completedCount
habits: completedHabits.length,
tasks: completedTasks.length
};
});
return (
<Card>
<CardHeader>
<CardTitle>Daily Habit Completion Streak</CardTitle>
<CardTitle>Daily Completion Streak</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full aspect-[2/1]">
@@ -51,14 +58,25 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(value) => [`${value} habits`, 'Completed']} />
<Tooltip formatter={(value, name) => [`${value} ${name}`, 'Completed']} />
<Line
type="monotone"
dataKey="completed"
name="habits"
dataKey="habits"
stroke="#14b8a6"
strokeWidth={2}
dot={false}
/>
{hasTasks && (
<Line
type="monotone"
name="tasks"
dataKey="tasks"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>

View File

@@ -28,9 +28,8 @@ const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: fals
export default function Header({ className }: HeaderProps) {
const [settings] = useAtom(settingsAtom)
const [coins] = useAtom(coinsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const { balance } = useCoins()
return (
<>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -44,7 +43,7 @@ export default function Header({ className }: HeaderProps) {
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber
amount={coins.balance}
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>

View File

@@ -3,7 +3,7 @@ import { Sparkles } from "lucide-react"
export function Logo() {
return (
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
{/* <Sparkles className="h-6 w-6 text-primary" /> */}
<span className="font-bold text-xl">HabitTrove</span>
</div>
)

View File

@@ -0,0 +1,93 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { User as UserIcon } from 'lucide-react';
import { Permission, User } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useState } from 'react';
interface PasswordEntryFormProps {
user: User;
onCancel: () => void;
onSubmit: (password: string) => Promise<void>;
error?: string;
}
export default function PasswordEntryForm({
user,
onCancel,
onSubmit,
error
}: PasswordEntryFormProps) {
const hasPassword = !!user.password;
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await onSubmit(password);
} catch (err) {
toast({
title: "Error",
description: err instanceof Error ? err.message : 'Login failed',
variant: "destructive"
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
<Avatar className="h-24 w-24">
<AvatarImage
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
/>
<AvatarFallback>
<UserIcon className="h-12 w-12" />
</AvatarFallback>
</Avatar>
<div className="text-center">
<div className="font-medium text-lg">
{user.username}
</div>
<button
type="button"
onClick={onCancel}
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
>
Not you?
</button>
</div>
</div>
{hasPassword && <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
/>
{error && (
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
)}
</div>
</div>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={hasPassword && !password}>
Login
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { Switch } from './ui/switch';
import { Label } from './ui/label';
import { Permission } from '@/lib/types';
interface PermissionSelectorProps {
permissions: Permission[];
isAdmin: boolean;
onPermissionsChange: (permissions: Permission[]) => void;
onAdminChange: (isAdmin: boolean) => void;
}
const permissionLabels: { [key: string]: string } = {
habit: 'Habit / Task',
wishlist: 'Wishlist',
coins: 'Coins'
};
export function PermissionSelector({
permissions,
isAdmin,
onPermissionsChange,
onAdminChange,
}: PermissionSelectorProps) {
const currentPermissions = isAdmin ?
{
habit: { write: true, interact: true },
wishlist: { write: true, interact: true },
coins: { write: true, interact: true }
} :
permissions[0] || {
habit: { write: false, interact: true },
wishlist: { write: false, interact: true },
coins: { write: false, interact: true }
};
const handlePermissionChange = (resource: keyof Permission, type: 'write' | 'interact', checked: boolean) => {
const newPermissions = [{
...currentPermissions,
[resource]: {
...currentPermissions[resource],
[type]: checked
}
}];
onPermissionsChange(newPermissions);
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Permissions</Label>
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
<div className="flex items-center gap-2">
<div className="font-medium text-sm">Admin Access</div>
</div>
<Switch
id="isAdmin"
className="h-4 w-7"
checked={isAdmin}
onCheckedChange={onAdminChange}
/>
</div>
{isAdmin ? (
<p className="text-xs text-muted-foreground px-3">
Admins have full permission to all data for all users
</p>
) : (
<div className="grid grid-cols-3 gap-4">
{['habit', 'wishlist', 'coins'].map((resource) => (
<div key={resource} className="p-3 space-y-3 rounded-lg border bg-muted/50">
<div className="font-medium capitalize text-sm border-b pb-2">{permissionLabels[resource]}</div>
<div className="flex flex-col gap-2.5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">Write</Label>
<Switch
id={`${resource}-write`}
className="h-4 w-7"
checked={currentPermissions[resource as keyof Permission].write}
onCheckedChange={(checked) =>
handlePermissionChange(resource as keyof Permission, 'write', checked)
}
/>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">Interact</Label>
<Switch
id={`${resource}-interact`}
className="h-4 w-7"
checked={currentPermissions[resource as keyof Permission].interact}
onCheckedChange={(checked) =>
handlePermissionChange(resource as keyof Permission, 'interact', checked)
}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -3,26 +3,52 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Settings, Info, User, Moon, Sun, Palette } from "lucide-react"
import { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm'
import Link from "next/link"
import { useAtom } from "jotai"
import { settingsAtom } from "@/lib/atoms"
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
import AboutModal from "./AboutModal"
import { useState } from "react"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { signOut } from "@/app/actions/user"
import { toast } from "@/hooks/use-toast"
import { useHelpers } from "@/lib/client-helpers"
export function Profile() {
const [settings] = useAtom(settingsAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false)
const [showAbout, setShowAbout] = useState(false)
const { theme, setTheme } = useTheme()
const { currentUser: user } = useHelpers()
const [open, setOpen] = useState(false)
const handleSignOut = async () => {
try {
await signOut()
toast({
title: "Signed out successfully",
description: "You have been logged out of your account",
})
setTimeout(() => window.location.reload(), 300);
} catch (error) {
toast({
title: "Error",
description: "Failed to sign out",
variant: "destructive",
})
}
}
return (
<>
<DropdownMenu>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={settings?.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
@@ -30,6 +56,58 @@ export function Profile() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] p-2">
<div className="px-2 py-1.5 mb-2 border-b">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col mr-4">
<span className="text-sm font-semibold flex items-center gap-1">
{user?.username || "Guest"}
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
</span>
{user && (
<button
onClick={(e) => {
e.stopPropagation();
setOpen(false);
setIsEditing(true);
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
>
Edit profile
</button>
)}
</div>
{user && (
<button
onClick={(e) => {
e.stopPropagation();
setOpen(false);
handleSignOut();
}}
className="border border-primary/50 text-primary rounded-md p-1.5 transition-colors hover:bg-primary/10 hover:border-primary active:scale-95"
>
<LogOut className="h-4 w-4" />
</button>
)}
</div>
</div>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
setOpen(false); // Close the dropdown
setUserSelect(true); // Open the user select modal
}}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-4 w-4" />
<span>Switch user</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
<Link
href="/settings"
@@ -56,12 +134,15 @@ export function Profile() {
<span>Theme</span>
</div>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
onClick={(e) => {
e.stopPropagation();
setTheme(theme === 'dark' ? 'light' : 'dark');
}}
className={`
w-12 h-6 rounded-full relative transition-all duration-300 ease-in-out
hover:scale-105 shadow-inner
${theme === 'dark'
? 'bg-blue-600/90 hover:bg-blue-600'
${theme === 'dark'
? 'bg-blue-600/90 hover:bg-blue-600'
: 'bg-gray-200 hover:bg-gray-300'
}
`}
@@ -87,6 +168,25 @@ export function Profile() {
</DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* Add the UserForm dialog */}
{isEditing && user && (
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
</DialogHeader>
<UserForm
userId={user.id}
onCancel={() => setIsEditing(false)}
onSuccess={() => {
setIsEditing(false);
window.location.reload();
}}
/>
</DialogContent>
</Dialog>
)}
</>
)
}
}

286
components/UserForm.tsx Normal file
View File

@@ -0,0 +1,286 @@
'use client';
import { useState } from 'react';
import { passwordSchema, usernameSchema } from '@/lib/zod';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
import { Permission } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useAtom } from 'jotai';
import { usersAtom } from '@/lib/atoms';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import { SafeUser, User } from '@/lib/types';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { User as UserIcon } from 'lucide-react';
import _ from 'lodash';
import { PermissionSelector } from './PermissionSelector';
import { useHelpers } from '@/lib/client-helpers';
interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating
onCancel: () => void;
onSuccess: () => void;
}
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
const [users, setUsersData] = useAtom(usersAtom);
const user = userId ? users.users.find(u => u.id === userId) : undefined;
const { currentUser } = useHelpers()
const getDefaultPermissions = (): Permission[] => [{
habit: {
write: true,
interact: true
},
wishlist: {
write: true,
interact: true
},
coins: {
write: true,
interact: true
}
}];
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
const [username, setUsername] = useState(user?.username || '');
const [password, setPassword] = useState<string | undefined>('');
const [disablePassword, setDisablePassword] = useState(user?.password === '' || process.env.NEXT_PUBLIC_DEMO === 'true');
const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
const [permissions, setPermissions] = useState<Permission[]>(
user?.permissions || getDefaultPermissions()
);
const isEditing = !!user;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Validate username
const usernameResult = usernameSchema.safeParse(username);
if (!usernameResult.success) {
setError(usernameResult.error.errors[0].message);
return;
}
// Validate password unless disabled
if (!disablePassword && password) {
const passwordResult = passwordSchema.safeParse(password);
if (!passwordResult.success) {
setError(passwordResult.error.errors[0].message);
return;
}
}
if (isEditing) {
// Update existing user
if (username !== user.username || avatarPath !== user.avatarPath || !_.isEqual(permissions, user.permissions) || isAdmin !== user.isAdmin) {
await updateUser(user.id, { username, avatarPath, permissions, isAdmin });
}
// Handle password update
if (disablePassword) {
await updateUserPassword(user.id, undefined);
} else if (password) {
await updateUserPassword(user.id, password);
}
setUsersData(prev => ({
...prev,
users: prev.users.map(u =>
u.id === user.id ? {
...u,
username,
avatarPath,
permissions,
isAdmin,
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
} : u
),
}));
toast({
title: "User updated",
description: `Successfully updated user ${username}`,
variant: 'default'
});
} else {
// Create new user
const formData = new FormData();
formData.append('username', username);
if (disablePassword) {
formData.append('password', '');
} else if (password) {
formData.append('password', password);
}
formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions));
formData.append('isAdmin', JSON.stringify(isAdmin));
formData.append('avatarPath', avatarPath || '');
const newUser = await createUser(formData);
setUsersData(prev => ({
...prev,
users: [...prev.users, newUser]
}));
toast({
title: "User created",
description: `Successfully created user ${username}`,
variant: 'default'
});
}
setPassword('');
setError('');
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} user`);
}
};
const handleAvatarChange = async (file: File) => {
if (file.size > 5 * 1024 * 1024) {
toast({
title: "Error",
description: "File size must be less than 5MB",
variant: 'destructive'
});
return;
}
const formData = new FormData();
formData.append('avatar', file);
try {
const path = await uploadAvatar(formData);
setAvatarPath(path);
setAvatarFile(null); // Clear the file since we've uploaded it
toast({
title: "Avatar uploaded",
description: "Successfully uploaded avatar",
variant: 'default'
});
} catch (err) {
toast({
title: "Error",
description: "Failed to upload avatar",
variant: 'destructive'
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6 max-h-[80vh] overflow-y-auto p-4">
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
<Avatar className="h-24 w-24">
<AvatarImage
src={avatarPath && `/api/avatars/${avatarPath.split('/').pop()}`}
alt={username}
/>
<AvatarFallback>
<UserIcon className="h-12 w-12" />
</AvatarFallback>
</Avatar>
<div>
<input
type="file"
id="avatar"
name="avatar"
accept="image/png, image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleAvatarChange(file);
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.getElementById('avatar') as HTMLInputElement;
input.value = ''; // Reset input to allow selecting same file again
input.click();
}}
className="w-full"
>
{isEditing ? 'Change Avatar' : 'Upload Avatar'}
</Button>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={error ? 'border-red-500' : ''}
/>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">
{isEditing ? 'New Password' : 'Password'}
</Label>
<Input
id="password"
type="password"
placeholder={isEditing ? "Leave blank to keep current" : "Enter password"}
value={password || ''}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
disabled={disablePassword}
/>
{process.env.NEXT_PUBLIC_DEMO === 'true' && (
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
)}
</div>
<div className="flex items-center space-x-2">
<Switch
id="disable-password"
checked={disablePassword}
onCheckedChange={setDisablePassword}
/>
<Label htmlFor="disable-password">Disable password</Label>
</div>
</div>
{error && (
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
)}
{currentUser && currentUser.isAdmin && <PermissionSelector
permissions={permissions}
isAdmin={isAdmin}
onPermissionsChange={setPermissions}
onAdminChange={setIsAdmin}
/>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
>
Cancel
</Button>
<Button type="submit" disabled={!username}>
{isEditing ? 'Save Changes' : 'Create User'}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import { useState } from 'react';
import PasswordEntryForm from './PasswordEntryForm';
import UserForm from './UserForm';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { useAtom } from 'jotai';
import { usersAtom } from '@/lib/atoms';
import { signIn } from '@/app/actions/user';
import { createUser } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast';
import { Description } from '@radix-ui/react-dialog';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
function UserCard({
user,
onSelect,
onEdit,
showEdit,
isCurrentUser
}: {
user: User,
onSelect: () => void,
onEdit: () => void,
showEdit: boolean,
isCurrentUser: boolean
}) {
return (
<div key={user.id} className="relative group">
<button
onClick={onSelect}
className={cn(
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
isCurrentUser && "ring-2 ring-primary"
)}
>
<Avatar className="h-16 w-16">
<AvatarImage
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
alt={user.username}
/>
<AvatarFallback>
<UserIcon className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium flex items-center gap-1">
{user.username}
{user.isAdmin && <Crown className="h-4 w-4 text-yellow-500" />}
</span>
</button>
{showEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="absolute top-0 right-0 p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
>
<UserRoundPen className="h-4 w-4" />
</button>
)}
</div>
);
}
function AddUserButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<Avatar className="h-16 w-16">
<AvatarFallback>
<Plus className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">Add User</span>
</button>
);
}
function UserSelectionView({
users,
currentUser,
onUserSelect,
onEditUser,
onCreateUser
}: {
users: User[],
currentUser?: SafeUser,
onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void,
onCreateUser: () => void
}) {
return (
<div className="grid grid-cols-3 gap-4 p-2">
{users
.filter(user => user.id !== currentUser?.id)
.map((user) => (
<UserCard
key={user.id}
user={user}
onSelect={() => onUserSelect(user.id)}
onEdit={() => onEditUser(user.id)}
showEdit={!!currentUser?.isAdmin}
isCurrentUser={false}
/>
))}
{currentUser?.isAdmin && <AddUserButton onClick={onCreateUser} />}
</div>
);
}
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
const [selectedUser, setSelectedUser] = useState<string>();
const [isCreating, setIsCreating] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [error, setError] = useState('');
const [usersData] = useAtom(usersAtom);
const users = usersData.users;
const {currentUser} = useHelpers();
const handleUserSelect = (userId: string) => {
setSelectedUser(userId);
setError('');
};
const handleEditUser = (userId: string) => {
setSelectedUser(userId);
setIsEditing(true);
};
const handleCreateUser = () => {
setIsCreating(true);
};
const handleFormSuccess = () => {
setSelectedUser(undefined);
setIsCreating(false);
setIsEditing(false);
onClose();
};
const handleFormCancel = () => {
setSelectedUser(undefined);
setIsCreating(false);
setIsEditing(false);
setError('');
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<Description></Description>
<DialogHeader>
<DialogTitle>{isCreating ? 'Create New User' : 'Select User'}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{!selectedUser && !isCreating && !isEditing ? (
<UserSelectionView
users={users}
currentUser={currentUser}
onUserSelect={handleUserSelect}
onEditUser={handleEditUser}
onCreateUser={handleCreateUser}
/>
) : isCreating || isEditing ? (
<UserForm
userId={isEditing ? selectedUser : undefined}
onCancel={handleFormCancel}
onSuccess={handleFormSuccess}
/>
) : (
<PasswordEntryForm
user={users.find(u => u.id === selectedUser)!}
onCancel={() => setSelectedUser(undefined)}
onSubmit={async (password) => {
try {
setError('');
const user = users.find(u => u.id === selectedUser);
if (!user) throw new Error("User not found");
await signIn(user.username, password);
setError('');
onClose();
toast({
title: "Signed in successfully",
description: `Welcome back, ${user.username}!`,
variant: "default"
});
setTimeout(() => window.location.reload(), 300);
} catch (err) {
setError('invalid password');
throw err;
}
}}
error={error}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,8 @@
import { WishlistItemType } from '@/lib/types'
import { WishlistItemType, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
@@ -24,6 +28,25 @@ interface WishlistItemProps {
isArchived?: boolean
}
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
if (!item.userIds || item.userIds.length <= 1) return null;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return null
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function WishlistItem({
item,
onEdit,
@@ -35,6 +58,11 @@ export default function WishlistItem({
isHighlighted,
isRecentlyRedeemed
}: WishlistItemProps) {
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('wishlist', 'write')
const canInteract = hasPermission('wishlist', 'interact')
const [usersData] = useAtom(usersAtom)
return (
<Card
id={`wishlist-${item.id}`}
@@ -53,11 +81,16 @@ export default function WishlistItem({
</span>
)}
</div>
{item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
<div className="flex items-center justify-between">
<div className="flex-1">
{item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
</div>
{renderUserAvatars(item, currentUser as User, usersData)}
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center gap-2">
@@ -73,7 +106,7 @@ export default function WishlistItem({
variant={canRedeem ? "default" : "secondary"}
size="sm"
onClick={onRedeem}
disabled={!canRedeem || item.archived}
disabled={!canRedeem || !canInteract || item.archived}
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`}
>
<Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
@@ -98,6 +131,7 @@ export default function WishlistItem({
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
@@ -112,13 +146,13 @@ export default function WishlistItem({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!item.archived && (
<DropdownMenuItem onClick={onArchive}>
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
)}
{item.archived && (
<DropdownMenuItem onClick={onUnarchive}>
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>
@@ -131,6 +165,7 @@ export default function WishlistItem({
<DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
onClick={onDelete}
disabled={!canWrite}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete

View File

@@ -1,6 +1,6 @@
'use client'
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom } from "@/lib/atoms"
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom } from "@/lib/atoms"
import { useHydrateAtoms } from "jotai/utils"
import { JotaiHydrateInitialValues } from "@/lib/types"
@@ -12,7 +12,8 @@ export function JotaiHydrate({
[settingsAtom, initialValues.settings],
[habitsAtom, initialValues.habits],
[coinsAtom, initialValues.coins],
[wishlistAtom, initialValues.wishlist]
[wishlistAtom, initialValues.wishlist],
[usersAtom, initialValues.users]
])
return children
}