diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f81433..7143aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Version 0.2.29 + +### Added + +* ✏️ Freehand drawing capability for habits and wishlist items + +### Fixed + +* Wishlist and Habit card layout - time and rewards sections now stay at bottom regardless of description length +* Wishlist card user avatars now appear on same row as title for consistency with habit cards + ## Version 0.2.28 ### Added diff --git a/README.md b/README.md index 7ccb686..5155983 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Differences (as of writing) are: - 🏆 Earn coins for completing habits - 💰 Create a wishlist of rewards to redeem with earned coins - 📊 View your habit completion streaks and statistics +- ✏️ Add freehand drawings to habits and wishlist items for visual reminders - 📅 Calendar heatmap to visualize your progress (WIP) - 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語) - 🌙 Dark mode support diff --git a/app/settings/page.tsx b/app/settings/page.tsx index a299b83..556927e 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -14,11 +14,14 @@ import { toast } from '@/hooks/use-toast'; import { serverSettingsAtom, settingsAtom } from '@/lib/atoms'; import { Settings, WeekDay } from '@/lib/types'; import { useAtom } from 'jotai'; + import { Info } from 'lucide-react'; // Import Info icon -import { useSession } from 'next-auth/react'; // signOut removed + import { useTranslations } from 'next-intl'; -import { useRouter } from 'next/navigation'; import { saveSettings } from '../actions/data'; + +import { useSession } from 'next-auth/react'; // signOut removed +import { useRouter } from 'next/navigation'; // AlertDialog components and useState removed // Trash2 icon removed diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index fb5c909..d7d0f02 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -12,11 +12,13 @@ import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } fro import { Habit } from '@/lib/types' import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils' import { useAtom } from 'jotai' -import { Zap } from 'lucide-react' +import { Brush, Zap } from 'lucide-react' import { DateTime } from 'luxon' import { useTranslations } from 'next-intl' import { useState } from 'react' import { RRule } from 'rrule' +import DrawingDisplay from './DrawingDisplay' +import DrawingModal from './DrawingModal' import EmojiPickerButton from './EmojiPickerButton' import ModalOverlay from './ModalOverlay'; // Import the new component @@ -48,6 +50,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad const [selectedUserIds, setSelectedUserIds] = useState((habit?.userIds || []).filter(id => id !== currentUser?.id)) const [usersData] = useAtom(usersAtom) const users = usersData.users + const [drawing, setDrawing] = useState(habit?.drawing || '') + const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false) function getFrequencyUpdate() { if (ruleText === initialRuleText && habit?.frequency) { @@ -82,7 +86,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, completions: habit?.completions || [], frequency: getFrequencyUpdate(), - userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) + userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]), + drawing: drawing && drawing !== '[]' ? drawing : undefined }) } @@ -91,7 +96,11 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad return ( <> - + { + if (!open && !isDrawingModalOpen) { + onClose() + } + }} modal={false}> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */} @@ -275,6 +284,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad +
+ +
+
+ + {drawing && ( +
+ +
+ )} +
+
+
{users && users.length > 1 && (
@@ -318,6 +359,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
+ setIsDrawingModalOpen(false)} + onSave={(drawingData) => setDrawing(drawingData)} + initialDrawing={drawing} + title={name} + /> ) } diff --git a/components/AddEditWishlistItemModal.tsx b/components/AddEditWishlistItemModal.tsx index 068251b..4ab09fe 100644 --- a/components/AddEditWishlistItemModal.tsx +++ b/components/AddEditWishlistItemModal.tsx @@ -7,8 +7,11 @@ import { currentUserAtom, usersAtom } from '@/lib/atoms' import { MAX_COIN_LIMIT } from '@/lib/constants' import { WishlistItemType } from '@/lib/types' import { useAtom } from 'jotai' +import { Brush } from 'lucide-react' import { useTranslations } from 'next-intl' import { useEffect, useState } from 'react' +import DrawingDisplay from './DrawingDisplay' +import DrawingModal from './DrawingModal' import EmojiPickerButton from './EmojiPickerButton' import ModalOverlay from './ModalOverlay' import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' @@ -38,6 +41,8 @@ export default function AddEditWishlistItemModal({ const [selectedUserIds, setSelectedUserIds] = useState((editingItem?.userIds || []).filter(id => id !== currentUser?.id)) const [errors, setErrors] = useState<{ [key: string]: string }>({}) const [usersData] = useAtom(usersAtom) + const [drawing, setDrawing] = useState(editingItem?.drawing || '') + const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false) useEffect(() => { if (editingItem) { @@ -46,12 +51,14 @@ export default function AddEditWishlistItemModal({ setCoinCost(editingItem.coinCost) setTargetCompletions(editingItem.targetCompletions) setLink(editingItem.link || '') + setDrawing(editingItem.drawing || '') } else { setName('') setDescription('') setCoinCost(1) setTargetCompletions(undefined) setLink('') + setDrawing('') } setErrors({}) }, [editingItem]) @@ -100,7 +107,8 @@ export default function AddEditWishlistItemModal({ coinCost, targetCompletions: targetCompletions || undefined, link: link.trim() || undefined, - userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) + userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]), + drawing: drawing && drawing !== '[]' ? drawing : undefined } if (editingItem) { @@ -116,7 +124,11 @@ export default function AddEditWishlistItemModal({ return ( <> - + { + if (!open && !isDrawingModalOpen) { + handleClose() + } + }} modal={false}> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */} {editingItem ? t('editTitle') : t('addTitle')} @@ -267,6 +279,38 @@ export default function AddEditWishlistItemModal({ )} +
+ +
+
+ + {drawing && ( +
+ +
+ )} +
+
+
{usersData.users && usersData.users.length > 1 && (
@@ -306,6 +350,13 @@ export default function AddEditWishlistItemModal({
+ setIsDrawingModalOpen(false)} + onSave={(drawingData) => setDrawing(drawingData)} + initialDrawing={drawing} + title={name} + /> ) } diff --git a/components/CoinsManager.tsx b/components/CoinsManager.tsx index 81f63a3..f344465 100644 --- a/components/CoinsManager.tsx +++ b/components/CoinsManager.tsx @@ -3,6 +3,7 @@ import { FormattedNumber } from '@/components/FormattedNumber' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' + import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { useCoins } from '@/hooks/useCoins' diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index 6fc90a0..fe5a640 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/tooltip" import { useHabits } from '@/hooks/useHabits' import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms' +import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants' import { Habit, WishlistItemType } from '@/lib/types' import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils' import { useAtom } from 'jotai' @@ -27,7 +28,7 @@ import ConfirmDialog from './ConfirmDialog' import { HabitContextMenuItems } from './HabitContextMenuItems' import Linkify from './linkify' import { Button } from './ui/button' -import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants' +import DrawingDisplay from './DrawingDisplay' interface UpcomingItemsProps { habits: Habit[] @@ -246,6 +247,16 @@ const ItemSection = ({ {habit.name} + {habit.drawing && ( +
+ +
+ )} @@ -458,9 +469,19 @@ export default function DailyOverview({ )} >
- - {item.name} - +
+ + {item.name} + + {item.drawing && ( + + )} +
void + onClear?: () => void +} + +export default function DrawingCanvas({ initialDrawing, onSave, onClear }: DrawingCanvasProps) { + const t = useTranslations('DrawingModal') + const [drawingHistory, setDrawingHistory] = useState + }>>([]) + const [isDrawing, setIsDrawing] = useState(false) + const [color, setColor] = useState('#000000') + const [thickness, setThickness] = useState(4) + + const canvasRef = useRef(null) + const contextRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const context = canvas.getContext('2d') + if (!context) return + + context.lineCap = 'round' + context.lineJoin = 'round' + contextRef.current = context + + const resizeCanvas = () => { + const rect = canvas.getBoundingClientRect() + canvas.width = rect.width + canvas.height = rect.height + } + + window.addEventListener('resize', resizeCanvas) + resizeCanvas() + + return () => { + window.removeEventListener('resize', resizeCanvas) + } + }, []) + + useEffect(() => { + if (initialDrawing) { + try { + const loadedData = JSON.parse(initialDrawing) + if (Array.isArray(loadedData)) { + setDrawingHistory(loadedData) + } + } catch (e) { + console.warn('Failed to load initial drawing data') + } + } + }, [initialDrawing]) + + useEffect(() => { + redrawCanvas() + }, [drawingHistory]) + + const getMousePos = (event: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return { x: 0, y: 0 } + + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY + } + } + + const startDrawing = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const { x, y } = getMousePos(event) + setIsDrawing(true) + contextRef.current?.beginPath() + contextRef.current?.moveTo(x, y) + + setDrawingHistory(prevHistory => [ + ...prevHistory, + { color, thickness, points: [{ x, y }] } + ]) + } + + const draw = (event: React.MouseEvent) => { + if (!isDrawing || !contextRef.current) return + + event.preventDefault() + event.stopPropagation() + const { x, y } = getMousePos(event) + contextRef.current.lineTo(x, y) + contextRef.current.strokeStyle = color + contextRef.current.lineWidth = thickness + contextRef.current.stroke() + + setDrawingHistory(prevHistory => { + const lastStroke = prevHistory[prevHistory.length - 1] + if (lastStroke) { + lastStroke.points.push({ x, y }) + } + return [...prevHistory] + }) + } + + const stopDrawing = (event?: React.MouseEvent) => { + if (event) { + event.preventDefault() + event.stopPropagation() + } + setIsDrawing(false) + contextRef.current?.closePath() + } + + const redrawCanvas = () => { + const canvas = canvasRef.current + if (!canvas || !contextRef.current) return + + const context = contextRef.current + context.clearRect(0, 0, canvas.width, canvas.height) + + drawingHistory.forEach(stroke => { + if (stroke.points.length === 0) return + context.beginPath() + context.strokeStyle = stroke.color + context.lineWidth = stroke.thickness + context.moveTo(stroke.points[0].x, stroke.points[0].y) + stroke.points.forEach(point => { + context.lineTo(point.x, point.y) + }) + context.stroke() + }) + } + + const handleUndo = () => { + setDrawingHistory(prevHistory => { + const newHistory = [...prevHistory] + newHistory.pop() + return newHistory + }) + } + + const handleClear = () => { + setDrawingHistory([]) + onClear?.() + } + + const handleSave = () => { + const jsonString = drawingHistory.length > 0 ? JSON.stringify(drawingHistory) : '' + onSave(jsonString) + } + + return ( +
+ stopDrawing(e)} + onMouseLeave={(e) => stopDrawing(e)} + className="border border-gray-300 rounded-lg bg-white touch-none w-full h-80 cursor-crosshair" + /> + +
+
+ +
+ + setColor(e.target.value)} + className="w-8 h-8 border-2 border-gray-300 rounded cursor-pointer p-0" + /> +
+
+ +
+ + setThickness(Number(e.target.value))} + className="w-20" + /> + {thickness} +
+ +
+ + +
+
+ +
+ +
+
+ ) +} \ No newline at end of file diff --git a/components/DrawingDisplay.tsx b/components/DrawingDisplay.tsx new file mode 100644 index 0000000..7981a1f --- /dev/null +++ b/components/DrawingDisplay.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useEffect, useRef } from 'react' + +interface DrawingDisplayProps { + drawingData?: string + width?: number + height?: number + className?: string +} + +interface DrawingStroke { + color: string + thickness: number + points: Array<{ x: number; y: number }> +} + +export default function DrawingDisplay({ + drawingData, + width = 120, + height = 80, + className = '' +}: DrawingDisplayProps) { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !drawingData) return + + const context = canvas.getContext('2d') + if (!context) return + + try { + const strokes: DrawingStroke[] = JSON.parse(drawingData) + + // Clear canvas + context.clearRect(0, 0, canvas.width, canvas.height) + + // Set up context for drawing + context.lineCap = 'round' + context.lineJoin = 'round' + + // Calculate scaling to fit the drawing in the small canvas + if (strokes.length === 0) return + + // Find bounds of the drawing + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + + strokes.forEach(stroke => { + stroke.points.forEach(point => { + minX = Math.min(minX, point.x) + minY = Math.min(minY, point.y) + maxX = Math.max(maxX, point.x) + maxY = Math.max(maxY, point.y) + }) + }) + + // Add padding + const padding = 10 + const drawingWidth = maxX - minX + padding * 2 + const drawingHeight = maxY - minY + padding * 2 + + // Calculate scale to fit in canvas + const scaleX = canvas.width / drawingWidth + const scaleY = canvas.height / drawingHeight + const scale = Math.min(scaleX, scaleY, 1) // Don't scale up + + // Center the drawing + const offsetX = (canvas.width - drawingWidth * scale) / 2 - (minX - padding) * scale + const offsetY = (canvas.height - drawingHeight * scale) / 2 - (minY - padding) * scale + + // Draw each stroke + strokes.forEach(stroke => { + if (stroke.points.length === 0) return + + context.beginPath() + context.strokeStyle = stroke.color + context.lineWidth = Math.max(1, stroke.thickness * scale) // Ensure minimum line width + + const firstPoint = stroke.points[0] + context.moveTo( + firstPoint.x * scale + offsetX, + firstPoint.y * scale + offsetY + ) + + stroke.points.forEach(point => { + context.lineTo( + point.x * scale + offsetX, + point.y * scale + offsetY + ) + }) + + context.stroke() + }) + } catch (error) { + console.warn('Failed to render drawing:', error) + } + }, [drawingData, width, height]) + + if (!drawingData) { + return null + } + + return ( + + ) +} diff --git a/components/DrawingModal.tsx b/components/DrawingModal.tsx new file mode 100644 index 0000000..61dba19 --- /dev/null +++ b/components/DrawingModal.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { X } from 'lucide-react' +import DrawingCanvas from './DrawingCanvas' +import { useTranslations } from 'next-intl' + +interface DrawingModalProps { + isOpen: boolean + onClose: () => void + onSave: (drawingData: string) => void + initialDrawing?: string + title?: string +} + +export default function DrawingModal({ + isOpen, + onClose, + onSave, + initialDrawing, + title = 'Drawing' +}: DrawingModalProps) { + const t = useTranslations('DrawingModal') + const [currentDrawing, setCurrentDrawing] = useState(initialDrawing || '') + + const handleSave = (drawingData: string) => { + setCurrentDrawing(drawingData) + onSave(drawingData) + onClose() + } + + const handleClear = () => { + setCurrentDrawing('') + } + + if (!isOpen) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + > + {/* Header */} +
+

{title}

+ +
+ + {/* Content */} +
e.stopPropagation()}> + +
+ + {/* Footer */} +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index c9f1bfd..19b5b5d 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -1,9 +1,12 @@ -import { Button } from '@/components/ui/button' +import { Habit, SafeUser, User, Permission } from '@/lib/types' +import { useAtom } from 'jotai' +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 { DropdownMenu, DropdownMenuContent, - DropdownMenuTrigger + DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { useHabits } from '@/hooks/useHabits' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms' @@ -12,10 +15,11 @@ import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, import { useAtom } from 'jotai' import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react' import { useTranslations } from 'next-intl' -import { usePathname } from 'next/navigation' -import { useEffect, useState } from 'react' -import { HabitContextMenuItems } from './HabitContextMenuItems' +import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants' +import { DateTime } from 'luxon' import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' +import { hasPermission } from '@/lib/utils' +import { HabitContextMenuItems } from './HabitContextMenuItems' interface HabitItemProps { habit: Habit @@ -81,7 +85,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { id={`habit-${habit.id}`} className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`} > - +
@@ -98,28 +102,44 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { {renderUserAvatars(habit, currentUser as User, usersData)}
- {habit.description && ( - - {habit.description} - + {(habit.description || habit.drawing) && ( +
+ {habit.description && ( + + {habit.description} + + )} + {habit.drawing && ( +
+ +
+ )} +
)} - -

- {t('whenLabel', { - frequency: convertMachineReadableFrequencyToHumanReadable({ - frequency: habit.frequency, - isRecurRule: pathname.includes("habits"), - timezone: settings.system.timezone - }) - })} -

-
- - {t('coinsPerCompletion', { count: habit.coinReward })} + +
+

+ {t('whenLabel', { + frequency: convertMachineReadableFrequencyToHumanReadable({ + frequency: habit.frequency, + isRecurRule, + timezone: settings.system.timezone + }) + })} +

+
+ + {t('coinsPerCompletion', { count: habit.coinReward })} +
- +