mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Multiuser support (#60)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
40
components/CompletionCountBadge.tsx
Normal file
40
components/CompletionCountBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} /> */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
93
components/PasswordEntryForm.tsx
Normal file
93
components/PasswordEntryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
components/PermissionSelector.tsx
Normal file
107
components/PermissionSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
286
components/UserForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
components/UserSelectModal.tsx
Normal file
213
components/UserSelectModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user