mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
Merge Tag 'v0.2.29'
This commit is contained in:
11
CHANGELOG.md
11
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const users = usersData.users
|
||||
const [drawing, setDrawing] = useState<string>(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 (
|
||||
<>
|
||||
<ModalOverlay />
|
||||
<Dialog open={true} onOpenChange={onClose} modal={false}>
|
||||
<Dialog open={true} onOpenChange={(open) => {
|
||||
if (!open && !isDrawingModalOpen) {
|
||||
onClose()
|
||||
}
|
||||
}} modal={false}>
|
||||
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -275,6 +284,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">
|
||||
{t('drawingLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDrawingModalOpen(true)
|
||||
}}
|
||||
className="flex-1 justify-start"
|
||||
>
|
||||
<Brush className="h-4 w-4 mr-2" />
|
||||
{drawing ? t('editDrawing') : t('addDrawing')}
|
||||
</Button>
|
||||
{drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={drawing}
|
||||
width={80}
|
||||
height={53}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -318,6 +359,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DrawingModal
|
||||
isOpen={isDrawingModalOpen}
|
||||
onClose={() => setIsDrawingModalOpen(false)}
|
||||
onSave={(drawingData) => setDrawing(drawingData)}
|
||||
initialDrawing={drawing}
|
||||
title={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const [drawing, setDrawing] = useState<string>(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 (
|
||||
<>
|
||||
<ModalOverlay />
|
||||
<Dialog open={true} onOpenChange={handleClose} modal={false}>
|
||||
<Dialog open={true} onOpenChange={(open) => {
|
||||
if (!open && !isDrawingModalOpen) {
|
||||
handleClose()
|
||||
}
|
||||
}} modal={false}>
|
||||
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||
@@ -267,6 +279,38 @@ export default function AddEditWishlistItemModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">
|
||||
{t('drawingLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDrawingModalOpen(true)
|
||||
}}
|
||||
className="flex-1 justify-start"
|
||||
>
|
||||
<Brush className="h-4 w-4 mr-2" />
|
||||
{drawing ? t('editDrawing') : t('addDrawing')}
|
||||
</Button>
|
||||
{drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={drawing}
|
||||
width={80}
|
||||
height={53}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -306,6 +350,13 @@ export default function AddEditWishlistItemModal({
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DrawingModal
|
||||
isOpen={isDrawingModalOpen}
|
||||
onClose={() => setIsDrawingModalOpen(false)}
|
||||
onSave={(drawingData) => setDrawing(drawingData)}
|
||||
initialDrawing={drawing}
|
||||
title={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
</Link>
|
||||
{habit.drawing && (
|
||||
<div className="ml-2 pr-2">
|
||||
<DrawingDisplay
|
||||
drawingData={habit.drawing}
|
||||
width={40}
|
||||
height={26}
|
||||
className="border-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -458,9 +469,19 @@ export default function DailyOverview({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm">
|
||||
<Linkify>{item.name}</Linkify>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
<Linkify>{item.name}</Linkify>
|
||||
</span>
|
||||
{item.drawing && (
|
||||
<DrawingDisplay
|
||||
drawingData={item.drawing}
|
||||
width={40}
|
||||
height={26}
|
||||
className="border-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs flex items-center">
|
||||
<Coins className={cn(
|
||||
"h-3 w-3 mr-1 transition-all",
|
||||
|
||||
241
components/DrawingCanvas.tsx
Normal file
241
components/DrawingCanvas.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Undo2, Trash2, Palette } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
initialDrawing?: string
|
||||
onSave: (drawingData: string) => void
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export default function DrawingCanvas({ initialDrawing, onSave, onClear }: DrawingCanvasProps) {
|
||||
const t = useTranslations('DrawingModal')
|
||||
const [drawingHistory, setDrawingHistory] = useState<Array<{
|
||||
color: string
|
||||
thickness: number
|
||||
points: Array<{ x: number; y: number }>
|
||||
}>>([])
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [color, setColor] = useState('#000000')
|
||||
const [thickness, setThickness] = useState(4)
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const contextRef = useRef<CanvasRenderingContext2D | null>(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 (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={(e) => stopDrawing(e)}
|
||||
onMouseLeave={(e) => stopDrawing(e)}
|
||||
className="border border-gray-300 rounded-lg bg-white touch-none w-full h-80 cursor-crosshair"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="colorPicker" className="text-sm font-medium">
|
||||
{t('colorLabel')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="color"
|
||||
id="colorPicker"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 border-2 border-gray-300 rounded cursor-pointer p-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="lineThickness" className="text-sm font-medium">
|
||||
{t('thicknessLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
type="range"
|
||||
id="lineThickness"
|
||||
min="1"
|
||||
max="20"
|
||||
value={thickness}
|
||||
onChange={(e) => setThickness(Number(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-6">{thickness}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUndo}
|
||||
disabled={drawingHistory.length === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4 mr-1" />
|
||||
{t('undoButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
disabled={drawingHistory.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{t('clearButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave}>
|
||||
{t('saveDrawingButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
components/DrawingDisplay.tsx
Normal file
113
components/DrawingDisplay.tsx
Normal file
@@ -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<HTMLCanvasElement>(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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`border-2 border-muted-foreground rounded bg-white ${className}`}
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
83
components/DrawingModal.tsx
Normal file
83
components/DrawingModal.tsx
Normal file
@@ -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<string>(initialDrawing || '')
|
||||
|
||||
const handleSave = (drawingData: string) => {
|
||||
setCurrentDrawing(drawingData)
|
||||
onSave(drawingData)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setCurrentDrawing('')
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DrawingCanvas
|
||||
initialDrawing={currentDrawing}
|
||||
onSave={handleSave}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 p-6 border-t">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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' : ''}`}
|
||||
>
|
||||
<CardHeader className="flex-none">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -98,28 +102,44 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</CardTitle>
|
||||
{renderUserAvatars(habit, currentUser as User, usersData)}
|
||||
</div>
|
||||
{habit.description && (
|
||||
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{habit.description}
|
||||
</CardDescription>
|
||||
{(habit.description || habit.drawing) && (
|
||||
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
|
||||
{habit.description && (
|
||||
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{habit.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
{habit.drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={habit.drawing}
|
||||
width={120}
|
||||
height={80}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
{t('whenLabel', {
|
||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule: pathname.includes("habits"),
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
|
||||
<CardContent className="flex-grow flex flex-col justify-end">
|
||||
<div className="mt-auto">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
{t('whenLabel', {
|
||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Button
|
||||
@@ -205,4 +225,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAtom } from 'jotai';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
|
||||
interface HabitStreakProps {
|
||||
habits: Habit[]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import UserForm from './UserForm';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
|
||||
|
||||
function UserCard({
|
||||
user,
|
||||
onSelect,
|
||||
|
||||
@@ -11,9 +11,10 @@ import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||
import { User, WishlistItemType } from '@/lib/types'
|
||||
import { hasPermission } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import DrawingDisplay from './DrawingDisplay'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||
|
||||
interface WishlistItemProps {
|
||||
item: WishlistItemType
|
||||
@@ -72,37 +73,51 @@ export default function WishlistItem({
|
||||
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
|
||||
} ${item.archived ? 'opacity-75' : ''}`}
|
||||
>
|
||||
<CardHeader className="flex-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.name}
|
||||
</CardTitle>
|
||||
{item.targetCompletions && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{item.description && (
|
||||
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.name}
|
||||
</CardTitle>
|
||||
{item.targetCompletions && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderUserAvatars(item, currentUser as User, usersData)}
|
||||
</div>
|
||||
{(item.description || item.drawing) && (
|
||||
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
|
||||
{item.description && (
|
||||
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
{item.drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={item.drawing}
|
||||
width={120}
|
||||
height={80}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.coinCost} {t('coinsSuffix')}
|
||||
</span>
|
||||
<CardContent className="flex-grow flex flex-col justify-end">
|
||||
<div className="mt-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.coinCost} {t('coinsSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={canRedeem ? "default" : "secondary"}
|
||||
@@ -179,4 +194,3 @@ export default function WishlistItem({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export type Habit = {
|
||||
archived?: boolean // mark the habit as archived
|
||||
pinned?: boolean // mark the habit as pinned
|
||||
userIds?: UserId[]
|
||||
drawing?: string // Optional JSON string of drawing data
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +61,7 @@ export type WishlistItemType = {
|
||||
targetCompletions?: number // Optional field, infinity when unset
|
||||
link?: string // Optional URL to external resource
|
||||
userIds?: UserId[]
|
||||
drawing?: string // Optional JSON string of drawing data
|
||||
}
|
||||
|
||||
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "vegades",
|
||||
"rewardLabel": "Recompensa",
|
||||
"coinsSuffix": "monedes",
|
||||
"drawingLabel": "Dibuix",
|
||||
"addDrawing": "Afegeix Dibuix",
|
||||
"editDrawing": "Edita Dibuix",
|
||||
"shareLabel": "Comparteix",
|
||||
"saveChangesButton": "Desa els canvis",
|
||||
"addTaskButton": "Afegeix tasca",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "El nombre de finalitzacions objectiu ha de ser com a mínim 1",
|
||||
"errorInvalidUrl": "Si us plau, introdueix una URL vàlida",
|
||||
"linkLabel": "Enllaç",
|
||||
"drawingLabel": "Dibuix",
|
||||
"addDrawing": "Afegeix Dibuix",
|
||||
"editDrawing": "Edita Dibuix",
|
||||
"shareLabel": "Comparteix",
|
||||
"saveButton": "Desa els canvis",
|
||||
"addButton": "Afegeix recompensa"
|
||||
@@ -429,5 +435,13 @@
|
||||
"successTitle": "Èxit",
|
||||
"transactionNotFoundDescription": "Transacció no trobada",
|
||||
"maxAmountExceededDescription": "La quantitat no pot excedir {max}."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Color:",
|
||||
"thicknessLabel": "Gruix:",
|
||||
"undoButton": "Desfés",
|
||||
"clearButton": "Neteja",
|
||||
"saveDrawingButton": "Desa el dibuix",
|
||||
"cancelButton": "Cancel·la"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "mal",
|
||||
"rewardLabel": "Belohnung",
|
||||
"coinsSuffix": "Münzen",
|
||||
"drawingLabel": "Zeichnung",
|
||||
"addDrawing": "Zeichnung hinzufügen",
|
||||
"editDrawing": "Zeichnung bearbeiten",
|
||||
"shareLabel": "Teilen",
|
||||
"saveChangesButton": "Änderungen speichern",
|
||||
"addTaskButton": "Aufgabe hinzufügen",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
|
||||
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
|
||||
"linkLabel": "Link",
|
||||
"drawingLabel": "Zeichnung",
|
||||
"addDrawing": "Zeichnung hinzufügen",
|
||||
"editDrawing": "Zeichnung bearbeiten",
|
||||
"shareLabel": "Teilen",
|
||||
"saveButton": "Änderungen speichern",
|
||||
"addButton": "Belohnung hinzufügen"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
|
||||
"transactionNotFoundDescription": "Transaktion nicht gefunden",
|
||||
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Farbe:",
|
||||
"thicknessLabel": "Dicke:",
|
||||
"undoButton": "Rückgängig",
|
||||
"clearButton": "Löschen",
|
||||
"saveDrawingButton": "Zeichnung speichern",
|
||||
"cancelButton": "Abbrechen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "times",
|
||||
"rewardLabel": "Reward",
|
||||
"coinsSuffix": "coins",
|
||||
"drawingLabel": "Drawing",
|
||||
"addDrawing": "Add Drawing",
|
||||
"editDrawing": "Edit Drawing",
|
||||
"shareLabel": "Share",
|
||||
"saveChangesButton": "Save Changes",
|
||||
"addTaskButton": "Add Task",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Target completions must be at least 1",
|
||||
"errorInvalidUrl": "Please enter a valid URL",
|
||||
"linkLabel": "Link",
|
||||
"drawingLabel": "Drawing",
|
||||
"addDrawing": "Add Drawing",
|
||||
"editDrawing": "Edit Drawing",
|
||||
"shareLabel": "Share",
|
||||
"saveButton": "Save Changes",
|
||||
"addButton": "Add Reward"
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "veces",
|
||||
"rewardLabel": "Recompensa",
|
||||
"coinsSuffix": "monedas",
|
||||
"drawingLabel": "Dibujo",
|
||||
"addDrawing": "Añadir Dibujo",
|
||||
"editDrawing": "Editar Dibujo",
|
||||
"shareLabel": "Compartir",
|
||||
"saveChangesButton": "Guardar cambios",
|
||||
"addTaskButton": "Añadir tarea",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
|
||||
"errorInvalidUrl": "Por favor ingresa una URL válida",
|
||||
"linkLabel": "Enlace",
|
||||
"drawingLabel": "Dibujo",
|
||||
"addDrawing": "Añadir Dibujo",
|
||||
"editDrawing": "Editar Dibujo",
|
||||
"shareLabel": "Compartir",
|
||||
"saveButton": "Guardar cambios",
|
||||
"addButton": "Añadir recompensa"
|
||||
@@ -385,6 +391,14 @@
|
||||
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
|
||||
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Color:",
|
||||
"thicknessLabel": "Grosor:",
|
||||
"undoButton": "Deshacer",
|
||||
"clearButton": "Limpiar",
|
||||
"saveDrawingButton": "Guardar Dibujo",
|
||||
"cancelButton": "Cancelar"
|
||||
},
|
||||
"Common": {
|
||||
"authenticationRequiredTitle": "Autenticación requerida",
|
||||
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "fois",
|
||||
"rewardLabel": "Récompense",
|
||||
"coinsSuffix": "pièces",
|
||||
"drawingLabel": "Dessin",
|
||||
"addDrawing": "Ajouter un Dessin",
|
||||
"editDrawing": "Modifier le Dessin",
|
||||
"shareLabel": "Partager",
|
||||
"saveChangesButton": "Sauvegarder les modifications",
|
||||
"addTaskButton": "Ajouter une tâche",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
|
||||
"errorInvalidUrl": "Veuillez entrer une URL valide",
|
||||
"linkLabel": "Lien",
|
||||
"drawingLabel": "Dessin",
|
||||
"addDrawing": "Ajouter un Dessin",
|
||||
"editDrawing": "Modifier le Dessin",
|
||||
"shareLabel": "Partager",
|
||||
"saveButton": "Sauvegarder les modifications",
|
||||
"addButton": "Ajouter une récompense"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
|
||||
"transactionNotFoundDescription": "Transaction non trouvée",
|
||||
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Couleur :",
|
||||
"thicknessLabel": "Épaisseur :",
|
||||
"undoButton": "Annuler",
|
||||
"clearButton": "Effacer",
|
||||
"saveDrawingButton": "Enregistrer le dessin",
|
||||
"cancelButton": "Annuler"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "回",
|
||||
"rewardLabel": "報酬",
|
||||
"coinsSuffix": "コイン",
|
||||
"drawingLabel": "描画",
|
||||
"addDrawing": "描画を追加",
|
||||
"editDrawing": "描画を編集",
|
||||
"shareLabel": "共有",
|
||||
"saveChangesButton": "変更を保存",
|
||||
"addTaskButton": "タスクを追加",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
|
||||
"errorInvalidUrl": "有効なURLを入力してください",
|
||||
"linkLabel": "リンク",
|
||||
"drawingLabel": "描画",
|
||||
"addDrawing": "描画を追加",
|
||||
"editDrawing": "描画を編集",
|
||||
"shareLabel": "共有",
|
||||
"saveButton": "変更を保存",
|
||||
"addButton": "報酬を追加"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
|
||||
"transactionNotFoundDescription": "取引が見つかりません",
|
||||
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "色:",
|
||||
"thicknessLabel": "太さ:",
|
||||
"undoButton": "元に戻す",
|
||||
"clearButton": "クリア",
|
||||
"saveDrawingButton": "描画を保存",
|
||||
"cancelButton": "キャンセル"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "회",
|
||||
"rewardLabel": "보상",
|
||||
"coinsSuffix": "코인",
|
||||
"drawingLabel": "그림",
|
||||
"addDrawing": "그림 추가",
|
||||
"editDrawing": "그림 편집",
|
||||
"shareLabel": "공유",
|
||||
"saveChangesButton": "변경 사항 저장",
|
||||
"addTaskButton": "할 일 추가",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "목표 완료 횟수는 최소 1이어야 합니다",
|
||||
"errorInvalidUrl": "유효한 URL을 입력하세요",
|
||||
"linkLabel": "링크",
|
||||
"drawingLabel": "그림",
|
||||
"addDrawing": "그림 추가",
|
||||
"editDrawing": "그림 편집",
|
||||
"shareLabel": "공유",
|
||||
"saveButton": "변경 사항 저장",
|
||||
"addButton": "보상 추가"
|
||||
@@ -431,5 +437,13 @@
|
||||
"Warning": {
|
||||
"areYouSure": "확실합니까?",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "색상:",
|
||||
"thicknessLabel": "두께:",
|
||||
"undoButton": "실행 취소",
|
||||
"clearButton": "지우기",
|
||||
"saveDrawingButton": "그림 저장",
|
||||
"cancelButton": "취소"
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "раз",
|
||||
"rewardLabel": "Награда",
|
||||
"coinsSuffix": "монет",
|
||||
"drawingLabel": "Рисунок",
|
||||
"addDrawing": "Добавить Рисунок",
|
||||
"editDrawing": "Редактировать Рисунок",
|
||||
"shareLabel": "Поделиться",
|
||||
"saveChangesButton": "Сохранить",
|
||||
"addTaskButton": "Добавить задачу",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Минимум 1 выполнение",
|
||||
"errorInvalidUrl": "Некорректная ссылка",
|
||||
"linkLabel": "Ссылка",
|
||||
"drawingLabel": "Рисунок",
|
||||
"addDrawing": "Добавить Рисунок",
|
||||
"editDrawing": "Редактировать Рисунок",
|
||||
"shareLabel": "Поделиться",
|
||||
"saveButton": "Сохранить",
|
||||
"addButton": "Добавить цель"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
|
||||
"transactionNotFoundDescription": "Транзакция не найдена",
|
||||
"maxAmountExceededDescription": "Сумма не может превышать {max}."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Цвет:",
|
||||
"thicknessLabel": "Толщина:",
|
||||
"undoButton": "Отменить",
|
||||
"clearButton": "Очистить",
|
||||
"saveDrawingButton": "Сохранить рисунок",
|
||||
"cancelButton": "Отмена"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "次",
|
||||
"rewardLabel": "奖励",
|
||||
"coinsSuffix": "金币",
|
||||
"drawingLabel": "绘图",
|
||||
"addDrawing": "添加绘图",
|
||||
"editDrawing": "编辑绘图",
|
||||
"shareLabel": "分享",
|
||||
"saveChangesButton": "保存更改",
|
||||
"addTaskButton": "添加任务",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "目标完成次数至少为 1",
|
||||
"errorInvalidUrl": "请输入有效的 URL",
|
||||
"linkLabel": "链接",
|
||||
"drawingLabel": "绘图",
|
||||
"addDrawing": "添加绘图",
|
||||
"editDrawing": "编辑绘图",
|
||||
"shareLabel": "分享",
|
||||
"saveButton": "保存更改",
|
||||
"addButton": "添加奖励"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "金额不能超过 {max}。",
|
||||
"transactionNotFoundDescription": "未找到交易记录",
|
||||
"maxAmountExceededDescription": "金额不能超过 {max}。"
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "颜色:",
|
||||
"thicknessLabel": "粗细:",
|
||||
"undoButton": "撤销",
|
||||
"clearButton": "清除",
|
||||
"saveDrawingButton": "保存绘图",
|
||||
"cancelButton": "取消"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.28",
|
||||
"version": "0.2.29",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user