mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Merge Tag v0.2.21
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## Version 0.2.21
|
||||
|
||||
### Fixed
|
||||
|
||||
* emoji picker overlay issue (#150)
|
||||
|
||||
## Version 0.2.20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { RRule } from 'rrule'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import ModalOverlay from './ModalOverlay'; // Import the new component
|
||||
|
||||
|
||||
interface AddEditHabitModalProps {
|
||||
@@ -88,233 +89,235 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||
|
||||
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
|
||||
/>
|
||||
<EmojiPickerButton
|
||||
inputIdToFocus="name"
|
||||
onEmojiSelect={(emoji) => {
|
||||
setName(prev => {
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji}`;
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
|
||||
</span>
|
||||
</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>ohsimpson
|
||||
<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">
|
||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
|
||||
</span>
|
||||
</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" disabled={!!errorMessage}>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useState } from 'react'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import ModalOverlay from './ModalOverlay'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
|
||||
interface AddEditWishlistItemModalProps {
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
editingItem: WishlistItemType | null
|
||||
setEditingItem: (item: WishlistItemType | null) => void
|
||||
@@ -22,7 +22,6 @@ interface AddEditWishlistItemModalProps {
|
||||
}
|
||||
|
||||
export default function AddEditWishlistItemModal({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
editingItem,
|
||||
setEditingItem,
|
||||
@@ -115,196 +114,199 @@ export default function AddEditWishlistItemModal({
|
||||
}
|
||||
|
||||
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
|
||||
/>
|
||||
<EmojiPickerButton
|
||||
inputIdToFocus="name"
|
||||
onEmojiSelect={(emoji) => {
|
||||
setName(prev => {
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji}`;
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: Emo
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
||||
<Popover modal={false} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Habit, User } from '@/lib/types';
|
||||
import { Habit } from '@/lib/types';
|
||||
import { useHabits } from '@/hooks/useHabits';
|
||||
import { useAtom } from 'jotai';
|
||||
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
|
||||
|
||||
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" />
|
||||
}
|
||||
@@ -152,14 +152,15 @@ export default function WishlistManager() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AddEditWishlistItemModal
|
||||
isOpen={isModalOpen}
|
||||
setIsOpen={setIsModalOpen}
|
||||
editingItem={editingItem}
|
||||
setEditingItem={setEditingItem}
|
||||
addWishlistItem={addWishlistItem}
|
||||
editWishlistItem={editWishlistItem}
|
||||
/>
|
||||
{isModalOpen &&
|
||||
<AddEditWishlistItemModal
|
||||
setIsOpen={setIsModalOpen}
|
||||
editingItem={editingItem}
|
||||
setEditingItem={setEditingItem}
|
||||
addWishlistItem={addWishlistItem}
|
||||
editWishlistItem={editWishlistItem}
|
||||
/>
|
||||
}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmation.isOpen}
|
||||
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.20",
|
||||
"version": "0.2.21",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user