mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Fix emojipicker (#152)
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.2.21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* emoji picker overlay issue (#150)
|
||||||
|
|
||||||
## Version 0.2.20
|
## Version 0.2.20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Zap } from 'lucide-react'
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { Habit, SafeUser } from '@/lib/types'
|
import { Habit, SafeUser } from '@/lib/types'
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
import ModalOverlay from './ModalOverlay' // Import the new component
|
||||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
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 { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP, MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
@@ -87,249 +88,252 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<>
|
||||||
<DialogContent>
|
<ModalOverlay />
|
||||||
<DialogHeader>
|
<Dialog open={true} onOpenChange={onClose} modal={false}>
|
||||||
<DialogTitle>
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
{habit
|
<DialogHeader>
|
||||||
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
|
<DialogTitle>
|
||||||
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
|
{habit
|
||||||
</DialogTitle>
|
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
|
||||||
</DialogHeader>
|
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
|
||||||
<form onSubmit={handleSubmit}>
|
</DialogTitle>
|
||||||
<div className="grid gap-4 py-4">
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<form onSubmit={handleSubmit}>
|
||||||
<Label htmlFor="name" className="text-right">
|
<div className="grid gap-4 py-4">
|
||||||
{t('nameLabel')}
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
</Label>
|
<Label htmlFor="name" className="text-right">
|
||||||
<div className='flex col-span-3 gap-2'>
|
{t('nameLabel')}
|
||||||
<Input
|
</Label>
|
||||||
id="name"
|
<div className='flex col-span-3 gap-2'>
|
||||||
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">
|
|
||||||
<Input
|
<Input
|
||||||
id="recurrence"
|
id="name"
|
||||||
value={ruleText}
|
value={name}
|
||||||
onChange={(e) => setRuleText(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{isTask && (
|
<EmojiPickerButton
|
||||||
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
|
inputIdToFocus="name"
|
||||||
<PopoverTrigger asChild>
|
onEmojiSelect={(emoji) => {
|
||||||
<Button
|
setName(prev => {
|
||||||
type="button"
|
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||||
variant="ghost"
|
return `${prev}${space}${emoji}`;
|
||||||
size="icon"
|
})
|
||||||
className="h-8 w-8"
|
}}
|
||||||
>
|
/>
|
||||||
<Zap className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{QUICK_DATES.map((date) => (
|
|
||||||
<Button
|
|
||||||
key={date.value}
|
|
||||||
variant="outline"
|
|
||||||
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setRuleText(date.value);
|
|
||||||
setIsQuickDatesOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{date.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 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="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<Label htmlFor="description" className="text-right">
|
||||||
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
|
{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>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex items-center gap-4">
|
||||||
{users.filter((u) => u.id !== currentUser?.id).map(user => (
|
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||||
<Avatar
|
<button
|
||||||
key={user.id}
|
type="button"
|
||||||
className={`h-8 w-8 border-2 cursor-pointer
|
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
|
||||||
${selectedUserIds.includes(user.id)
|
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||||
? '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>
|
</button>
|
||||||
</Avatar>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
</div>
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DialogFooter>
|
<Label htmlFor="coinReward">
|
||||||
<Button type="submit" disabled={!!errorMessage}>
|
{t('rewardLabel')}
|
||||||
{habit
|
</Label>
|
||||||
? t('saveChangesButton')
|
</div>
|
||||||
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
<div className="col-span-3">
|
||||||
</Button>
|
<div className="flex items-center gap-4">
|
||||||
</DialogFooter>
|
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||||
</form>
|
<button
|
||||||
</DialogContent>
|
type="button"
|
||||||
</Dialog>
|
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,6 +10,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
import ModalOverlay from './ModalOverlay'
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
|
|
||||||
interface AddEditWishlistItemModalProps {
|
interface AddEditWishlistItemModalProps {
|
||||||
@@ -115,196 +116,199 @@ export default function AddEditWishlistItemModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<>
|
||||||
<DialogContent>
|
<ModalOverlay />
|
||||||
<DialogHeader>
|
<Dialog open={true} onOpenChange={handleClose} modal={false}>
|
||||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
</DialogHeader>
|
<DialogHeader>
|
||||||
<form onSubmit={handleSave}>
|
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||||
<div className="grid gap-4 py-4">
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<form onSubmit={handleSave}>
|
||||||
<Label htmlFor="name" className="text-right">
|
<div className="grid gap-4 py-4">
|
||||||
{t('nameLabel')}
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
</Label>
|
<Label htmlFor="name" className="text-right">
|
||||||
<div className="col-span-3 flex gap-2">
|
{t('nameLabel')}
|
||||||
<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')}
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
<div className="col-span-3 flex gap-2">
|
||||||
<div className="col-span-3">
|
<Input
|
||||||
<div className="flex items-center gap-4">
|
id="name"
|
||||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
value={name}
|
||||||
<button
|
onChange={(e) => setName(e.target.value)}
|
||||||
type="button"
|
className="flex-1"
|
||||||
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
|
required
|
||||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
/>
|
||||||
>
|
<EmojiPickerButton
|
||||||
-
|
inputIdToFocus="name"
|
||||||
</button>
|
onEmojiSelect={(emoji) => {
|
||||||
<Input
|
setName(prev => {
|
||||||
id="coinReward"
|
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||||
type="number"
|
return `${prev}${space}${emoji}`;
|
||||||
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>
|
||||||
</div>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<Label htmlFor="description" className="text-right">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
{t('descriptionLabel')}
|
||||||
<Label htmlFor="targetCompletions">
|
|
||||||
{t('redeemableLabel')}
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
<Textarea
|
||||||
<div className="col-span-3">
|
id="description"
|
||||||
<div className="flex items-center gap-4">
|
value={description}
|
||||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
<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"
|
className="col-span-3"
|
||||||
/>
|
/>
|
||||||
{errors.link && (
|
|
||||||
<div className="text-sm text-red-500">
|
|
||||||
{errors.link}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{usersData.users && usersData.users.length > 1 && (
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
|
<Label htmlFor="coinReward">
|
||||||
|
{t('costLabel')}
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex items-center gap-4">
|
||||||
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
|
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||||
<Avatar
|
<button
|
||||||
key={user.id}
|
type="button"
|
||||||
className={`h-8 w-8 border-2 cursor-pointer
|
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
|
||||||
${selectedUserIds.includes(user.id)
|
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||||
? '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>
|
</button>
|
||||||
</Avatar>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
</div>
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DialogFooter>
|
<Label htmlFor="targetCompletions">
|
||||||
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
|
{t('redeemableLabel')}
|
||||||
</DialogFooter>
|
</Label>
|
||||||
</form>
|
</div>
|
||||||
</DialogContent>
|
<div className="col-span-3">
|
||||||
</Dialog>
|
<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)
|
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover modal={true} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
<Popover modal={false} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
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,16 @@ export default function WishlistManager() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AddEditWishlistItemModal
|
{isModalOpen &&
|
||||||
isOpen={isModalOpen}
|
<AddEditWishlistItemModal
|
||||||
setIsOpen={setIsModalOpen}
|
isOpen={isModalOpen}
|
||||||
editingItem={editingItem}
|
setIsOpen={setIsModalOpen}
|
||||||
setEditingItem={setEditingItem}
|
editingItem={editingItem}
|
||||||
addWishlistItem={addWishlistItem}
|
setEditingItem={setEditingItem}
|
||||||
editWishlistItem={editWishlistItem}
|
addWishlistItem={addWishlistItem}
|
||||||
/>
|
editWishlistItem={editWishlistItem}
|
||||||
|
/>
|
||||||
|
}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirmation.isOpen}
|
isOpen={deleteConfirmation.isOpen}
|
||||||
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
||||||
|
|||||||
Reference in New Issue
Block a user