mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-08 03:29:49 +01:00
Merge Tag 'v0.2.29'
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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
|
## Version 0.2.28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Differences (as of writing) are:
|
|||||||
- 🏆 Earn coins for completing habits
|
- 🏆 Earn coins for completing habits
|
||||||
- 💰 Create a wishlist of rewards to redeem with earned coins
|
- 💰 Create a wishlist of rewards to redeem with earned coins
|
||||||
- 📊 View your habit completion streaks and statistics
|
- 📊 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)
|
- 📅 Calendar heatmap to visualize your progress (WIP)
|
||||||
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
|
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
|
||||||
- 🌙 Dark mode support
|
- 🌙 Dark mode support
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import { toast } from '@/hooks/use-toast';
|
|||||||
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
|
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
|
||||||
import { Settings, WeekDay } from '@/lib/types';
|
import { Settings, WeekDay } from '@/lib/types';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
|
|
||||||
import { Info } from 'lucide-react'; // Import Info icon
|
import { Info } from 'lucide-react'; // Import Info icon
|
||||||
import { useSession } from 'next-auth/react'; // signOut removed
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { saveSettings } from '../actions/data';
|
import { saveSettings } from '../actions/data';
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react'; // signOut removed
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
// AlertDialog components and useState removed
|
// AlertDialog components and useState removed
|
||||||
// Trash2 icon 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 { Habit } from '@/lib/types'
|
||||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Zap } from 'lucide-react'
|
import { Brush, Zap } from 'lucide-react'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { RRule } from 'rrule'
|
import { RRule } from 'rrule'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
import DrawingModal from './DrawingModal'
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
import ModalOverlay from './ModalOverlay'; // Import the new component
|
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 [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const users = usersData.users
|
const users = usersData.users
|
||||||
|
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
|
||||||
|
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
||||||
|
|
||||||
function getFrequencyUpdate() {
|
function getFrequencyUpdate() {
|
||||||
if (ruleText === initialRuleText && habit?.frequency) {
|
if (ruleText === initialRuleText && habit?.frequency) {
|
||||||
@@ -82,7 +86,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||||
completions: habit?.completions || [],
|
completions: habit?.completions || [],
|
||||||
frequency: getFrequencyUpdate(),
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ModalOverlay />
|
<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 */}
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
@@ -275,6 +284,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 && (
|
{users && users.length > 1 && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -318,6 +359,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
|
import { Brush } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
import DrawingModal from './DrawingModal'
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
import ModalOverlay from './ModalOverlay'
|
import ModalOverlay from './ModalOverlay'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
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 [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
|
||||||
|
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
@@ -46,12 +51,14 @@ export default function AddEditWishlistItemModal({
|
|||||||
setCoinCost(editingItem.coinCost)
|
setCoinCost(editingItem.coinCost)
|
||||||
setTargetCompletions(editingItem.targetCompletions)
|
setTargetCompletions(editingItem.targetCompletions)
|
||||||
setLink(editingItem.link || '')
|
setLink(editingItem.link || '')
|
||||||
|
setDrawing(editingItem.drawing || '')
|
||||||
} else {
|
} else {
|
||||||
setName('')
|
setName('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setCoinCost(1)
|
setCoinCost(1)
|
||||||
setTargetCompletions(undefined)
|
setTargetCompletions(undefined)
|
||||||
setLink('')
|
setLink('')
|
||||||
|
setDrawing('')
|
||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
}, [editingItem])
|
}, [editingItem])
|
||||||
@@ -100,7 +107,8 @@ export default function AddEditWishlistItemModal({
|
|||||||
coinCost,
|
coinCost,
|
||||||
targetCompletions: targetCompletions || undefined,
|
targetCompletions: targetCompletions || undefined,
|
||||||
link: link.trim() || 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) {
|
if (editingItem) {
|
||||||
@@ -116,7 +124,11 @@ export default function AddEditWishlistItemModal({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ModalOverlay />
|
<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 */}
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||||
@@ -267,6 +279,38 @@ export default function AddEditWishlistItemModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
{usersData.users && usersData.users.length > 1 && (
|
{usersData.users && usersData.users.length > 1 && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -306,6 +350,13 @@ export default function AddEditWishlistItemModal({
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||||
|
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
||||||
import { Habit, WishlistItemType } from '@/lib/types'
|
import { Habit, WishlistItemType } from '@/lib/types'
|
||||||
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
@@ -27,7 +28,7 @@ import ConfirmDialog from './ConfirmDialog'
|
|||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -246,6 +247,16 @@ const ItemSection = ({
|
|||||||
{habit.name}
|
{habit.name}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{habit.drawing && (
|
||||||
|
<div className="ml-2 pr-2">
|
||||||
|
<DrawingDisplay
|
||||||
|
drawingData={habit.drawing}
|
||||||
|
width={40}
|
||||||
|
height={26}
|
||||||
|
className="border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -458,9 +469,19 @@ export default function DailyOverview({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<Linkify>{item.name}</Linkify>
|
<span className="text-sm">
|
||||||
</span>
|
<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">
|
<span className="text-xs flex items-center">
|
||||||
<Coins className={cn(
|
<Coins className={cn(
|
||||||
"h-3 w-3 mr-1 transition-all",
|
"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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
@@ -12,10 +15,11 @@ import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday,
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
|
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { usePathname } from 'next/navigation'
|
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||||
import { useEffect, useState } from 'react'
|
import { DateTime } from 'luxon'
|
||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
import { hasPermission } from '@/lib/utils'
|
||||||
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
|
|
||||||
interface HabitItemProps {
|
interface HabitItemProps {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
@@ -81,7 +85,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
id={`habit-${habit.id}`}
|
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' : ''}`}
|
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">
|
<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`}>
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
@@ -98,28 +102,44 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
{renderUserAvatars(habit, currentUser as User, usersData)}
|
{renderUserAvatars(habit, currentUser as User, usersData)}
|
||||||
</div>
|
</div>
|
||||||
{habit.description && (
|
{(habit.description || habit.drawing) && (
|
||||||
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
|
||||||
{habit.description}
|
{habit.description && (
|
||||||
</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-grow flex flex-col justify-end">
|
||||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
<div className="mt-auto">
|
||||||
{t('whenLabel', {
|
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
{t('whenLabel', {
|
||||||
frequency: habit.frequency,
|
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||||
isRecurRule: pathname.includes("habits"),
|
frequency: habit.frequency,
|
||||||
timezone: settings.system.timezone
|
isRecurRule,
|
||||||
})
|
timezone: settings.system.timezone
|
||||||
})}
|
})
|
||||||
</p>
|
})}
|
||||||
<div className="flex items-center mt-2">
|
</p>
|
||||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
<div className="flex items-center mt-2">
|
||||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</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="flex gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
@@ -205,4 +225,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAtom } from 'jotai';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
|
||||||
interface HabitStreakProps {
|
interface HabitStreakProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import UserForm from './UserForm';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function UserCard({
|
function UserCard({
|
||||||
user,
|
user,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
|||||||
import { User, WishlistItemType } from '@/lib/types'
|
import { User, WishlistItemType } from '@/lib/types'
|
||||||
import { hasPermission } from '@/lib/utils'
|
import { hasPermission } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
interface WishlistItemProps {
|
interface WishlistItemProps {
|
||||||
item: WishlistItemType
|
item: WishlistItemType
|
||||||
@@ -72,37 +73,51 @@ export default function WishlistItem({
|
|||||||
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
|
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
|
||||||
} ${item.archived ? 'opacity-75' : ''}`}
|
} ${item.archived ? 'opacity-75' : ''}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-none">
|
<CardHeader className="flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex justify-between items-start">
|
||||||
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{item.name}
|
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||||
</CardTitle>
|
{item.name}
|
||||||
{item.targetCompletions && (
|
</CardTitle>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
{item.targetCompletions && (
|
||||||
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||||
</span>
|
({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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{renderUserAvatars(item, currentUser as User, usersData)}
|
{renderUserAvatars(item, currentUser as User, usersData)}
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-grow flex flex-col justify-end">
|
||||||
<div className="flex items-center gap-2">
|
<div className="mt-auto">
|
||||||
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||||
{item.coinCost} {t('coinsSuffix')}
|
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||||
</span>
|
{item.coinCost} {t('coinsSuffix')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={canRedeem ? "default" : "secondary"}
|
variant={canRedeem ? "default" : "secondary"}
|
||||||
@@ -179,4 +194,3 @@ export default function WishlistItem({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type Habit = {
|
|||||||
archived?: boolean // mark the habit as archived
|
archived?: boolean // mark the habit as archived
|
||||||
pinned?: boolean // mark the habit as pinned
|
pinned?: boolean // mark the habit as pinned
|
||||||
userIds?: UserId[]
|
userIds?: UserId[]
|
||||||
|
drawing?: string // Optional JSON string of drawing data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export type WishlistItemType = {
|
|||||||
targetCompletions?: number // Optional field, infinity when unset
|
targetCompletions?: number // Optional field, infinity when unset
|
||||||
link?: string // Optional URL to external resource
|
link?: string // Optional URL to external resource
|
||||||
userIds?: UserId[]
|
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';
|
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
"timesSuffix": "vegades",
|
"timesSuffix": "vegades",
|
||||||
"rewardLabel": "Recompensa",
|
"rewardLabel": "Recompensa",
|
||||||
"coinsSuffix": "monedes",
|
"coinsSuffix": "monedes",
|
||||||
|
"drawingLabel": "Dibuix",
|
||||||
|
"addDrawing": "Afegeix Dibuix",
|
||||||
|
"editDrawing": "Edita Dibuix",
|
||||||
"shareLabel": "Comparteix",
|
"shareLabel": "Comparteix",
|
||||||
"saveChangesButton": "Desa els canvis",
|
"saveChangesButton": "Desa els canvis",
|
||||||
"addTaskButton": "Afegeix tasca",
|
"addTaskButton": "Afegeix tasca",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "El nombre de finalitzacions objectiu ha de ser com a mínim 1",
|
"errorTargetCompletionsMin": "El nombre de finalitzacions objectiu ha de ser com a mínim 1",
|
||||||
"errorInvalidUrl": "Si us plau, introdueix una URL vàlida",
|
"errorInvalidUrl": "Si us plau, introdueix una URL vàlida",
|
||||||
"linkLabel": "Enllaç",
|
"linkLabel": "Enllaç",
|
||||||
|
"drawingLabel": "Dibuix",
|
||||||
|
"addDrawing": "Afegeix Dibuix",
|
||||||
|
"editDrawing": "Edita Dibuix",
|
||||||
"shareLabel": "Comparteix",
|
"shareLabel": "Comparteix",
|
||||||
"saveButton": "Desa els canvis",
|
"saveButton": "Desa els canvis",
|
||||||
"addButton": "Afegeix recompensa"
|
"addButton": "Afegeix recompensa"
|
||||||
@@ -429,5 +435,13 @@
|
|||||||
"successTitle": "Èxit",
|
"successTitle": "Èxit",
|
||||||
"transactionNotFoundDescription": "Transacció no trobada",
|
"transactionNotFoundDescription": "Transacció no trobada",
|
||||||
"maxAmountExceededDescription": "La quantitat no pot excedir {max}."
|
"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",
|
"timesSuffix": "mal",
|
||||||
"rewardLabel": "Belohnung",
|
"rewardLabel": "Belohnung",
|
||||||
"coinsSuffix": "Münzen",
|
"coinsSuffix": "Münzen",
|
||||||
|
"drawingLabel": "Zeichnung",
|
||||||
|
"addDrawing": "Zeichnung hinzufügen",
|
||||||
|
"editDrawing": "Zeichnung bearbeiten",
|
||||||
"shareLabel": "Teilen",
|
"shareLabel": "Teilen",
|
||||||
"saveChangesButton": "Änderungen speichern",
|
"saveChangesButton": "Änderungen speichern",
|
||||||
"addTaskButton": "Aufgabe hinzufügen",
|
"addTaskButton": "Aufgabe hinzufügen",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
|
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
|
||||||
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
|
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
|
||||||
"linkLabel": "Link",
|
"linkLabel": "Link",
|
||||||
|
"drawingLabel": "Zeichnung",
|
||||||
|
"addDrawing": "Zeichnung hinzufügen",
|
||||||
|
"editDrawing": "Zeichnung bearbeiten",
|
||||||
"shareLabel": "Teilen",
|
"shareLabel": "Teilen",
|
||||||
"saveButton": "Änderungen speichern",
|
"saveButton": "Änderungen speichern",
|
||||||
"addButton": "Belohnung hinzufügen"
|
"addButton": "Belohnung hinzufügen"
|
||||||
@@ -431,5 +437,13 @@
|
|||||||
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
|
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
|
||||||
"transactionNotFoundDescription": "Transaktion nicht gefunden",
|
"transactionNotFoundDescription": "Transaktion nicht gefunden",
|
||||||
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten."
|
"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",
|
"timesSuffix": "times",
|
||||||
"rewardLabel": "Reward",
|
"rewardLabel": "Reward",
|
||||||
"coinsSuffix": "coins",
|
"coinsSuffix": "coins",
|
||||||
|
"drawingLabel": "Drawing",
|
||||||
|
"addDrawing": "Add Drawing",
|
||||||
|
"editDrawing": "Edit Drawing",
|
||||||
"shareLabel": "Share",
|
"shareLabel": "Share",
|
||||||
"saveChangesButton": "Save Changes",
|
"saveChangesButton": "Save Changes",
|
||||||
"addTaskButton": "Add Task",
|
"addTaskButton": "Add Task",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "Target completions must be at least 1",
|
"errorTargetCompletionsMin": "Target completions must be at least 1",
|
||||||
"errorInvalidUrl": "Please enter a valid URL",
|
"errorInvalidUrl": "Please enter a valid URL",
|
||||||
"linkLabel": "Link",
|
"linkLabel": "Link",
|
||||||
|
"drawingLabel": "Drawing",
|
||||||
|
"addDrawing": "Add Drawing",
|
||||||
|
"editDrawing": "Edit Drawing",
|
||||||
"shareLabel": "Share",
|
"shareLabel": "Share",
|
||||||
"saveButton": "Save Changes",
|
"saveButton": "Save Changes",
|
||||||
"addButton": "Add Reward"
|
"addButton": "Add Reward"
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
"timesSuffix": "veces",
|
"timesSuffix": "veces",
|
||||||
"rewardLabel": "Recompensa",
|
"rewardLabel": "Recompensa",
|
||||||
"coinsSuffix": "monedas",
|
"coinsSuffix": "monedas",
|
||||||
|
"drawingLabel": "Dibujo",
|
||||||
|
"addDrawing": "Añadir Dibujo",
|
||||||
|
"editDrawing": "Editar Dibujo",
|
||||||
"shareLabel": "Compartir",
|
"shareLabel": "Compartir",
|
||||||
"saveChangesButton": "Guardar cambios",
|
"saveChangesButton": "Guardar cambios",
|
||||||
"addTaskButton": "Añadir tarea",
|
"addTaskButton": "Añadir tarea",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
|
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
|
||||||
"errorInvalidUrl": "Por favor ingresa una URL válida",
|
"errorInvalidUrl": "Por favor ingresa una URL válida",
|
||||||
"linkLabel": "Enlace",
|
"linkLabel": "Enlace",
|
||||||
|
"drawingLabel": "Dibujo",
|
||||||
|
"addDrawing": "Añadir Dibujo",
|
||||||
|
"editDrawing": "Editar Dibujo",
|
||||||
"shareLabel": "Compartir",
|
"shareLabel": "Compartir",
|
||||||
"saveButton": "Guardar cambios",
|
"saveButton": "Guardar cambios",
|
||||||
"addButton": "Añadir recompensa"
|
"addButton": "Añadir recompensa"
|
||||||
@@ -385,6 +391,14 @@
|
|||||||
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
|
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
|
||||||
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
|
"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": {
|
"Common": {
|
||||||
"authenticationRequiredTitle": "Autenticación requerida",
|
"authenticationRequiredTitle": "Autenticación requerida",
|
||||||
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",
|
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
"timesSuffix": "fois",
|
"timesSuffix": "fois",
|
||||||
"rewardLabel": "Récompense",
|
"rewardLabel": "Récompense",
|
||||||
"coinsSuffix": "pièces",
|
"coinsSuffix": "pièces",
|
||||||
|
"drawingLabel": "Dessin",
|
||||||
|
"addDrawing": "Ajouter un Dessin",
|
||||||
|
"editDrawing": "Modifier le Dessin",
|
||||||
"shareLabel": "Partager",
|
"shareLabel": "Partager",
|
||||||
"saveChangesButton": "Sauvegarder les modifications",
|
"saveChangesButton": "Sauvegarder les modifications",
|
||||||
"addTaskButton": "Ajouter une tâche",
|
"addTaskButton": "Ajouter une tâche",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
|
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
|
||||||
"errorInvalidUrl": "Veuillez entrer une URL valide",
|
"errorInvalidUrl": "Veuillez entrer une URL valide",
|
||||||
"linkLabel": "Lien",
|
"linkLabel": "Lien",
|
||||||
|
"drawingLabel": "Dessin",
|
||||||
|
"addDrawing": "Ajouter un Dessin",
|
||||||
|
"editDrawing": "Modifier le Dessin",
|
||||||
"shareLabel": "Partager",
|
"shareLabel": "Partager",
|
||||||
"saveButton": "Sauvegarder les modifications",
|
"saveButton": "Sauvegarder les modifications",
|
||||||
"addButton": "Ajouter une récompense"
|
"addButton": "Ajouter une récompense"
|
||||||
@@ -431,5 +437,13 @@
|
|||||||
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
|
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
|
||||||
"transactionNotFoundDescription": "Transaction non trouvée",
|
"transactionNotFoundDescription": "Transaction non trouvée",
|
||||||
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}."
|
"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": "回",
|
"timesSuffix": "回",
|
||||||
"rewardLabel": "報酬",
|
"rewardLabel": "報酬",
|
||||||
"coinsSuffix": "コイン",
|
"coinsSuffix": "コイン",
|
||||||
|
"drawingLabel": "描画",
|
||||||
|
"addDrawing": "描画を追加",
|
||||||
|
"editDrawing": "描画を編集",
|
||||||
"shareLabel": "共有",
|
"shareLabel": "共有",
|
||||||
"saveChangesButton": "変更を保存",
|
"saveChangesButton": "変更を保存",
|
||||||
"addTaskButton": "タスクを追加",
|
"addTaskButton": "タスクを追加",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
|
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
|
||||||
"errorInvalidUrl": "有効なURLを入力してください",
|
"errorInvalidUrl": "有効なURLを入力してください",
|
||||||
"linkLabel": "リンク",
|
"linkLabel": "リンク",
|
||||||
|
"drawingLabel": "描画",
|
||||||
|
"addDrawing": "描画を追加",
|
||||||
|
"editDrawing": "描画を編集",
|
||||||
"shareLabel": "共有",
|
"shareLabel": "共有",
|
||||||
"saveButton": "変更を保存",
|
"saveButton": "変更を保存",
|
||||||
"addButton": "報酬を追加"
|
"addButton": "報酬を追加"
|
||||||
@@ -431,5 +437,13 @@
|
|||||||
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
|
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
|
||||||
"transactionNotFoundDescription": "取引が見つかりません",
|
"transactionNotFoundDescription": "取引が見つかりません",
|
||||||
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
|
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
|
||||||
|
},
|
||||||
|
"DrawingModal": {
|
||||||
|
"colorLabel": "色:",
|
||||||
|
"thicknessLabel": "太さ:",
|
||||||
|
"undoButton": "元に戻す",
|
||||||
|
"clearButton": "クリア",
|
||||||
|
"saveDrawingButton": "描画を保存",
|
||||||
|
"cancelButton": "キャンセル"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
"timesSuffix": "회",
|
"timesSuffix": "회",
|
||||||
"rewardLabel": "보상",
|
"rewardLabel": "보상",
|
||||||
"coinsSuffix": "코인",
|
"coinsSuffix": "코인",
|
||||||
|
"drawingLabel": "그림",
|
||||||
|
"addDrawing": "그림 추가",
|
||||||
|
"editDrawing": "그림 편집",
|
||||||
"shareLabel": "공유",
|
"shareLabel": "공유",
|
||||||
"saveChangesButton": "변경 사항 저장",
|
"saveChangesButton": "변경 사항 저장",
|
||||||
"addTaskButton": "할 일 추가",
|
"addTaskButton": "할 일 추가",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "목표 완료 횟수는 최소 1이어야 합니다",
|
"errorTargetCompletionsMin": "목표 완료 횟수는 최소 1이어야 합니다",
|
||||||
"errorInvalidUrl": "유효한 URL을 입력하세요",
|
"errorInvalidUrl": "유효한 URL을 입력하세요",
|
||||||
"linkLabel": "링크",
|
"linkLabel": "링크",
|
||||||
|
"drawingLabel": "그림",
|
||||||
|
"addDrawing": "그림 추가",
|
||||||
|
"editDrawing": "그림 편집",
|
||||||
"shareLabel": "공유",
|
"shareLabel": "공유",
|
||||||
"saveButton": "변경 사항 저장",
|
"saveButton": "변경 사항 저장",
|
||||||
"addButton": "보상 추가"
|
"addButton": "보상 추가"
|
||||||
@@ -431,5 +437,13 @@
|
|||||||
"Warning": {
|
"Warning": {
|
||||||
"areYouSure": "확실합니까?",
|
"areYouSure": "확실합니까?",
|
||||||
"cancel": "취소"
|
"cancel": "취소"
|
||||||
|
},
|
||||||
|
"DrawingModal": {
|
||||||
|
"colorLabel": "색상:",
|
||||||
|
"thicknessLabel": "두께:",
|
||||||
|
"undoButton": "실행 취소",
|
||||||
|
"clearButton": "지우기",
|
||||||
|
"saveDrawingButton": "그림 저장",
|
||||||
|
"cancelButton": "취소"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,9 @@
|
|||||||
"timesSuffix": "раз",
|
"timesSuffix": "раз",
|
||||||
"rewardLabel": "Награда",
|
"rewardLabel": "Награда",
|
||||||
"coinsSuffix": "монет",
|
"coinsSuffix": "монет",
|
||||||
|
"drawingLabel": "Рисунок",
|
||||||
|
"addDrawing": "Добавить Рисунок",
|
||||||
|
"editDrawing": "Редактировать Рисунок",
|
||||||
"shareLabel": "Поделиться",
|
"shareLabel": "Поделиться",
|
||||||
"saveChangesButton": "Сохранить",
|
"saveChangesButton": "Сохранить",
|
||||||
"addTaskButton": "Добавить задачу",
|
"addTaskButton": "Добавить задачу",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "Минимум 1 выполнение",
|
"errorTargetCompletionsMin": "Минимум 1 выполнение",
|
||||||
"errorInvalidUrl": "Некорректная ссылка",
|
"errorInvalidUrl": "Некорректная ссылка",
|
||||||
"linkLabel": "Ссылка",
|
"linkLabel": "Ссылка",
|
||||||
|
"drawingLabel": "Рисунок",
|
||||||
|
"addDrawing": "Добавить Рисунок",
|
||||||
|
"editDrawing": "Редактировать Рисунок",
|
||||||
"shareLabel": "Поделиться",
|
"shareLabel": "Поделиться",
|
||||||
"saveButton": "Сохранить",
|
"saveButton": "Сохранить",
|
||||||
"addButton": "Добавить цель"
|
"addButton": "Добавить цель"
|
||||||
@@ -431,5 +437,13 @@
|
|||||||
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
|
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
|
||||||
"transactionNotFoundDescription": "Транзакция не найдена",
|
"transactionNotFoundDescription": "Транзакция не найдена",
|
||||||
"maxAmountExceededDescription": "Сумма не может превышать {max}."
|
"maxAmountExceededDescription": "Сумма не может превышать {max}."
|
||||||
|
},
|
||||||
|
"DrawingModal": {
|
||||||
|
"colorLabel": "Цвет:",
|
||||||
|
"thicknessLabel": "Толщина:",
|
||||||
|
"undoButton": "Отменить",
|
||||||
|
"clearButton": "Очистить",
|
||||||
|
"saveDrawingButton": "Сохранить рисунок",
|
||||||
|
"cancelButton": "Отмена"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
"timesSuffix": "次",
|
"timesSuffix": "次",
|
||||||
"rewardLabel": "奖励",
|
"rewardLabel": "奖励",
|
||||||
"coinsSuffix": "金币",
|
"coinsSuffix": "金币",
|
||||||
|
"drawingLabel": "绘图",
|
||||||
|
"addDrawing": "添加绘图",
|
||||||
|
"editDrawing": "编辑绘图",
|
||||||
"shareLabel": "分享",
|
"shareLabel": "分享",
|
||||||
"saveChangesButton": "保存更改",
|
"saveChangesButton": "保存更改",
|
||||||
"addTaskButton": "添加任务",
|
"addTaskButton": "添加任务",
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
"errorTargetCompletionsMin": "目标完成次数至少为 1",
|
"errorTargetCompletionsMin": "目标完成次数至少为 1",
|
||||||
"errorInvalidUrl": "请输入有效的 URL",
|
"errorInvalidUrl": "请输入有效的 URL",
|
||||||
"linkLabel": "链接",
|
"linkLabel": "链接",
|
||||||
|
"drawingLabel": "绘图",
|
||||||
|
"addDrawing": "添加绘图",
|
||||||
|
"editDrawing": "编辑绘图",
|
||||||
"shareLabel": "分享",
|
"shareLabel": "分享",
|
||||||
"saveButton": "保存更改",
|
"saveButton": "保存更改",
|
||||||
"addButton": "添加奖励"
|
"addButton": "添加奖励"
|
||||||
@@ -431,5 +437,13 @@
|
|||||||
"maxAmountExceededDescription": "金额不能超过 {max}。",
|
"maxAmountExceededDescription": "金额不能超过 {max}。",
|
||||||
"transactionNotFoundDescription": "未找到交易记录",
|
"transactionNotFoundDescription": "未找到交易记录",
|
||||||
"maxAmountExceededDescription": "金额不能超过 {max}。"
|
"maxAmountExceededDescription": "金额不能超过 {max}。"
|
||||||
|
},
|
||||||
|
"DrawingModal": {
|
||||||
|
"colorLabel": "颜色:",
|
||||||
|
"thicknessLabel": "粗细:",
|
||||||
|
"undoButton": "撤销",
|
||||||
|
"clearButton": "清除",
|
||||||
|
"saveDrawingButton": "保存绘图",
|
||||||
|
"cancelButton": "取消"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.28",
|
"version": "0.2.29",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
Reference in New Issue
Block a user