mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 03:59:50 +01:00
Merge Tag v0.2.12
This commit is contained in:
@@ -5,6 +5,7 @@ import { Button } from "./ui/button"
|
||||
import { Star, History } from "lucide-react"
|
||||
import packageJson from '../package.json'
|
||||
import { DialogTitle } from "@radix-ui/react-dialog"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Logo } from "./Logo"
|
||||
import ChangelogModal from "./ChangelogModal"
|
||||
import { useState } from "react"
|
||||
@@ -15,6 +16,7 @@ interface AboutModalProps {
|
||||
}
|
||||
|
||||
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
const t = useTranslations('AboutModal')
|
||||
const version = packageJson.version
|
||||
const [changelogOpen, setChangelogOpen] = useState(false)
|
||||
|
||||
@@ -22,7 +24,7 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle aria-label="about"></DialogTitle>
|
||||
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 text-center py-4">
|
||||
<div>
|
||||
@@ -40,14 +42,14 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
onClick={() => setChangelogOpen(true)}
|
||||
>
|
||||
<History className="w-3 h-3 mr-1" />
|
||||
Changelog
|
||||
{t('changelogButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm">
|
||||
Created with ❤️ by{' '}
|
||||
{t('createdByPrefix')}{' '}
|
||||
<a
|
||||
href="https://github.com/dohsimpson"
|
||||
target="_blank"
|
||||
@@ -68,7 +70,7 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Star on GitHub
|
||||
{t('starOnGitHubButton')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import Picker from '@emoji-mart/react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { SmilePlus, Zap } from 'lucide-react'
|
||||
import { DateTime } from 'luxon'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { RRule } from 'rrule'
|
||||
|
||||
@@ -28,6 +29,7 @@ interface AddEditHabitModalProps {
|
||||
}
|
||||
|
||||
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
|
||||
const t = useTranslations('AddEditHabitModal');
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [name, setName] = useState(habit?.name || '')
|
||||
const [description, setDescription] = useState(habit?.description || '')
|
||||
@@ -90,13 +92,17 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{habit ? `Edit ${isTask ? 'Task' : 'Habit'}` : `Add New ${isTask ? 'Task' : 'Habit'}`}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{habit
|
||||
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
|
||||
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name *
|
||||
{t('nameLabel')}
|
||||
</Label>
|
||||
<div className='flex col-span-3 gap-2'>
|
||||
<Input
|
||||
@@ -136,7 +142,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
Description
|
||||
{t('descriptionLabel')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
@@ -147,7 +153,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="recurrence" className="text-right">
|
||||
When *
|
||||
{t('whenLabel')}
|
||||
</Label>
|
||||
{/* date input (task) */}
|
||||
<div className="col-span-3 space-y-2">
|
||||
@@ -203,7 +209,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="targetCompletions">
|
||||
Complete
|
||||
{t('completeLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
@@ -237,7 +243,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
times
|
||||
{t('timesSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +251,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
Reward
|
||||
{t('rewardLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
@@ -276,7 +282,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
coins
|
||||
{t('coinsSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,7 +290,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
{users && users.length > 1 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Label htmlFor="sharing-toggle">Share</Label>
|
||||
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -315,7 +321,11 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={errorMessage !== null}>{habit ? 'Save Changes' : `Add ${isTask ? 'Task' : 'Habit'}`}</Button>
|
||||
<Button type="submit">
|
||||
{habit
|
||||
? t('saveChangesButton')
|
||||
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -11,6 +12,7 @@ import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { SmilePlus } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
|
||||
@@ -31,6 +33,7 @@ export default function AddEditWishlistItemModal({
|
||||
addWishlistItem,
|
||||
editWishlistItem
|
||||
}: AddEditWishlistItemModalProps) {
|
||||
const t = useTranslations('AddEditWishlistItemModal')
|
||||
const [name, setName] = useState(editingItem?.name || '')
|
||||
const [description, setDescription] = useState(editingItem?.description || '')
|
||||
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
|
||||
@@ -61,16 +64,16 @@ export default function AddEditWishlistItemModal({
|
||||
const validate = () => {
|
||||
const newErrors: { [key: string]: string } = {}
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Name is required'
|
||||
newErrors.name = t('errorNameRequired')
|
||||
}
|
||||
if (coinCost < 1) {
|
||||
newErrors.coinCost = 'Coin cost must be at least 1'
|
||||
newErrors.coinCost = t('errorCoinCostMin')
|
||||
}
|
||||
if (targetCompletions !== undefined && targetCompletions < 1) {
|
||||
newErrors.targetCompletions = 'Target completions must be at least 1'
|
||||
newErrors.targetCompletions = t('errorTargetCompletionsMin')
|
||||
}
|
||||
if (link && !isValidUrl(link)) {
|
||||
newErrors.link = 'Please enter a valid URL'
|
||||
newErrors.link = t('errorInvalidUrl')
|
||||
}
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
@@ -117,13 +120,13 @@ export default function AddEditWishlistItemModal({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
|
||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name *
|
||||
{t('nameLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<Input
|
||||
@@ -160,7 +163,7 @@ export default function AddEditWishlistItemModal({
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
Description
|
||||
{t('descriptionLabel')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
@@ -172,7 +175,7 @@ export default function AddEditWishlistItemModal({
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
Cost
|
||||
{t('costLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
@@ -203,7 +206,7 @@ export default function AddEditWishlistItemModal({
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
coins
|
||||
{t('coinsSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,7 +214,7 @@ export default function AddEditWishlistItemModal({
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="targetCompletions">
|
||||
Redeemable
|
||||
{t('redeemableLabel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
@@ -245,7 +248,7 @@ export default function AddEditWishlistItemModal({
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
times
|
||||
{t('timesSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
{errors.targetCompletions && (
|
||||
@@ -257,7 +260,7 @@ export default function AddEditWishlistItemModal({
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="link" className="text-right">
|
||||
Link
|
||||
{t('linkLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input
|
||||
@@ -278,7 +281,7 @@ export default function AddEditWishlistItemModal({
|
||||
{usersData.users && usersData.users.length > 1 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Label htmlFor="sharing-toggle">Share</Label>
|
||||
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -309,7 +312,7 @@ export default function AddEditWishlistItemModal({
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button>
|
||||
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,17 +2,19 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Coins } from 'lucide-react'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||
|
||||
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
|
||||
const t = useTranslations('CoinBalance');
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coin Balance</CardTitle>
|
||||
<CardTitle>{t('coinBalanceTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -8,9 +8,11 @@ import { Input } from '@/components/ui/input'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { TransactionType } from '@/lib/types'
|
||||
import { d2s, t2d } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { History } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
|
||||
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
|
||||
@@ -18,6 +20,7 @@ import EmptyState from './EmptyState'
|
||||
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||
|
||||
export default function CoinsManager() {
|
||||
const t = useTranslations('CoinsManager')
|
||||
const { currentUser } = useHelpers()
|
||||
const [selectedUser, setSelectedUser] = useState<string>()
|
||||
const {
|
||||
@@ -31,7 +34,7 @@ export default function CoinsManager() {
|
||||
totalSpent,
|
||||
coinsSpentToday,
|
||||
transactionsToday
|
||||
} = useCoins({selectedUser})
|
||||
} = useCoins({ selectedUser })
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const DEFAULT_AMOUNT = '0'
|
||||
@@ -87,10 +90,21 @@ export default function CoinsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const getTransactionTypeLabel = (type: TransactionType) => {
|
||||
switch (type) {
|
||||
case 'HABIT_COMPLETION': return t('transactionTypeHabitCompletion');
|
||||
case 'TASK_COMPLETION': return t('transactionTypeTaskCompletion');
|
||||
case 'HABIT_UNDO': return t('transactionTypeHabitUndo');
|
||||
case 'TASK_UNDO': return t('transactionTypeTaskUndo');
|
||||
case 'WISH_REDEMPTION': return t('transactionTypeWishRedemption');
|
||||
case 'MANUAL_ADJUSTMENT': return t('transactionTypeManualAdjustment');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold mr-6">Coins Management</h1>
|
||||
<h1 className="text-3xl font-bold mr-6">{t('title')}</h1>
|
||||
{currentUser?.isAdmin && (
|
||||
<select
|
||||
className="border rounded p-2"
|
||||
@@ -112,8 +126,8 @@ export default function CoinsManager() {
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
|
||||
<div>
|
||||
<div className="text-sm font-normal text-muted-foreground">Current Balance</div>
|
||||
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> coins</div>
|
||||
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
|
||||
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -158,7 +172,7 @@ export default function CoinsManager() {
|
||||
variant="default"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
|
||||
{Number(amount) >= 0 ? t('addCoinsButton') : t('removeCoinsButton')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -170,27 +184,27 @@ export default function CoinsManager() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistics</CardTitle>
|
||||
<CardTitle>{t('statisticsTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
{/* Top Row - Totals */}
|
||||
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
|
||||
<div className="text-sm text-green-800 dark:text-green-100 mb-1">Total Earned</div>
|
||||
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div>
|
||||
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
|
||||
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
|
||||
<div className="text-sm text-red-800 dark:text-red-100 mb-1">Total Spent</div>
|
||||
<div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div>
|
||||
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
|
||||
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900">
|
||||
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">Total Transactions</div>
|
||||
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">{t('totalTransactionsLabel')}</div>
|
||||
<div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
|
||||
{transactions.length} 📈
|
||||
</div>
|
||||
@@ -198,21 +212,21 @@ export default function CoinsManager() {
|
||||
|
||||
{/* Bottom Row - Today */}
|
||||
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Earned</div>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div>
|
||||
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
|
||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">Today's Spent</div>
|
||||
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div>
|
||||
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
|
||||
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">Today's Transactions</div>
|
||||
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div>
|
||||
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
|
||||
{transactionsToday} 📊
|
||||
</div>
|
||||
@@ -223,13 +237,13 @@ export default function CoinsManager() {
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction History</CardTitle>
|
||||
<CardTitle>{t('transactionHistoryTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<span className="text-sm text-muted-foreground">{t('showLabel')}</span>
|
||||
<select
|
||||
className="border rounded p-1"
|
||||
value={pageSize}
|
||||
@@ -240,18 +254,18 @@ export default function CoinsManager() {
|
||||
>
|
||||
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground">entries</span>
|
||||
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {Math.min((currentPage - 1) * pageSize + 1, transactions.length)} to {Math.min(currentPage * pageSize, transactions.length)} of {transactions.length} entries
|
||||
{t('showingEntries', { from: Math.min((currentPage - 1) * pageSize + 1, transactions.length), to: Math.min(currentPage * pageSize, transactions.length), total: transactions.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transactions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="No transactions yet"
|
||||
description="Your transaction history will appear here once you start earning or spending coins"
|
||||
title={t('noTransactionsTitle')}
|
||||
description={t('noTransactionsDescription')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -279,9 +293,8 @@ export default function CoinsManager() {
|
||||
<div
|
||||
key={transaction.id}
|
||||
ref={(el) => { transactionRefs.current[transaction.id] = el; }} // Assign ref correctly
|
||||
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||
isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
|
||||
}`}
|
||||
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
|
||||
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
|
||||
@@ -299,7 +312,7 @@ export default function CoinsManager() {
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
|
||||
>
|
||||
{transaction.type.split('_').join(' ')}
|
||||
{getTransactionTypeLabel(transaction.type as TransactionType)}
|
||||
</span>
|
||||
{transaction.userId && currentUser?.isAdmin && (
|
||||
<Avatar className="h-6 w-6">
|
||||
@@ -357,9 +370,9 @@ export default function CoinsManager() {
|
||||
‹
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-4 py-2 rounded-md bg-muted">
|
||||
<span className="text-sm font-medium">Page</span>
|
||||
<span className="text-sm font-medium">{t('pageLabel')}</span>
|
||||
<span className="text-sm font-bold">{currentPage}</span>
|
||||
<span className="text-sm font-medium">of</span>
|
||||
<span className="text-sm font-medium">{t('ofLabel')}</span>
|
||||
<span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface CompletionCountBadgeProps {
|
||||
type: 'habits' | 'tasks'
|
||||
@@ -12,6 +13,7 @@ export default function CompletionCountBadge({
|
||||
type,
|
||||
date
|
||||
}: CompletionCountBadgeProps) {
|
||||
const t = useTranslations('CompletionCountBadge');
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
const targetDate = date || getTodayInTimezone(settings.system.timezone)
|
||||
@@ -27,7 +29,7 @@ export default function CompletionCountBadge({
|
||||
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{`${completedCount}/${totalCount} Completed`}
|
||||
{t('countCompleted', { completedCount, totalCount })}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -23,9 +24,13 @@ export default function ConfirmDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel"
|
||||
confirmText,
|
||||
cancelText,
|
||||
}: ConfirmDialogProps) {
|
||||
const t = useTranslations('ConfirmDialog');
|
||||
const finalConfirmText = confirmText || t('confirmButton');
|
||||
const finalCancelText = cancelText || t('cancelButton');
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
@@ -37,10 +42,10 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelText}
|
||||
{finalCancelText}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
{finalConfirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Habit, WishlistItemType } from '@/lib/types'
|
||||
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import AddEditHabitModal from './AddEditHabitModal'
|
||||
@@ -50,6 +51,7 @@ const ItemSection = ({
|
||||
viewLink,
|
||||
addNewItem,
|
||||
}: ItemSectionProps) => {
|
||||
const t = useTranslations('DailyOverview');
|
||||
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits();
|
||||
const [_, setPomo] = useAtom(pomodoroAtom);
|
||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
|
||||
@@ -101,7 +103,7 @@ const ItemSection = ({
|
||||
onClick={addNewItem}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
|
||||
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
@@ -126,7 +128,7 @@ const ItemSection = ({
|
||||
onClick={addNewItem}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
|
||||
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +231,7 @@ const ItemSection = ({
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Overdue</p>
|
||||
<p>{t('overdueTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -295,12 +297,12 @@ const ItemSection = ({
|
||||
>
|
||||
{currentExpanded ? (
|
||||
<>
|
||||
Show less
|
||||
{t('showLessButton')}
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show all
|
||||
{t('showAllButton')}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
@@ -343,6 +345,7 @@ export default function DailyOverview({
|
||||
wishlistItems,
|
||||
coinBalance,
|
||||
}: UpcomingItemsProps) {
|
||||
const t = useTranslations('DailyOverview');
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
@@ -396,16 +399,16 @@ export default function DailyOverview({
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Overview</CardTitle>
|
||||
<CardTitle>{t('todaysOverviewTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Tasks Section */}
|
||||
{hasTasks && (
|
||||
<ItemSection
|
||||
title="Daily Tasks"
|
||||
title={t('dailyTasksTitle')}
|
||||
items={dailyTasks}
|
||||
emptyMessage="No tasks due today. Add some tasks to get started!"
|
||||
emptyMessage={t('noTasksDueTodayMessage')}
|
||||
isTask={true}
|
||||
viewLink="/habits?view=tasks"
|
||||
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
|
||||
@@ -414,9 +417,9 @@ export default function DailyOverview({
|
||||
|
||||
{/* Habits Section */}
|
||||
<ItemSection
|
||||
title="Daily Habits"
|
||||
title={t('dailyHabitsTitle')}
|
||||
items={dailyHabits}
|
||||
emptyMessage="No habits due today. Add some habits to get started!"
|
||||
emptyMessage={t('noHabitsDueTodayMessage')}
|
||||
isTask={false}
|
||||
viewLink="/habits"
|
||||
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
|
||||
@@ -424,16 +427,19 @@ export default function DailyOverview({
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold">Wishlist Goals</h3>
|
||||
<h3 className="font-semibold">{t('wishlistGoalsTitle')}</h3>
|
||||
<Badge variant="secondary">
|
||||
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
|
||||
{t('redeemableBadgeLabel', {
|
||||
count: wishlistItems.filter(item => item.coinCost <= coinBalance).length,
|
||||
total: wishlistItems.length
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${browserSettings.expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
{sortedWishlistItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
No wishlist items yet. Add some goals to work towards!
|
||||
{t('noWishlistItemsMessage')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -480,8 +486,8 @@ export default function DailyOverview({
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{isRedeemable
|
||||
? "Ready to redeem!"
|
||||
: `${item.coinCost - coinBalance} coins to go`
|
||||
? t('readyToRedeemMessage')
|
||||
: t('coinsToGoMessage', { amount: item.coinCost - coinBalance })
|
||||
}
|
||||
</p>
|
||||
</Link>
|
||||
@@ -497,12 +503,12 @@ export default function DailyOverview({
|
||||
>
|
||||
{browserSettings.expandedWishlist ? (
|
||||
<>
|
||||
Show less
|
||||
{t('showLessButton')}
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show all
|
||||
{t('showAllButton')}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
@@ -511,7 +517,7 @@ export default function DailyOverview({
|
||||
href="/wishlist"
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
View
|
||||
{t('viewButton')}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import CoinBalance from './CoinBalance'
|
||||
import DailyOverview from './DailyOverview'
|
||||
import HabitStreak from './HabitStreak'
|
||||
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations('Dashboard');
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
const habits = habitsData.habits
|
||||
const { balance } = useCoins()
|
||||
@@ -17,7 +19,7 @@ export default function Dashboard() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold">{t('title')}</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<CoinBalance coinBalance={balance} />
|
||||
|
||||
@@ -10,19 +10,21 @@ import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/li
|
||||
import { useAtom } from 'jotai'
|
||||
import { Circle, CircleCheck } from 'lucide-react'
|
||||
import { DateTime } from 'luxon'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Linkify from './linkify'
|
||||
|
||||
export default function HabitCalendar() {
|
||||
const t = useTranslations('HabitCalendar')
|
||||
const { completePastHabit } = useHabits()
|
||||
|
||||
const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => {
|
||||
try {
|
||||
await completePastHabit(habit, date)
|
||||
} catch (error) {
|
||||
console.error('Error completing past habit:', error)
|
||||
console.error(t('errorCompletingPastHabit'), error)
|
||||
}
|
||||
}, [completePastHabit])
|
||||
}, [completePastHabit, t])
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
||||
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
|
||||
@@ -41,11 +43,11 @@ export default function HabitCalendar() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
|
||||
<h1 className="text-3xl font-bold mb-6">{t('title')}</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calendar</CardTitle>
|
||||
<CardTitle>{t('calendarCardTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Calendar
|
||||
@@ -74,7 +76,7 @@ export default function HabitCalendar() {
|
||||
{selectedDateTime ? (
|
||||
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
|
||||
) : (
|
||||
'Select a date'
|
||||
t('selectDatePrompt')
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -84,7 +86,7 @@ export default function HabitCalendar() {
|
||||
{hasTasks && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3>
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>
|
||||
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
@@ -143,7 +145,7 @@ export default function HabitCalendar() {
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3>
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('habitsSectionTitle')}</h3>
|
||||
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdow
|
||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
||||
import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface HabitContextMenuItemsProps {
|
||||
habit: Habit;
|
||||
@@ -23,6 +24,7 @@ export function HabitContextMenuItems({
|
||||
context = 'habit-item',
|
||||
onClose,
|
||||
}: HabitContextMenuItemsProps) {
|
||||
const t = useTranslations('HabitContextMenuItems');
|
||||
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
||||
const [settings] = useAtom(settingsAtom);
|
||||
const [, setPomo] = useAtom(pomodoroAtom);
|
||||
@@ -55,7 +57,7 @@ export function HabitContextMenuItems({
|
||||
})}
|
||||
>
|
||||
<Timer className="mr-2 h-4 w-4" />
|
||||
<span>Start Pomodoro</span>
|
||||
<span>{t('startPomodoro')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -69,7 +71,7 @@ export function HabitContextMenuItems({
|
||||
})}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Move to Today</span>
|
||||
<span>{t('moveToToday')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -83,7 +85,7 @@ export function HabitContextMenuItems({
|
||||
})}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Move to Tomorrow</span>
|
||||
<span>{t('moveToTomorrow')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -93,7 +95,7 @@ export function HabitContextMenuItems({
|
||||
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
|
||||
>
|
||||
<Pin className="mr-2 h-4 w-4" />
|
||||
<span>{habit.pinned ? 'Unpin' : 'Pin'}</span>
|
||||
<span>{t(habit.pinned ? 'unpin' : 'pin')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -104,7 +106,7 @@ export function HabitContextMenuItems({
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
<span>{t('edit')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -114,7 +116,7 @@ export function HabitContextMenuItems({
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
<span>{t('edit')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -125,7 +127,7 @@ export function HabitContextMenuItems({
|
||||
onClick={() => handleAction(() => archiveHabit(habit.id))}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span>Archive</span>
|
||||
<span>{t('archive')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -135,7 +137,7 @@ export function HabitContextMenuItems({
|
||||
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
|
||||
>
|
||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||
<span>Unarchive</span>
|
||||
<span>{t('unarchive')}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
@@ -150,7 +152,7 @@ export function HabitContextMenuItems({
|
||||
disabled={!canWrite} // Assuming delete is a write operation
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
<span>{t('delete')}</span>
|
||||
</MenuItemComponent>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,11 @@ import { Habit, User } from '@/lib/types'
|
||||
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'; // Removed unused icons
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
interface HabitItemProps {
|
||||
habit: Habit
|
||||
@@ -50,6 +51,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
const target = habit.targetCompletions || 1
|
||||
const isCompletedToday = completionsToday >= target
|
||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||
const t = useTranslations('HabitItem');
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const { currentUser, hasPermission } = useHelpers()
|
||||
const canWrite = hasPermission('habit', 'write')
|
||||
@@ -91,7 +93,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</div>
|
||||
{isTaskOverdue(habit, settings.system.timezone) && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
|
||||
Overdue
|
||||
{t('overdue')}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
@@ -105,15 +107,15 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
When: {convertMachineReadableFrequencyToHumanReadable({
|
||||
{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' : ''}`}>{habit.coinReward} coins per completion</span>
|
||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
@@ -131,19 +133,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
{isCompletedToday ? (
|
||||
target > 1 ? (
|
||||
<>
|
||||
<span className="sm:hidden">{completionsToday}/{target}</span>
|
||||
<span className="hidden sm:inline">Completed ({completionsToday}/{target})</span>
|
||||
<span className="sm:hidden">{t('completedStatusCountMobile', { completed: completionsToday, target })}</span>
|
||||
<span className="hidden sm:inline">{t('completedStatusCount', { completed: completionsToday, target })}</span>
|
||||
</>
|
||||
) : (
|
||||
'Completed'
|
||||
t('completedStatus')
|
||||
)
|
||||
) : (
|
||||
target > 1 ? (
|
||||
<>
|
||||
<span className="sm:hidden">{completionsToday}/{target}</span>
|
||||
<span className="hidden sm:inline">Complete ({completionsToday}/{target})</span>
|
||||
<span className="sm:hidden">{t('completeButtonCountMobile', { completed: completionsToday, target })}</span>
|
||||
<span className="hidden sm:inline">{t('completeButtonCount', { completed: completionsToday, target })}</span>
|
||||
</>
|
||||
) : 'Complete'
|
||||
) : t('completeButton')
|
||||
)}
|
||||
</span>
|
||||
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
||||
@@ -165,7 +167,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
className="w-10 sm:w-auto"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-2">Undo</span>
|
||||
<span className="hidden sm:inline ml-2">{t('undoButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -179,7 +181,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-2">Edit</span>
|
||||
<span className="ml-2">{t('editButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -12,14 +12,15 @@ import { getHabitFreq } from '@/lib/utils'; // Added
|
||||
import { useAtom } from 'jotai'
|
||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
|
||||
import { DateTime } from 'luxon'; // Added
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
|
||||
import AddEditHabitModal from './AddEditHabitModal'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import EmptyState from './EmptyState'
|
||||
import HabitItem from './HabitItem'
|
||||
import { ViewToggle } from './ViewToggle'
|
||||
|
||||
export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
const t = useTranslations('HabitList');
|
||||
const { saveHabit, deleteHabit } = useHabits()
|
||||
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
||||
|
||||
@@ -121,10 +122,10 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">{`My ${isTasksView ? "Tasks" : "Habits"}`}</h1>
|
||||
<h1 className="text-3xl font-bold">{t(isTasksView ? "myTasks" : "myHabits")}</h1>
|
||||
<span>
|
||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
||||
<Plus className='mr-2 h-4 w-4' />{`Add ${isTasksView ? "Task" : "Habit"}`}
|
||||
<Plus className='mr-2 h-4 w-4' />{isTasksView ? t("addTaskButton") : t("addHabitButton")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -137,28 +138,28 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`Search ${isTasksView ? 'tasks' : 'habits'}...`}
|
||||
placeholder={t(isTasksView ? 'searchTasksPlaceholder' : 'searchHabitsPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-center w-full sm:w-auto">
|
||||
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">Sort by:</Label>
|
||||
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">{t('sortByLabel')}</Label>
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
|
||||
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
<SelectValue placeholder={t('sortByLabel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="coinReward">Coin Reward</SelectItem>
|
||||
{isTasksView && <SelectItem value="dueDate">Due Date</SelectItem>}
|
||||
{!isTasksView && <SelectItem value="frequency">Frequency</SelectItem>}
|
||||
<SelectItem value="name">{t('sortByName')}</SelectItem>
|
||||
<SelectItem value="coinReward">{t('sortByCoinReward')}</SelectItem>
|
||||
{isTasksView && <SelectItem value="dueDate">{t('sortByDueDate')}</SelectItem>}
|
||||
{!isTasksView && <SelectItem value="frequency">{t('sortByFrequency')}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
|
||||
{sortOrder === 'asc' ? <ArrowUpNarrowWide className="h-4 w-4" /> : <ArrowDownWideNarrow className="h-4 w-4" />}
|
||||
<span className="sr-only">Toggle sort order</span>
|
||||
<span className="sr-only">{t('toggleSortOrderAriaLabel')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,14 +167,14 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
{activeHabits.length === 0 && searchTerm.trim() ? (
|
||||
<div className="col-span-2 text-center text-muted-foreground py-8">
|
||||
No {isTasksView ? 'tasks' : 'habits'} found matching your search.
|
||||
{t(isTasksView ? 'noTasksFoundMessage' : 'noHabitsFoundMessage')}
|
||||
</div>
|
||||
) : activeHabits.length === 0 ? (
|
||||
<div className="col-span-2">
|
||||
<EmptyState
|
||||
icon={isTasksView ? TaskIcon : HabitIcon}
|
||||
title={isTasksView ? "No tasks yet" : "No habits yet"}
|
||||
description={isTasksView ? "Create your first task to start tracking your progress" : "Create your first habit to start tracking your progress"}
|
||||
title={t(isTasksView ? 'emptyStateTasksTitle' : 'emptyStateHabitsTitle')}
|
||||
description={t(isTasksView ? 'emptyStateTasksDescription' : 'emptyStateHabitsDescription')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -194,7 +195,7 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
<>
|
||||
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
|
||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
|
||||
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
|
||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
{archivedHabits.map((habit: Habit) => (
|
||||
@@ -235,9 +236,9 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
}
|
||||
setDeleteConfirmation({ isOpen: false, habitId: null })
|
||||
}}
|
||||
title={isTasksView ? "Delete Task" : "Delete Habit"}
|
||||
message={isTasksView ? "Are you sure you want to delete this task? This action cannot be undone." : "Are you sure you want to delete this habit? This action cannot be undone."}
|
||||
confirmText="Delete"
|
||||
title={t(isTasksView ? 'deleteTaskDialogTitle' : 'deleteHabitDialogTitle')}
|
||||
message={t(isTasksView ? 'deleteTaskDialogMessage' : 'deleteHabitDialogMessage')}
|
||||
confirmText={t('deleteButton')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
|
||||
import { Habit } from '@/lib/types'
|
||||
import { Habit } from '@/lib/types';
|
||||
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
|
||||
import { useAtom } from 'jotai'
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
interface HabitStreakProps {
|
||||
habits: Habit[]
|
||||
}
|
||||
|
||||
export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
const t = useTranslations('HabitStreak');
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [hasTasks] = useAtom(hasTasksAtom)
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
||||
@@ -40,7 +42,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Completion Streak</CardTitle>
|
||||
<CardTitle>{t('dailyCompletionStreakTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="w-full aspect-[2/1]">
|
||||
@@ -56,11 +58,14 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value, name) => [`${value} ${name}`, 'Completed']} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip formatter={(value, name) => {
|
||||
const translatedName = name === 'habits' ? t('tooltipHabitsLabel') : t('tooltipTasksLabel');
|
||||
return [`${value} ${translatedName}`, t('tooltipCompletedLabel')];
|
||||
}} />
|
||||
<Line
|
||||
type="monotone"
|
||||
name="habits"
|
||||
name={t('tooltipHabitsLabel')}
|
||||
dataKey="habits"
|
||||
stroke="#14b8a6"
|
||||
strokeWidth={2}
|
||||
@@ -69,7 +74,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
{hasTasks && (
|
||||
<Line
|
||||
type="monotone"
|
||||
name="tasks"
|
||||
name={t('tooltipTasksLabel')}
|
||||
dataKey="tasks"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
|
||||
@@ -3,32 +3,34 @@
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AboutModal from './AboutModal'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
const navItems = () => [
|
||||
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
|
||||
{ icon: HabitIcon, label: 'Habits', href: '/habits', position: 'main' },
|
||||
{ icon: TaskIcon, label: 'Tasks', href: '/tasks', position: 'main' },
|
||||
{ icon: Calendar, label: 'Calendar', href: '/calendar', position: 'main' },
|
||||
{ icon: Gift, label: 'Wishlist', href: '/wishlist', position: 'main' },
|
||||
{ icon: Coins, label: 'Coins', href: '/coins', position: 'main' },
|
||||
]
|
||||
|
||||
interface NavigationProps {
|
||||
viewPort: ViewPort
|
||||
}
|
||||
|
||||
export default function Navigation({ viewPort }: NavigationProps) {
|
||||
const t = useTranslations('Navigation')
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
const { isIOS } = useHelpers()
|
||||
const pathname = usePathname();
|
||||
|
||||
const navItems = () => [
|
||||
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
|
||||
{ icon: HabitIcon, label: t('habits'), href: '/habits', position: 'main' },
|
||||
{ icon: TaskIcon, label: t('tasks'), href: '/tasks', position: 'main' },
|
||||
{ icon: Calendar, label: t('calendar'), href: '/calendar', position: 'main' },
|
||||
{ icon: Gift, label: t('wishlist'), href: '/wishlist', position: 'main' },
|
||||
{ icon: Coins, label: t('coins'), href: '/coins', position: 'main' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobileView(window.innerWidth < 1024)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAtom } from 'jotai'
|
||||
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
|
||||
import { Bell } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import NotificationDropdown from './NotificationDropdown';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -17,6 +18,7 @@ import { useHelpers } from '@/lib/client-helpers';
|
||||
import { User, CoinTransaction } from '@/lib/types';
|
||||
|
||||
export default function NotificationBell() {
|
||||
const t = useTranslations('NotificationBell');
|
||||
const { currentUser } = useHelpers();
|
||||
const [coinsData] = useAtom(coinsAtom)
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
@@ -99,7 +101,7 @@ export default function NotificationBell() {
|
||||
const nowTimestamp = d2t({ dateTime: getNow({}) });
|
||||
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
|
||||
} catch (error) {
|
||||
console.error("Failed to update notification read timestamp:", error);
|
||||
console.error(t('errorUpdateTimestamp'), error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,33 +11,18 @@ import {
|
||||
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
|
||||
import { t2d } from '@/lib/utils';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface NotificationDropdownProps {
|
||||
currentUser: User | null;
|
||||
unreadNotifications: CoinTransaction[];
|
||||
displayedReadNotifications: CoinTransaction[];
|
||||
habitsData: HabitsData; // Keep needed props
|
||||
habitsData: HabitsData;
|
||||
wishlistData: WishlistData;
|
||||
usersData: UserData;
|
||||
}
|
||||
|
||||
// Helper function to generate notification message
|
||||
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
|
||||
const username = triggeringUser?.username || 'Someone';
|
||||
const itemName = relatedItemName || 'a shared item';
|
||||
switch (tx.type) {
|
||||
case 'HABIT_COMPLETION':
|
||||
case 'TASK_COMPLETION':
|
||||
return `${username} completed ${itemName}.`;
|
||||
case 'WISH_REDEMPTION':
|
||||
return `${username} redeemed ${itemName}.`;
|
||||
// Add other relevant transaction types if needed
|
||||
default:
|
||||
return `Activity related to ${itemName} by ${username}.`; // Fallback message
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get the name of the related item
|
||||
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
|
||||
if (!tx.relatedItemId) return undefined;
|
||||
@@ -59,19 +44,33 @@ export default function NotificationDropdown({
|
||||
wishlistData,
|
||||
usersData,
|
||||
}: NotificationDropdownProps) {
|
||||
if (!currentUser) {
|
||||
return <div className="p-4 text-sm text-gray-500">Not logged in.</div>;
|
||||
}
|
||||
const t = useTranslations('NotificationDropdown');
|
||||
|
||||
// Removed the useMemo block for calculating notifications
|
||||
// Helper function to generate notification message, now using t
|
||||
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
|
||||
const username = triggeringUser?.username || t('defaultUsername');
|
||||
const itemName = relatedItemName || t('defaultItemName');
|
||||
switch (tx.type) {
|
||||
case 'HABIT_COMPLETION':
|
||||
case 'TASK_COMPLETION':
|
||||
return t('userCompletedItem', { username, itemName });
|
||||
case 'WISH_REDEMPTION':
|
||||
return t('userRedeemedItem', { username, itemName });
|
||||
default:
|
||||
return t('activityRelatedToItem', { username, itemName });
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser) {
|
||||
return <div className="p-4 text-sm text-gray-500">{t('notLoggedIn')}</div>;
|
||||
}
|
||||
|
||||
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
|
||||
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
|
||||
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
|
||||
const message = getNotificationMessage(tx, triggeringUser, relatedItemName);
|
||||
const message = getNotificationMessage(tx, triggeringUser, relatedItemName); // Uses the new t-aware helper
|
||||
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
|
||||
const timeAgo = txTimestamp.toRelative(); // e.g., "2 hours ago"
|
||||
// Add the triggering user's ID to the query params if it exists
|
||||
const timeAgo = txTimestamp.toRelative();
|
||||
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
|
||||
|
||||
return (
|
||||
@@ -98,21 +97,21 @@ export default function NotificationDropdown({
|
||||
{/* Removed the outer div as width is now set on DropdownMenuContent in NotificationBell */}
|
||||
<>
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium">Notifications</h4>
|
||||
<h4 className="text-sm font-medium">{t('notificationsTitle')}</h4>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)
|
||||
{t('notificationsTooltip')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ScrollArea className="h-[400px]">
|
||||
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
|
||||
<div className="p-4 text-center text-sm text-gray-500">No notifications yet.</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500">{t('noNotificationsYet')}</div>
|
||||
)}
|
||||
|
||||
{unreadNotifications.length > 0 && (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { User } from '@/lib/types';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Button } from './ui/button';
|
||||
@@ -22,6 +23,7 @@ export default function PasswordEntryForm({
|
||||
onSubmit,
|
||||
error
|
||||
}: PasswordEntryFormProps) {
|
||||
const t = useTranslations('PasswordEntryForm');
|
||||
const hasPassword = !!user.password;
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -31,8 +33,8 @@ export default function PasswordEntryForm({
|
||||
await onSubmit(password);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err instanceof Error ? err.message : 'Login failed',
|
||||
title: t('loginErrorToastTitle'),
|
||||
description: err instanceof Error ? err.message : t('loginFailedErrorToastDescription'),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
@@ -58,18 +60,18 @@ export default function PasswordEntryForm({
|
||||
onClick={onCancel}
|
||||
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
|
||||
>
|
||||
Not you?
|
||||
{t('notYouButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasPassword && <div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
@@ -82,10 +84,10 @@ export default function PasswordEntryForm({
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t('cancelButton')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={hasPassword && !password}>
|
||||
Login
|
||||
{t('loginButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Switch } from './ui/switch';
|
||||
import { Label } from './ui/label';
|
||||
import { Permission } from '@/lib/types';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface PermissionSelectorProps {
|
||||
permissions: Permission[];
|
||||
@@ -11,18 +12,20 @@ interface PermissionSelectorProps {
|
||||
onAdminChange: (isAdmin: boolean) => void;
|
||||
}
|
||||
|
||||
const permissionLabels: { [key: string]: string } = {
|
||||
habit: 'Habit / Task',
|
||||
wishlist: 'Wishlist',
|
||||
coins: 'Coins'
|
||||
};
|
||||
|
||||
export function PermissionSelector({
|
||||
permissions,
|
||||
isAdmin,
|
||||
onPermissionsChange,
|
||||
onAdminChange,
|
||||
}: PermissionSelectorProps) {
|
||||
const t = useTranslations('PermissionSelector');
|
||||
|
||||
const permissionLabels: { [key: string]: string } = {
|
||||
habit: t('resourceHabitTask'),
|
||||
wishlist: t('resourceWishlist'),
|
||||
coins: t('resourceCoins')
|
||||
};
|
||||
|
||||
const currentPermissions = isAdmin ?
|
||||
{
|
||||
habit: { write: true, interact: true },
|
||||
@@ -49,11 +52,11 @@ export function PermissionSelector({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Permissions</Label>
|
||||
<Label>{t('permissionsTitle')}</Label>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-sm">Admin Access</div>
|
||||
<div className="font-medium text-sm">{t('adminAccessLabel')}</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="isAdmin"
|
||||
@@ -65,7 +68,7 @@ export function PermissionSelector({
|
||||
|
||||
{isAdmin ? (
|
||||
<p className="text-xs text-muted-foreground px-3">
|
||||
Admins have full permission to all data for all users
|
||||
{t('adminAccessDescription')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
@@ -74,7 +77,7 @@ export function PermissionSelector({
|
||||
<div className="font-medium capitalize text-sm border-b pb-2">{permissionLabels[resource]}</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">Write</Label>
|
||||
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">{t('permissionWrite')}</Label>
|
||||
<Switch
|
||||
id={`${resource}-write`}
|
||||
className="h-4 w-7"
|
||||
@@ -85,7 +88,7 @@ export function PermissionSelector({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">Interact</Label>
|
||||
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">{t('permissionInteract')}</Label>
|
||||
<Switch
|
||||
id={`${resource}-interact`}
|
||||
className="h-4 w-7"
|
||||
|
||||
@@ -3,55 +3,41 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface PomoConfig {
|
||||
labels: string[]
|
||||
getLabels: () => string[]
|
||||
duration: number
|
||||
type: 'focus' | 'break'
|
||||
}
|
||||
|
||||
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
|
||||
focus: {
|
||||
labels: [
|
||||
'Stay Focused',
|
||||
'You Got This',
|
||||
'Keep Going',
|
||||
'Crush It',
|
||||
'Make It Happen',
|
||||
'Stay Strong',
|
||||
'Push Through',
|
||||
'One Step at a Time',
|
||||
'You Can Do It',
|
||||
'Focus and Conquer'
|
||||
],
|
||||
duration: 25 * 60,
|
||||
type: 'focus',
|
||||
},
|
||||
break: {
|
||||
labels: [
|
||||
'Take a Break',
|
||||
'Relax and Recharge',
|
||||
'Breathe Deeply',
|
||||
'Stretch It Out',
|
||||
'Refresh Yourself',
|
||||
'You Deserve This',
|
||||
'Recharge Your Energy',
|
||||
'Step Away for a Bit',
|
||||
'Clear Your Mind',
|
||||
'Rest and Rejuvenate'
|
||||
],
|
||||
duration: 5 * 60,
|
||||
type: 'break',
|
||||
},
|
||||
}
|
||||
|
||||
export default function PomodoroTimer() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const t = useTranslations('PomodoroTimer')
|
||||
|
||||
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
|
||||
focus: {
|
||||
getLabels: () => [
|
||||
t('focusLabel1'), t('focusLabel2'), t('focusLabel3'), t('focusLabel4'), t('focusLabel5'),
|
||||
t('focusLabel6'), t('focusLabel7'), t('focusLabel8'), t('focusLabel9'), t('focusLabel10')
|
||||
],
|
||||
duration: 25 * 60,
|
||||
type: 'focus',
|
||||
},
|
||||
break: {
|
||||
getLabels: () => [
|
||||
t('breakLabel1'), t('breakLabel2'), t('breakLabel3'), t('breakLabel4'), t('breakLabel5'),
|
||||
t('breakLabel6'), t('breakLabel7'), t('breakLabel8'), t('breakLabel9'), t('breakLabel10')
|
||||
],
|
||||
duration: 5 * 60,
|
||||
type: 'break',
|
||||
},
|
||||
}
|
||||
|
||||
const [pomo, setPomo] = useAtom(pomodoroAtom)
|
||||
const { show, selectedHabitId, autoStart, minimized } = pomo
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
@@ -61,21 +47,23 @@ export default function PomodoroTimer() {
|
||||
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
|
||||
const wakeLock = useRef<WakeLockSentinel | null>(null)
|
||||
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
|
||||
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus)
|
||||
const [currentLabel, setCurrentLabel] = useState(
|
||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||
)
|
||||
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
|
||||
const [currentLabel, setCurrentLabel] = useState(() => {
|
||||
const labels = currentTimerRef.current.getLabels();
|
||||
return labels[Math.floor(Math.random() * labels.length)];
|
||||
});
|
||||
|
||||
|
||||
// Handle wake lock
|
||||
useEffect(() => {
|
||||
const requestWakeLock = async () => {
|
||||
try {
|
||||
if (!('wakeLock' in navigator)) {
|
||||
console.debug('Browser does not support wakelock')
|
||||
console.debug(t('wakeLockNotSupported'))
|
||||
return
|
||||
}
|
||||
if (wakeLock.current && !wakeLock.current.released) {
|
||||
console.debug('Wake lock already in use')
|
||||
console.debug(t('wakeLockInUse'))
|
||||
return
|
||||
}
|
||||
if (state === 'started') {
|
||||
@@ -84,7 +72,7 @@ export default function PomodoroTimer() {
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error requesting wake lock:', err)
|
||||
console.error(t('wakeLockRequestError'), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +83,7 @@ export default function PomodoroTimer() {
|
||||
wakeLock.current = null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error releasing wake lock:', err)
|
||||
console.error(t('wakeLockReleaseError'), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,40 +112,43 @@ export default function PomodoroTimer() {
|
||||
|
||||
// Timer logic
|
||||
useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (state === 'started') {
|
||||
if (state === "started") {
|
||||
// Calculate the target end time based on current timeLeft
|
||||
const targetEndTime = Date.now() + timeLeft * 1000
|
||||
const targetEndTime = Date.now() + timeLeft * 1000;
|
||||
|
||||
interval = setInterval(() => {
|
||||
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
|
||||
const remaining = Math.floor((targetEndTime - Date.now()) / 1000);
|
||||
|
||||
if (remaining <= 0) {
|
||||
setState("stopped")
|
||||
const currentTimerType = currentTimer.current.type
|
||||
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
|
||||
setTimeLeft(currentTimer.current.duration)
|
||||
setCurrentLabel(
|
||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||
)
|
||||
|
||||
// update habits only after focus sessions
|
||||
if (selectedHabit && currentTimerType === 'focus') {
|
||||
completeHabit(selectedHabit)
|
||||
// The atom will automatically update with the new completions
|
||||
}
|
||||
handleTimerEnd();
|
||||
} else {
|
||||
setTimeLeft(remaining)
|
||||
setTimeLeft(remaining);
|
||||
}
|
||||
}, 1000)
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// return handles any other states
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const handleTimerEnd = async () => {
|
||||
setState("stopped")
|
||||
const currentTimerType = currentTimerRef.current.type
|
||||
currentTimerRef.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
|
||||
setTimeLeft(currentTimerRef.current.duration)
|
||||
const newLabels = currentTimerRef.current.getLabels();
|
||||
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
|
||||
|
||||
// update habits only after focus sessions
|
||||
if (selectedHabit && currentTimerType === 'focus') {
|
||||
await completeHabit(selectedHabit)
|
||||
// The atom will automatically update with the new completions
|
||||
}
|
||||
}, [state, timeLeft, completeHabit, selectedHabit])
|
||||
}
|
||||
|
||||
const toggleTimer = () => {
|
||||
setState(prev => prev === 'started' ? 'paused' : 'started')
|
||||
@@ -165,17 +156,16 @@ export default function PomodoroTimer() {
|
||||
|
||||
const resetTimer = () => {
|
||||
setState("stopped")
|
||||
setTimeLeft(currentTimer.current.duration)
|
||||
setTimeLeft(currentTimerRef.current.duration)
|
||||
}
|
||||
|
||||
const skipTimer = () => {
|
||||
currentTimer.current = currentTimer.current.type === 'focus'
|
||||
currentTimerRef.current = currentTimerRef.current.type === 'focus'
|
||||
? PomoConfigs.break
|
||||
: PomoConfigs.focus
|
||||
resetTimer()
|
||||
setCurrentLabel(
|
||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||
)
|
||||
resetTimer() // This will also reset timeLeft to the new timer's duration
|
||||
const newLabels = currentTimerRef.current.getLabels();
|
||||
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
@@ -184,7 +174,7 @@ export default function PomodoroTimer() {
|
||||
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
|
||||
}
|
||||
|
||||
const progress = (timeLeft / currentTimer.current.duration) * 100
|
||||
const progress = (timeLeft / currentTimerRef.current.duration) * 100
|
||||
|
||||
if (!show) return null
|
||||
|
||||
@@ -237,11 +227,11 @@ export default function PomodoroTimer() {
|
||||
<div className={cn(
|
||||
'w-2 h-2 rounded-full flex-none',
|
||||
// order matters here
|
||||
currentTimer.current.type === 'focus' && 'bg-green-500',
|
||||
currentTimerRef.current.type === 'focus' && 'bg-green-500',
|
||||
state === 'started' && 'animate-pulse',
|
||||
state === 'paused' && 'bg-yellow-500',
|
||||
state === 'stopped' && 'bg-red-500',
|
||||
currentTimer.current.type === 'break' && 'bg-blue-500',
|
||||
currentTimerRef.current.type === 'break' && 'bg-blue-500',
|
||||
)} />
|
||||
<div className="font-bold text-foreground">
|
||||
{selectedHabit.name}
|
||||
@@ -249,7 +239,9 @@ export default function PomodoroTimer() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span>{currentTimer.current.type.charAt(0).toUpperCase() + currentTimer.current.type.slice(1)}: {currentLabel}</span>
|
||||
<span>
|
||||
{currentTimerRef.current.type === 'focus' ? t('focusType') : t('breakType')}: {currentLabel}
|
||||
</span>
|
||||
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
|
||||
<div className="flex justify-center gap-1 mt-2">
|
||||
{(() => {
|
||||
@@ -288,12 +280,12 @@ export default function PomodoroTimer() {
|
||||
{state === "started" ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Pause</span>
|
||||
<span className="hidden sm:inline">{t('pauseButton')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Start</span>
|
||||
<span className="hidden sm:inline">{t('startButton')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -304,7 +296,7 @@ export default function PomodoroTimer() {
|
||||
className="sm:px-4"
|
||||
>
|
||||
<RotateCw className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Reset</span>
|
||||
<span className="hidden sm:inline">{t('resetButton')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -313,7 +305,7 @@ export default function PomodoroTimer() {
|
||||
className="sm:px-4"
|
||||
>
|
||||
<SkipForward className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Skip</span>
|
||||
<span className="hidden sm:inline">{t('skipButton')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||
import { useHelpers } from "@/lib/client-helpers"
|
||||
import { useAtom } from "jotai"
|
||||
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useTheme } from "next-themes"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
@@ -17,6 +18,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import UserForm from './UserForm'
|
||||
|
||||
export function Profile() {
|
||||
const t = useTranslations('Profile');
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
@@ -29,14 +31,14 @@ export function Profile() {
|
||||
try {
|
||||
await signOut()
|
||||
toast({
|
||||
title: "Signed out successfully",
|
||||
description: "You have been logged out of your account",
|
||||
title: t('signOutSuccessTitle'),
|
||||
description: t('signOutSuccessDescription'),
|
||||
})
|
||||
setTimeout(() => window.location.reload(), 300);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to sign out",
|
||||
title: t('signOutErrorTitle'),
|
||||
description: t('signOutErrorDescription'),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
@@ -66,7 +68,7 @@ export function Profile() {
|
||||
</Avatar>
|
||||
<div className="flex flex-col mr-4">
|
||||
<span className="text-sm font-semibold flex items-center gap-1">
|
||||
{user?.username || "Guest"}
|
||||
{user?.username || t('guestUsername')}
|
||||
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
||||
</span>
|
||||
{user && (
|
||||
@@ -78,7 +80,7 @@ export function Profile() {
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
|
||||
>
|
||||
Edit profile
|
||||
{t('editProfileButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -104,18 +106,18 @@ export function Profile() {
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
<span>Switch user</span>
|
||||
<span>{t('switchUserButton')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-label='settings'
|
||||
aria-label={t('settingsLink')}
|
||||
className="flex items-center w-full gap-3"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
<span>{t('settingsLink')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
@@ -124,14 +126,14 @@ export function Profile() {
|
||||
className="flex items-center w-full gap-3"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span>About</span>
|
||||
<span>{t('aboutButton')}</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>Theme</span>
|
||||
<span>{t('themeLabel')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -174,7 +176,7 @@ export function Profile() {
|
||||
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogTitle>{t('editProfileModalTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UserForm
|
||||
userId={user.id}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
|
||||
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
|
||||
const t = useTranslations('TodayEarnedCoins')
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const { coinsEarnedToday } = useCoins()
|
||||
|
||||
@@ -14,7 +16,7 @@ export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean
|
||||
{"+"}
|
||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
|
||||
{longFormat ?
|
||||
<span className="text-sm text-muted-foreground"> today</span>
|
||||
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
|
||||
: null}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Loader2, Pencil, Trash2, X } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface TransactionNoteEditorProps {
|
||||
transactionId: string
|
||||
@@ -19,6 +20,7 @@ export function TransactionNoteEditor({
|
||||
onSave,
|
||||
onDelete
|
||||
}: TransactionNoteEditorProps) {
|
||||
const t = useTranslations('TransactionNoteEditor');
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [noteText, setNoteText] = useState(initialNote)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -27,8 +29,8 @@ export function TransactionNoteEditor({
|
||||
const trimmedNote = noteText.trim()
|
||||
if (trimmedNote.length > 200) {
|
||||
toast({
|
||||
title: 'Note too long',
|
||||
description: 'Notes must be less than 200 characters',
|
||||
title: t('noteTooLongTitle'),
|
||||
description: t('noteTooLongDescription'),
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
@@ -40,8 +42,8 @@ export function TransactionNoteEditor({
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error saving note',
|
||||
description: 'Please try again',
|
||||
title: t('errorSavingNoteTitle'),
|
||||
description: t('pleaseTryAgainDescription'),
|
||||
variant: 'destructive'
|
||||
})
|
||||
// Revert to initial value on error
|
||||
@@ -59,8 +61,8 @@ export function TransactionNoteEditor({
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error deleting note',
|
||||
description: 'Please try again',
|
||||
title: t('errorDeletingNoteTitle'),
|
||||
description: t('pleaseTryAgainDescription'),
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
@@ -74,7 +76,7 @@ export function TransactionNoteEditor({
|
||||
<Input
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
placeholder="Add a note..."
|
||||
placeholder={t('addNotePlaceholder')}
|
||||
className="w-64"
|
||||
maxLength={200}
|
||||
/>
|
||||
@@ -85,7 +87,7 @@ export function TransactionNoteEditor({
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400 transition-colors"
|
||||
title="Save note"
|
||||
title={t('saveNoteTitle')}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -98,7 +100,7 @@ export function TransactionNoteEditor({
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors"
|
||||
title="Cancel"
|
||||
title={t('cancelButtonTitle')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -109,7 +111,7 @@ export function TransactionNoteEditor({
|
||||
onClick={handleDelete}
|
||||
disabled={isSaving}
|
||||
className="text-gray-600 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 transition-colors"
|
||||
title="Delete note"
|
||||
title={t('deleteNoteTitle')}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -129,7 +131,7 @@ export function TransactionNoteEditor({
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
aria-label="Edit note"
|
||||
aria-label={t('editNoteAriaLabel')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { passwordSchema, usernameSchema } from '@/lib/zod';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import _ from 'lodash';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { PermissionSelector } from './PermissionSelector';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
@@ -24,6 +25,7 @@ interface UserFormProps {
|
||||
}
|
||||
|
||||
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
|
||||
const t = useTranslations('UserForm');
|
||||
const [users, setUsersData] = useAtom(usersAtom);
|
||||
const serverSettings = useAtomValue(serverSettingsAtom)
|
||||
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
||||
@@ -103,8 +105,8 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: "User updated",
|
||||
description: `Successfully updated user ${username}`,
|
||||
title: t('toastUserUpdatedTitle'),
|
||||
description: t('toastUserUpdatedDescription', { username }),
|
||||
variant: 'default'
|
||||
});
|
||||
} else {
|
||||
@@ -127,8 +129,8 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: "User created",
|
||||
description: `Successfully created user ${username}`,
|
||||
title: t('toastUserCreatedTitle'),
|
||||
description: t('toastUserCreatedDescription', { username }),
|
||||
variant: 'default'
|
||||
});
|
||||
}
|
||||
@@ -137,15 +139,16 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
setError('');
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} user`);
|
||||
const action = isEditing ? t('actionUpdate') : t('actionCreate');
|
||||
setError(err instanceof Error ? err.message : t('errorFailedUserAction', { action }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = async (file: File) => {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
if (file.size > 5 * 1024 * 1024) { // 5MB
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "File size must be less than 5MB",
|
||||
title: t('errorTitle'),
|
||||
description: t('errorFileSizeLimit'),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
@@ -159,14 +162,14 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
setAvatarPath(path);
|
||||
setAvatarFile(null); // Clear the file since we've uploaded it
|
||||
toast({
|
||||
title: "Avatar uploaded",
|
||||
description: "Successfully uploaded avatar",
|
||||
title: t('toastAvatarUploadedTitle'),
|
||||
description: t('toastAvatarUploadedDescription'),
|
||||
variant: 'default'
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to upload avatar",
|
||||
title: t('errorTitle'),
|
||||
description: t('errorFailedAvatarUpload'),
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
@@ -208,18 +211,18 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{isEditing ? 'Change Avatar' : 'Upload Avatar'}
|
||||
{isEditing ? t('changeAvatarButton') : t('uploadAvatarButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">{t('usernameLabel')}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
placeholder={t('usernamePlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
@@ -229,19 +232,19 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{isEditing ? 'New Password' : 'Password'}
|
||||
{isEditing ? t('newPasswordLabel') : t('passwordLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={isEditing ? "Leave blank to keep current" : "Enter password"}
|
||||
placeholder={isEditing ? t('passwordPlaceholderEdit') : t('passwordPlaceholderCreate')}
|
||||
value={password || ''}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
disabled={disablePassword}
|
||||
/>
|
||||
{serverSettings.isDemo && (
|
||||
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
|
||||
<p className="text-sm text-red-500">{t('demoPasswordDisabledMessage')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -252,7 +255,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
onCheckedChange={setDisablePassword}
|
||||
disabled={serverSettings.isDemo}
|
||||
/>
|
||||
<Label htmlFor="disable-password">Disable password</Label>
|
||||
<Label htmlFor="disable-password">{t('disablePasswordLabel')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,10 +279,10 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
{t('cancelButton')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!username}>
|
||||
{isEditing ? 'Save Changes' : 'Create User'}
|
||||
{isEditing ? t('saveChangesButton') : t('createUserButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -9,14 +9,15 @@ import { cn } from '@/lib/utils';
|
||||
import { Description } from '@radix-ui/react-dialog';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import PasswordEntryForm from './PasswordEntryForm';
|
||||
import UserForm from './UserForm';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
function UserCard({
|
||||
user,
|
||||
function UserCard({
|
||||
user,
|
||||
onSelect,
|
||||
onEdit,
|
||||
showEdit,
|
||||
@@ -38,9 +39,9 @@ function UserCard({
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage
|
||||
<AvatarImage
|
||||
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
|
||||
alt={user.username}
|
||||
alt={user.username}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<UserIcon className="h-8 w-8" />
|
||||
@@ -67,6 +68,7 @@ function UserCard({
|
||||
}
|
||||
|
||||
function AddUserButton({ onClick }: { onClick: () => void }) {
|
||||
const t = useTranslations('UserSelectModal');
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
@@ -77,7 +79,7 @@ function AddUserButton({ onClick }: { onClick: () => void }) {
|
||||
<Plus className="h-8 w-8" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">Add User</span>
|
||||
<span className="text-sm font-medium">{t('addUserButton')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -87,13 +89,13 @@ function UserSelectionView({
|
||||
currentUser,
|
||||
onUserSelect,
|
||||
onEditUser,
|
||||
onCreateUser
|
||||
onCreateUser,
|
||||
}: {
|
||||
users: User[],
|
||||
currentUser?: SafeUser,
|
||||
onUserSelect: (userId: string) => void,
|
||||
onEditUser: (userId: string) => void,
|
||||
onCreateUser: () => void
|
||||
onCreateUser: () => void,
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||
@@ -108,20 +110,21 @@ function UserSelectionView({
|
||||
showEdit={!!currentUser?.isAdmin}
|
||||
isCurrentUser={false}
|
||||
/>
|
||||
))}
|
||||
))}
|
||||
{currentUser?.isAdmin && <AddUserButton onClick={onCreateUser} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
||||
const t = useTranslations('UserSelectModal');
|
||||
const [selectedUser, setSelectedUser] = useState<string>();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [usersData] = useAtom(usersAtom);
|
||||
const users = usersData.users;
|
||||
const {currentUser} = useHelpers();
|
||||
const { currentUser } = useHelpers();
|
||||
|
||||
const handleUserSelect = (userId: string) => {
|
||||
setSelectedUser(userId);
|
||||
@@ -156,7 +159,7 @@ const {currentUser} = useHelpers();
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Description></Description>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isCreating ? 'Create New User' : 'Select User'}</DialogTitle>
|
||||
<DialogTitle>{isCreating ? t('createNewUserTitle') : t('selectUserTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -184,19 +187,19 @@ const {currentUser} = useHelpers();
|
||||
const user = users.find(u => u.id === selectedUser);
|
||||
if (!user) throw new Error("User not found");
|
||||
await signIn(user.username, password);
|
||||
|
||||
|
||||
setError('');
|
||||
onClose();
|
||||
|
||||
toast({
|
||||
title: "Signed in successfully",
|
||||
description: `Welcome back, ${user.username}!`,
|
||||
title: t('signInSuccessTitle'),
|
||||
description: t('signInSuccessDescription', { username: user.username }),
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
setTimeout(() => window.location.reload(), 300);
|
||||
} catch (err) {
|
||||
setError('invalid password');
|
||||
setError(t('errorInvalidPassword'));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { cn, isHabitDueToday } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { NotificationBadge } from './ui/notification-badge'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { NotificationBadge } from './ui/notification-badge'
|
||||
|
||||
interface ViewToggleProps {
|
||||
className?: string
|
||||
@@ -14,6 +15,7 @@ interface ViewToggleProps {
|
||||
export function ViewToggle({
|
||||
className
|
||||
}: ViewToggleProps) {
|
||||
const t = useTranslations('ViewToggle')
|
||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||
const [habits] = useAtom(habitsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
@@ -40,9 +42,9 @@ export function ViewToggle({
|
||||
)}
|
||||
>
|
||||
<HabitIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Habits</span>
|
||||
<span className="hidden sm:inline">{t('habitsLabel')}</span>
|
||||
</button>
|
||||
<NotificationBadge
|
||||
<NotificationBadge
|
||||
label={dueTasksCount}
|
||||
show={dueTasksCount > 0}
|
||||
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
|
||||
@@ -56,7 +58,7 @@ export function ViewToggle({
|
||||
)}
|
||||
>
|
||||
<TaskIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Tasks</span>
|
||||
<span className="hidden sm:inline">{t('tasksLabel')}</span>
|
||||
</button>
|
||||
</NotificationBadge>
|
||||
<div
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useHelpers } from '@/lib/client-helpers'
|
||||
import { User, WishlistItemType } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
|
||||
interface WishlistItemProps {
|
||||
@@ -29,7 +30,7 @@ interface WishlistItemProps {
|
||||
|
||||
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
|
||||
if (!item.userIds || item.userIds.length <= 1) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||
@@ -57,11 +58,13 @@ export default function WishlistItem({
|
||||
isHighlighted,
|
||||
isRecentlyRedeemed
|
||||
}: WishlistItemProps) {
|
||||
const t = useTranslations('WishlistItem')
|
||||
const { currentUser, hasPermission } = useHelpers()
|
||||
const canWrite = hasPermission('wishlist', 'write')
|
||||
const canInteract = hasPermission('wishlist', 'interact')
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
id={`wishlist-${item.id}`}
|
||||
@@ -76,7 +79,7 @@ export default function WishlistItem({
|
||||
</CardTitle>
|
||||
{item.targetCompletions && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({item.targetCompletions} {item.targetCompletions === 1 ? 'use' : 'uses'} left)
|
||||
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -95,7 +98,7 @@ export default function WishlistItem({
|
||||
<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} coins
|
||||
{item.coinCost} {t('coinsSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -112,13 +115,13 @@ export default function WishlistItem({
|
||||
<span>
|
||||
{isRecentlyRedeemed ? (
|
||||
<>
|
||||
<span className="sm:hidden">Done</span>
|
||||
<span className="hidden sm:inline">Redeemed!</span>
|
||||
<span className="sm:hidden">{t('redeemedDone')}</span>
|
||||
<span className="hidden sm:inline">{t('redeemedExclamation')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="sm:hidden">Redeem</span>
|
||||
<span className="hidden sm:inline">Redeem</span>
|
||||
<span className="sm:hidden">{t('redeem')}</span>
|
||||
<span className="hidden sm:inline">{t('redeem')}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -134,7 +137,7 @@ export default function WishlistItem({
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-2">Edit</span>
|
||||
<span className="ml-2">{t('editButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
@@ -147,18 +150,18 @@ export default function WishlistItem({
|
||||
{!item.archived && (
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span>Archive</span>
|
||||
<span>{t('archiveButton')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{item.archived && (
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
|
||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||
<span>Unarchive</span>
|
||||
<span>{t('unarchiveButton')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
{t('editButton')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="sm:hidden" />
|
||||
<DropdownMenuItem
|
||||
@@ -167,7 +170,7 @@ export default function WishlistItem({
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t('deleteButton')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useWishlist } from '@/hooks/useWishlist'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Plus, Gift } from 'lucide-react'
|
||||
import EmptyState from './EmptyState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -13,6 +14,7 @@ import { openWindow } from '@/lib/utils'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
|
||||
export default function WishlistManager() {
|
||||
const t = useTranslations('WishlistManager')
|
||||
const {
|
||||
addWishlistItem,
|
||||
editWishlistItem,
|
||||
@@ -70,8 +72,8 @@ export default function WishlistManager() {
|
||||
const opened = openWindow(item.link!)
|
||||
if (!opened) {
|
||||
toast({
|
||||
title: "Popup Blocked",
|
||||
description: "Please allow popups to open the link",
|
||||
title: t('popupBlockedTitle'),
|
||||
description: t('popupBlockedDescription'),
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
@@ -83,9 +85,9 @@ export default function WishlistManager() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">My Wishlist</h1>
|
||||
<h1 className="text-3xl font-bold">{t('title')}</h1>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Reward
|
||||
<Plus className="mr-2 h-4 w-4" /> {t('addRewardButton')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
|
||||
@@ -93,8 +95,8 @@ export default function WishlistManager() {
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<EmptyState
|
||||
icon={Gift}
|
||||
title="Your wishlist is empty"
|
||||
description="Add rewards that you'd like to earn with your coins"
|
||||
title={t('emptyStateTitle')}
|
||||
description={t('emptyStateDescription')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -129,7 +131,7 @@ export default function WishlistManager() {
|
||||
<>
|
||||
<div className="col-span-1 lg:col-span-2 relative flex items-center my-6">
|
||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
|
||||
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
|
||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
{archivedItems.map((item) => (
|
||||
@@ -167,9 +169,9 @@ export default function WishlistManager() {
|
||||
}
|
||||
setDeleteConfirmation({ isOpen: false, itemId: null })
|
||||
}}
|
||||
title="Delete Reward"
|
||||
message="Are you sure you want to delete this reward? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
title={t('deleteDialogTitle')}
|
||||
message={t('deleteDialogMessage')}
|
||||
confirmText={t('deleteButton')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user