From 3e6b4b75ecac0c23f643def13ccfcd86b867bc91 Mon Sep 17 00:00:00 2001 From: Doh Date: Thu, 21 Aug 2025 23:04:50 -0400 Subject: [PATCH] feat: freehand drawing capability and card layout improvements and v0.2.29 release (#180) --- CHANGELOG.md | 11 ++ README.md | 1 + app/actions/data.ts | 7 +- app/calendar/page.tsx | 3 - app/coins/page.tsx | 1 - app/debug/habits/page.tsx | 1 - app/habits/page.tsx | 2 - app/layout.tsx | 1 - app/settings/page.tsx | 6 +- app/wishlist/page.tsx | 1 - components/AddEditHabitModal.tsx | 64 ++++++- components/AddEditWishlistItemModal.tsx | 55 +++++- components/CoinsManager.tsx | 4 +- components/CompletionCountBadge.tsx | 2 +- components/DailyOverview.tsx | 33 +++- components/DrawingCanvas.tsx | 241 ++++++++++++++++++++++++ components/DrawingDisplay.tsx | 113 +++++++++++ components/DrawingModal.tsx | 83 ++++++++ components/HabitCalendar.tsx | 5 +- components/HabitContextMenuItems.tsx | 2 +- components/HabitItem.tsx | 64 ++++--- components/HabitList.tsx | 2 +- components/HabitStreak.tsx | 2 +- components/NotificationDropdown.tsx | 2 +- components/PasswordEntryForm.tsx | 2 +- components/PomodoroTimer.tsx | 2 +- components/Profile.tsx | 2 +- components/UserForm.tsx | 1 - components/UserSelectModal.tsx | 18 +- components/WishlistItem.tsx | 65 ++++--- components/theme-toggle.tsx | 2 +- lib/atoms.ts | 5 - lib/types.ts | 2 + messages/ca.json | 14 ++ messages/de.json | 14 ++ messages/en.json | 6 + messages/es.json | 14 ++ messages/fr.json | 14 ++ messages/ja.json | 14 ++ messages/ko.json | 14 ++ messages/ru.json | 14 ++ messages/zh.json | 14 ++ package.json | 2 +- 43 files changed, 802 insertions(+), 123 deletions(-) create mode 100644 components/DrawingCanvas.tsx create mode 100644 components/DrawingDisplay.tsx create mode 100644 components/DrawingModal.tsx 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 dc296a6..a2f2253 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https - 🏆 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/actions/data.ts b/app/actions/data.ts index cd438f1..8dc34c7 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -22,18 +22,15 @@ import { Permission, ServerSettings } from '@/lib/types' -import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils'; +import { d2t, getNow, uuid } from '@/lib/utils'; import { verifyPassword } from "@/lib/server-helpers"; import { saltAndHashPassword } from "@/lib/server-helpers"; import { signInSchema } from '@/lib/zod'; -import { auth } from '@/auth'; import _ from 'lodash'; -import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers' -import stableStringify from 'json-stable-stringify'; +import { getCurrentUser } from '@/lib/server-helpers' import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils'; -import { PermissionError } from '@/lib/exceptions' type ResourceType = 'habit' | 'wishlist' | 'coins' type ActionType = 'write' | 'interact' diff --git a/app/calendar/page.tsx b/app/calendar/page.tsx index 8993563..f025056 100644 --- a/app/calendar/page.tsx +++ b/app/calendar/page.tsx @@ -1,7 +1,4 @@ -import Layout from '@/components/Layout' import HabitCalendar from '@/components/HabitCalendar' -import { ViewToggle } from '@/components/ViewToggle' -import CompletionCountBadge from '@/components/CompletionCountBadge' export default function CalendarPage() { return ( diff --git a/app/coins/page.tsx b/app/coins/page.tsx index 50b8552..7dec4ae 100644 --- a/app/coins/page.tsx +++ b/app/coins/page.tsx @@ -1,4 +1,3 @@ -import Layout from '@/components/Layout' import CoinsManager from '@/components/CoinsManager' export default function CoinsPage() { diff --git a/app/debug/habits/page.tsx b/app/debug/habits/page.tsx index 9ae7e84..2647ff5 100644 --- a/app/debug/habits/page.tsx +++ b/app/debug/habits/page.tsx @@ -1,6 +1,5 @@ 'use client' -import { useHabits } from "@/hooks/useHabits"; import { habitsAtom, settingsAtom } from "@/lib/atoms"; import { Habit } from "@/lib/types"; import { useAtom } from "jotai"; diff --git a/app/habits/page.tsx b/app/habits/page.tsx index 1e551f0..45ee2b6 100644 --- a/app/habits/page.tsx +++ b/app/habits/page.tsx @@ -1,6 +1,4 @@ -import Layout from '@/components/Layout' import HabitList from '@/components/HabitList' -import { ViewToggle } from '@/components/ViewToggle' export default function HabitsPage() { return ( diff --git a/app/layout.tsx b/app/layout.tsx index 572ad55..5be78ac 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,4 @@ import './globals.css' -import { Inter } from 'next/font/google' import { DM_Sans } from 'next/font/google' import { JotaiProvider } from '@/components/jotai-providers' import { JotaiHydrate } from '@/components/jotai-hydrate' diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 1a407f3..e372712 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -14,10 +14,8 @@ import { useAtom } from 'jotai'; import { useTranslations } from 'next-intl'; import { settingsAtom, serverSettingsAtom } from '@/lib/atoms'; import { Settings, WeekDay } from '@/lib/types' -import { saveSettings, uploadAvatar } from '../actions/data' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Button } from '@/components/ui/button'; -import { User, Info } from 'lucide-react'; // Import Info icon +import { saveSettings } from '../actions/data' +import { Info } from 'lucide-react'; // Import Info icon import { toast } from '@/hooks/use-toast' import { useSession } from 'next-auth/react'; // signOut removed import { useRouter } from 'next/navigation'; diff --git a/app/wishlist/page.tsx b/app/wishlist/page.tsx index cfc410c..38a2b0a 100644 --- a/app/wishlist/page.tsx +++ b/app/wishlist/page.tsx @@ -1,4 +1,3 @@ -import Layout from '@/components/Layout' import WishlistManager from '@/components/WishlistManager' export default function WishlistPage() { diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index f4adb3b..620bcaa 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -1,23 +1,25 @@ 'use client' import { useState } from 'react' -import { RRule, RRuleSet, rrulestr } from 'rrule' +import { RRule } from 'rrule' import { useAtom } from 'jotai' import { useTranslations } from 'next-intl' -import { settingsAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms' +import { settingsAtom, 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 { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' -import { Zap } from 'lucide-react' +import { Zap, Brush } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Habit, SafeUser } from '@/lib/types' +import { Habit } 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 DrawingModal from './DrawingModal' +import DrawingDisplay from './DrawingDisplay' +import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils' +import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, MAX_COIN_LIMIT } from '@/lib/constants' import { DateTime } from 'luxon' @@ -49,6 +51,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) { @@ -83,14 +87,19 @@ 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 }) } return ( <> - + { + if (!open && !isDrawingModalOpen) { + onClose() + } + }} modal={false}> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */} @@ -290,6 +299,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad +
+ +
+
+ + {drawing && ( +
+ +
+ )} +
+
+
{users && users.length > 1 && (
@@ -333,6 +374,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 53b3c80..1e055e1 100644 --- a/components/AddEditWishlistItemModal.tsx +++ b/components/AddEditWishlistItemModal.tsx @@ -11,6 +11,9 @@ import { Textarea } from '@/components/ui/textarea' import { WishlistItemType } from '@/lib/types' import EmojiPickerButton from './EmojiPickerButton' import ModalOverlay from './ModalOverlay' +import DrawingModal from './DrawingModal' +import DrawingDisplay from './DrawingDisplay' +import { Brush } from 'lucide-react' import { MAX_COIN_LIMIT } from '@/lib/constants' interface AddEditWishlistItemModalProps { @@ -40,6 +43,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) { @@ -48,12 +53,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]) @@ -102,7 +109,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) { @@ -118,7 +126,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')} @@ -269,6 +281,38 @@ export default function AddEditWishlistItemModal({ )} +
+ +
+
+ + {drawing && ( +
+ +
+ )} +
+
+
{usersData.users && usersData.users.length > 1 && (
@@ -308,6 +352,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 33fd394..103d57b 100644 --- a/components/CoinsManager.tsx +++ b/components/CoinsManager.tsx @@ -2,10 +2,10 @@ import { useState, useEffect, useRef } from 'react' // Import useEffect, useRef import { useSearchParams } from 'next/navigation' // Import useSearchParams -import { t2d, d2s, getNow, isSameDate } from '@/lib/utils' +import { t2d, d2s } from '@/lib/utils' import { Button } from '@/components/ui/button' import { FormattedNumber } from '@/components/FormattedNumber' -import { History, Pencil } from 'lucide-react' +import { History } from 'lucide-react' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import EmptyState from './EmptyState' import { Input } from '@/components/ui/input' diff --git a/components/CompletionCountBadge.tsx b/components/CompletionCountBadge.tsx index 595dbdd..dbab219 100644 --- a/components/CompletionCountBadge.tsx +++ b/components/CompletionCountBadge.tsx @@ -1,6 +1,6 @@ import { Badge } from "@/components/ui/badge" import { useAtom } from 'jotai' -import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms' +import { completedHabitsMapAtom, habitsByDateFamily } from '@/lib/atoms' import { getTodayInTimezone } from '@/lib/utils' // import { useHabits } from '@/hooks/useHabits' // Not used import { settingsAtom } from '@/lib/atoms' diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index dca5af4..6f37cf3 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -3,8 +3,6 @@ import CompletionCountBadge from './CompletionCountBadge' import { ContextMenu, ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu" import { cn } from '@/lib/utils' @@ -12,7 +10,7 @@ import Link from 'next/link' import { useState } from 'react' import { useAtom } from 'jotai' import { useTranslations } from 'next-intl' -import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms' +import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, hasTasksAtom } from '@/lib/atoms' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } from '@/lib/utils' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -23,7 +21,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { Progress } from '@/components/ui/progress' -import { Settings, WishlistItemType } from '@/lib/types' +import { WishlistItemType } from '@/lib/types' import { Habit } from '@/lib/types' import Linkify from './linkify' import { useHabits } from '@/hooks/useHabits' @@ -31,6 +29,7 @@ import AddEditHabitModal from './AddEditHabitModal' import ConfirmDialog from './ConfirmDialog' import { Button } from './ui/button' import { HabitContextMenuItems } from './HabitContextMenuItems' +import DrawingDisplay from './DrawingDisplay' interface UpcomingItemsProps { habits: Habit[] @@ -255,6 +254,16 @@ const ItemSection = ({ {habit.name} + {habit.drawing && ( +
+ +
+ )} @@ -473,9 +482,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/HabitCalendar.tsx b/components/HabitCalendar.tsx index 1106efb..905f233 100644 --- a/components/HabitCalendar.tsx +++ b/components/HabitCalendar.tsx @@ -4,9 +4,8 @@ import { useState, useMemo, useCallback } from 'react' import { Calendar } from '@/components/ui/calendar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import CompletionCountBadge from '@/components/CompletionCountBadge' -import { Button } from '@/components/ui/button' -import { Check, Circle, CircleCheck } from 'lucide-react' -import { d2s, getNow, t2d, isHabitDue, getISODate, getCompletionsForDate } from '@/lib/utils' +import { Circle, CircleCheck } from 'lucide-react' +import { d2s, getNow, isHabitDue, getISODate, getCompletionsForDate } from '@/lib/utils' import { useAtom } from 'jotai' import { useTranslations } from 'next-intl' import { useHabits } from '@/hooks/useHabits' diff --git a/components/HabitContextMenuItems.tsx b/components/HabitContextMenuItems.tsx index a034629..438157a 100644 --- a/components/HabitContextMenuItems.tsx +++ b/components/HabitContextMenuItems.tsx @@ -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'; diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index ae717a5..c8be946 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -1,25 +1,22 @@ -import { Habit, SafeUser, User, Permission } from '@/lib/types' +import { Habit, User } 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 { 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' import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { useEffect, useState } from 'react' import { useHabits } from '@/hooks/useHabits' 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 { hasPermission } from '@/lib/utils' import { HabitContextMenuItems } from './HabitContextMenuItems' +import DrawingDisplay from './DrawingDisplay' interface HabitItemProps { habit: Habit @@ -88,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' : ''}`} > - +
@@ -105,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, - 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 })} +
- +