mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1967d154ed | ||
|
|
9e0ae1e0da | ||
|
|
5ae659469b | ||
|
|
6ef4aacfb8 | ||
|
|
95203426a3 | ||
|
|
b673d54ede | ||
|
|
42c8d14d6d | ||
|
|
3ac311c3fd |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## Version 0.2.21
|
||||
|
||||
### Fixed
|
||||
|
||||
* emoji picker overlay issue (#150)
|
||||
|
||||
## Version 0.2.20
|
||||
|
||||
### Fixed
|
||||
|
||||
* coin balance shows correct value for selected user in coin management view (#151)
|
||||
|
||||
### Improved
|
||||
|
||||
* refactor code to remove client-helpers hook
|
||||
|
||||
## Version 0.2.19
|
||||
|
||||
### Fixed
|
||||
|
||||
* settings button not working
|
||||
* fixed delete dialog modal blocks page interaction (#149)
|
||||
* disable submit button when frequency is invaid
|
||||
|
||||
## Version 0.2.18
|
||||
|
||||
### Improved
|
||||
|
||||
* nicer loading UI (#147)
|
||||
* header and navigation code refactor
|
||||
|
||||
## Version 0.2.17
|
||||
|
||||
### Fixed
|
||||
|
||||
* fix emoji selector (#142)
|
||||
* fix about modal (#145)
|
||||
|
||||
## Version 0.2.16
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# HabitTrove
|
||||
|
||||

|
||||
|
||||
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
||||
|
||||
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
||||
|
||||
@@ -2,7 +2,6 @@ import './globals.css'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { DM_Sans } from 'next/font/google'
|
||||
import { JotaiProvider } from '@/components/jotai-providers'
|
||||
import { Suspense } from 'react'
|
||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
|
||||
import Layout from '@/components/Layout'
|
||||
@@ -11,6 +10,8 @@ import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
|
||||
|
||||
// Inter (clean, modern, excellent readability)
|
||||
@@ -75,7 +76,7 @@ export default async function RootLayout({
|
||||
}}
|
||||
/>
|
||||
<JotaiProvider>
|
||||
<Suspense fallback="loading">
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<JotaiHydrate
|
||||
initialValues={{
|
||||
settings: initialSettings,
|
||||
|
||||
@@ -11,17 +11,16 @@ import ChangelogModal from "./ChangelogModal"
|
||||
import { useState } from "react"
|
||||
|
||||
interface AboutModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
export default function AboutModal({ onClose }: AboutModalProps) {
|
||||
const t = useTranslations('AboutModal')
|
||||
const version = packageJson.version
|
||||
const [changelogOpen, setChangelogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
|
||||
|
||||
@@ -4,31 +4,22 @@ import { useState } from 'react'
|
||||
import { RRule, RRuleSet, rrulestr } from 'rrule'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { settingsAtom, browserSettingsAtom, usersAtom, currentUserAtom } 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, Zap } from 'lucide-react'
|
||||
import { 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, SafeUser } from '@/lib/types'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import ModalOverlay from './ModalOverlay' // Import the new component
|
||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP, MAX_COIN_LIMIT } 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
|
||||
@@ -46,15 +37,15 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||
const isRecurRule = !isTask
|
||||
// Initialize ruleText with the actual frequency string or default, not the display text
|
||||
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||
const { currentUser } = useHelpers()
|
||||
const [currentUser] = useAtom(currentUserAtom)
|
||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null); // State for validation message
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const users = usersData.users
|
||||
@@ -97,266 +88,252 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{habit
|
||||
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
|
||||
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
{t('nameLabel')}
|
||||
</Label>
|
||||
<div className='flex col-span-3 gap-2'>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<SmilePlus className="h-8 w-8" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
setName(prev => {
|
||||
// Add space before emoji if there isn't one already
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji.native}`;
|
||||
})
|
||||
// Focus back on input after selection
|
||||
const input = document.getElementById('name') as HTMLInputElement
|
||||
input?.focus()
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
{t('descriptionLabel')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="recurrence" className="text-right">
|
||||
{t('whenLabel')}
|
||||
</Label>
|
||||
{/* date input (task) */}
|
||||
<div className="col-span-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<>
|
||||
<ModalOverlay />
|
||||
<Dialog open={true} onOpenChange={onClose} modal={false}>
|
||||
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{habit
|
||||
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
|
||||
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
{t('nameLabel')}
|
||||
</Label>
|
||||
<div className='flex col-span-3 gap-2'>
|
||||
<Input
|
||||
id="recurrence"
|
||||
value={ruleText}
|
||||
onChange={(e) => setRuleText(e.target.value)}
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(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>
|
||||
)}
|
||||
<EmojiPickerButton
|
||||
inputIdToFocus="name"
|
||||
onEmojiSelect={(emoji) => {
|
||||
setName(prev => {
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji}`;
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* rrule input (habit) */}
|
||||
<div className="col-start-2 col-span-3 text-sm">
|
||||
{(() => {
|
||||
let displayText = '';
|
||||
let errorMessage: string | null = null;
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||
errorMessage = message;
|
||||
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{displayText}
|
||||
</span>
|
||||
{errorMessage && (
|
||||
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="targetCompletions">
|
||||
{t('completeLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="targetCompletions"
|
||||
type="number"
|
||||
value={targetCompletions}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value)
|
||||
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
|
||||
}}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('timesSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
{t('rewardLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="coinReward"
|
||||
type="number"
|
||||
value={coinReward}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
|
||||
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
|
||||
}}
|
||||
min={0}
|
||||
max={MAX_COIN_LIMIT}
|
||||
required
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinReward(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('coinsSuffix')}
|
||||
</span>
|
||||
</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">{t('shareLabel')}</Label>
|
||||
<Label htmlFor="description" className="text-right">
|
||||
{t('descriptionLabel')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="recurrence" className="text-right">
|
||||
{t('whenLabel')}
|
||||
</Label>
|
||||
{/* date input (task) */}
|
||||
<div className="col-span-3 space-y-2">
|
||||
<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>
|
||||
{/* rrule input (habit) */}
|
||||
<div className="col-start-2 col-span-3 text-sm">
|
||||
{(() => {
|
||||
let displayText = '';
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||
if (message !== errorMessage) { // Only update if it changed to avoid re-renders
|
||||
setErrorMessage(message);
|
||||
}
|
||||
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{displayText}
|
||||
</span>
|
||||
{errorMessage && (
|
||||
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="targetCompletions">
|
||||
{t('completeLabel')}
|
||||
</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]
|
||||
)
|
||||
}}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="targetCompletions"
|
||||
type="number"
|
||||
value={targetCompletions}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value)
|
||||
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
|
||||
}}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('timesSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
{habit
|
||||
? t('saveChangesButton')
|
||||
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
{t('rewardLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="coinReward"
|
||||
type="number"
|
||||
value={coinReward}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
|
||||
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
|
||||
}}
|
||||
min={0}
|
||||
max={MAX_COIN_LIMIT}
|
||||
required
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinReward(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('coinsSuffix')}
|
||||
</span>
|
||||
</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">{t('shareLabel')}</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" disabled={!!errorMessage}>
|
||||
{habit
|
||||
? t('saveChangesButton')
|
||||
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { usersAtom, currentUserAtom } from '@/lib/atoms'
|
||||
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'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { SmilePlus, Info } from 'lucide-react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import ModalOverlay from './ModalOverlay'
|
||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||
|
||||
interface AddEditWishlistItemModalProps {
|
||||
@@ -40,7 +36,7 @@ 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 [currentUser] = useAtom(currentUserAtom)
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
@@ -114,216 +110,205 @@ export default function AddEditWishlistItemModal({
|
||||
} else {
|
||||
addWishlistItem(itemData)
|
||||
}
|
||||
|
||||
|
||||
setIsOpen(false)
|
||||
setEditingItem(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
{t('nameLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
required
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
setName(prev => `${prev}${emoji.native}`)
|
||||
// Focus back on input after selection
|
||||
const input = document.getElementById('name') as HTMLInputElement
|
||||
input?.focus()
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
{t('descriptionLabel')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
{t('costLabel')}
|
||||
<>
|
||||
<ModalOverlay />
|
||||
<Dialog open={true} onOpenChange={handleClose} modal={false}>
|
||||
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
{t('nameLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="coinReward"
|
||||
type="number"
|
||||
value={coinCost}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
|
||||
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
|
||||
}}
|
||||
min={0}
|
||||
max={MAX_COIN_LIMIT}
|
||||
required
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinCost(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('coinsSuffix')}
|
||||
</span>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
required
|
||||
/>
|
||||
<EmojiPickerButton
|
||||
inputIdToFocus="name"
|
||||
onEmojiSelect={(emoji) => {
|
||||
setName(prev => {
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji}`;
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="targetCompletions">
|
||||
{t('redeemableLabel')}
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
{t('descriptionLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="targetCompletions"
|
||||
type="number"
|
||||
value={targetCompletions || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
|
||||
}}
|
||||
min={0}
|
||||
placeholder="∞"
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('timesSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
{errors.targetCompletions && (
|
||||
<div className="text-sm text-red-500">
|
||||
{errors.targetCompletions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="link" className="text-right">
|
||||
{t('linkLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input
|
||||
id="link"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
{errors.link && (
|
||||
<div className="text-sm text-red-500">
|
||||
{errors.link}
|
||||
</div>
|
||||
)}
|
||||
</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">{t('shareLabel')}</Label>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
{t('costLabel')}
|
||||
</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]
|
||||
)
|
||||
}}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="coinReward"
|
||||
type="number"
|
||||
value={coinCost}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
|
||||
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
|
||||
}}
|
||||
min={0}
|
||||
max={MAX_COIN_LIMIT}
|
||||
required
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoinCost(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('coinsSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="targetCompletions">
|
||||
{t('redeemableLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
id="targetCompletions"
|
||||
type="number"
|
||||
value={targetCompletions || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
|
||||
}}
|
||||
min={0}
|
||||
placeholder="∞"
|
||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('timesSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
{errors.targetCompletions && (
|
||||
<div className="text-sm text-red-500">
|
||||
{errors.targetCompletions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="link" className="text-right">
|
||||
{t('linkLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input
|
||||
id="link"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
{errors.link && (
|
||||
<div className="text-sm text-red-500">
|
||||
{errors.link}
|
||||
</div>
|
||||
)}
|
||||
</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">{t('shareLabel')}</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 ? t('saveButton') : t('addButton')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
|
||||
import { ReactNode, Suspense, useEffect, useState } from 'react'
|
||||
import { useAtom, useSetAtom } from 'jotai' // Import useSetAtom
|
||||
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom } from '@/lib/atoms' // Import currentUserIdAtom
|
||||
import PomodoroTimer from './PomodoroTimer'
|
||||
import UserSelectModal from './UserSelectModal'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import AboutModal from './AboutModal'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||
const [pomo] = useAtom(pomodoroAtom)
|
||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
|
||||
const { data: session, status } = useSession()
|
||||
const currentUserId = session?.user.id
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), to prevent SSR hydration errors in the children components
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return
|
||||
if (!currentUserId && !userSelect) {
|
||||
setUserSelect(true)
|
||||
}
|
||||
}, [currentUserId, status, userSelect])
|
||||
}, [currentUserId, status, userSelect, setUserSelect])
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUserIdAtom(currentUserId)
|
||||
}, [currentUserId, setCurrentUserIdAtom])
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
@@ -27,7 +44,10 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||
<PomodoroTimer />
|
||||
)}
|
||||
{userSelect && (
|
||||
<UserSelectModal onClose={() => setUserSelect(false)}/>
|
||||
<UserSelectModal onClose={() => setUserSelect(false)} />
|
||||
)}
|
||||
{aboutOpen && (
|
||||
<AboutModal onClose={() => setAboutOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -10,19 +10,18 @@ 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, usersAtom } from '@/lib/atoms'
|
||||
import { settingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import Link from 'next/link'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { TransactionType } from '@/lib/types'
|
||||
|
||||
export default function CoinsManager() {
|
||||
const t = useTranslations('CoinsManager')
|
||||
const { currentUser } = useHelpers()
|
||||
const [currentUser] = useAtom(currentUserAtom)
|
||||
const [selectedUser, setSelectedUser] = useState<string>()
|
||||
const {
|
||||
add,
|
||||
|
||||
42
components/DesktopNavDisplay.tsx
Normal file
42
components/DesktopNavDisplay.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Link from 'next/link'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface DesktopNavDisplayProps {
|
||||
navItems: NavItemType[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DesktopNavDisplay({ navItems, className }: DesktopNavDisplayProps) {
|
||||
// Filter for items relevant to desktop view, typically 'main' position
|
||||
const desktopNavItems = navItems.filter(item => item.position === 'main');
|
||||
|
||||
return (
|
||||
<div className={`hidden lg:flex lg:flex-shrink-0 ${className || ''}`}>
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{desktopNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label} // Assuming labels are unique
|
||||
href={item.href}
|
||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/EmojiPickerButton.tsx
Normal file
51
components/EmojiPickerButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { SmilePlus } from 'lucide-react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
|
||||
interface EmojiPickerButtonProps {
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
inputIdToFocus?: string // Optional: ID of the input to focus after selection
|
||||
}
|
||||
|
||||
export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: EmojiPickerButtonProps) {
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover modal={false} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8" // Consistent sizing
|
||||
>
|
||||
<SmilePlus className="h-4 w-4" /> {/* Consistent icon size */}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[300px] p-0"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (inputIdToFocus) {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById(inputIdToFocus) as HTMLInputElement;
|
||||
input?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
onEmojiSelect(emoji.native);
|
||||
setIsEmojiPickerOpen(false);
|
||||
// Focus is handled by onCloseAutoFocus
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Habit } from '@/lib/types';
|
||||
import { Habit, User } from '@/lib/types';
|
||||
import { useHabits } from '@/hooks/useHabits';
|
||||
import { useAtom } from 'jotai';
|
||||
import { pomodoroAtom, settingsAtom } from '@/lib/atoms';
|
||||
import { d2t, getNow, isHabitDueToday } from '@/lib/utils';
|
||||
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
|
||||
import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
||||
import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface HabitContextMenuItemsProps {
|
||||
@@ -28,10 +27,10 @@ export function HabitContextMenuItems({
|
||||
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
||||
const [settings] = useAtom(settingsAtom);
|
||||
const [, setPomo] = useAtom(pomodoroAtom);
|
||||
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
||||
const canInteract = hasPermission('habit', 'interact');
|
||||
const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
||||
const canInteract = hasPermission(currentUser, 'habit', 'interact');
|
||||
|
||||
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
|
||||
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react' // Removed unused icons
|
||||
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -18,7 +18,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||
import { DateTime } from 'luxon'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { hasPermission } from '@/lib/utils'
|
||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||
|
||||
interface HabitItemProps {
|
||||
@@ -29,7 +29,7 @@ interface HabitItemProps {
|
||||
|
||||
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 => {
|
||||
@@ -57,9 +57,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||
const t = useTranslations('HabitItem');
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const { currentUser, hasPermission } = useHelpers()
|
||||
const canWrite = hasPermission('habit', 'write')
|
||||
const canInteract = hasPermission('habit', 'interact')
|
||||
const [currentUser] = useAtom(currentUserAtom)
|
||||
const canWrite = hasPermission(currentUser, 'habit', 'write')
|
||||
const canInteract = hasPermission(currentUser, 'habit', 'interact')
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const isRecurRule = !isTasksView
|
||||
@@ -113,11 +113,13 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
{t('whenLabel', { frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
})})}
|
||||
{t('whenLabel', {
|
||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
@@ -190,7 +192,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
<span className="ml-2">{t('editButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import AboutModal from './AboutModal'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Profile } from './Profile'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import HeaderActions from './HeaderActions'
|
||||
|
||||
interface HeaderProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||
|
||||
export default function Header({ className }: HeaderProps) {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const { balance } = useCoins()
|
||||
return (
|
||||
<>
|
||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
||||
@@ -40,23 +16,7 @@ export default function Header({ className }: HeaderProps) {
|
||||
<Link href="/" className="mr-3 sm:mr-4">
|
||||
<Logo />
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
||||
<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={balance}
|
||||
settings={settings}
|
||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
||||
/>
|
||||
<div className="hidden sm:block">
|
||||
<TodayEarnedCoins />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<NotificationBell />
|
||||
<Profile />
|
||||
</div>
|
||||
<HeaderActions />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
38
components/HeaderActions.tsx
Normal file
38
components/HeaderActions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Coins } from 'lucide-react'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Profile } from './Profile'
|
||||
|
||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const { balance } = useCoins()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
||||
<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={balance}
|
||||
settings={settings}
|
||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
||||
/>
|
||||
<div className="hidden sm:block">
|
||||
<TodayEarnedCoins />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<NotificationBell />
|
||||
<Profile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,21 +6,21 @@ import Navigation from './Navigation'
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||
<Header className="sticky top-0 z-50" />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Navigation viewPort='main' />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||
{/* responsive container (optimized for mobile) */}
|
||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||
<ClientWrapper>
|
||||
<ClientWrapper>
|
||||
<Header className="sticky top-0 z-50" />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Navigation viewPort='main' />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||
{/* responsive container (optimized for mobile) */}
|
||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||
{children}
|
||||
</ClientWrapper>
|
||||
</div>
|
||||
</main>
|
||||
<Navigation viewPort='mobile' />
|
||||
</div>
|
||||
</main>
|
||||
<Navigation viewPort='mobile' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClientWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
61
components/LoadingSpinner.tsx
Normal file
61
components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Coins } from 'lucide-react';
|
||||
import { Logo } from '@/components/Logo';
|
||||
|
||||
const subtexts = [
|
||||
"Unearthing your treasures",
|
||||
"Polishing your gems",
|
||||
"Mining for good habits",
|
||||
"Stumbling upon brilliance",
|
||||
"Discovering your potential",
|
||||
"Crafting your success story",
|
||||
"Forging new paths",
|
||||
"Summoning success",
|
||||
"Brewing brilliance",
|
||||
"Charging up your awesome",
|
||||
"Assembling achievements",
|
||||
"Leveling up your day",
|
||||
"Questing for quality",
|
||||
"Unlocking awesomeness",
|
||||
"Plotting your progress",
|
||||
];
|
||||
|
||||
const LoadingSpinner: React.FC = () => {
|
||||
const [currentSubtext, setCurrentSubtext] = useState<string>('Loading your data');
|
||||
const [animatedDots, setAnimatedDots] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const randomIndex = Math.floor(Math.random() * subtexts.length);
|
||||
setCurrentSubtext(subtexts[randomIndex]);
|
||||
|
||||
const dotAnimationInterval = setInterval(() => {
|
||||
setAnimatedDots(prevDots => {
|
||||
if (prevDots.length >= 3) {
|
||||
return '';
|
||||
}
|
||||
return prevDots + '.';
|
||||
});
|
||||
}, 200); // Adjust timing as needed
|
||||
|
||||
return () => clearInterval(dotAnimationInterval);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Coins className="h-12 w-12 animate-bounce text-yellow-500" />
|
||||
<Logo />
|
||||
{currentSubtext && (
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
{currentSubtext}{animatedDots}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
60
components/MobileNavDisplay.tsx
Normal file
60
components/MobileNavDisplay.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import Link from 'next/link'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface MobileNavDisplayProps {
|
||||
navItems: NavItemType[];
|
||||
}
|
||||
|
||||
// detect iOS: https://stackoverflow.com/a/9039885
|
||||
function iOS() {
|
||||
return [
|
||||
'iPad Simulator',
|
||||
'iPhone Simulator',
|
||||
'iPod Simulator',
|
||||
'iPad',
|
||||
'iPhone',
|
||||
'iPod',
|
||||
].includes(navigator.platform)
|
||||
// iPad on iOS 13 detection
|
||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
}
|
||||
|
||||
|
||||
export default function MobileNavDisplay({ navItems }: MobileNavDisplayProps) {
|
||||
// Filter for items relevant to mobile view, typically 'main' and 'bottom' positions
|
||||
const mobileNavItems = navItems.filter(item => item.position === 'main' || item.position === 'bottom');
|
||||
// The original code spread main and bottom items separately, effectively concatenating them.
|
||||
// If specific ordering or duplication was intended, that logic would be here.
|
||||
// For now, a simple filter and map should suffice if all items are distinct.
|
||||
// The original code: [...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')]
|
||||
// This implies that items could be in 'main' or 'bottom'. The current navItems only have 'main'.
|
||||
// A simple combined list is fine.
|
||||
const isIOS = iOS()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
|
||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||
<div className="grid grid-cols-5 w-full">
|
||||
{mobileNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label} // Assuming labels are unique
|
||||
href={item.href}
|
||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
components/ModalOverlay.tsx
Normal file
9
components/ModalOverlay.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* ModalOverlay provides a dimming effect for non-modal dialogs or popovers
|
||||
* that need to appear modal (e.g., to prevent interaction with background elements).
|
||||
* It should be rendered alongside the dialog/popover it's intended to overlay for.
|
||||
* Ensure the dialog/popover has a z-index higher than this overlay (default z-40).
|
||||
*/
|
||||
export default function ModalOverlay() {
|
||||
return <div className="fixed inset-0 z-50 bg-black/80" />
|
||||
}
|
||||
@@ -1,31 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
|
||||
import { Home, Calendar, Gift, Coins } from 'lucide-react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, ElementType } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import AboutModal from './AboutModal'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import MobileNavDisplay from './MobileNavDisplay'
|
||||
import DesktopNavDisplay from './DesktopNavDisplay'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface NavigationProps {
|
||||
className?: string
|
||||
viewPort: ViewPort
|
||||
}
|
||||
|
||||
|
||||
export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
const t = useTranslations('Navigation')
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const { isIOS } = useHelpers()
|
||||
|
||||
const navItems = (isTasksView: boolean) => [
|
||||
const currentNavItems: NavItemType[] = [
|
||||
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
|
||||
{
|
||||
icon: isTasksView ? TaskIcon : HabitIcon,
|
||||
@@ -54,51 +59,12 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
}, [])
|
||||
|
||||
if (viewPort === 'mobile' && isMobileView) {
|
||||
return (
|
||||
<>
|
||||
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
|
||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||
<div className="grid grid-cols-5 w-full">
|
||||
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
</>
|
||||
)
|
||||
return <MobileNavDisplay navItems={currentNavItems} />
|
||||
}
|
||||
|
||||
if (viewPort === 'main' && !isMobileView) {
|
||||
return (
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{navItems(isTasksView).filter(item => item.position === 'main').map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
</div>
|
||||
)
|
||||
return <DesktopNavDisplay navItems={currentNavItems} className={className} />
|
||||
}
|
||||
|
||||
return null // Explicitly return null if no view matches
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
|
||||
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import { Bell } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@@ -14,12 +14,11 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
||||
import { d2t, getNow, t2d } from '@/lib/utils';
|
||||
import { useHelpers } from '@/lib/client-helpers';
|
||||
import { User, CoinTransaction } from '@/lib/types';
|
||||
|
||||
export default function NotificationBell() {
|
||||
const t = useTranslations('NotificationBell');
|
||||
const { currentUser } = useHelpers();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [coinsData] = useAtom(coinsAtom)
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
const [wishlistData] = useAtom(wishlistAtom)
|
||||
@@ -122,7 +121,7 @@ export default function NotificationBell() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
|
||||
<NotificationDropdown
|
||||
currentUser={currentUser as User | null} // Cast needed as useHelpers can return undefined initially
|
||||
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
|
||||
unreadNotifications={unreadNotifications}
|
||||
displayedReadNotifications={displayedReadNotifications}
|
||||
habitsData={habitsData} // Pass necessary data down
|
||||
|
||||
@@ -8,13 +8,11 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import UserForm from './UserForm'
|
||||
import Link from "next/link"
|
||||
import { useAtom } from "jotai"
|
||||
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||
import AboutModal from "./AboutModal"
|
||||
import { aboutOpenAtom, settingsAtom, userSelectAtom, currentUserAtom } from "@/lib/atoms"
|
||||
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"
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
export function Profile() {
|
||||
@@ -22,9 +20,9 @@ export function Profile() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { currentUser: user } = useHelpers()
|
||||
const [user] = useAtom(currentUserAtom)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleSignOut = async () => {
|
||||
@@ -111,27 +109,33 @@ export function Profile() {
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-label={t('settingsLink')}
|
||||
className="flex items-center w-full gap-3"
|
||||
className="flex items-center justify-between w-full"
|
||||
onClick={() => setOpen(false)} // Ensure dropdown closes on click
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t('settingsLink')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t('settingsLink')}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
<button
|
||||
onClick={() => setShowAbout(true)}
|
||||
className="flex items-center w-full gap-3"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span>{t('aboutButton')}</span>
|
||||
</button>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
|
||||
setOpen(false); // Close the dropdown
|
||||
setAboutOpen(true); // Open the about modal
|
||||
}}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span>{t('aboutButton')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>{t('themeLabel')}</span>
|
||||
</div>
|
||||
@@ -169,8 +173,6 @@ export function Profile() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
|
||||
{/* Add the UserForm dialog */}
|
||||
{isEditing && user && (
|
||||
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
||||
|
||||
@@ -21,14 +21,14 @@ import { Switch } from './ui/switch';
|
||||
import { Permission } from '@/lib/types';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||
import { serverSettingsAtom, usersAtom, currentUserAtom } 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
|
||||
@@ -41,7 +41,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
const [users, setUsersData] = useAtom(usersAtom);
|
||||
const serverSettings = useAtomValue(serverSettingsAtom)
|
||||
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
||||
const { currentUser } = useHelpers()
|
||||
const [currentUser] = useAtom(currentUserAtom)
|
||||
const getDefaultPermissions = (): Permission[] => [{
|
||||
habit: {
|
||||
write: true,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useAtom } from 'jotai';
|
||||
import { usersAtom } from '@/lib/atoms';
|
||||
import { usersAtom, currentUserAtom } from '@/lib/atoms';
|
||||
import { signIn } from '@/app/actions/user';
|
||||
import { createUser } from '@/app/actions/data';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@@ -28,7 +28,7 @@ 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,
|
||||
@@ -145,7 +145,7 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
||||
const [error, setError] = useState('');
|
||||
const [usersData, setUsersData] = useAtom(usersAtom);
|
||||
const users = usersData.users;
|
||||
const { currentUser } = useHelpers();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
|
||||
const handleUserSelect = (userId: string) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { WishlistItemType, User } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { usersAtom } from '@/lib/atoms'
|
||||
import { usersAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { hasPermission } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -60,9 +60,9 @@ export default function WishlistItem({
|
||||
isRecentlyRedeemed
|
||||
}: WishlistItemProps) {
|
||||
const t = useTranslations('WishlistItem')
|
||||
const { currentUser, hasPermission } = useHelpers()
|
||||
const canWrite = hasPermission('wishlist', 'write')
|
||||
const canInteract = hasPermission('wishlist', 'interact')
|
||||
const [currentUser] = useAtom(currentUserAtom)
|
||||
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
|
||||
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function WishlistItem({
|
||||
<span className="ml-2">{t('editButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
@@ -166,7 +166,7 @@ export default function WishlistItem({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="sm:hidden" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||
onClick={onDelete}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
|
||||
@@ -152,14 +152,16 @@ export default function WishlistManager() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AddEditWishlistItemModal
|
||||
isOpen={isModalOpen}
|
||||
setIsOpen={setIsModalOpen}
|
||||
editingItem={editingItem}
|
||||
setEditingItem={setEditingItem}
|
||||
addWishlistItem={addWishlistItem}
|
||||
editWishlistItem={editWishlistItem}
|
||||
/>
|
||||
{isModalOpen &&
|
||||
<AddEditWishlistItemModal
|
||||
isOpen={isModalOpen}
|
||||
setIsOpen={setIsModalOpen}
|
||||
editingItem={editingItem}
|
||||
setEditingItem={setEditingItem}
|
||||
addWishlistItem={addWishlistItem}
|
||||
editWishlistItem={editWishlistItem}
|
||||
/>
|
||||
}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmation.isOpen}
|
||||
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAtom } from 'jotai';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
|
||||
import {
|
||||
coinsAtom,
|
||||
@@ -11,11 +12,11 @@ import {
|
||||
coinsBalanceAtom,
|
||||
settingsAtom,
|
||||
usersAtom,
|
||||
currentUserAtom,
|
||||
} from '@/lib/atoms'
|
||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
||||
import { CoinsData, User } from '@/lib/types'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||
|
||||
function handlePermissionCheck(
|
||||
@@ -51,23 +52,59 @@ export function useCoins(options?: { selectedUser?: string }) {
|
||||
const [coins, setCoins] = useAtom(coinsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [users] = useAtom(usersAtom)
|
||||
const { currentUser } = useHelpers()
|
||||
let user: User | undefined;
|
||||
if (!options?.selectedUser) {
|
||||
user = currentUser;
|
||||
} else {
|
||||
user = users.users.find(u => u.id === options.selectedUser)
|
||||
}
|
||||
const [currentUser] = useAtom(currentUserAtom)
|
||||
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
|
||||
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
|
||||
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
|
||||
const [atomTotalEarned] = useAtom(totalEarnedAtom)
|
||||
const [atomTotalSpent] = useAtom(totalSpentAtom)
|
||||
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
|
||||
const [atomTransactionsToday] = useAtom(transactionsTodayAtom);
|
||||
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
|
||||
|
||||
const transactions = useMemo(() => {
|
||||
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id);
|
||||
}, [allCoinsData, targetUser?.id]);
|
||||
|
||||
// Filter transactions for the selectd user
|
||||
const transactions = coins.transactions.filter(t => t.userId === user?.id)
|
||||
const timezone = settings.system.timezone;
|
||||
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
|
||||
const [totalEarned, setTotalEarned] = useState(0);
|
||||
const [totalSpent, setTotalSpent] = useState(0);
|
||||
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
|
||||
const [transactionsToday, setTransactionsToday] = useState<number>(0);
|
||||
const [balance, setBalance] = useState(0);
|
||||
|
||||
const [balance] = useAtom(coinsBalanceAtom)
|
||||
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
||||
const [totalEarned] = useAtom(totalEarnedAtom)
|
||||
const [totalSpent] = useAtom(totalSpentAtom)
|
||||
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
||||
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
||||
useEffect(() => {
|
||||
// Calculate other metrics
|
||||
if (targetUser?.id && targetUser.id === currentUser?.id) {
|
||||
// If the target user is the currently logged-in user, use the derived atom's value
|
||||
setCoinsEarnedToday(atomCoinsEarnedToday);
|
||||
setTotalEarned(atomTotalEarned);
|
||||
setTotalSpent(atomTotalSpent);
|
||||
setCoinsSpentToday(atomCoinsSpentToday);
|
||||
setTransactionsToday(atomTransactionsToday);
|
||||
setBalance(loggedInUserBalance);
|
||||
} else if (targetUser?.id) {
|
||||
// If an admin is viewing another user, calculate their metrics manually
|
||||
setCoinsEarnedToday(calculateCoinsEarnedToday(transactions, timezone));
|
||||
setTotalEarned(calculateTotalEarned(transactions));
|
||||
setTotalSpent(calculateTotalSpent(transactions));
|
||||
setCoinsSpentToday(calculateCoinsSpentToday(transactions, timezone));
|
||||
setTransactionsToday(calculateTransactionsToday(transactions, timezone));
|
||||
setBalance(transactions.reduce((acc, t) => acc + t.amount, 0));
|
||||
}
|
||||
}, [
|
||||
targetUser?.id,
|
||||
currentUser?.id,
|
||||
transactions, // Memoized: depends on allCoinsData and targetUser?.id
|
||||
timezone,
|
||||
loggedInUserBalance,
|
||||
atomCoinsEarnedToday,
|
||||
atomTotalEarned,
|
||||
atomTotalSpent,
|
||||
atomCoinsSpentToday,
|
||||
atomTransactionsToday,
|
||||
]);
|
||||
|
||||
const add = async (amount: number, description: string, note?: string) => {
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
|
||||
@@ -91,7 +128,7 @@ export function useCoins(options?: { selectedUser?: string }) {
|
||||
description,
|
||||
type: 'MANUAL_ADJUSTMENT',
|
||||
note,
|
||||
userId: user?.id
|
||||
userId: targetUser?.id
|
||||
})
|
||||
setCoins(data)
|
||||
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
|
||||
@@ -121,7 +158,7 @@ export function useCoins(options?: { selectedUser?: string }) {
|
||||
description,
|
||||
type: 'MANUAL_ADJUSTMENT',
|
||||
note,
|
||||
userId: user?.id
|
||||
userId: targetUser?.id
|
||||
})
|
||||
setCoins(data)
|
||||
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAtom, atom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
|
||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
} from '@/lib/utils'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
|
||||
|
||||
function handlePermissionCheck(
|
||||
user: SafeUser | undefined,
|
||||
user: SafeUser | User | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact',
|
||||
tCommon: (key: string, values?: Record<string, any>) => string
|
||||
@@ -54,7 +54,7 @@ export function useHabits() {
|
||||
const t = useTranslations('useHabits');
|
||||
const tCommon = useTranslations('Common');
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const { currentUser } = useHelpers()
|
||||
const [currentUser] = useAtom(currentUserAtom)
|
||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
||||
const [coins, setCoins] = useAtom(coinsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
|
||||
import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { WishlistItemType, User, SafeUser } from '@/lib/types'
|
||||
import { celebrations } from '@/utils/celebrations'
|
||||
import { checkPermission } from '@/lib/utils'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { useCoins } from './useCoins'
|
||||
|
||||
function handlePermissionCheck(
|
||||
user: any, // Consider using a more specific type like SafeUser | User | undefined
|
||||
user: User | SafeUser | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact',
|
||||
tCommon: (key: string, values?: Record<string, any>) => string
|
||||
@@ -39,7 +38,7 @@ function handlePermissionCheck(
|
||||
export function useWishlist() {
|
||||
const t = useTranslations('useWishlist');
|
||||
const tCommon = useTranslations('Common');
|
||||
const { currentUser: user } = useHelpers()
|
||||
const [user] = useAtom(currentUserAtom)
|
||||
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
||||
const [coins, setCoins] = useAtom(coinsAtom)
|
||||
const { balance } = useCoins()
|
||||
|
||||
22
lib/atoms.ts
22
lib/atoms.ts
@@ -10,6 +10,7 @@ import {
|
||||
CompletionCache,
|
||||
getDefaultServerSettings,
|
||||
User,
|
||||
UserId,
|
||||
} from "./types";
|
||||
import {
|
||||
getTodayInTimezone,
|
||||
@@ -85,10 +86,26 @@ export const transactionsTodayAtom = atom((get) => {
|
||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||
});
|
||||
|
||||
// Derived atom for current balance from all transactions
|
||||
// Atom to store the current logged-in user's ID.
|
||||
// This should be set by your application when the user session is available.
|
||||
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
|
||||
|
||||
export const currentUserAtom = atom((get) => {
|
||||
const currentUserId = get(currentUserIdAtom);
|
||||
const users = get(usersAtom);
|
||||
return users.users.find(user => user.id === currentUserId);
|
||||
})
|
||||
|
||||
// Derived atom for current balance for the logged-in user
|
||||
export const coinsBalanceAtom = atom((get) => {
|
||||
const loggedInUserId = get(currentUserIdAtom);
|
||||
if (!loggedInUserId) {
|
||||
return 0; // No user logged in or ID not set, so balance is 0
|
||||
}
|
||||
const coins = get(coinsAtom);
|
||||
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||
return coins.transactions
|
||||
.filter(transaction => transaction.userId === loggedInUserId)
|
||||
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||
});
|
||||
|
||||
/* transient atoms */
|
||||
@@ -107,6 +124,7 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
})
|
||||
|
||||
export const userSelectAtom = atom<boolean>(false)
|
||||
export const aboutOpenAtom = atom<boolean>(false)
|
||||
|
||||
// Derived atom for completion cache
|
||||
export const completionCacheAtom = atom((get) => {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// client helpers
|
||||
'use-client'
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { User, UserId } from './types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { usersAtom } from './atoms'
|
||||
import { checkPermission } from './utils'
|
||||
|
||||
export function useHelpers() {
|
||||
const { data: session, status } = useSession()
|
||||
const currentUserId = session?.user.id
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||
// detect iOS: https://stackoverflow.com/a/9039885
|
||||
function iOS() {
|
||||
return [
|
||||
'iPad Simulator',
|
||||
'iPhone Simulator',
|
||||
'iPod Simulator',
|
||||
'iPad',
|
||||
'iPhone',
|
||||
'iPod',
|
||||
].includes(navigator.platform)
|
||||
// iPad on iOS 13 detection
|
||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
}
|
||||
|
||||
return {
|
||||
currentUserId,
|
||||
currentUser,
|
||||
usersData,
|
||||
status,
|
||||
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
|
||||
checkPermission(currentUser?.permissions, resource, action),
|
||||
isIOS: iOS(),
|
||||
}
|
||||
}
|
||||
19
lib/utils.ts
19
lib/utils.ts
@@ -2,7 +2,7 @@ import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User } from '@/lib/types'
|
||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import * as chrono from 'chrono-node'
|
||||
import _ from "lodash"
|
||||
@@ -464,3 +464,20 @@ export function checkPermission(
|
||||
export function uuid() {
|
||||
return uuidv4()
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
currentUser: User | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact'
|
||||
): boolean {
|
||||
// If no current user, no permissions.
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
// If user is admin, they have all permissions.
|
||||
if (currentUser.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise, check specific permissions.
|
||||
return checkPermission(currentUser.permissions, resource, action);
|
||||
}
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
|
||||
"invalidAmountTitle": "Ungültiger Betrag",
|
||||
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
|
||||
"successTitle": "Erfolg",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "Added {amount} coins",
|
||||
"invalidAmountTitle": "Invalid amount",
|
||||
"invalidAmountDescription": "Please enter a valid positive number",
|
||||
"successTitle": "Success",
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "Se añadieron {amount} monedas",
|
||||
"invalidAmountTitle": "Cantidad inválida",
|
||||
"invalidAmountDescription": "Por favor ingresa un número positivo válido",
|
||||
"successTitle": "Éxito",
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "{amount} pièces ajoutées",
|
||||
"invalidAmountTitle": "Montant invalide",
|
||||
"invalidAmountDescription": "Veuillez entrer un nombre positif valide",
|
||||
"successTitle": "Succès",
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "{amount}コインを追加しました",
|
||||
"invalidAmountTitle": "無効な値です",
|
||||
"invalidAmountDescription": "有効な正の数を入力してください",
|
||||
"successTitle": "成功しました",
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "Добавлено {amount} монет",
|
||||
"invalidAmountTitle": "Неверная сумма",
|
||||
"invalidAmountDescription": "Пожалуйста, введите положительное число",
|
||||
"successTitle": "Успех",
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"cancel": "取消"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "已添加 {amount} 个金币",
|
||||
"invalidAmountTitle": "无效金额",
|
||||
"invalidAmountDescription": "请输入有效的正数",
|
||||
"successTitle": "成功",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.21",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user