Compare commits

..

11 Commits

Author SHA1 Message Date
dohsimpson
5ae659469b fix infinite render 2025-05-28 17:43:28 -04:00
dohsimpson
6ef4aacfb8 fix coin balance 2025-05-28 17:17:13 -04:00
dohsimpson
95203426a3 fix modal and invalid frequency 2025-05-27 02:42:13 -04:00
Doh
b673d54ede Added improved loading screen (#148) 2025-05-26 08:42:00 -04:00
Doh
42c8d14d6d fix emoji picker and about modal (#146) 2025-05-25 20:33:08 -04:00
Doh
3ac311c3fd add cover image in README 2025-05-25 20:27:46 -04:00
Doh
1a286a99f4 feat: Move delete account button to user edit modal (#144)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-25 17:30:41 -04:00
Doh
82f45343ae max coin limit (#140)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 22:05:49 -04:00
Doh
a3d2b1ef96 support delete user (#139)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 21:24:30 -04:00
Doh
ac71c94d53 fix responsive (#135) 2025-05-20 22:12:31 -04:00
Doh
91ffe46863 Added i18n support (#129) 2025-05-18 09:00:48 -04:00
64 changed files with 5465 additions and 910 deletions

View File

@@ -53,6 +53,7 @@ jobs:
tags: |
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
dohsimpson/habittrove:demo
dohsimpson/habittrove:latest
deploy-demo:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,71 @@
# Changelog
## Version 0.2.20
### Fixed
* coin balance shows correct value for selected user in coin management view (#151)
### Improved
* refactor code to remove client-helpers hook
## Version 0.2.19
### Fixed
* settings button not working
* fixed delete dialog modal blocks page interaction (#149)
* disable submit button when frequency is invaid
## Version 0.2.18
### Improved
* nicer loading UI (#147)
* header and navigation code refactor
## Version 0.2.17
### Fixed
* fix emoji selector (#142)
* fix about modal (#145)
## Version 0.2.16
### Improved
* move delete user button to user form
* disable deleting user on demo instance
## Version 0.2.15
### Improved
* max coins set to 9999, to prevent js large number precision issue (#137)
## Version 0.2.14
### Added
* support deleting user (#93)
## Version 0.2.13
### Fixed
* fix responsive design on mobile (#134)
* fix translation (#132)
* fix latest docker tag auto build (#131)
## Version 0.2.12
### Added
* 🌍 Added multi-language support! Users can now select their preferred language in settings.
* Supported languages: English, Español (Spanish), Deutsch (German), Français (French), Русский (Russian), 简体中文 (Simplified Chinese) and 日本語 (Japanese).
## Version 0.2.11
### Added

View File

@@ -1,5 +1,7 @@
# HabitTrove
![cover](https://github.com/user-attachments/assets/b63e98b4-64ae-49c7-ae7e-21ef76c04a5a)
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
@@ -15,6 +17,7 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
- 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics
- 📅 Calendar heatmap to visualize your progress (WIP)
- 🌍 Multi-language support (English, Español, Deutsch, Français, Русский, 简体中文, 日本語)
- 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation

View File

@@ -489,21 +489,80 @@ export async function updateUserPassword(userId: string, newPassword?: string):
}
export async function deleteUser(userId: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
// Load all necessary data
const wishlistData = await loadData<WishlistData>('wishlist')
const habitsData = await loadData<HabitsData>('habits')
const coinsData = await loadData<CoinsData>('coins')
const authData = await loadUsersData()
// Process Wishlist Data
const updatedWishlistItems = wishlistData.items.reduce((acc, item) => {
if (item.userIds?.includes(userId)) {
if (item.userIds.length === 1) {
// Remove item if this is the only user
return acc
} else {
// Remove userId from item's userIds
acc.push({
...item,
userIds: item.userIds.filter(id => id !== userId)
})
}
} else {
// Keep item as is
acc.push(item)
}
return acc
}, [] as WishlistItemType[])
wishlistData.items = updatedWishlistItems
await saveData('wishlist', wishlistData)
// Process Habits Data
const updatedHabits = habitsData.habits.reduce((acc, habit) => {
if (habit.userIds?.includes(userId)) {
if (habit.userIds.length === 1) {
// Remove habit if this is the only user
return acc
} else {
// Remove userId from habit's userIds
acc.push({
...habit,
userIds: habit.userIds.filter(id => id !== userId)
})
}
} else {
// Keep habit as is
acc.push(habit)
}
return acc
}, [] as HabitsData['habits'])
habitsData.habits = updatedHabits
await saveData('habits', habitsData)
// Process Coins Data
coinsData.transactions = coinsData.transactions.filter(
transaction => transaction.userId !== userId
)
// Recalculate balance
coinsData.balance = coinsData.transactions.reduce(
(sum, transaction) => sum + transaction.amount,
0
)
await saveData('coins', coinsData)
// Delete User from Auth Data
const userIndex = authData.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
...data.users.slice(userIndex + 1)
]
}
authData.users = [
...authData.users.slice(0, userIndex),
...authData.users.slice(userIndex + 1)
]
await saveUsersData(newData)
await saveUsersData(authData)
}
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { deleteUser } from '@/app/actions/data'
import { getCurrentUser } from '@/lib/server-helpers'
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const currentUserId = session.user.id
const currentUser = await getCurrentUser()
if (!currentUser) {
// This case should ideally not happen if session.user.id exists,
// but as a safeguard:
return NextResponse.json({ error: 'Unauthorized: User not found in system' }, { status: 401 })
}
let userIdToDelete: string
try {
const body = await req.json()
userIdToDelete = body.userId
} catch (error) {
return NextResponse.json({ error: 'Invalid request body: Could not parse JSON.' }, { status: 400 })
}
if (!userIdToDelete) {
return NextResponse.json({ error: 'Bad Request: userId is required' }, { status: 400 })
}
// Security Check: Users can only delete their own account unless they are an admin.
if (!currentUser.isAdmin && userIdToDelete !== currentUserId) {
return NextResponse.json({ error: 'Forbidden: You do not have permission to delete this user.' }, { status: 403 })
}
await deleteUser(userIdToDelete)
return NextResponse.json({ message: 'User deleted successfully' }, { status: 200 })
} catch (error) {
console.error('Error deleting user:', error)
if (error instanceof Error && error.message === 'User not found') {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}

View File

@@ -2,13 +2,16 @@ import './globals.css'
import { Inter } from 'next/font/google'
import { DM_Sans } from 'next/font/google'
import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
import Layout from '@/components/Layout'
import { Toaster } from '@/components/ui/toaster'
import { ThemeProvider } from "@/components/theme-provider"
import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { Suspense } from 'react'
import LoadingSpinner from '@/components/LoadingSpinner'
// Inter (clean, modern, excellent readability)
@@ -37,6 +40,11 @@ export default async function RootLayout({
}: {
children: React.ReactNode
}) {
const locale = await getLocale();
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
loadSettings(),
loadHabitsData(),
@@ -48,7 +56,7 @@ export default async function RootLayout({
return (
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
<html lang="en" suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning>
<body className={activeFont.className}>
<script
dangerouslySetInnerHTML={{
@@ -68,7 +76,7 @@ export default async function RootLayout({
}}
/>
<JotaiProvider>
<Suspense fallback="loading">
<Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate
initialValues={{
settings: initialSettings,
@@ -79,18 +87,20 @@ export default async function RootLayout({
serverSettings: initialServerSettings,
}}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider>
<Layout>
{children}
</Layout>
</SessionProvider>
</ThemeProvider>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider>
<Layout>
{children}
</Layout>
</SessionProvider>
</ThemeProvider>
</NextIntlClientProvider>
</JotaiHydrate>
</Suspense>
</JotaiProvider>

View File

@@ -11,38 +11,51 @@ import {
} from "@/components/ui/tooltip";
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
import { useAtom } from 'jotai';
import { settingsAtom } from '@/lib/atoms';
import { useTranslations } from 'next-intl';
import { settingsAtom, serverSettingsAtom } from '@/lib/atoms';
import { Settings, WeekDay } from '@/lib/types'
import { saveSettings, uploadAvatar } from '../actions/data'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button';
import { User, Info } from 'lucide-react'; // Import Info icon
import { toast } from '@/hooks/use-toast'
import { useSession } from 'next-auth/react'; // signOut removed
import { useRouter } from 'next/navigation';
// AlertDialog components and useState removed
// Trash2 icon removed
export default function SettingsPage() {
const t = useTranslations('SettingsPage');
// tWarning removed
const [settings, setSettings] = useAtom(settingsAtom);
const [serverSettings] = useAtom(serverSettingsAtom);
const { data: session } = useSession();
const router = useRouter();
// showConfirmDialog and isDeleting states removed
const updateSettings = async (newSettings: Settings) => {
await saveSettings(newSettings)
setSettings(newSettings)
}
// handleDeleteAccount function removed
if (!settings) return null
return (
<>
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Settings</h1>
<div>
<h1 className="text-3xl font-bold mb-6">{t('title')}</h1>
<Card className="mb-6">
<CardHeader>
<CardTitle>UI Settings</CardTitle>
<CardTitle>{t('uiSettingsTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="number-formatting">Number Formatting</Label>
<Label htmlFor="number-formatting">{t('numberFormattingLabel')}</Label>
<div className="text-sm text-muted-foreground">
Format large numbers (e.g., 1K, 1M, 1B)
{t('numberFormattingDescription')}
</div>
</div>
<Switch
@@ -59,9 +72,9 @@ export default function SettingsPage() {
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="number-grouping">Number Grouping</Label>
<Label htmlFor="number-grouping">{t('numberGroupingLabel')}</Label>
<div className="text-sm text-muted-foreground">
Use thousand separators (e.g., 1,000 vs 1000)
{t('numberGroupingDescription')}
</div>
</div>
<Switch
@@ -80,14 +93,14 @@ export default function SettingsPage() {
<Card className="mb-6">
<CardHeader>
<CardTitle>System Settings</CardTitle>
<CardTitle>{t('systemSettingsTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="timezone">Timezone</Label>
<Label htmlFor="timezone">{t('timezoneLabel')}</Label>
<div className="text-sm text-muted-foreground">
Select your timezone for accurate date tracking
{t('timezoneDescription')}
</div>
</div>
<div className="flex flex-col items-end gap-2">
@@ -100,7 +113,7 @@ export default function SettingsPage() {
system: { ...settings.system, timezone: e.target.value }
})
}
className="w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
>
{Intl.supportedValuesOf('timeZone').map((tz) => (
<option key={tz} value={tz}>
@@ -113,9 +126,9 @@ export default function SettingsPage() {
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="timezone">Week Start Day</Label>
<Label htmlFor="weekStartDay">{t('weekStartDayLabel')}</Label>
<div className="text-sm text-muted-foreground">
Select your preferred first day of the week
{t('weekStartDayDescription')}
</div>
</div>
<div className="flex flex-col items-end gap-2">
@@ -128,7 +141,7 @@ export default function SettingsPage() {
system: { ...settings.system, weekStartDay: Number(e.target.value) as WeekDay }
})
}
className="w-[200px] rounded-md border border-input bg-background px-3 py-2"
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2"
>
{([
['sunday', 0],
@@ -138,9 +151,9 @@ export default function SettingsPage() {
['thursday', 4],
['friday', 5],
['saturday', 6]
] as Array<[string, WeekDay]>).map(([dayName, dayNumber]) => (
] as Array<["sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday", WeekDay]>).map(([dayName, dayNumber]) => (
<option key={dayNumber} value={dayNumber}>
{dayName.charAt(0).toUpperCase() + dayName.slice(1)}
{t(`weekdays.${dayName}`)}
</option>
))}
</select>
@@ -151,7 +164,7 @@ export default function SettingsPage() {
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="auto-backup">Auto Backup</Label>
<Label htmlFor="auto-backup">{t('autoBackupLabel')}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -159,18 +172,14 @@ export default function SettingsPage() {
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p className="max-w-xs text-sm">
When enabled, the application data (habits, coins, settings, etc.)
will be automatically backed up daily around 2 AM server time.
Backups are stored as ZIP files in the `backups/` directory
at the project root. Only the last 7 backups are kept; older
ones are automatically deleted.
{t('autoBackupTooltip')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="text-sm text-muted-foreground">
Automatically back up data daily
{t('autoBackupDescription')}
</div>
</div>
<Switch
@@ -186,8 +195,52 @@ export default function SettingsPage() {
</div>
{/* End of Auto Backup section */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="language-select">{t('languageLabel')}</Label>
</div>
<div className="text-sm text-muted-foreground">
{t('languageDescription')}
</div>
{serverSettings.isDemo && (
<div className="text-sm text-red-500">
{t('languageDisabledInDemoTooltip')}
</div>
)}
</div>
<select
id="language-select"
value={settings.system.language}
disabled={serverSettings.isDemo}
onChange={(e) => {
updateSettings({
...settings,
system: { ...settings.system, language: e.target.value }
});
toast({
title: t('languageChangedTitle'),
description: t('languageChangedDescription'),
variant: 'default',
});
}}
className={`w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 ${serverSettings.isDemo ? 'cursor-not-allowed opacity-50' : ''}`}
>
{/* Add more languages as needed */}
<option value="en">English</option>
<option value="es">Español</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ja"></option>
</select>
</div>
</CardContent>
</Card>
{/* Danger Zone Card Removed */}
</div >
</>
)

View File

@@ -5,24 +5,25 @@ 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"
interface AboutModalProps {
isOpen: boolean
onClose: () => void
}
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
export default function AboutModal({ onClose }: AboutModalProps) {
const t = useTranslations('AboutModal')
const version = packageJson.version
const [changelogOpen, setChangelogOpen] = useState(false)
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={true} 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 +41,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"
@@ -66,7 +67,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>

View File

@@ -3,31 +3,22 @@
import { useState } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import { settingsAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Info, SmilePlus, Zap } from 'lucide-react'
import { Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Habit, SafeUser } from '@/lib/types'
import EmojiPickerButton from './EmojiPickerButton'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
import * as chrono from 'chrono-node';
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP, MAX_COIN_LIMIT } from '@/lib/constants'
import { DateTime } from 'luxon'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useHelpers } from '@/lib/client-helpers'
interface AddEditHabitModalProps {
onClose: () => void
@@ -37,6 +28,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 || '')
@@ -44,14 +36,15 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const isRecurRule = !isTask
// Initialize ruleText with the actual frequency string or default, not the display text
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
isRecurRule,
timezone: settings.system.timezone
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
const [ruleText, setRuleText] = useState<string>(initialRuleText)
const { currentUser } = useHelpers()
const [currentUser] = useAtom(currentUserAtom)
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null); // State for validation message
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom)
const users = usersData.users
@@ -93,19 +86,21 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
})
}
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
return (
<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
@@ -114,38 +109,20 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
onChange={(e) => setName(e.target.value)}
required
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-8 w-8" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => {
// Add space before emoji if there isn't one already
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji.native}`;
})
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}}
/>
</PopoverContent>
</Popover>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
@@ -156,7 +133,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">
@@ -204,15 +181,31 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</div>
{/* rrule input (habit) */}
<div className="col-start-2 col-span-3 text-sm">
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
</span>
{(() => {
let displayText = '';
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
if (message !== errorMessage) { // Only update if it changed to avoid re-renders
setErrorMessage(message);
}
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
return (
<>
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{displayText}
</span>
{errorMessage && (
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
)}
</>
);
})()}
</div>
</div>
<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">
@@ -246,7 +239,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>
@@ -254,7 +247,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">
@@ -271,21 +264,25 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinReward(prev => prev + 1)}
onClick={() => setCoinReward(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
coins
{t('coinsSuffix')}
</span>
</div>
</div>
@@ -293,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">
@@ -324,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" disabled={!!errorMessage}>
{habit
? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -1,19 +1,16 @@
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { useTranslations } from 'next-intl'
import { usersAtom, currentUserAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { SmilePlus, Info } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { WishlistItemType } from '@/lib/types'
import EmojiPickerButton from './EmojiPickerButton'
import { MAX_COIN_LIMIT } from '@/lib/constants'
interface AddEditWishlistItemModalProps {
isOpen: boolean
@@ -32,12 +29,13 @@ 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)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '')
const { currentUser } = useHelpers()
const [currentUser] = useAtom(currentUserAtom)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
@@ -62,16 +60,18 @@ 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')
} else if (coinCost > MAX_COIN_LIMIT) {
newErrors.coinCost = t('errorCoinCostMax', { max: MAX_COIN_LIMIT })
}
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
@@ -109,7 +109,7 @@ export default function AddEditWishlistItemModal({
} else {
addWishlistItem(itemData)
}
setIsOpen(false)
setEditingItem(null)
}
@@ -118,13 +118,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
@@ -134,34 +134,20 @@ export default function AddEditWishlistItemModal({
className="flex-1"
required
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => `${prev}${emoji.native}`)
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}}
/>
</PopoverContent>
</Popover>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
@@ -173,7 +159,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">
@@ -190,21 +176,25 @@ export default function AddEditWishlistItemModal({
id="coinReward"
type="number"
value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinCost(prev => prev + 1)}
onClick={() => setCoinCost(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
coins
{t('coinsSuffix')}
</span>
</div>
</div>
@@ -212,7 +202,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">
@@ -246,7 +236,7 @@ export default function AddEditWishlistItemModal({
</button>
</div>
<span className="text-sm text-muted-foreground">
times
{t('timesSuffix')}
</span>
</div>
{errors.targetCompletions && (
@@ -258,7 +248,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
@@ -279,7 +269,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">
@@ -287,13 +277,13 @@ export default function AddEditWishlistItemModal({
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
@@ -310,7 +300,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>

View File

@@ -1,25 +1,42 @@
'use client'
import { ReactNode, useEffect } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
import { ReactNode, Suspense, useEffect, useState } from 'react'
import { useAtom, useSetAtom } from 'jotai' // Import useSetAtom
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom } from '@/lib/atoms' // Import currentUserIdAtom
import PomodoroTimer from './PomodoroTimer'
import UserSelectModal from './UserSelectModal'
import { useSession } from 'next-auth/react'
import AboutModal from './AboutModal'
import LoadingSpinner from './LoadingSpinner'
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
const { data: session, status } = useSession()
const currentUserId = session?.user.id
const [isMounted, setIsMounted] = useState(false);
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), to prevent SSR hydration errors in the children components
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (status === 'loading') return
if (!currentUserId && !userSelect) {
setUserSelect(true)
}
}, [currentUserId, status, userSelect])
}, [currentUserId, status, userSelect, setUserSelect])
useEffect(() => {
setCurrentUserIdAtom(currentUserId)
}, [currentUserId, setCurrentUserIdAtom])
if (!isMounted) {
return <LoadingSpinner />
}
return (
<>
{children}
@@ -27,7 +44,10 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
<PomodoroTimer />
)}
{userSelect && (
<UserSelectModal onClose={() => setUserSelect(false)}/>
<UserSelectModal onClose={() => setUserSelect(false)} />
)}
{aboutOpen && (
<AboutModal onClose={() => setAboutOpen(false)} />
)}
</>
)

View File

@@ -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">

View File

@@ -10,15 +10,18 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { settingsAtom, usersAtom } from '@/lib/atoms'
import { settingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { useCoins } from '@/hooks/useCoins'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { TransactionNoteEditor } from './TransactionNoteEditor'
import { useHelpers } from '@/lib/client-helpers'
import { TransactionType } from '@/lib/types'
export default function CoinsManager() {
const { currentUser } = useHelpers()
const t = useTranslations('CoinsManager')
const [currentUser] = useAtom(currentUserAtom)
const [selectedUser, setSelectedUser] = useState<string>()
const {
add,
@@ -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'
@@ -86,13 +89,24 @@ 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>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold mr-6">Coins Management</h1>
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
{currentUser?.isAdmin && (
<select
className="border rounded p-2"
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2"
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
>
@@ -111,8 +125,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>
@@ -124,7 +138,11 @@ export default function CoinsManager() {
variant="outline"
size="icon"
className="h-10 w-10 text-lg"
onClick={() => setAmount(prev => (Number(prev) - 1).toString())}
onClick={() => setAmount(prev => {
const current = Number(prev);
const next = current - 1;
return (Math.abs(next) > MAX_COIN_LIMIT ? (next < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT) : next).toString();
})}
>
-
</Button>
@@ -132,7 +150,22 @@ export default function CoinsManager() {
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onChange={(e) => {
const rawValue = e.target.value;
if (rawValue === '' || rawValue === '-') {
setAmount(rawValue);
return;
}
let numericValue = Number(rawValue); // Changed const to let
if (isNaN(numericValue)) return; // Or handle error
if (Math.abs(numericValue) > MAX_COIN_LIMIT) {
numericValue = numericValue < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT;
}
setAmount(numericValue.toString());
}}
min={-MAX_COIN_LIMIT}
max={MAX_COIN_LIMIT}
className="text-center text-xl font-medium h-12"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
@@ -143,7 +176,11 @@ export default function CoinsManager() {
variant="outline"
size="icon"
className="h-10 w-10 text-lg"
onClick={() => setAmount(prev => (Number(prev) + 1).toString())}
onClick={() => setAmount(prev => {
const current = Number(prev);
const next = current + 1;
return (Math.abs(next) > MAX_COIN_LIMIT ? (next < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT) : next).toString();
})}
>
+
</Button>
@@ -157,7 +194,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>
@@ -169,27 +206,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>
@@ -197,21 +234,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>
@@ -222,13 +259,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}
@@ -241,18 +278,18 @@ export default function CoinsManager() {
<option value={100}>100</option>
<option value={500}>500</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 +316,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 +335,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 +393,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

View File

@@ -2,8 +2,9 @@ import { Badge } from "@/components/ui/badge"
import { useAtom } from 'jotai'
import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils'
import { useHabits } from '@/hooks/useHabits'
// import { useHabits } from '@/hooks/useHabits' // Not used
import { settingsAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
interface CompletionCountBadgeProps {
type: 'habits' | 'tasks'
@@ -14,6 +15,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)
@@ -29,7 +31,7 @@ export default function CompletionCountBadge({
return (
<Badge variant="secondary">
{`${completedCount}/${totalCount} Completed`}
{t('countCompleted', { completedCount, totalCount })}
</Badge>
)
}

View File

@@ -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>

View File

@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'
import Link from 'next/link'
import { useState } from 'react'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -54,6 +55,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);
@@ -105,7 +107,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">
@@ -130,7 +132,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>
@@ -239,7 +241,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>
@@ -305,12 +307,12 @@ const ItemSection = ({
>
{currentExpanded ? (
<>
Show less
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
@@ -359,6 +361,7 @@ export default function DailyOverview({
wishlistItems,
coinBalance,
}: UpcomingItemsProps) {
const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
@@ -412,16 +415,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 })}
@@ -430,9 +433,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 })}
@@ -440,16 +443,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>
) : (
<>
@@ -496,8 +502,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>
@@ -513,12 +519,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" />
</>
)}
@@ -527,7 +533,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>

View File

@@ -5,10 +5,12 @@ import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits'
// import { useHabits } from '@/hooks/useHabits' // useHabits is not used
import { useCoins } from '@/hooks/useCoins'
import { useTranslations } from 'next-intl';
export default function Dashboard() {
const t = useTranslations('Dashboard');
const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
@@ -17,9 +19,9 @@ export default function Dashboard() {
const wishlistItems = wishlist.items
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<h1 className="text-xl xs: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} />

View File

@@ -0,0 +1,42 @@
import Link from 'next/link'
import type { ElementType } from 'react'
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface DesktopNavDisplayProps {
navItems: NavItemType[];
className?: string;
}
export default function DesktopNavDisplay({ navItems, className }: DesktopNavDisplayProps) {
// Filter for items relevant to desktop view, typically 'main' position
const desktopNavItems = navItems.filter(item => item.position === 'main');
return (
<div className={`hidden lg:flex lg:flex-shrink-0 ${className || ''}`}>
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{desktopNavItems.map((item) => (
<Link
key={item.label} // Assuming labels are unique
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SmilePlus } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
interface EmojiPickerButtonProps {
onEmojiSelect: (emoji: string) => void
inputIdToFocus?: string // Optional: ID of the input to focus after selection
}
export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: EmojiPickerButtonProps) {
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
return (
<Popover modal={true} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8" // Consistent sizing
>
<SmilePlus className="h-4 w-4" /> {/* Consistent icon size */}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[300px] p-0"
onCloseAutoFocus={(event) => {
if (inputIdToFocus) {
event.preventDefault();
const input = document.getElementById(inputIdToFocus) as HTMLInputElement;
input?.focus();
}
}}
>
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
onEmojiSelect(emoji.native);
setIsEmojiPickerOpen(false);
// Focus is handled by onCloseAutoFocus
}}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -6,8 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Button } from '@/components/ui/button'
import { Check, Circle, CircleCheck } from 'lucide-react'
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
import { d2s, getNow, t2d, isHabitDue, getISODate, getCompletionsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms'
import { DateTime } from 'luxon'
@@ -15,15 +16,16 @@ import Linkify from './linkify'
import { Habit } from '@/lib/types'
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,12 +43,12 @@ export default function HabitCalendar() {
}, [completedHabitsMap, settings.system.timezone])
return (
<div className="container mx-auto px-4 py-6">
<h1 className="text-2xl font-semibold mb-6">Habit Calendar</h1>
<div>
<h1 className="text-xl xs:text-3xl font-semibold 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
@@ -75,7 +77,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>
@@ -85,7 +87,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">
@@ -144,7 +146,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">
@@ -155,49 +157,49 @@ export default function HabitCalendar() {
date: selectedDateTime
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
</ul>
</div>
</div>

View File

@@ -1,12 +1,12 @@
import { Habit } from '@/lib/types';
import { Habit, User } from '@/lib/types';
import { useHabits } from '@/hooks/useHabits';
import { useAtom } from 'jotai';
import { pomodoroAtom, settingsAtom } from '@/lib/atoms';
import { d2t, getNow, isHabitDueToday } from '@/lib/utils';
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
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,13 +23,14 @@ 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);
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions
const [currentUser] = useAtom(currentUserAtom);
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions
const canInteract = hasPermission('habit', 'interact');
const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
const canInteract = hasPermission(currentUser, 'habit', 'interact');
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
@@ -55,7 +56,7 @@ export function HabitContextMenuItems({
})}
>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
<span>{t('startPomodoro')}</span>
</MenuItemComponent>
)}
@@ -69,7 +70,7 @@ export function HabitContextMenuItems({
})}
>
<Calendar className="mr-2 h-4 w-4" />
<span>Move to Today</span>
<span>{t('moveToToday')}</span>
</MenuItemComponent>
)}
@@ -83,7 +84,7 @@ export function HabitContextMenuItems({
})}
>
<Calendar className="mr-2 h-4 w-4" />
<span>Move to Tomorrow</span>
<span>{t('moveToTomorrow')}</span>
</MenuItemComponent>
)}
@@ -93,7 +94,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 +105,7 @@ export function HabitContextMenuItems({
disabled={!canWrite}
>
<Edit className="mr-2 h-4 w-4" />
<span>Edit</span>
<span>{t('edit')}</span>
</MenuItemComponent>
)}
@@ -114,7 +115,7 @@ export function HabitContextMenuItems({
disabled={!canWrite}
>
<Edit className="mr-2 h-4 w-4" />
<span>Edit</span>
<span>{t('edit')}</span>
</MenuItemComponent>
)}
@@ -125,7 +126,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 +136,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 +151,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>
</>
);

View File

@@ -1,10 +1,10 @@
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react' // Removed unused icons
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,10 +14,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { useTranslations } from 'next-intl'
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
import { DateTime } from 'luxon'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
import { hasPermission } from '@/lib/utils'
import { HabitContextMenuItems } from './HabitContextMenuItems'
interface HabitItemProps {
@@ -28,7 +29,7 @@ interface HabitItemProps {
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return null;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
@@ -54,10 +55,11 @@ 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')
const canInteract = hasPermission('habit', 'interact')
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'habit', 'write')
const canInteract = hasPermission(currentUser, 'habit', 'interact')
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const isRecurRule = !isTasksView
@@ -97,7 +99,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>
@@ -111,15 +113,17 @@ 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({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
{t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
})
})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{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">
@@ -137,19 +141,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 && (
@@ -171,7 +175,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>
@@ -185,10 +189,10 @@ 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>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />

View File

@@ -3,6 +3,7 @@
import { useState, useMemo, useEffect } from 'react' // Added useMemo, useEffect
import { Plus, ArrowUpNarrowWide, ArrowDownWideNarrow, Search } from 'lucide-react' // Added sort icons, Search icon
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
@@ -20,6 +21,7 @@ import { DateTime } from 'luxon' // Added
import { getHabitFreq } from '@/lib/utils' // Added
export default function HabitList() {
const t = useTranslations('HabitList');
const { saveHabit, deleteHabit } = useHabits()
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
const [browserSettings] = useAtom(browserSettingsAtom)
@@ -122,17 +124,17 @@ export default function HabitList() {
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
<h1 className="text-xl xs:text-3xl font-bold">
{t(isTasksView ? 'myTasks' : 'myHabits')}
</h1>
<span>
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
<Plus className="mr-2 h-4 w-4" /> {t('addTaskButton')}
</Button>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
<Plus className="mr-2 h-4 w-4" /> {t('addHabitButton')}
</Button>
</span>
</div>
@@ -148,28 +150,28 @@ export default function HabitList() {
</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>
@@ -177,35 +179,35 @@ export default function HabitList() {
<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>
) : (
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
{archivedHabits.length > 0 && (
<>
<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) => (
@@ -246,9 +248,9 @@ export default function HabitList() {
}
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>
)

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { d2s, getNow, t2d } from '@/lib/utils' // Removed getCompletedHabitsForDate
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom, hasTasksAtom, completedHabitsMapAtom } from '@/lib/atoms' // Added completedHabitsMapAtom
interface HabitStreakProps {
@@ -12,6 +13,7 @@ interface HabitStreakProps {
}
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}

View File

@@ -1,37 +1,13 @@
'use client'
import { useEffect, useState } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/Logo'
import NotificationBell from './NotificationBell'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import AboutModal from './AboutModal'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { Profile } from './Profile'
import { useHelpers } from '@/lib/client-helpers'
import HeaderActions from './HeaderActions'
interface HeaderProps {
className?: string
}
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function Header({ className }: HeaderProps) {
const [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const { balance } = useCoins()
return (
<>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -40,23 +16,7 @@ export default function Header({ className }: HeaderProps) {
<Link href="/" className="mr-3 sm:mr-4">
<Logo />
</Link>
<div className="flex items-center gap-1 sm:gap-2">
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
<div className="hidden sm:block">
<TodayEarnedCoins />
</div>
</div>
</Link>
<NotificationBell />
<Profile />
</div>
<HeaderActions />
</div>
</div>
</header>

View File

@@ -0,0 +1,38 @@
'use client'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Coins } from 'lucide-react'
import NotificationBell from './NotificationBell'
import dynamic from 'next/dynamic'
import { Profile } from './Profile'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function HeaderActions() {
const [settings] = useAtom(settingsAtom)
const { balance } = useCoins()
return (
<div className="flex items-center gap-1 sm:gap-2">
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
<div className="hidden sm:block">
<TodayEarnedCoins />
</div>
</div>
</Link>
<NotificationBell />
<Profile />
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { ResponsiveContainer } from 'recharts'
import ClientWrapper from './ClientWrapper'
import Header from './Header'
import Navigation from './Navigation'
@@ -5,18 +6,21 @@ import Navigation from './Navigation'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
<Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden">
<Navigation viewPort='main' />
<div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
<ClientWrapper>
{children}
</ClientWrapper>
</main>
<Navigation viewPort='mobile' />
<ClientWrapper>
<Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden">
<Navigation viewPort='main' />
<div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
{/* responsive container (optimized for mobile) */}
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
{children}
</div>
</main>
<Navigation viewPort='mobile' />
</div>
</div>
</div>
</ClientWrapper>
</div>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import React, { useEffect, useState } from 'react';
import { Coins } from 'lucide-react';
import { Logo } from '@/components/Logo';
const subtexts = [
"Unearthing your treasures",
"Polishing your gems",
"Mining for good habits",
"Stumbling upon brilliance",
"Discovering your potential",
"Crafting your success story",
"Forging new paths",
"Summoning success",
"Brewing brilliance",
"Charging up your awesome",
"Assembling achievements",
"Leveling up your day",
"Questing for quality",
"Unlocking awesomeness",
"Plotting your progress",
];
const LoadingSpinner: React.FC = () => {
const [currentSubtext, setCurrentSubtext] = useState<string>('Loading your data');
const [animatedDots, setAnimatedDots] = useState<string>('');
useEffect(() => {
const randomIndex = Math.floor(Math.random() * subtexts.length);
setCurrentSubtext(subtexts[randomIndex]);
const dotAnimationInterval = setInterval(() => {
setAnimatedDots(prevDots => {
if (prevDots.length >= 3) {
return '';
}
return prevDots + '.';
});
}, 200); // Adjust timing as needed
return () => clearInterval(dotAnimationInterval);
}, []);
return (
<div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-col items-center space-y-4">
<Coins className="h-12 w-12 animate-bounce text-yellow-500" />
<Logo />
{currentSubtext && (
<p className="text-lg text-gray-600 dark:text-gray-400">
{currentSubtext}{animatedDots}
</p>
)}
</div>
</div>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,60 @@
import Link from 'next/link'
import type { ElementType } from 'react'
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface MobileNavDisplayProps {
navItems: NavItemType[];
}
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}
export default function MobileNavDisplay({ navItems }: MobileNavDisplayProps) {
// Filter for items relevant to mobile view, typically 'main' and 'bottom' positions
const mobileNavItems = navItems.filter(item => item.position === 'main' || item.position === 'bottom');
// The original code spread main and bottom items separately, effectively concatenating them.
// If specific ordering or duplication was intended, that logic would be here.
// For now, a simple filter and map should suffice if all items are distinct.
// The original code: [...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')]
// This implies that items could be in 'main' or 'bottom'. The current navItems only have 'main'.
// A simple combined list is fine.
const isIOS = iOS()
return (
<>
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-5 w-full">
{mobileNavItems.map((item) => (
<Link
key={item.label} // Assuming labels are unique
href={item.href}
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
</>
);
}

View File

@@ -1,40 +1,47 @@
'use client'
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
import { Home, Calendar, Gift, Coins } from 'lucide-react'
import { useAtom } from 'jotai'
import { browserSettingsAtom } from '@/lib/atoms'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
import { useEffect, useState, ElementType } from 'react'
import { useTranslations } from 'next-intl'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { useHelpers } from '@/lib/client-helpers'
import MobileNavDisplay from './MobileNavDisplay'
import DesktopNavDisplay from './DesktopNavDisplay'
type ViewPort = 'main' | 'mobile'
const navItems = (isTasksView: boolean) => [
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
{
icon: isTasksView ? TaskIcon : HabitIcon,
label: isTasksView ? 'Tasks' : 'Habits',
href: '/habits',
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' },
]
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface NavigationProps {
className?: string
viewPort: ViewPort
}
export default function Navigation({ className, viewPort }: NavigationProps) {
const [showAbout, setShowAbout] = useState(false)
const t = useTranslations('Navigation')
const [isMobileView, setIsMobileView] = useState(false)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const { isIOS } = useHelpers()
const currentNavItems: NavItemType[] = [
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
{
icon: isTasksView ? TaskIcon : HabitIcon,
label: isTasksView ? t('tasks') : t('habits'),
href: '/habits',
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 = () => {
@@ -52,51 +59,12 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
}, [])
if (viewPort === 'mobile' && isMobileView) {
return (
<>
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-5 w-full">
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
<Link
key={item.label}
href={item.href}
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
return <MobileNavDisplay navItems={currentNavItems} />
}
if (viewPort === 'main' && !isMobileView) {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems(isTasksView).filter(item => item.position === 'main').map((item) => (
<Link
key={item.label}
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</div>
)
return <DesktopNavDisplay navItems={currentNavItems} className={className} />
}
return null // Explicitly return null if no view matches
}

View File

@@ -2,9 +2,10 @@
import { useMemo } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } 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,
@@ -13,11 +14,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
import { User, CoinTransaction } from '@/lib/types';
export default function NotificationBell() {
const { currentUser } = useHelpers();
const t = useTranslations('NotificationBell');
const [currentUser] = useAtom(currentUserAtom);
const [coinsData] = useAtom(coinsAtom)
const [habitsData] = useAtom(habitsAtom)
const [wishlistData] = useAtom(wishlistAtom)
@@ -99,7 +100,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);
}
};
@@ -120,7 +121,7 @@ export default function NotificationBell() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as useHelpers can return undefined initially
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down

View File

@@ -7,6 +7,7 @@ import { t2d } from '@/lib/utils';
import Link from 'next/link';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Info } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Tooltip,
TooltipContent,
@@ -18,27 +19,11 @@ 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;
@@ -60,19 +45,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 (
@@ -99,21 +98,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 && (

View File

@@ -8,6 +8,7 @@ import { User as UserIcon } from 'lucide-react';
import { Permission, User } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
interface PasswordEntryFormProps {
user: User;
@@ -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>

View File

@@ -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"

View File

@@ -4,54 +4,41 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
import { cn, getCompletionsForToday } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils'
// import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils' // Not used after pomodoroTodayCompletionsAtom
import { useHabits } from '@/hooks/useHabits'
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 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 [settings] = useAtom(settingsAtom)
const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo
@@ -62,21 +49,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') {
@@ -85,7 +74,7 @@ export default function PomodoroTimer() {
return
}
} catch (err) {
console.error('Error requesting wake lock:', err)
console.error(t('wakeLockRequestError'), err)
}
}
@@ -96,7 +85,7 @@ export default function PomodoroTimer() {
wakeLock.current = null
}
} catch (err) {
console.error('Error releasing wake lock:', err)
console.error(t('wakeLockReleaseError'), err)
}
}
@@ -150,12 +139,11 @@ export default function PomodoroTimer() {
const handleTimerEnd = async () => {
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)]
)
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') {
@@ -170,17 +158,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) => {
@@ -189,7 +176,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
@@ -242,11 +229,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}
@@ -254,7 +241,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">
{(() => {
@@ -293,12 +282,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>
@@ -309,7 +298,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"
@@ -318,7 +307,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>

View File

@@ -8,35 +8,35 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm'
import Link from "next/link"
import { useAtom } from "jotai"
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
import AboutModal from "./AboutModal"
import { aboutOpenAtom, settingsAtom, userSelectAtom, currentUserAtom } from "@/lib/atoms"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { signOut } from "@/app/actions/user"
import { toast } from "@/hooks/use-toast"
import { useHelpers } from "@/lib/client-helpers"
import { useTranslations } from 'next-intl'
export function Profile() {
const t = useTranslations('Profile');
const [settings] = useAtom(settingsAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false)
const [showAbout, setShowAbout] = useState(false)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const { theme, setTheme } = useTheme()
const { currentUser: user } = useHelpers()
const [user] = useAtom(currentUserAtom)
const [open, setOpen] = useState(false)
const handleSignOut = async () => {
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 +66,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 +78,7 @@ export function Profile() {
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
>
Edit profile
{t('editProfileButton')}
</button>
)}
</div>
@@ -104,34 +104,40 @@ 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>
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
<Link
href="/settings"
aria-label='settings'
className="flex items-center w-full gap-3"
aria-label={t('settingsLink')}
className="flex items-center justify-between w-full"
onClick={() => setOpen(false)} // Ensure dropdown closes on click
>
<Settings className="h-4 w-4" />
<span>Settings</span>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<span>{t('settingsLink')}</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
<button
onClick={() => setShowAbout(true)}
className="flex items-center w-full gap-3"
>
<Info className="h-4 w-4" />
<span>About</span>
</button>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
setOpen(false); // Close the dropdown
setAboutOpen(true); // Open the about modal
}}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" />
<span>{t('aboutButton')}</span>
</div>
</div>
</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">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4" />
<span>Theme</span>
<span>{t('themeLabel')}</span>
</div>
<button
onClick={(e) => {
@@ -167,14 +173,12 @@ export function Profile() {
</DropdownMenuContent>
</DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* Add the UserForm dialog */}
{isEditing && user && (
<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}

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -2,21 +2,33 @@
import { useState } from 'react';
import { passwordSchema, usernameSchema } from '@/lib/zod';
import { useTranslations } from 'next-intl';
import { Input } from './ui/input';
import { Button } from './ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Label } from './ui/label';
import { Switch } from './ui/switch';
import { Permission } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useAtom, useAtomValue } from 'jotai';
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { serverSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import { SafeUser, User } from '@/lib/types';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { User as UserIcon } from 'lucide-react';
import _ from 'lodash';
import { PermissionSelector } from './PermissionSelector';
import { useHelpers } from '@/lib/client-helpers';
interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating
@@ -25,10 +37,11 @@ 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;
const { currentUser } = useHelpers()
const [currentUser] = useAtom(currentUserAtom)
const getDefaultPermissions = (): Permission[] => [{
habit: {
write: true,
@@ -56,6 +69,69 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
);
const isEditing = !!user;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteUser = async () => {
if (!user) return;
if (serverSettings.isDemo) {
toast({
title: t('errorTitle'),
description: t('toastDemoDeleteDisabled'),
variant: 'destructive',
});
return;
}
if (currentUser && currentUser.id === user.id) {
toast({
title: t('errorTitle'),
description: t('toastCannotDeleteSelf'),
variant: 'destructive',
});
return;
}
setIsDeleting(true);
try {
const response = await fetch('/api/user/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }),
});
if (response.ok) {
setUsersData(prev => ({
...prev,
users: prev.users.filter(u => u.id !== user.id),
}));
toast({
title: t('toastUserDeletedTitle'),
description: t('toastUserDeletedDescription', { username: user.username }),
variant: 'default'
});
onSuccess();
} else {
const errorData = await response.json();
toast({
title: t('errorTitle'),
description: errorData.error || t('genericError'),
variant: 'destructive',
});
}
} catch (error) {
toast({
title: t('errorTitle'),
description: t('networkError'),
variant: 'destructive',
});
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -92,11 +168,11 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
setUsersData(prev => ({
...prev,
users: prev.users.map(u =>
u.id === user.id ? {
...u,
username,
avatarPath,
permissions,
u.id === user.id ? {
...u,
username,
avatarPath,
permissions,
isAdmin,
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
} : u
@@ -104,8 +180,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 {
@@ -128,8 +204,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'
});
}
@@ -138,15 +214,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;
@@ -160,14 +237,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'
});
}
@@ -209,18 +286,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' : ''}
@@ -230,22 +307,22 @@ 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>
<div className="flex items-center space-x-2">
<Switch
id="disable-password"
@@ -253,7 +330,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>
@@ -261,7 +338,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
)}
{currentUser && currentUser.isAdmin && <PermissionSelector
permissions={permissions}
isAdmin={isAdmin}
@@ -272,15 +349,47 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
</div>
<div className="flex justify-end gap-2 pt-2">
{isEditing && (
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
className="mr-auto"
disabled={serverSettings.isDemo || isDeleting}
>
{isDeleting ? t('deletingButtonText') : t('deleteAccountButton')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteUserConfirmation', { username: user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button
type="button"
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>

View File

@@ -5,32 +5,46 @@ import PasswordEntryForm from './PasswordEntryForm';
import UserForm from './UserForm';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen, Trash2 } from 'lucide-react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { useAtom } from 'jotai';
import { usersAtom } from '@/lib/atoms';
import { usersAtom, currentUserAtom } from '@/lib/atoms';
import { signIn } from '@/app/actions/user';
import { createUser } from '@/app/actions/data';
import { useTranslations } from 'next-intl';
import { toast } from '@/hooks/use-toast';
import { Description } from '@radix-ui/react-dialog';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
function UserCard({
user,
function UserCard({
user,
onSelect,
onEdit,
showEdit,
isCurrentUser
isCurrentUser,
}: {
user: User,
onSelect: () => void,
onEdit: () => void,
showEdit: boolean,
isCurrentUser: boolean
isCurrentUser: boolean,
}) {
const t = useTranslations('UserSelectModal');
return (
<div key={user.id} className="relative group">
<button
@@ -41,9 +55,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" />
@@ -55,21 +69,27 @@ function UserCard({
</span>
</button>
{showEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="absolute top-0 right-0 p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
>
<UserRoundPen className="h-4 w-4" />
</button>
<div className="absolute top-0 right-0 flex space-x-1">
{showEdit && (
<button
onClick={(e) => {
e.stopPropagation(); // Prevent card selection
onEdit();
}}
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
title={t('editUserTooltip')}
>
<UserRoundPen className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
}
function AddUserButton({ onClick }: { onClick: () => void }) {
const t = useTranslations('UserSelectModal');
return (
<button
onClick={onClick}
@@ -80,51 +100,53 @@ 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>
);
}
function UserSelectionView({
users,
currentUser,
currentUserFromHook, // Renamed to avoid confusion with map variable
onUserSelect,
onEditUser,
onCreateUser
onCreateUser,
}: {
users: User[],
currentUser?: SafeUser,
currentUserFromHook?: 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">
{users
.filter(user => user.id !== currentUser?.id)
.filter(user => user.id !== currentUserFromHook?.id) // Show other users
.map((user) => (
<UserCard
key={user.id}
user={user}
onSelect={() => onUserSelect(user.id)}
onEdit={() => onEditUser(user.id)}
showEdit={!!currentUser?.isAdmin}
isCurrentUser={false}
showEdit={!!currentUserFromHook?.isAdmin}
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
/>
))}
{currentUser?.isAdmin && <AddUserButton onClick={onCreateUser} />}
))}
{currentUserFromHook?.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 [usersData, setUsersData] = useAtom(usersAtom);
const users = usersData.users;
const {currentUser} = useHelpers();
const [currentUser] = useAtom(currentUserAtom);
const handleUserSelect = (userId: string) => {
setSelectedUser(userId);
@@ -159,14 +181,14 @@ 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">
{!selectedUser && !isCreating && !isEditing ? (
<UserSelectionView
users={users}
currentUser={currentUser}
currentUserFromHook={currentUser}
onUserSelect={handleUserSelect}
onEditUser={handleEditUser}
onCreateUser={handleCreateUser}
@@ -187,19 +209,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;
}
}}

View File

@@ -2,7 +2,7 @@
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { CheckSquare, ListChecks } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import type { ViewType } from '@/lib/types'
import { HabitIcon, TaskIcon } from '@/lib/constants'
@@ -18,6 +18,7 @@ export function ViewToggle({
defaultView = 'habits',
className
}: ViewToggleProps) {
const t = useTranslations('ViewToggle')
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const [habits] = useAtom(habitsAtom)
const [settings] = useAtom(settingsAtom)
@@ -46,9 +47,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={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
@@ -62,7 +63,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

View File

@@ -1,8 +1,9 @@
import { WishlistItemType, User, Permission } from '@/lib/types'
import { WishlistItemType, User } from '@/lib/types'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import { usersAtom, currentUserAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
import { hasPermission } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
@@ -30,7 +31,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 => {
@@ -58,11 +59,13 @@ export default function WishlistItem({
isHighlighted,
isRecentlyRedeemed
}: WishlistItemProps) {
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('wishlist', 'write')
const canInteract = hasPermission('wishlist', 'interact')
const t = useTranslations('WishlistItem')
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
const [usersData] = useAtom(usersAtom)
return (
<Card
id={`wishlist-${item.id}`}
@@ -77,7 +80,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>
@@ -96,7 +99,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>
@@ -113,13 +116,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>
@@ -135,10 +138,10 @@ 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>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
@@ -148,27 +151,27 @@ 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
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onClick={onDelete}
disabled={!canWrite}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t('deleteButton')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -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,
@@ -64,14 +66,14 @@ export default function WishlistManager() {
setTimeout(() => {
setRecentlyRedeemedId(null)
}, 3000)
if (item.link) {
setTimeout(() => {
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"
})
}
@@ -81,11 +83,11 @@ export default function WishlistManager() {
}
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">My Wishlist</h1>
<h1 className="text-xl xs: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>
) : (
@@ -124,12 +126,12 @@ export default function WishlistManager() {
</div>
))
)}
{archivedItems.length > 0 && (
<>
<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>
)

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -1,4 +1,6 @@
import { useAtom } from 'jotai'
import { useAtom } from 'jotai';
import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
import {
coinsAtom,
@@ -10,21 +12,23 @@ import {
coinsBalanceAtom,
settingsAtom,
usersAtom,
currentUserAtom,
} from '@/lib/atoms'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
import { CoinsData, User } from '@/lib/types'
import { toast } from '@/hooks/use-toast'
import { useHelpers } from '@/lib/client-helpers'
import { MAX_COIN_LIMIT } from '@/lib/constants'
function handlePermissionCheck(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
@@ -32,8 +36,8 @@ function handlePermissionCheck(
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
@@ -43,33 +47,78 @@ function handlePermissionCheck(
}
export function useCoins(options?: { selectedUser?: string }) {
const t = useTranslations('useCoins');
const tCommon = useTranslations('Common');
const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom)
const [users] = useAtom(usersAtom)
const { currentUser } = useHelpers()
let user: User | undefined;
if (!options?.selectedUser) {
user = currentUser;
} else {
user = users.users.find(u => u.id === options.selectedUser)
}
const [currentUser] = useAtom(currentUserAtom)
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
const [atomTotalEarned] = useAtom(totalEarnedAtom)
const [atomTotalSpent] = useAtom(totalSpentAtom)
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
const [atomTransactionsToday] = useAtom(transactionsTodayAtom);
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
const transactions = useMemo(() => {
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id);
}, [allCoinsData, targetUser?.id]);
// Filter transactions for the selectd user
const transactions = coins.transactions.filter(t => t.userId === user?.id)
const timezone = settings.system.timezone;
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
const [totalEarned, setTotalEarned] = useState(0);
const [totalSpent, setTotalSpent] = useState(0);
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
const [transactionsToday, setTransactionsToday] = useState<number>(0);
const [balance, setBalance] = useState(0);
const [balance] = useAtom(coinsBalanceAtom)
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
const [totalEarned] = useAtom(totalEarnedAtom)
const [totalSpent] = useAtom(totalSpentAtom)
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
const [transactionsToday] = useAtom(transactionsTodayAtom)
useEffect(() => {
// Calculate other metrics
if (targetUser?.id && targetUser.id === currentUser?.id) {
// If the target user is the currently logged-in user, use the derived atom's value
setCoinsEarnedToday(atomCoinsEarnedToday);
setTotalEarned(atomTotalEarned);
setTotalSpent(atomTotalSpent);
setCoinsSpentToday(atomCoinsSpentToday);
setTransactionsToday(atomTransactionsToday);
setBalance(loggedInUserBalance);
} else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually
setCoinsEarnedToday(calculateCoinsEarnedToday(transactions, timezone));
setTotalEarned(calculateTotalEarned(transactions));
setTotalSpent(calculateTotalSpent(transactions));
setCoinsSpentToday(calculateCoinsSpentToday(transactions, timezone));
setTransactionsToday(calculateTransactionsToday(transactions, timezone));
setBalance(transactions.reduce((acc, t) => acc + t.amount, 0));
}
}, [
targetUser?.id,
currentUser?.id,
transactions, // Memoized: depends on allCoinsData and targetUser?.id
timezone,
loggedInUserBalance,
atomCoinsEarnedToday,
atomTotalEarned,
atomTotalSpent,
atomCoinsSpentToday,
atomTransactionsToday,
]);
const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
if (isNaN(amount) || amount <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid positive number"
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
}
if (amount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return null
}
@@ -79,20 +128,27 @@ export function useCoins(options?: { selectedUser?: string }) {
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: user?.id
userId: targetUser?.id
})
setCoins(data)
toast({ title: "Success", description: `Added ${amount} coins` })
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
return data
}
const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid positive number"
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
}
if (numAmount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return null
}
@@ -102,20 +158,20 @@ export function useCoins(options?: { selectedUser?: string }) {
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: user?.id
userId: targetUser?.id
})
setCoins(data)
toast({ title: "Success", description: `Removed ${numAmount} coins` })
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
return data
}
const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) {
toast({
title: "Error",
description: "Transaction not found"
title: tCommon("errorTitle"),
description: t("transactionNotFoundDescription")
})
return null
}

View File

@@ -1,5 +1,6 @@
import { useAtom, atom } from 'jotai'
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom, currentUserAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit, Permission, SafeUser, User } from '@/lib/types'
import { toast } from '@/hooks/use-toast'
@@ -19,17 +20,18 @@ import {
} from '@/lib/utils'
import { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react'
import { useHelpers } from '@/lib/client-helpers'
function handlePermissionCheck(
user: SafeUser | undefined,
user: SafeUser | User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
@@ -37,8 +39,8 @@ function handlePermissionCheck(
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
@@ -49,15 +51,17 @@ function handlePermissionCheck(
export function useHabits() {
const t = useTranslations('useHabits');
const tCommon = useTranslations('Common');
const [usersData] = useAtom(usersAtom)
const { currentUser } = useHelpers()
const [currentUser] = useAtom(currentUserAtom)
const [habitsData, setHabitsData] = useAtom(habitsAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom)
const [habitFreqMap] = useAtom(habitFreqMapAtom)
const completeHabit = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone)
@@ -72,8 +76,8 @@ export function useHabits() {
// Check if already completed
if (completionsToday >= target) {
toast({
title: "Already completed",
description: `You've already completed this habit today.`,
title: t("alreadyCompletedTitle"),
description: t("alreadyCompletedDescription"),
variant: "destructive",
})
return
@@ -104,19 +108,19 @@ export function useHabits() {
})
isTargetReached && playSound()
toast({
title: "Completed!",
description: `You earned ${habit.coinReward} coins.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
title: t("completedTitle"),
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
setCoins(updatedCoins)
} else {
toast({
title: "Progress!",
description: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
title: t("progressTitle"),
description: t("progressDescription", { count: completionsToday + 1, target }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
}
@@ -131,7 +135,7 @@ export function useHabits() {
}
const undoComplete = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
@@ -170,14 +174,17 @@ export function useHabits() {
}
toast({
title: "Completion undone",
description: `You have ${getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
})}/${target} completions today.`,
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />Redo
title: t("completionUndoneTitle"),
description: t("completionUndoneDescription", {
count: getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
}),
target
}),
action: <ToastAction altText={tCommon('redoButton')} onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('redoButton')}
</ToastAction>
})
@@ -188,8 +195,8 @@ export function useHabits() {
}
} else {
toast({
title: "No completions to undo",
description: "This habit hasn't been completed today.",
title: t("noCompletionsToUndoTitle"),
description: t("noCompletionsToUndoDescription"),
variant: "destructive",
})
return
@@ -197,7 +204,7 @@ export function useHabits() {
}
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
@@ -212,7 +219,7 @@ export function useHabits() {
}
const deleteHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
@@ -220,7 +227,7 @@ export function useHabits() {
}
const completePastHabit = async (habit: Habit, date: DateTime) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const dateKey = getISODate({ dateTime: date, timezone })
@@ -232,8 +239,8 @@ export function useHabits() {
if (completionsOnDate >= target) {
toast({
title: "Already completed",
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`,
title: t("alreadyCompletedPastDateTitle"),
description: t("alreadyCompletedPastDateDescription", { dateKey: d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' }) }),
variant: "destructive",
})
return
@@ -273,12 +280,12 @@ export function useHabits() {
}
toast({
title: isTargetReached ? "Completed!" : "Progress!",
title: isTargetReached ? t("completedTitle") : t("progressTitle"),
description: isTargetReached
? `You earned ${habit.coinReward} coins for ${dateKey}.`
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
? t("earnedCoinsPastDateDescription", { coinReward: habit.coinReward, dateKey })
: t("progressPastDateDescription", { count: completionsOnDate + 1, target, dateKey }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
@@ -290,7 +297,7 @@ export function useHabits() {
}
const archiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: true } : h
)
@@ -299,7 +306,7 @@ export function useHabits() {
}
const unarchiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: false } : h
)

View File

@@ -1,22 +1,23 @@
import { useAtom } from 'jotai'
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { WishlistItemType } from '@/lib/types'
import { WishlistItemType, User, SafeUser } from '@/lib/types'
import { celebrations } from '@/utils/celebrations'
import { checkPermission } from '@/lib/utils'
import { useHelpers } from '@/lib/client-helpers'
import { useCoins } from './useCoins'
function handlePermissionCheck(
user: any,
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
@@ -24,8 +25,8 @@ function handlePermissionCheck(
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
@@ -35,13 +36,15 @@ function handlePermissionCheck(
}
export function useWishlist() {
const { currentUser: user } = useHelpers()
const t = useTranslations('useWishlist');
const tCommon = useTranslations('Common');
const [user] = useAtom(currentUserAtom)
const [wishlist, setWishlist] = useAtom(wishlistAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const { balance } = useCoins()
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItem = { ...item, id: Date.now().toString() }
const newItems = [...wishlist.items, newItem]
const newWishListData = { items: newItems }
@@ -50,7 +53,7 @@ export function useWishlist() {
}
const editWishlistItem = async (updatedItem: WishlistItemType) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.map(item =>
item.id === updatedItem.id ? updatedItem : item
)
@@ -60,7 +63,7 @@ export function useWishlist() {
}
const deleteWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.filter(item => item.id !== id)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
@@ -68,13 +71,13 @@ export function useWishlist() {
}
const redeemWishlistItem = async (item: WishlistItemType) => {
if (!handlePermissionCheck(user, 'wishlist', 'interact')) return false
if (!handlePermissionCheck(user, 'wishlist', 'interact', tCommon)) return false
if (balance >= item.coinCost) {
// Check if item has target completions and if we've reached the limit
if (item.targetCompletions && item.targetCompletions <= 0) {
toast({
title: "Redemption limit reached",
description: `You've reached the maximum redemptions for "${item.name}".`,
title: t("redemptionLimitReachedTitle"),
description: t("redemptionLimitReachedDescription", { itemName: item.name }),
variant: "destructive",
})
return false
@@ -121,15 +124,15 @@ export function useWishlist() {
randomEffect()
toast({
title: "🎉 Reward Redeemed!",
description: `You've redeemed "${item.name}" for ${item.coinCost} coins.`,
title: t("rewardRedeemedTitle"),
description: t("rewardRedeemedDescription", { itemName: item.name, itemCoinCost: item.coinCost }),
})
return true
} else {
toast({
title: "Not enough coins",
description: `You need ${item.coinCost - balance} more coins to redeem this reward.`,
title: t("notEnoughCoinsTitle"),
description: t("notEnoughCoinsDescription", { coinsNeeded: item.coinCost - balance }),
variant: "destructive",
})
return false
@@ -139,7 +142,7 @@ export function useWishlist() {
const canRedeem = (cost: number) => balance >= cost
const archiveWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: true } : item
)
@@ -149,7 +152,7 @@ export function useWishlist() {
}
const unarchiveWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: false } : item
)

13
i18n/request.ts Normal file
View File

@@ -0,0 +1,13 @@
import { getRequestConfig } from 'next-intl/server';
import { loadSettings } from '@/app/actions/data'; // Adjust path as necessary
export default getRequestConfig(async () => {
// Load settings to get the user's preferred language
const settings = await loadSettings();
const locale = settings.system.language || 'en'; // Fallback to 'en' if not set
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});

View File

@@ -10,6 +10,7 @@ import {
CompletionCache,
getDefaultServerSettings,
User,
UserId,
} from "./types";
import {
getTodayInTimezone,
@@ -85,10 +86,26 @@ export const transactionsTodayAtom = atom((get) => {
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
});
// Derived atom for current balance from all transactions
// Atom to store the current logged-in user's ID.
// This should be set by your application when the user session is available.
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const currentUserAtom = atom((get) => {
const currentUserId = get(currentUserIdAtom);
const users = get(usersAtom);
return users.users.find(user => user.id === currentUserId);
})
// Derived atom for current balance for the logged-in user
export const coinsBalanceAtom = atom((get) => {
const loggedInUserId = get(currentUserIdAtom);
if (!loggedInUserId) {
return 0; // No user logged in or ID not set, so balance is 0
}
const coins = get(coinsAtom);
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
return coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
});
/* transient atoms */
@@ -107,6 +124,7 @@ export const pomodoroAtom = atom<PomodoroAtom>({
})
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
// Derived atom for completion cache
export const completionCacheAtom = atom((get) => {

View File

@@ -1,38 +0,0 @@
// client helpers
'use-client'
import { useSession } from "next-auth/react"
import { User, UserId } from './types'
import { useAtom } from 'jotai'
import { usersAtom } from './atoms'
import { checkPermission } from './utils'
export function useHelpers() {
const { data: session, status } = useSession()
const currentUserId = session?.user.id
const [usersData] = useAtom(usersAtom)
const currentUser = usersData.users.find((u) => u.id === currentUserId)
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}
return {
currentUserId,
currentUser,
usersData,
status,
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
checkPermission(currentUser?.permissions, resource, action),
isIOS: iOS(),
}
}

View File

@@ -29,4 +29,6 @@ export const QUICK_DATES = [
{ label: 'Friday', value: 'this friday' },
{ label: 'Saturday', value: 'this saturday' },
{ label: 'Sunday', value: 'this sunday' },
] as const
] as const
export const MAX_COIN_LIMIT = 9999

View File

@@ -132,6 +132,7 @@ export const getDefaultSettings = (): Settings => ({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
},
profile: {}
});
@@ -163,6 +164,7 @@ export interface SystemSettings {
timezone: string;
weekStartDay: WeekDay;
autoBackupEnabled: boolean; // Add this line
language: string; // Add this line for language preference
}
export interface ProfileSettings {

View File

@@ -2,7 +2,7 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { DateTime, DateTimeFormatOptions } from "luxon"
import { datetime, RRule } from 'rrule'
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node'
import _ from "lodash"
@@ -464,3 +464,20 @@ export function checkPermission(
export function uuid() {
return uuidv4()
}
export function hasPermission(
currentUser: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
// If no current user, no permissions.
if (!currentUser) {
return false;
}
// If user is admin, they have all permissions.
if (currentUser.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return checkPermission(currentUser.permissions, resource, action);
}

435
messages/de.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "Dashboard"
},
"HabitList": {
"myTasks": "Meine Aufgaben",
"myHabits": "Meine Gewohnheiten",
"addTaskButton": "Aufgabe hinzufügen",
"addHabitButton": "Gewohnheit hinzufügen",
"searchTasksPlaceholder": "Aufgaben suchen...",
"searchHabitsPlaceholder": "Gewohnheiten suchen...",
"sortByLabel": "Sortieren nach:",
"sortByName": "Name",
"sortByCoinReward": "Münzbelohnung",
"sortByDueDate": "Fälligkeitsdatum",
"sortByFrequency": "Häufigkeit",
"toggleSortOrderAriaLabel": "Sortierreihenfolge umkehren",
"noTasksFoundMessage": "Keine Aufgaben gefunden, die Ihrer Suche entsprechen.",
"noHabitsFoundMessage": "Keine Gewohnheiten gefunden, die Ihrer Suche entsprechen.",
"emptyStateTasksTitle": "Noch keine Aufgaben",
"emptyStateHabitsTitle": "Noch keine Gewohnheiten",
"emptyStateTasksDescription": "Erstellen Sie Ihre erste Aufgabe, um Ihren Fortschritt zu verfolgen",
"emptyStateHabitsDescription": "Erstellen Sie Ihre erste Gewohnheit, um Ihren Fortschritt zu verfolgen",
"archivedSectionTitle": "Archiviert",
"deleteTaskDialogTitle": "Aufgabe löschen",
"deleteHabitDialogTitle": "Gewohnheit löschen",
"deleteTaskDialogMessage": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteHabitDialogMessage": "Sind Sie sicher, dass Sie diese Gewohnheit löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen"
},
"DailyOverview": {
"addTaskButtonLabel": "Aufgabe hinzufügen",
"addHabitButtonLabel": "Gewohnheit hinzufügen",
"todaysOverviewTitle": "Heutige Übersicht",
"dailyTasksTitle": "Tägliche Aufgaben",
"noTasksDueTodayMessage": "Heute keine Aufgaben fällig. Fügen Sie Aufgaben hinzu, um zu beginnen!",
"dailyHabitsTitle": "Tägliche Gewohnheiten",
"noHabitsDueTodayMessage": "Heute keine Gewohnheiten fällig. Fügen Sie Gewohnheiten hinzu, um zu beginnen!",
"wishlistGoalsTitle": "Wunschlisten-Ziele",
"redeemableBadgeLabel": "{count}/{total} einlösbar",
"noWishlistItemsMessage": "Noch keine Elemente auf der Wunschliste. Fügen Sie Ziele hinzu, auf die Sie hinarbeiten können!",
"readyToRedeemMessage": "Bereit zum Einlösen!",
"coinsToGoMessage": "Noch {amount} Münzen benötigt",
"showLessButton": "Weniger anzeigen",
"showAllButton": "Alles anzeigen",
"viewButton": "Anzeigen",
"deleteTaskDialogTitle": "Aufgabe löschen",
"deleteHabitDialogTitle": "Gewohnheit löschen",
"confirmDeleteDialogMessage": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen",
"overdueTooltip": "Überfällig"
},
"HabitContextMenuItems": {
"startPomodoro": "Pomodoro starten",
"moveToToday": "Auf heute verschieben",
"moveToTomorrow": "Auf morgen verschieben",
"unpin": "Abheften",
"pin": "Anheften",
"edit": "Bearbeiten",
"archive": "Archivieren",
"unarchive": "Dearchivieren",
"delete": "Löschen"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Tägliche Abschluss-Serie",
"tooltipHabitsLabel": "Gewohnheiten",
"tooltipTasksLabel": "Aufgaben",
"tooltipCompletedLabel": "Abgeschlossen"
},
"CoinBalance": {
"coinBalanceTitle": "Münzguthaben"
},
"AddEditHabitModal": {
"editTaskTitle": "Aufgabe bearbeiten",
"editHabitTitle": "Gewohnheit bearbeiten",
"addNewTaskTitle": "Neue Aufgabe hinzufügen",
"addNewHabitTitle": "Neue Gewohnheit hinzufügen",
"nameLabel": "Name *",
"descriptionLabel": "Beschreibung",
"whenLabel": "Wann *",
"completeLabel": "Abschließen",
"timesSuffix": "mal",
"rewardLabel": "Belohnung",
"coinsSuffix": "Münzen",
"shareLabel": "Teilen",
"saveChangesButton": "Änderungen speichern",
"addTaskButton": "Aufgabe hinzufügen",
"addHabitButton": "Gewohnheit hinzufügen"
},
"ConfirmDialog": {
"confirmButton": "Bestätigen",
"cancelButton": "Abbrechen"
},
"AddEditWishlistItemModal": {
"editTitle": "Belohnung bearbeiten",
"addTitle": "Neue Belohnung hinzufügen",
"nameLabel": "Name *",
"descriptionLabel": "Beschreibung",
"costLabel": "Kosten",
"coinsSuffix": "Münzen",
"redeemableLabel": "Einlösbar",
"timesSuffix": "mal",
"errorNameRequired": "Name ist erforderlich",
"errorCoinCostMin": "Münzkosten müssen mindestens 1 sein",
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
"linkLabel": "Link",
"shareLabel": "Teilen",
"saveButton": "Änderungen speichern",
"addButton": "Belohnung hinzufügen"
},
"Navigation": {
"dashboard": "Dashboard",
"tasks": "Aufgaben",
"habits": "Gewohnheiten",
"calendar": "Kalender",
"wishlist": "Wunschliste",
"coins": "Münzen"
},
"TodayEarnedCoins": {
"todaySuffix": "heute"
},
"WishlistItem": {
"usesLeftSingular": "Verwendung übrig",
"usesLeftPlural": "Verwendungen übrig",
"coinsSuffix": "Münzen",
"redeem": "Einlösen",
"redeemedDone": "Erledigt",
"redeemedExclamation": "Eingelöst!",
"editButton": "Bearbeiten",
"archiveButton": "Archivieren",
"unarchiveButton": "Dearchivieren",
"deleteButton": "Löschen"
},
"WishlistManager": {
"title": "Meine Wunschliste",
"addRewardButton": "Belohnung hinzufügen",
"emptyStateTitle": "Ihre Wunschliste ist leer",
"emptyStateDescription": "Fügen Sie Belohnungen hinzu, die Sie mit Ihren Münzen verdienen möchten",
"archivedSectionTitle": "Archiviert",
"popupBlockedTitle": "Popup blockiert",
"popupBlockedDescription": "Bitte erlauben Sie Popups, um den Link zu öffnen",
"deleteDialogTitle": "Belohnung löschen",
"deleteDialogMessage": "Sind Sie sicher, dass Sie diese Belohnung löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen"
},
"UserSelectModal": {
"addUserButton": "Benutzer hinzufügen",
"createNewUserTitle": "Neuen Benutzer erstellen",
"selectUserTitle": "Benutzer auswählen",
"signInSuccessTitle": "Erfolgreich angemeldet",
"signInSuccessDescription": "Willkommen zurück, {username}!",
"errorInvalidPassword": "Ungültiges Passwort",
"deleteUserConfirmation": "Sind Sie sicher, dass Sie Benutzer {username} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"confirmDeleteButtonText": "Löschen",
"deletingButtonText": "Wird gelöscht...",
"deleteUserSuccessTitle": "Benutzer gelöscht",
"deleteUserSuccessDescription": "Benutzer {username} wurde erfolgreich gelöscht.",
"deleteUserErrorTitle": "Löschen fehlgeschlagen",
"genericError": "Ein unerwarteter Fehler ist aufgetreten.",
"networkError": "Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"editUserTooltip": "Benutzer bearbeiten",
"deleteUserTooltip": "Benutzer löschen"
},
"CoinsManager": {
"title": "Münzverwaltung",
"currentBalanceLabel": "Aktuelles Guthaben",
"coinsSuffix": "Münzen",
"addCoinsButton": "Münzen hinzufügen",
"removeCoinsButton": "Münzen entfernen",
"statisticsTitle": "Statistiken",
"totalEarnedLabel": "Gesamt verdient",
"totalSpentLabel": "Gesamt ausgegeben",
"totalTransactionsLabel": "Gesamt Transaktionen",
"todaysEarnedLabel": "Heute verdient",
"todaysSpentLabel": "Heute ausgegeben",
"todaysTransactionsLabel": "Heutige Transaktionen",
"transactionHistoryTitle": "Transaktionsverlauf",
"showLabel": "Anzeigen:",
"entriesSuffix": "Einträge",
"showingEntries": "Zeige {from} bis {to} von {total} Einträgen",
"noTransactionsTitle": "Noch keine Transaktionen",
"noTransactionsDescription": "Ihr Transaktionsverlauf wird hier angezeigt, sobald Sie beginnen, Münzen zu verdienen oder auszugeben",
"pageLabel": "Seite",
"ofLabel": "von",
"transactionTypeHabitCompletion": "Gewohnheitsabschluss",
"transactionTypeTaskCompletion": "Aufgabenabschluss",
"transactionTypeHabitUndo": "Gewohnheitsrückgängig",
"transactionTypeTaskUndo": "Aufgabenrückgängig",
"transactionTypeWishRedemption": "Wunscherfüllung",
"transactionTypeManualAdjustment": "Manuelle Anpassung",
"transactionTypeCoinReset": "Münzrücksetzung",
"transactionTypeInitialBalance": "Anfangsguthaben"
},
"NotificationBell": {
"errorUpdateTimestamp": "Fehler beim Aktualisieren des gelesenen Benachrichtigungszeitstempels:"
},
"PomodoroTimer": {
"focusLabel1": "Bleiben Sie konzentriert",
"focusLabel2": "Sie schaffen das",
"focusLabel3": "Machen Sie weiter",
"focusLabel4": "Zerquetschen Sie es",
"focusLabel5": "Lassen Sie es geschehen",
"focusLabel6": "Bleiben Sie stark",
"focusLabel7": "Durchhalten",
"focusLabel8": "Ein Schritt nach dem anderen",
"focusLabel9": "Sie können es schaffen",
"focusLabel10": "Konzentrieren und Erobern",
"breakLabel1": "Machen Sie eine Pause",
"breakLabel2": "Entspannen und Aufladen",
"breakLabel3": "Tief durchatmen",
"breakLabel4": "Dehnen Sie sich",
"breakLabel5": "Erfrischen Sie sich",
"breakLabel6": "Sie verdienen dies",
"breakLabel7": "Laden Sie Ihre Energie auf",
"breakLabel8": "Gehen Sie kurz weg",
"breakLabel9": "Klaren Sie Ihren Geist",
"breakLabel10": "Ruhen Sie sich aus und erfrischen Sie sich",
"focusType": "Konzentration",
"breakType": "Pause",
"pauseButton": "Pause",
"startButton": "Start",
"resetButton": "Zurücksetzen",
"skipButton": "Überspringen",
"wakeLockNotSupported": "Browser unterstützt WakeLock nicht",
"wakeLockInUse": "WakeLock bereits in Benutzung",
"wakeLockRequestError": "Fehler beim Anfordern des WakeLock:",
"wakeLockReleaseError": "Fehler beim Freigeben des WakeLock:"
},
"HabitCalendar": {
"title": "Gewohnheitskalender",
"calendarCardTitle": "Kalender",
"selectDatePrompt": "Wählen Sie ein Datum",
"tasksSectionTitle": "Aufgaben",
"habitsSectionTitle": "Gewohnheiten",
"errorCompletingPastHabit": "Fehler beim Abschließen vergangener Gewohnheit:"
},
"NotificationDropdown": {
"notLoggedIn": "Nicht eingeloggt.",
"userCompletedItem": "{username} hat {itemName} abgeschlossen.",
"userRedeemedItem": "{username} hat {itemName} eingelöst.",
"activityRelatedToItem": "Aktivität bezüglich {itemName} von {username}.",
"defaultUsername": "Jemand",
"defaultItemName": "ein geteilter Gegenstand",
"notificationsTitle": "Benachrichtungen",
"notificationsTooltip": "Zeigt Abschlüsse oder Einlösungen von anderen Benutzern für Gewohnheiten oder Wunschlisten, die Sie mit ihnen geteilt haben (Sie müssen Admin sein)",
"noNotificationsYet": "Noch keine Benachrichtigkeiten"
},
"AboutModal": {
"dialogArisLabel": "über",
"changelogButton": "Änderungsprotokoll",
"createdByPrefix": "Erstellt mit ❤️ von",
"starOnGitHubButton": "Auf GitHub bewerten"
},
"PermissionSelector": {
"permissionsTitle": "Berechtigungen",
"adminAccessLabel": "Admin-Zugriff",
"adminAccessDescription": "Admins haben uneingeschränkte Berechtigungen für alle Daten aller Benutzer",
"resourceHabitTask": "Gewohnheit / Aufgabe",
"resourceWishlist": "Wunschliste",
"resourceCoins": "Münzen",
"permissionWrite": "Schreiben",
"permissionInteract": "Interagieren"
},
"UserForm": {
"toastUserUpdatedTitle": "Benutzer aktualisiert",
"toastUserUpdatedDescription": "Benutzer {username} erfolgreich aktualisiert",
"toastUserCreatedTitle": "Benutzer erstellt",
"toastUserCreatedDescription": "Benutzer {username} erfolgreich erstellt",
"actionUpdate": "aktualisieren",
"actionCreate": "erstellen",
"errorFailedUserAction": "Fehler beim {action} des Benutzers",
"toastDemoDeleteDisabled": "Löschen ist in der Demo-Instanz deaktiviert",
"toastCannotDeleteSelf": "Sie können Ihr eigenes Konto nicht löschen",
"confirmDeleteUser": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
"toastUserDeletedTitle": "Benutzer gelöscht",
"toastUserDeletedDescription": "Benutzer {username} wurde erfolgreich gelöscht",
"toastDeleteUserFailed": "Fehler beim Löschen des Benutzers: {error}",
"errorTitle": "Fehler",
"errorFileSizeLimit": "Die Dateigröße muss kleiner als 5MB sein",
"toastAvatarUploadedTitle": "Avatar hochgeladen",
"toastAvatarUploadedDescription": "Avatar erfolgreich hochgeladen",
"errorFailedAvatarUpload": "Fehler beim Hochladen des Avatars",
"changeAvatarButton": "Avatar ändern",
"uploadAvatarButton": "Avatar hochladen",
"usernameLabel": "Benutzername",
"usernamePlaceholder": "Benutzername",
"newPasswordLabel": "Neues Passwort",
"passwordLabel": "Passwort",
"passwordPlaceholderEdit": "Leerlassen, um das aktuelle beizubehalten",
"passwordPlaceholderCreate": "Passwort eingeben",
"demoPasswordDisabledMessage": "Passwort ist in der Demo-Instanz automatisch deaktiviert",
"disablePasswordLabel": "Passwort deaktivieren",
"cancelButton": "Abbrechen",
"saveChangesButton": "Änderungen speichern",
"createUserButton": "Benutzer erstellen",
"deleteAccountButton": "Konto löschen",
"deletingButtonText": "Wird gelöscht...",
"areYouSure": "Sind Sie sicher?",
"deleteUserConfirmation": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
"cancel": "Abbrechen",
"confirmDeleteButtonText": "Löschen"
},
"ViewToggle": {
"habitsLabel": "Gewohnheiten",
"tasksLabel": "Aufgaben"
},
"HabitItem": {
"overdue": "Überfällig",
"whenLabel": "Wann: {frequency}",
"coinsPerCompletion": "{count} Münzen pro Abschluss",
"completedStatus": "Abgeschlossen",
"completedStatusCount": "Abgeschlossen ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Abschließen",
"completeButtonCount": "Abschließen ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Rückgängig",
"editButton": "Bearbeiten"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Notiz zu lang",
"noteTooLongDescription": "Notizen müssen weniger als 200 Zeichen haben",
"errorSavingNoteTitle": "Fehler beim Speichern der Notiz",
"errorDeletingNoteTitle": "Fehler beim Löschen der Notiz",
"pleaseTryAgainDescription": "Bitte versuchen Sie es erneut",
"addNotePlaceholder": "Notiz hinzufügen...",
"saveNoteTitle": "Notiz speichern",
"cancelButtonTitle": "Abbrechen",
"deleteNoteTitle": "Notiz löschen",
"editNoteAriaLabel": "Notiz bearbeiten"
},
"Profile": {
"guestUsername": "Gast",
"editProfileButton": "Profil bearbeiten",
"signOutSuccessTitle": "Erfolgreich abgemeldet",
"signOutSuccessDescription": "Sie wurden von Ihrem Konto abgemeldet",
"signOutErrorTitle": "Abmeldefehler",
"signOutErrorDescription": "Abmeldung fehlgeschlagen",
"switchUserButton": "Benutzer wechseln",
"settingsLink": "Einstellungen",
"aboutButton": "Über",
"themeLabel": "Thema",
"editProfileModalTitle": "Profil bearbeiten"
},
"PasswordEntryForm": {
"notYouButton": "Sind Sie es nicht?",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"loginErrorToastTitle": "Fehler",
"loginFailedErrorToastDescription": "Anmeldung fehlgeschlagen",
"cancelButton": "Abbrechen",
"loginButton": "Anmelden"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} abgeschlossen"
},
"SettingsPage": {
"title": "Einstellungen",
"uiSettingsTitle": "UI-Einstellungen",
"numberFormattingLabel": "Zahlenformatierung",
"numberFormattingDescription": "Große Zahlen formatieren (z.B. 1K, 1M, 1B)",
"numberGroupingLabel": "Zahlengruppierung",
"numberGroupingDescription": "Tausendertrennzeichen verwenden (z.B. 1,000 vs 1000)",
"systemSettingsTitle": "Systemeinstellungen",
"timezoneLabel": "Zeitzone",
"timezoneDescription": "Wählen Sie Ihre Zeitzone für eine genaue Datumsverfolgung",
"weekStartDayLabel": "Wochenstarttag",
"weekStartDayDescription": "Wählen Sie Ihren bevorzugten ersten Tag der Woche",
"weekdays": {
"sunday": "Sonntag",
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag"
},
"autoBackupLabel": "Automatische Sicherung",
"autoBackupTooltip": "Wenn aktiviert, werden die Anwendungsdaten (Gewohnheiten, Münzen, Einstellungen usw.) täglich um 2 Uhr morgens Serverzeit automatisch gesichert. Backups werden als ZIP-Dateien im Verzeichnis `backups/` im Projektstamm gespeichert. Nur die letzten 7 Backups werden gespeichert; ältere werden automatisch gelöscht.",
"autoBackupDescription": "Daten automatisch täglich sichern",
"languageLabel": "Sprache",
"languageDescription": "Wählen Sie Ihre bevorzugte Anzeigesprache für die Anwendung.",
"languageChangedTitle": "Sprache geändert",
"languageChangedDescription": "Bitte aktualisieren Sie die Seite, um die Änderungen zu sehen",
"languageDisabledInDemoTooltip": "Das Ändern der Sprache ist in der Demoversion deaktiviert."
},
"Common": {
"authenticationRequiredTitle": "Authentifizierung erforderlich",
"authenticationRequiredDescription": "Bitte melden Sie sich an, um fortzufahren.",
"permissionDeniedTitle": "Berechtigung verweigert",
"permissionDeniedDescription": "Sie haben keine {action}-Berechtigung für {resource}.",
"undoButton": "Rückgängig",
"redoButton": "Wiederholen",
"errorTitle": "Fehler"
},
"useHabits": {
"alreadyCompletedTitle": "Schon abgeschlossen",
"alreadyCompletedDescription": "Sie haben diese Gewohnheit heute bereits abgeschlossen.",
"completedTitle": "Abgeschlossen!",
"earnedCoinsDescription": "Sie haben {coinReward} Münzen verdient.",
"progressTitle": "Fortschritt!",
"progressDescription": "Sie haben {count}/{target} mal heute abgeschlossen.",
"completionUndoneTitle": "Abschluss rückgängig gemacht",
"completionUndounDescription": "Sie haben {count}/{target} Abschlüsse heute.",
"noCompletionsToUndoTitle": "Keine Abschlüsse zum Rückgängigmachen",
"noCompletionsToUndoDescription": "Diese Gewohnheit wurde heute nicht abgeschlossen.",
"alreadyCompletedPastDateTitle": "Schon abgeschlossen",
"alreadyCompletedPastDateDescription": "Diese Gewohnheit wurde bereits am {dateKey} abgeschlossen.",
"earnedCoinsPastDateDescription": "Sie haben {coinReward} Münzen für {dateKey} verdient.",
"progressPastDateDescription": "Sie haben {count}/{target} mal am {dateKey} abgeschlossen."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Einlösungslimit erreicht",
"redemptionLimitReachedDescription": "Sie haben das maximale Einlösungslimit für \"{itemName}\" erreicht.",
"rewardRedeemedTitle": "🎉 Belohnung eingelöst!",
"rewardRedeemedDescription": "Sie haben \"{itemName}\" für {itemCoinCost} Münzen eingelöst.",
"notEnoughCoinsTitle": "Nicht genug Münzen",
"notEnoughCoinsDescription": "Sie benötigen {coinsNeeded} Münzen mehr, um diese Belohnung einzulösen."
},
"Warning": {
"areYouSure": "Sind Sie sicher?",
"cancel": "Abbrechen"
},
"useCoins": {
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
"invalidAmountTitle": "Ungültiger Betrag",
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
"successTitle": "Erfolg",
"transactionNotFoundDescription": "Transaktion nicht gefunden",
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
"transactionNotFoundDescription": "Transaktion nicht gefunden",
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten."
}
}

424
messages/en.json Normal file
View File

@@ -0,0 +1,424 @@
{
"Dashboard": {
"title": "Dashboard"
},
"HabitList": {
"myTasks": "My Tasks",
"myHabits": "My Habits",
"addTaskButton": "Add Task",
"addHabitButton": "Add Habit",
"searchTasksPlaceholder": "Search tasks...",
"searchHabitsPlaceholder": "Search habits...",
"sortByLabel": "Sort by:",
"sortByName": "Name",
"sortByCoinReward": "Coin Reward",
"sortByDueDate": "Due Date",
"sortByFrequency": "Frequency",
"toggleSortOrderAriaLabel": "Toggle sort order",
"noTasksFoundMessage": "No tasks found matching your search.",
"noHabitsFoundMessage": "No habits found matching your search.",
"emptyStateTasksTitle": "No tasks yet",
"emptyStateHabitsTitle": "No habits yet",
"emptyStateTasksDescription": "Create your first task to start tracking your progress",
"emptyStateHabitsDescription": "Create your first habit to start tracking your progress",
"archivedSectionTitle": "Archived",
"deleteTaskDialogTitle": "Delete Task",
"deleteHabitDialogTitle": "Delete Habit",
"deleteTaskDialogMessage": "Are you sure you want to delete this task? This action cannot be undone.",
"deleteHabitDialogMessage": "Are you sure you want to delete this habit? This action cannot be undone.",
"deleteButton": "Delete"
},
"DailyOverview": {
"addTaskButtonLabel": "Add Task",
"addHabitButtonLabel": "Add Habit",
"todaysOverviewTitle": "Today's Overview",
"dailyTasksTitle": "Daily Tasks",
"noTasksDueTodayMessage": "No tasks due today. Add some tasks to get started!",
"dailyHabitsTitle": "Daily Habits",
"noHabitsDueTodayMessage": "No habits due today. Add some habits to get started!",
"wishlistGoalsTitle": "Wishlist Goals",
"redeemableBadgeLabel": "{count}/{total} Redeemable",
"noWishlistItemsMessage": "No wishlist items yet. Add some goals to work towards!",
"readyToRedeemMessage": "Ready to redeem!",
"coinsToGoMessage": "{amount} coins to go",
"showLessButton": "Show less",
"showAllButton": "Show all",
"viewButton": "View",
"deleteTaskDialogTitle": "Delete Task",
"deleteHabitDialogTitle": "Delete Habit",
"confirmDeleteDialogMessage": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"deleteButton": "Delete",
"overdueTooltip": "Overdue"
},
"HabitContextMenuItems": {
"startPomodoro": "Start Pomodoro",
"moveToToday": "Move to Today",
"moveToTomorrow": "Move to Tomorrow",
"unpin": "Unpin",
"pin": "Pin",
"edit": "Edit",
"archive": "Archive",
"unarchive": "Unarchive",
"delete": "Delete"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Daily Completion Streak",
"tooltipHabitsLabel": "habits",
"tooltipTasksLabel": "tasks",
"tooltipCompletedLabel": "Completed"
},
"CoinBalance": {
"coinBalanceTitle": "Coin Balance"
},
"AddEditHabitModal": {
"editTaskTitle": "Edit Task",
"editHabitTitle": "Edit Habit",
"addNewTaskTitle": "Add New Task",
"addNewHabitTitle": "Add New Habit",
"nameLabel": "Name *",
"descriptionLabel": "Description",
"whenLabel": "When *",
"completeLabel": "Complete",
"timesSuffix": "times",
"rewardLabel": "Reward",
"coinsSuffix": "coins",
"shareLabel": "Share",
"saveChangesButton": "Save Changes",
"addTaskButton": "Add Task",
"addHabitButton": "Add Habit"
},
"ConfirmDialog": {
"confirmButton": "Confirm",
"cancelButton": "Cancel"
},
"AddEditWishlistItemModal": {
"editTitle": "Edit Reward",
"addTitle": "Add New Reward",
"nameLabel": "Name *",
"descriptionLabel": "Description",
"costLabel": "Cost",
"coinsSuffix": "coins",
"redeemableLabel": "Redeemable",
"timesSuffix": "times",
"errorNameRequired": "Name is required",
"errorCoinCostMin": "Coin cost must be at least 1",
"errorTargetCompletionsMin": "Target completions must be at least 1",
"errorInvalidUrl": "Please enter a valid URL",
"linkLabel": "Link",
"shareLabel": "Share",
"saveButton": "Save Changes",
"addButton": "Add Reward"
},
"Navigation": {
"dashboard": "Dashboard",
"tasks": "Tasks",
"habits": "Habits",
"calendar": "Calendar",
"wishlist": "Wishlist",
"coins": "Coins"
},
"TodayEarnedCoins": {
"todaySuffix": "today"
},
"WishlistItem": {
"usesLeftSingular": "use left",
"usesLeftPlural": "uses left",
"coinsSuffix": "coins",
"redeem": "Redeem",
"redeemedDone": "Done",
"redeemedExclamation": "Redeemed!",
"editButton": "Edit",
"archiveButton": "Archive",
"unarchiveButton": "Unarchive",
"deleteButton": "Delete"
},
"WishlistManager": {
"title": "My Wishlist",
"addRewardButton": "Add Reward",
"emptyStateTitle": "Your wishlist is empty",
"emptyStateDescription": "Add rewards that you'd like to earn with your coins",
"archivedSectionTitle": "Archived",
"popupBlockedTitle": "Popup Blocked",
"popupBlockedDescription": "Please allow popups to open the link",
"deleteDialogTitle": "Delete Reward",
"deleteDialogMessage": "Are you sure you want to delete this reward? This action cannot be undone.",
"deleteButton": "Delete"
},
"UserSelectModal": {
"addUserButton": "Add User",
"createNewUserTitle": "Create New User",
"selectUserTitle": "Select User",
"signInSuccessTitle": "Signed in successfully",
"signInSuccessDescription": "Welcome back, {username}!",
"errorInvalidPassword": "invalid password",
"deleteUserConfirmation": "Are you sure you want to delete user {username}? This action cannot be undone.",
"confirmDeleteButtonText": "Delete",
"deletingButtonText": "Deleting...",
"deleteUserSuccessTitle": "User Deleted",
"deleteUserSuccessDescription": "User {username} has been successfully deleted.",
"deleteUserErrorTitle": "Deletion Failed",
"genericError": "An unexpected error occurred.",
"networkError": "A network error occurred. Please try again.",
"deleteUserTooltip": "Delete user",
"editUserTooltip": "Edit user"
},
"CoinsManager": {
"title": "Coins Management",
"currentBalanceLabel": "Current Balance",
"coinsSuffix": "coins",
"addCoinsButton": "Add Coins",
"removeCoinsButton": "Remove Coins",
"statisticsTitle": "Statistics",
"totalEarnedLabel": "Total Earned",
"totalSpentLabel": "Total Spent",
"totalTransactionsLabel": "Total Transactions",
"todaysEarnedLabel": "Today's Earned",
"todaysSpentLabel": "Today's Spent",
"todaysTransactionsLabel": "Today's Transactions",
"transactionHistoryTitle": "Transaction History",
"showLabel": "Show:",
"entriesSuffix": "entries",
"showingEntries": "Showing {from} to {to} of {total} entries",
"noTransactionsTitle": "No transactions yet",
"noTransactionsDescription": "Your transaction history will appear here once you start earning or spending coins",
"pageLabel": "Page",
"ofLabel": "of",
"transactionTypeHabitCompletion": "Habit Completion",
"transactionTypeTaskCompletion": "Task Completion",
"transactionTypeHabitUndo": "Habit Undo",
"transactionTypeTaskUndo": "Task Undo",
"transactionTypeWishRedemption": "Wish Redemption",
"transactionTypeManualAdjustment": "Manual Adjustment",
"transactionTypeCoinReset": "Coin Reset",
"transactionTypeInitialBalance": "Initial Balance"
},
"NotificationBell": {
"errorUpdateTimestamp": "Failed to update notification read timestamp:"
},
"PomodoroTimer": {
"focusLabel1": "Stay Focused",
"focusLabel2": "You Got This",
"focusLabel3": "Keep Going",
"focusLabel4": "Crush It",
"focusLabel5": "Make It Happen",
"focusLabel6": "Stay Strong",
"focusLabel7": "Push Through",
"focusLabel8": "One Step at a Time",
"focusLabel9": "You Can Do It",
"focusLabel10": "Focus and Conquer",
"breakLabel1": "Take a Break",
"breakLabel2": "Relax and Recharge",
"breakLabel3": "Breathe Deeply",
"breakLabel4": "Stretch It Out",
"breakLabel5": "Refresh Yourself",
"breakLabel6": "You Deserve This",
"breakLabel7": "Recharge Your Energy",
"breakLabel8": "Step Away for a Bit",
"breakLabel9": "Clear Your Mind",
"breakLabel10": "Rest and Rejuvenate",
"focusType": "Focus",
"breakType": "Break",
"pauseButton": "Pause",
"startButton": "Start",
"resetButton": "Reset",
"skipButton": "Skip",
"wakeLockNotSupported": "Browser does not support wakelock",
"wakeLockInUse": "Wake lock already in use",
"wakeLockRequestError": "Error requesting wake lock:",
"wakeLockReleaseError": "Error releasing wake lock:"
},
"HabitCalendar": {
"title": "Habit Calendar",
"calendarCardTitle": "Calendar",
"selectDatePrompt": "Select a date",
"tasksSectionTitle": "Tasks",
"habitsSectionTitle": "Habits",
"errorCompletingPastHabit": "Error completing past habit:"
},
"NotificationDropdown": {
"notLoggedIn": "Not logged in.",
"userCompletedItem": "{username} completed {itemName}.",
"userRedeemedItem": "{username} redeemed {itemName}.",
"activityRelatedToItem": "Activity related to {itemName} by {username}.",
"defaultUsername": "Someone",
"defaultItemName": "a shared item",
"notificationsTitle": "Notifications",
"notificationsTooltip": "Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)",
"noNotificationsYet": "No notifications yet."
},
"AboutModal": {
"dialogArisLabel": "about",
"changelogButton": "Changelog",
"createdByPrefix": "Created with ❤️ by",
"starOnGitHubButton": "Star on GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permissions",
"adminAccessLabel": "Admin Access",
"adminAccessDescription": "Admins have full permission to all data for all users",
"resourceHabitTask": "Habit / Task",
"resourceWishlist": "Wishlist",
"resourceCoins": "Coins",
"permissionWrite": "Write",
"permissionInteract": "Interact"
},
"UserForm": {
"toastUserUpdatedTitle": "User updated",
"toastUserUpdatedDescription": "Successfully updated user {username}",
"toastUserCreatedTitle": "User created",
"toastUserCreatedDescription": "Successfully created user {username}",
"actionUpdate": "update",
"actionCreate": "create",
"errorFailedUserAction": "Failed to {action} user",
"errorTitle": "Error",
"errorFileSizeLimit": "File size must be less than 5MB",
"toastAvatarUploadedTitle": "Avatar uploaded",
"toastAvatarUploadedDescription": "Successfully uploaded avatar",
"errorFailedAvatarUpload": "Failed to upload avatar",
"changeAvatarButton": "Change Avatar",
"uploadAvatarButton": "Upload Avatar",
"usernameLabel": "Username",
"usernamePlaceholder": "Username",
"newPasswordLabel": "New Password",
"passwordLabel": "Password",
"passwordPlaceholderEdit": "Leave blank to keep current",
"passwordPlaceholderCreate": "Enter password",
"demoPasswordDisabledMessage": "Password is automatically disabled in demo instance",
"disablePasswordLabel": "Disable password",
"cancelButton": "Cancel",
"saveChangesButton": "Save Changes",
"createUserButton": "Create User",
"deleteAccountButton": "Delete Account"
},
"ViewToggle": {
"habitsLabel": "Habits",
"tasksLabel": "Tasks"
},
"HabitItem": {
"overdue": "Overdue",
"whenLabel": "When: {frequency}",
"coinsPerCompletion": "{count} coins per completion",
"completedStatus": "Completed",
"completedStatusCount": "Completed ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Complete",
"completeButtonCount": "Complete ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Undo",
"editButton": "Edit"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Note too long",
"noteTooLongDescription": "Notes must be less than 200 characters",
"errorSavingNoteTitle": "Error saving note",
"errorDeletingNoteTitle": "Error deleting note",
"pleaseTryAgainDescription": "Please try again",
"addNotePlaceholder": "Add a note...",
"saveNoteTitle": "Save note",
"cancelButtonTitle": "Cancel",
"deleteNoteTitle": "Delete note",
"editNoteAriaLabel": "Edit note"
},
"Profile": {
"guestUsername": "Guest",
"editProfileButton": "Edit profile",
"signOutSuccessTitle": "Signed out successfully",
"signOutSuccessDescription": "You have been logged out of your account",
"signOutErrorTitle": "Sign Out Error",
"signOutErrorDescription": "Failed to sign out",
"switchUserButton": "Switch user",
"settingsLink": "Settings",
"aboutButton": "About",
"themeLabel": "Theme",
"editProfileModalTitle": "Edit Profile"
},
"PasswordEntryForm": {
"notYouButton": "Not you?",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"loginErrorToastTitle": "Error",
"loginFailedErrorToastDescription": "Login failed",
"cancelButton": "Cancel",
"loginButton": "Login"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} completed"
},
"SettingsPage": {
"title": "Settings",
"uiSettingsTitle": "UI Settings",
"numberFormattingLabel": "Number Formatting",
"numberFormattingDescription": "Format large numbers (e.g., 1K, 1M, 1B)",
"numberGroupingLabel": "Number Grouping",
"numberGroupingDescription": "Use thousand separators (e.g., 1,000 vs 1000)",
"systemSettingsTitle": "System Settings",
"timezoneLabel": "Timezone",
"timezoneDescription": "Select your timezone for accurate date tracking",
"weekStartDayLabel": "Week Start Day",
"weekStartDayDescription": "Select your preferred first day of the week",
"weekdays": {
"sunday": "Sunday",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday"
},
"autoBackupLabel": "Auto Backup",
"autoBackupTooltip": "When enabled, the application data (habits, coins, settings, etc.) will be automatically backed up daily around 2 AM server time. Backups are stored as ZIP files in the `backups/` directory at the project root. Only the last 7 backups are kept; older ones are automatically deleted.",
"autoBackupDescription": "Automatically back up data daily",
"languageLabel": "Language",
"languageDescription": "Choose your preferred display language for the application.",
"languageChangedTitle": "Language Changed",
"languageChangedDescription": "Please refresh the page to see the changes",
"languageDisabledInDemoTooltip": "Changing the language is disabled in the demo version."
},
"Common": {
"authenticationRequiredTitle": "Authentication Required",
"authenticationRequiredDescription": "Please sign in to continue.",
"permissionDeniedTitle": "Permission Denied",
"permissionDeniedDescription": "You don't have {action} permission for {resource}s.",
"undoButton": "Undo",
"redoButton": "Redo",
"errorTitle": "Error"
},
"useHabits": {
"alreadyCompletedTitle": "Already completed",
"alreadyCompletedDescription": "You've already completed this habit today.",
"completedTitle": "Completed!",
"earnedCoinsDescription": "You earned {coinReward} coins.",
"progressTitle": "Progress!",
"progressDescription": "You've completed {count}/{target} times today.",
"completionUndoneTitle": "Completion undone",
"completionUndoneDescription": "You have {count}/{target} completions today.",
"noCompletionsToUndoTitle": "No completions to undo",
"noCompletionsToUndoDescription": "This habit hasn't been completed today.",
"alreadyCompletedPastDateTitle": "Already completed",
"alreadyCompletedPastDateDescription": "This habit was already completed on {dateKey}.",
"earnedCoinsPastDateDescription": "You earned {coinReward} coins for {dateKey}.",
"progressPastDateDescription": "You've completed {count}/{target} times on {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Redemption limit reached",
"redemptionLimitReachedDescription": "You've reached the maximum redemptions for \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Reward Redeemed!",
"rewardRedeemedDescription": "You've redeemed \"{itemName}\" for {itemCoinCost} coins.",
"notEnoughCoinsTitle": "Not enough coins",
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
},
"useCoins": {
"addedCoinsDescription": "Added {amount} coins",
"invalidAmountTitle": "Invalid amount",
"invalidAmountDescription": "Please enter a valid positive number",
"successTitle": "Success",
"transactionNotFoundDescription": "Transaction not found",
"maxAmountExceededDescription": "The amount cannot exceed {max}.",
"transactionNotFoundDescription": "Transaction not found",
"maxAmountExceededDescription": "The amount cannot exceed {max}."
},
"Warning": {
"areYouSure": "Are you sure?",
"cancel": "Cancel"
}
}

435
messages/es.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "Tablero"
},
"HabitList": {
"myTasks": "Mis tareas",
"myHabits": "Mis hábitos",
"addTaskButton": "Añadir tarea",
"addHabitButton": "Añadir hábito",
"searchTasksPlaceholder": "Buscar tareas...",
"searchHabitsPlaceholder": "Buscar hábitos...",
"sortByLabel": "Ordenar por:",
"sortByName": "Nombre",
"sortByCoinReward": "Recompensa de monedas",
"sortByDueDate": "Fecha límite",
"sortByFrequency": "Frecuencia",
"toggleSortOrderAriaLabel": "Cambiar orden de clasificación",
"noTasksFoundMessage": "No se encontraron tareas que coincidan con tu búsqueda.",
"noHabitsFoundMessage": "No se encontraron hábitos que coincidan con tu búsqueda.",
"emptyStateTasksTitle": "Aún no hay tareas",
"emptyStateHabitsTitle": "Aún no hay hábitos",
"emptyStateTasksDescription": "Crea tu primera tarea para empezar a seguir tu progreso",
"emptyStateHabitsDescription": "Crea tu primer hábito para empezar a seguir tu progreso",
"archivedSectionTitle": "Archivado",
"deleteTaskDialogTitle": "Eliminar tarea",
"deleteHabitDialogTitle": "Eliminar hábito",
"deleteTaskDialogMessage": "¿Estás seguro de que quieres eliminar esta tarea? Esta acción no se puede deshacer.",
"deleteHabitDialogMessage": "¿Estás seguro de que quieres eliminar este hábito? Esta acción no se puede deshacer.",
"deleteButton": "Eliminar"
},
"DailyOverview": {
"addTaskButtonLabel": "Añadir tarea",
"addHabitButtonLabel": "Añadir hábito",
"todaysOverviewTitle": "Resumen de hoy",
"dailyTasksTitle": "Tareas diarias",
"noTasksDueTodayMessage": "No hay tareas para hoy. ¡Añade algunas para empezar!",
"dailyHabitsTitle": "Hábitos diarios",
"noHabitsDueTodayMessage": "No hay hábitos para hoy. ¡Añade algunos para empezar!",
"wishlistGoalsTitle": "Objetivos de lista de deseos",
"redeemableBadgeLabel": "{count}/{total} canjeable",
"noWishlistItemsMessage": "Aún no hay items en la lista de deseos. ¡Añade algunas metas para trabajar!",
"readyToRedeemMessage": "¡Listo para canjear!",
"coinsToGoMessage": "Faltan {amount} monedas",
"showLessButton": "Mostrar menos",
"showAllButton": "Mostrar todo",
"viewButton": "Ver",
"deleteTaskDialogTitle": "Eliminar tarea",
"deleteHabitDialogTitle": "Eliminar hábito",
"confirmDeleteDialogMessage": "¿Estás seguro de que quieres eliminar \"{name}\"? Esta acción no se puede deshacer.",
"deleteButton": "Eliminar",
"overdueTooltip": "Vencido"
},
"HabitContextMenuItems": {
"startPomodoro": "Iniciar Pomodoro",
"moveToToday": "Mover a hoy",
"moveToTomorrow": "Mover a mañana",
"unpin": "Desanclar",
"pin": "Anclar",
"edit": "Editar",
"archive": "Archivar",
"unarchive": "Desarchivar",
"delete": "Eliminar"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Racha de finalización diaria",
"tooltipHabitsLabel": "hábitos",
"tooltipTasksLabel": "tareas",
"tooltipCompletedLabel": "Completado"
},
"CoinBalance": {
"coinBalanceTitle": "Saldo de monedas"
},
"AddEditHabitModal": {
"editTaskTitle": "Editar tarea",
"editHabitTitle": "Editar hábito",
"addNewTaskTitle": "Añadir nueva tarea",
"addNewHabitTitle": "Añadir nuevo hábito",
"nameLabel": "Nombre *",
"descriptionLabel": "Descripción",
"whenLabel": "Cuándo *",
"completeLabel": "Completar",
"timesSuffix": "veces",
"rewardLabel": "Recompensa",
"coinsSuffix": "monedas",
"shareLabel": "Compartir",
"saveChangesButton": "Guardar cambios",
"addTaskButton": "Añadir tarea",
"addHabitButton": "Añadir hábito"
},
"ConfirmDialog": {
"confirmButton": "Confirmar",
"cancelButton": "Cancelar"
},
"AddEditWishlistItemModal": {
"editTitle": "Editar recompensa",
"addTitle": "Añadir nueva recompensa",
"nameLabel": "Nombre *",
"descriptionLabel": "Descripción",
"costLabel": "Costo",
"coinsSuffix": "monedas",
"redeemableLabel": "Canjeable",
"timesSuffix": "veces",
"errorNameRequired": "El nombre es requerido",
"errorCoinCostMin": "El costo en monedas debe ser al menos 1",
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
"errorInvalidUrl": "Por favor ingresa una URL válida",
"linkLabel": "Enlace",
"shareLabel": "Compartir",
"saveButton": "Guardar cambios",
"addButton": "Añadir recompensa"
},
"Navigation": {
"dashboard": "Tablero",
"tasks": "Tareas",
"habits": "Hábitos",
"calendar": "Calendario",
"wishlist": "Lista de deseos",
"coins": "Monedas"
},
"TodayEarnedCoins": {
"todaySuffix": "hoy"
},
"WishlistItem": {
"usesLeftSingular": "uso restante",
"usesLeftPlural": "usos restantes",
"coinsSuffix": "monedas",
"redeem": "Canjear",
"redeemedDone": "Hecho",
"redeemedExclamation": "¡Canjeado!",
"editButton": "Editar",
"archiveButton": "Archivar",
"unarchiveButton": "Desarchivar",
"deleteButton": "Eliminar"
},
"WishlistManager": {
"title": "Mi lista de deseos",
"addRewardButton": "Añadir recompensa",
"emptyStateTitle": "Tu lista de deseos está vacía",
"emptyStateDescription": "Añade recompensas que te gustaría ganar con tus monedas",
"archivedSectionTitle": "Archivado",
"popupBlockedTitle": "Popup bloqueado",
"popupBlockedDescription": "Por favor permite los popups para abrir el enlace",
"deleteDialogTitle": "Eliminar recompensa",
"deleteDialogMessage": "¿Estás seguro de que quieres eliminar esta recompensa? Esta acción no se puede deshacer.",
"deleteButton": "Eliminar"
},
"UserSelectModal": {
"addUserButton": "Añadir usuario",
"createNewUserTitle": "Crear nuevo usuario",
"selectUserTitle": "Seleccionar usuario",
"signInSuccessTitle": "Inicio de sesión exitoso",
"signInSuccessDescription": "¡Bienvenido de nuevo, {username}!",
"errorInvalidPassword": "contraseña inválida",
"deleteUserConfirmation": "¿Estás seguro de que quieres eliminar al usuario {username}? Esta acción no se puede deshacer.",
"confirmDeleteButtonText": "Eliminar",
"deletingButtonText": "Eliminando...",
"deleteUserSuccessTitle": "Usuario eliminado",
"deleteUserSuccessDescription": "El usuario {username} ha sido eliminado correctamente.",
"deleteUserErrorTitle": "Error al eliminar",
"genericError": "Ocurrió un error inesperado.",
"networkError": "Ocurrió un error de red. Por favor, inténtalo de nuevo.",
"editUserTooltip": "Editar usuario",
"deleteUserTooltip": "Eliminar usuario"
},
"CoinsManager": {
"title": "Gestión de monedas",
"currentBalanceLabel": "Saldo actual",
"coinsSuffix": "monedas",
"addCoinsButton": "Añadir monedas",
"removeCoinsButton": "Quitar monedas",
"statisticsTitle": "Estadísticas",
"totalEarnedLabel": "Total ganado",
"totalSpentLabel": "Total gastado",
"totalTransactionsLabel": "Transacciones totales",
"todaysEarnedLabel": "Ganado hoy",
"todaysSpentLabel": "Gastado hoy",
"todaysTransactionsLabel": "Transacciones hoy",
"transactionHistoryTitle": "Historial de transacciones",
"showLabel": "Mostrar:",
"entriesSuffix": "entradas",
"showingEntries": "Mostrando {from} a {to} de {total} entradas",
"noTransactionsTitle": "Aún no hay transacciones",
"noTransactionsDescription": "Tu historial de transacciones aparecerá aquí una vez que empieces a ganar o gastar monedas",
"pageLabel": "Página",
"ofLabel": "de",
"transactionTypeHabitCompletion": "Finalización de hábito",
"transactionTypeTaskCompletion": "Finalización de tarea",
"transactionTypeHabitUndo": "Deshacer hábito",
"transactionTypeTaskUndo": "Deshacer tarea",
"transactionTypeWishRedemption": "Canje de deseo",
"transactionTypeManualAdjustment": "Ajuste manual",
"transactionTypeCoinReset": "Reinicio de monedas",
"transactionTypeInitialBalance": "Saldo inicial"
},
"NotificationBell": {
"errorUpdateTimestamp": "Error al actualizar la marca de tiempo de notificación leída:"
},
"PomodoroTimer": {
"focusLabel1": "Mantente enfocado",
"focusLabel2": "Tú puedes",
"focusLabel3": "Sigue adelante",
"focusLabel4": "Hazlo",
"focusLabel5": "Haz que suceda",
"focusLabel6": "Mantente fuerte",
"focusLabel7": "Esfuérzate",
"focusLabel8": "Un paso a la vez",
"focusLabel9": "Tú puedes hacerlo",
"focusLabel10": "Enfócate y conquista",
"breakLabel1": "Toma un descanso",
"breakLabel2": "Relájate y recarga",
"breakLabel3": "Respira profundamente",
"breakLabel4": "Estírate",
"breakLabel5": "Refréscate",
"breakLabel6": "Te lo mereces",
"breakLabel7": "Recarga tu energía",
"breakLabel8": "Aléjate un momento",
"breakLabel9": "Despeja tu mente",
"breakLabel10": "Descansa y recupérate",
"focusType": "Enfoque",
"breakType": "Descanso",
"pauseButton": "Pausar",
"startButton": "Iniciar",
"resetButton": "Reiniciar",
"skipButton": "Saltar",
"wakeLockNotSupported": "El navegador no soporta wake lock",
"wakeLockInUse": "Wake lock ya está en uso",
"wakeLockRequestError": "Error al solicitar wake lock:",
"wakeLockReleaseError": "Error al liberar wake lock:"
},
"HabitCalendar": {
"title": "Calendario de hábitos",
"calendarCardTitle": "Calendario",
"selectDatePrompt": "Selecciona una fecha",
"tasksSectionTitle": "Tareas",
"habitsSectionTitle": "Hábitos",
"errorCompletingPastHabit": "Error al completar hábito pasado:"
},
"NotificationDropdown": {
"notLoggedIn": "No has iniciado sesión.",
"userCompletedItem": "{username} completó {itemName}.",
"userRedeemedItem": "{username} canjeó {itemName}.",
"activityRelatedToItem": "Actividad relacionada con {itemName} por {username}.",
"defaultUsername": "Alguien",
"defaultItemName": "un item compartido",
"notificationsTitle": "Notificaciones",
"notificationsTooltip": "Muestra finalizaciones o canjes de otros usuarios para hábitos o lista de deseos que compartiste con ellos (debes ser admin)",
"noNotificationsYet": "Aún no hay notificaciones."
},
"AboutModal": {
"dialogArisLabel": "acerca de",
"changelogButton": "Registro de cambios",
"createdByPrefix": "Creado con ❤️ por",
"starOnGitHubButton": "Dar estrella en GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permisos",
"adminAccessLabel": "Acceso de administrador",
"adminAccessDescription": "Los administradores tienen permiso completo sobre todos los datos de todos los usuarios",
"resourceHabitTask": "Hábito / Tarea",
"resourceWishlist": "Lista de deseos",
"resourceCoins": "Monedas",
"permissionWrite": "Escritura",
"permissionInteract": "Interactuar"
},
"UserForm": {
"toastUserUpdatedTitle": "Usuario actualizado",
"toastUserUpdatedDescription": "Usuario {username} actualizado con éxito",
"toastUserCreatedTitle": "Usuario creado",
"toastUserCreatedDescription": "Usuario {username} creado con éxito",
"actionUpdate": "actualizar",
"actionCreate": "crear",
"errorFailedUserAction": "Error al {action} usuario",
"toastDemoDeleteDisabled": "La eliminación está deshabilitada en la instancia demo",
"toastCannotDeleteSelf": "No puedes eliminar tu propia cuenta",
"confirmDeleteUser": "¿Estás seguro de que deseas eliminar al usuario {username}?",
"toastUserDeletedTitle": "Usuario eliminado",
"toastUserDeletedDescription": "El usuario {username} ha sido eliminado correctamente",
"toastDeleteUserFailed": "Error al eliminar el usuario: {error}",
"errorTitle": "Error",
"errorFileSizeLimit": "El tamaño del archivo debe ser menor a 5MB",
"toastAvatarUploadedTitle": "Avatar subido",
"toastAvatarUploadedDescription": "Avatar subido con éxito",
"errorFailedAvatarUpload": "Error al subir avatar",
"changeAvatarButton": "Cambiar avatar",
"uploadAvatarButton": "Subir avatar",
"usernameLabel": "Nombre de usuario",
"usernamePlaceholder": "Nombre de usuario",
"newPasswordLabel": "Nueva contraseña",
"passwordLabel": "Contraseña",
"passwordPlaceholderEdit": "Dejar en blanco para mantener la actual",
"passwordPlaceholderCreate": "Ingresar contraseña",
"demoPasswordDisabledMessage": "La contraseña está automáticamente desactivada en la instancia demo",
"disablePasswordLabel": "Desactivar contraseña",
"cancelButton": "Cancelar",
"saveChangesButton": "Guardar cambios",
"createUserButton": "Crear usuario",
"deleteAccountButton": "Eliminar cuenta",
"deletingButtonText": "Eliminando...",
"areYouSure": "¿Estás seguro?",
"deleteUserConfirmation": "¿Estás seguro de que deseas eliminar al usuario {username}?",
"cancel": "Cancelar",
"confirmDeleteButtonText": "Eliminar"
},
"ViewToggle": {
"habitsLabel": "Hábitos",
"tasksLabel": "Tareas"
},
"HabitItem": {
"overdue": "Vencido",
"whenLabel": "Cuándo: {frequency}",
"coinsPerCompletion": "{count} monedas por finalización",
"completedStatus": "Completado",
"completedStatusCount": "Completado ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Completar",
"completeButtonCount": "Completar ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Deshacer",
"editButton": "Editar"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Nota demasiado larga",
"noteTooLongDescription": "Las notas deben tener menos de 200 caracteres",
"errorSavingNoteTitle": "Error al guardar nota",
"errorDeletingNoteTitle": "Error al eliminar nota",
"pleaseTryAgainDescription": "Por favor inténtalo de nuevo",
"addNotePlaceholder": "Añadir nota...",
"saveNoteTitle": "Guardar nota",
"cancelButtonTitle": "Cancelar",
"deleteNoteTitle": "Eliminar nota",
"editNoteAriaLabel": "Editar nota"
},
"Profile": {
"guestUsername": "Invitado",
"editProfileButton": "Editar perfil",
"signOutSuccessTitle": "Cierre de sesión exitoso",
"signOutSuccessDescription": "Has cerrado sesión en tu cuenta",
"signOutErrorTitle": "Error al cerrar sesión",
"signOutErrorDescription": "Error al cerrar sesión",
"switchUserButton": "Cambiar usuario",
"settingsLink": "Configuración",
"aboutButton": "Acerca de",
"themeLabel": "Tema",
"editProfileModalTitle": "Editar perfil"
},
"PasswordEntryForm": {
"notYouButton": "¿No eres tú?",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresar contraseña",
"loginErrorToastTitle": "Error",
"loginFailedErrorToastDescription": "Error al iniciar sesión",
"cancelButton": "Cancelar",
"loginButton": "Iniciar sesión"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} completado"
},
"SettingsPage": {
"title": "Configuración",
"uiSettingsTitle": "Configuración de interfaz",
"numberFormattingLabel": "Formato numérico",
"numberFormattingDescription": "Formatear números grandes (ej: 1K, 1M, 1B)",
"numberGroupingLabel": "Agrupación numérica",
"numberGroupingDescription": "Usar separadores de miles (ej: 1,000 vs 1000)",
"systemSettingsTitle": "Configuración del sistema",
"timezoneLabel": "Zona horaria",
"timezoneDescription": "Selecciona tu zona horaria para un seguimiento preciso de fechas",
"weekStartDayLabel": "Día de inicio de semana",
"weekStartDayDescription": "Selecciona tu día preferido para iniciar la semana",
"weekdays": {
"sunday": "Domingo",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado"
},
"autoBackupLabel": "Copia de seguridad automática",
"autoBackupTooltip": "Cuando está habilitado, los datos de la aplicación (hábitos, monedas, configuraciones, etc.) se respaldan automáticamente diariamente alrededor de las 2 AM hora del servidor. Las copias de seguridad se almacenan como archivos ZIP en el directorio `backups/` en la raíz del proyecto. Solo se conservan las últimas 7 copias de seguridad; las más antiguas se eliminan automáticamente.",
"autoBackupDescription": "Realizar copia de seguridad automática diaria",
"languageLabel": "Idioma",
"languageDescription": "Elige tu idioma preferido para mostrar en la aplicación.",
"languageChangedTitle": "Idioma cambiado",
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
},
"Common": {
"authenticationRequiredTitle": "Autenticación requerida",
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",
"permissionDeniedTitle": "Permiso denegado",
"permissionDeniedDescription": "No tienes permiso de {action} para {resource}.",
"undoButton": "Deshacer",
"redoButton": "Rehacer",
"errorTitle": "Error"
},
"useHabits": {
"alreadyCompletedTitle": "Ya completado",
"alreadyCompletedDescription": "Ya has completado este hábito hoy.",
"completedTitle": "¡Completado!",
"earnedCoinsDescription": "Ganaste {coinReward} monedas.",
"progressTitle": "¡Progreso!",
"progressDescription": "Has completado {count}/{target} veces hoy.",
"completionUndoneTitle": "Finalización deshecha",
"completionUndoneDescription": "Tienes {count}/{target} finalizaciones hoy.",
"noCompletionsToUndoTitle": "No hay finalizaciones para deshacer",
"noCompletionsToUndoDescription": "Este hábito no ha sido completado hoy.",
"alreadyCompletedPastDateTitle": "Ya completado",
"alreadyCompletedPastDateDescription": "Este hábito ya fue completado el {dateKey}.",
"earnedCoinsPastDateDescription": "Ganaste {coinReward} monedas por {dateKey}.",
"progressPastDateDescription": "Has completado {count}/{target} veces el {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Límite de canjes alcanzado",
"redemptionLimitReachedDescription": "Has alcanzado el máximo de canjes para \"{itemName}\".",
"rewardRedeemedTitle": "🎉 ¡Recompensa canjeada!",
"rewardRedeemedDescription": "Has canjeado \"{itemName}\" por {itemCoinCost} monedas.",
"notEnoughCoinsTitle": "No hay suficientes monedas",
"notEnoughCoinsDescription": "Necesitas {coinsNeeded} monedas más para canjear esta recompensa."
},
"Warning": {
"areYouSure": "¿Estás seguro?",
"cancel": "Cancelar"
},
"useCoins": {
"addedCoinsDescription": "Se añadieron {amount} monedas",
"invalidAmountTitle": "Cantidad inválida",
"invalidAmountDescription": "Por favor ingresa un número positivo válido",
"successTitle": "Éxito",
"transactionNotFoundDescription": "Transacción no encontrada",
"maxAmountExceededDescription": "La cantidad no puede exceder {max}.",
"transactionNotFoundDescription": "Transacción no encontrada",
"maxAmountExceededDescription": "La cantidad no puede exceder {max}."
}
}

435
messages/fr.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "Tableau de bord"
},
"HabitList": {
"myTasks": "Mes tâches",
"myHabits": "Mes habitudes",
"addTaskButton": "Ajouter une tâche",
"addHabitButton": "Ajouter une habitude",
"searchTasksPlaceholder": "Rechercher des tâches...",
"searchHabitsPlaceholder": "Rechercher des habitudes...",
"sortByLabel": "Trier par :",
"sortByName": "Nom",
"sortByCoinReward": "Récompense en pièces",
"sortByDueDate": "Date d'échéance",
"sortByFrequency": "Fréquence",
"toggleSortOrderAriaLabel": "Changer l'ordre de tri",
"noTasksFoundMessage": "Aucune tâche ne correspond à votre recherche.",
"noHabitsFoundMessage": "Aucune habitude ne correspond à votre recherche.",
"emptyStateTasksTitle": "Aucune tâche pour l'instant",
"emptyStateHabitsTitle": "Aucune habitude pour l'instant",
"emptyStateTasksDescription": "Créez votre première tâche pour commencer à suivre vos progrès",
"emptyStateHabitsDescription": "Créez votre première habitude pour commencer à suivre vos progrès",
"archivedSectionTitle": "Archivé",
"deleteTaskDialogTitle": "Supprimer la tâche",
"deleteHabitDialogTitle": "Supprimer l'habitude",
"deleteTaskDialogMessage": "Êtes-vous sûr de vouloir supprimer cette tâche ? Cette action est irréversible.",
"deleteHabitDialogMessage": "Êtes-vous sûr de vouloir supprimer cette habitude ? Cette action est irréversible.",
"deleteButton": "Supprimer"
},
"DailyOverview": {
"addTaskButtonLabel": "Ajouter une tâche",
"addHabitButtonLabel": "Ajouter une habitude",
"todaysOverviewTitle": "Aperçu du jour",
"dailyTasksTitle": "Tâches quotidiennes",
"noTasksDueTodayMessage": "Aucune tâche pour aujourd'hui. Ajoutez des tâches pour commencer !",
"dailyHabitsTitle": "Habitudes quotidiennes",
"noHishabitsDueTodayMessage": "Aucune habitude pour aujourd'hui. Ajoutez des habitudes pour commencer !",
"wishlistGoalsTitle": "Objectifs de la liste de souhaits",
"redeemableBadgeLabel": "{count}/{total} échangeable",
"noWishlistItemsMessage": "Aucun élément dans la liste de souhaits. Ajoutez des objectifs à atteindre !",
"readyToRedeemMessage": "Prêt à échanger !",
"coinsToGoMessage": "Il manque {amount} pièces",
"showLessButton": "Afficher moins",
"showAllButton": "Afficher tout",
"viewButton": "Voir",
"deleteTaskDialogTitle": "Supprimer la tâche",
"deleteHabitDialogTitle": "Supprimer l'habitude",
"confirmDeleteDialogMessage": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
"deleteButton": "Supprimer",
"overdueTooltip": "En retard"
},
"HabitContextMenuItems": {
"startPomodoro": "Démarrer Pomodoro",
"moveToToday": "Déplacer à aujourd'hui",
"moveToTomorrow": "Déplacer à demain",
"unpin": "Détacher",
"pin": "Attacher",
"edit": "Modifier",
"archive": "Archiver",
"unarchive": "Désarchiver",
"delete": "Supprimer"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Série de complétions quotidiennes",
"tooltipHabitsLabel": "habitudes",
"tooltipTasksLabel": "tâches",
"tooltipCompletedLabel": "Complété"
},
"CoinBalance": {
"coinBalanceTitle": "Solde de pièces"
},
"AddEditHabitModal": {
"editTaskTitle": "Modifier une tâche",
"editHabitTitle": "Modifier une habitude",
"addNewTaskTitle": "Ajouter une nouvelle tâche",
"AddNewHabitTitle": "Ajouter une nouvelle habitude",
"nameLabel": "Nom *",
"descriptionLabel": "Description",
"whenLabel": "Quand *",
"completeLabel": "Compléter",
"timesSuffix": "fois",
"rewardLabel": "Récompense",
"coinsSuffix": "pièces",
"shareLabel": "Partager",
"saveChangesButton": "Sauvegarder les modifications",
"addTaskButton": "Ajouter une tâche",
"addHabitButton": "Ajouter une habitude"
},
"ConfirmDialog": {
"confirmButton": "Confirmer",
"cancelButton": "Annuler"
},
"AddEditWishlistItemModal": {
"editTitle": "Modifier la récompense",
"addTitle": "Ajouter une nouvelle récompense",
"nameLabel": "Nom *",
"descriptionLabel": "Description",
"costLabel": "Coût",
"coinsSuffix": "pièces",
"redeemableLabel": "Échangeable",
"timesSuffix": "fois",
"errorNameRequired": "Le nom est requis",
"errorCoinCostMin": "Le coût en pièces doit être d'au moins 1",
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
"errorInvalidUrl": "Veuillez entrer une URL valide",
"linkLabel": "Lien",
"shareLabel": "Partager",
"saveButton": "Sauvegarder les modifications",
"addButton": "Ajouter une récompense"
},
"Navigation": {
"dashboard": "Tableau de bord",
"tasks": "Tâches",
"habits": "Habitudes",
"calendar": "Calendrier",
"wishlist": "Liste de souhaits",
"coins": "Pièces"
},
"TodayEarnedCoins": {
"todaySuffix": "aujourd'hui"
},
"WishlistItem": {
"usesLeftSingular": "utilisation restante",
"usesLeftPlural": "utilisations restantes",
"coinsSuffix": "pièces",
"redeem": "Échanger",
"redeemedDone": "Fait",
"redeemedExclamation": "Échangé !",
"editButton": "Modifier",
"archiveButton": "Archiver",
"unarchiveButton": "Désarchiver",
"deleteButton": "Supprimer"
},
"WishlistManager": {
"title": "Ma liste de souhaits",
"addRewardButton": "Ajouter une récompense",
"emptyStateTitle": "Votre liste de souhaits est vide",
"emptyStateDescription": "Ajoutez des récompenses que vous aimeriez gagner avec vos pièces",
"archivedSectionTitle": "Archivé",
"popupBlockedTitle": "Popup bloqué",
"popupBlockedDescription": "Veuillez autoriser les popups pour ouvrir le lien",
"deleteDialogTitle": "Supprimer la récompense",
"deleteDialogMessage": "Êtes-vous sûr de vouloir supprimer cette récompense ? Cette action est irréversible.",
"deleteButton": "Supprimer"
},
"UserSelectModal": {
"addUserButton": "Ajouter un utilisateur",
"createNewUserTitle": "Créer un nouvel utilisateur",
"selectUserTitle": "Sélectionner un utilisateur",
"signInSuccessTitle": "Connecté avec succès",
"signInSuccessDescription": "Bienvenue, {username} !",
"errorInvalidPassword": "mot de passe invalide",
"deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username} ? Cette action est irréversible.",
"confirmDeleteButtonText": "Supprimer",
"deletingButtonText": "Suppression en cours...",
"deleteUserSuccessTitle": "Utilisateur supprimé",
"deleteUserSuccessDescription": "L'utilisateur {username} a été supprimé avec succès.",
"deleteUserErrorTitle": "Échec de la suppression",
"genericError": "Une erreur inattendue s'est produite.",
"networkError": "Une erreur réseau s'est produite. Veuillez réessayer.",
"editUserTooltip": "Modifier l'utilisateur",
"deleteUserTooltip": "Supprimer l'utilisateur"
},
"CoinsManager": {
"title": "Gestion des pièces",
"currentBalanceLabel": "Solde actuel",
"coinsSuffix": "pièces",
"addCoinsButton": "Ajouter des pièces",
"removeCoinsButton": "Retirer des pièces",
"statisticsTitle": "Statistiques",
"totalEarnedLabel": "Total gagné",
"totalSpentLabel": "Total dépensé",
"totalTransactionsLabel": "Total des transactions",
"todaysEarnedLabel": "Gagné aujourd'hui",
"todaysSpentLabel": "Dépensé aujourd'hui",
"todaysTransactionsLabel": "Transactions d'aujourd'hui",
"transactionHistoryTitle": "Historique des transactions",
"showLabel": "Afficher :",
"entriesSuffix": "entrées",
"showingEntries": "Affichage de {from} à {to} de {total} entrées",
"noTransactionsTitle": "Aucune transaction pour l'instant",
"noTransactionsDescription": "Votre historique de transactions apparaîtra ici une fois que vous commencerez à gagner ou dépenser des pièces",
"pageLabel": "Page",
"ofLabel": "sur",
"transactionTypeHabitCompletion": "Complétion d'habitude",
"transactionTypeTaskCompletion": "Complétion de tâche",
"transactionTypeHabitUndo": "Annulation d'habitude",
"transactionTypeTaskUndo": "Annulation de tâche",
"transactionTypeWishRedemption": "Échange de souhait",
"transactionTypeManualAdjustment": "Ajustement manuel",
"transactionTypeCoinReset": "Réinitialisation des pièces",
"transactionTypeInitialBalance": "Solde initial"
},
"NotificationBell": {
"errorUpdateTimestamp": "Échec de la mise à jour du timestamp de lecture de notification :"
},
"PomodoroTimer": {
"focusLabel1": "Reste concentré",
"focusLabel2": "Tu peux le faire",
"focusLabel3": "Continue",
"focusLabel4": "Tout donner",
"focusLabel5": "Fais-le arriver",
"focusLabel6": "Reste fort",
"focusLabel7": "Persiste",
"focusLabel8": "Un pas à la fois",
"focusLabel9": "Tu peux y arriver",
"focusLabel10": "Concentre-toi et conquiers",
"breakLabel1": "Prends une pause",
"breakLabel2": "Relaxe-toi et recharge",
"breakLabel3": "Respire profondément",
"breakLabel4": "Étire-toi",
"breakLabel5": "Rafraîchis-toi",
"breakLabel6": "Tu le mérites",
"breakLabel7": "Recharge ton énergie",
"breakLabel8": "Éloigne-toi un moment",
"breakLabel9": "Vide ton esprit",
"breakLabel10": "Repose-toi et récupère",
"focusType": "Concentration",
"breakType": "Pause",
"pauseButton": "Pause",
"startButton": "Démarrer",
"resetButton": "Réinitialiser",
"skipButton": "Passer",
"wakeLockNotSupported": "Le navigateur ne supporte pas le verrouillage de veille",
"wakeLockInUse": "Le verrouillage de veille est déjà actif",
"wakeLockRequestError": "Erreur lors de la demande de verrouillage de veille :",
"wakeLockReleaseError": "Erreur lors de la libération du verrouillage de veille :"
},
"HabitCalendar": {
"title": "Calendrier des habitudes",
"calendarCardTitle": "Calendrier",
"selectDatePrompt": "Sélectionner une date",
"tasksSectionTitle": "Tâches",
"habitsSectionTitle": "Habitudes",
"errorCompletingPastHabit": "Erreur lors de la complétion d'une habitude passée :"
},
"NotificationDropdown": {
"notLoggedIn": "Non connecté.",
"userCompletedItem": "{username} a complété {itemName}.",
"userRedeemedItem": "{username} a échangé {itemName}.",
"activityRelatedToItem": "Activité liée à {itemName} par {username}.",
"defaultUsername": "Quelqu'un",
"defaultItemName": "un élément partagé",
"notificationsTitle": "Notifications",
"notificationsTooltip": "Affiche les complétions ou les échanges par d'autres utilisateurs pour les habitudes ou la liste de souhaits que vous avez partagés avec eux (vous devez être admin)",
"noNotificationsYet": "Aucune notification pour l'instant."
},
"AboutModal": {
"dialogArisLabel": "à propos",
"changelogButton": "Journal des modifications",
"createdByPrefix": "Créé avec ❤️ par",
"starOnGitHubButton": "Étoile sur GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permissions",
"adminAccessLabel": "Accès administrateur",
"adminAccessDescription": "Les administrateurs ont tous les droits sur les données de tous les utilisateurs",
"resourceHabitTask": "Habitude / Tâche",
"resourceWishlist": "Liste de souhaits",
"resourceCoins": "Pièces",
"permissionWrite": "Écriture",
"permissionInteract": "Interaction"
},
"UserForm": {
"toastUserUpdatedTitle": "Utilisateur mis à jour",
"toastUserUpdatedDescription": "Utilisateur {username} mis à jour avec succès",
"toastUserCreatedTitle": "Utilisateur créé",
"toastUserCreatedDescription": "Utilisateur {username} créé avec succès",
"actionUpdate": "mise à jour",
"actionCreate": "création",
"errorFailedUserAction": "Échec de la {action} de l'utilisateur",
"toastDemoDeleteDisabled": "La suppression est désactivée dans la version de démonstration",
"toastCannotDeleteSelf": "Vous ne pouvez pas supprimer votre propre compte",
"confirmDeleteUser": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username}?",
"toastUserDeletedTitle": "Utilisateur supprimé",
"toastUserDeletedDescription": "L'utilisateur {username} a été supprimé avec succès",
"toastDeleteUserFailed": "Échec de la suppression de l'utilisateur : {error}",
"errorTitle": "Erreur",
"errorFileSizeLimit": "La taille du fichier doit être inférieure à 5MB",
"toastAvatarUploadedTitle": "Avatar téléchargé",
"toastAvatarUploadedDescription": "Avatar téléchargé avec succès",
"errorFailedAvatarUpload": "Échec du téléchargement de l'avatar",
"changeAvatarButton": "Changer l'avatar",
"uploadAvatarButton": "Télécharger l'avatar",
"usernameLabel": "Nom d'utilisateur",
"usernamePlaceholder": "Nom d'utilisateur",
"newPasswordLabel": "Nouveau mot de passe",
"passwordLabel": "Mot de passe",
"passwordPlaceholderEdit": "Laisser vide pour conserver l'actuel",
"passwordPlaceholderCreate": "Entrer le mot de passe",
"demoPasswordDisabledMessage": "Le mot de passe est automatiquement désactivé dans l'instance de démonstration",
"disablePasswordLabel": "Désactiver le mot de passe",
"cancelButton": "Annuler",
"saveChangesButton": "Sauvegarder les modifications",
"createUserButton": "Créer un utilisateur",
"deleteAccountButton": "Supprimer le compte",
"deletingButtonText": "Suppression en cours...",
"areYouSure": "Êtes-vous sûr ?",
"deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username} ?",
"cancel": "Annuler",
"confirmDeleteButtonText": "Supprimer"
},
"ViewToggle": {
"habitsLabel": "Habitudes",
"tasksLabel": "Tâches"
},
"HabitItem": {
"overdue": "En retard",
"whenLabel": "Quand : {frequency}",
"coinsPerCompletion": "{count} pièces par complétion",
"completedStatus": "Complété",
"completedStatusCount": "Complété ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Compléter",
"completeButtonCount": "Compléter ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Annuler",
"editButton": "Modifier"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Note trop longue",
"noteTooLongDescription": "Les notes doivent faire moins de 200 caractères",
"errorSavingNoteTitle": "Erreur lors de la sauvegarde de la note",
"errorDeletingNoteTitle": "Erreur lors de la suppression de la note",
"pleaseTryAgainDescription": "Veuillez réessayer",
"addNotePlaceholder": "Ajouter une note...",
"saveNoteTitle": "Sauvegarder la note",
"cancelButtonTitle": "Annuler",
"deleteNoteTitle": "Supprimer la note",
"editNoteAriaLabel": "Modifier la note"
},
"Profile": {
"guestUsername": "Invité",
"editProfileButton": "Modifier le profil",
"signOutSuccessTitle": "Déconnexion réussie",
"signOutSuccessDescription": "Vous avez été déconnecté de votre compte",
"signOutErrorTitle": "Erreur de déconnexion",
"signOutErrorDescription": "Échec de la déconnexion",
"switchUserButton": "Changer d'utilisateur",
"settingsLink": "Paramètres",
"aboutButton": "À propos",
"themeLabel": "Thème",
"editProfileModalTitle": "Modifier le profil"
},
"PasswordEntryForm": {
"notYouButton": "Ce n'est pas vous ?",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrer le mot de passe",
"loginErrorToastTitle": "Erreur",
"loginFailedErrorToastDescription": "Échec de la connexion",
"cancelButton": "Annuler",
"loginButton": "Se connecter"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} complété"
},
"SettingsPage": {
"title": "Paramètres",
"uiSettingsTitle": "Paramètres de l'interface",
"numberFormattingLabel": "Formatage des nombres",
"numberFormattingDescription": "Formater les grands nombres (ex: 1K, 1M, 1B)",
"numberGroupingLabel": "Regroupement des nombres",
"numberGroupingDescription": "Utiliser les séparateurs de milliers (ex: 1,000 vs 1000)",
"systemSettingsTitle": "Paramètres système",
"timezoneLabel": "Fuseau horaire",
"timezoneDescription": "Sélectionnez votre fuseau horaire pour un suivi précis des dates",
"weekStartDayLabel": "Jour de début de semaine",
"weekStartDayDescription": "Sélectionnez votre jour préféré pour commencer la semaine",
"weekdays": {
"sunday": "Dimanche",
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi"
},
"autoBackupLabel": "Sauvegarde automatique",
"autoBackupTooltip": "Lorsqu'il est activé, les données de l'application (habitudes, pièces, paramètres, etc.) sont automatiquement sauvegardées quotidiennement vers 2 heures du matin, heure du serveur. Les sauvegardes sont stockées sous forme de fichiers ZIP dans le répertoire `backups/` à la racine du projet. Seules les 7 dernières sauvegardes sont conservées ; les plus anciennes sont automatiquement supprimées.",
"autoBackupDescription": "Effectuer une sauvegarde automatique quotidienne",
"languageLabel": "Langue",
"languageDescription": "Choisissez votre langue d'affichage préférée pour l'application.",
"languageChangedTitle": "Langue modifiée",
"languageChangedDescription": "Veuillez actualiser la page pour voir les changements",
"languageDisabledInDemoTooltip": "Le changement de langue est désactivé dans la version de démonstration."
},
"Common": {
"authenticationRequiredTitle": "Authentification requise",
"authenticationRequiredDescription": "Veuillez vous connecter pour continuer.",
"permissionDeniedTitle": "Permission refusée",
"permissionDeniedDescription": "Vous n'avez pas la permission de {action} pour {resource}.",
"undoButton": "Annuler",
"redoButton": "Rétablir",
"errorTitle": "Erreur"
},
"useHabits": {
"alreadyCompletedTitle": "Déjà complété",
"alreadyCompletedDescription": "Vous avez déjà complété cette habitude aujourd'hui.",
"completedTitle": "Complété !",
"earnedCoinsDescription": "Vous avez gagné {coinReward} pièces.",
"progressTitle": "Progrès !",
"progressDescription": "Vous avez complété {count}/{target} fois aujourd'hui.",
"completionUndoneTitle": "Complétion annulée",
"completionUndoneDescription": "Vous avez {count}/{target} complétions aujourd'hui.",
"noCompletionsToUndoTitle": "Aucune complétion à annuler",
"noCompletionsToUndoDescription": "Cette habitude n'a pas été complétée aujourd'hui.",
"alreadyCompletedPastDateTitle": "Déjà complété",
"alreadyCompletedPastDateDescription": "Cette habitude a déjà été complétée le {dateKey}.",
"earnedCoinsPastDateDescription": "Vous avez gagné {coinReward} pièces pour {dateKey}.",
"progressPastDateDescription": "Vous avez complété {count}/{target} fois le {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Limite de rachat atteinte",
"redemptionLimitReachedDescription": "Vous avez atteint le nombre maximum de rachats pour \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Récompense échangée !",
"rewardRedeemedDescription": "Vous avez échangé \"{itemName}\" pour {itemCoinCost} pièces.",
"notEnoughCoinsTitle": "Pas assez de pièces",
"notEnoughCoinsDescription": "Il vous manque {coinsNeeded} pièces pour échanger cette récompense."
},
"Warning": {
"areYouSure": "Êtes-vous sûr ?",
"cancel": "Annuler"
},
"useCoins": {
"addedCoinsDescription": "{amount} pièces ajoutées",
"invalidAmountTitle": "Montant invalide",
"invalidAmountDescription": "Veuillez entrer un nombre positif valide",
"successTitle": "Succès",
"transactionNotFoundDescription": "Transaction non trouvée",
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
"transactionNotFoundDescription": "Transaction non trouvée",
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}."
}
}

435
messages/ja.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "ダッシュボード"
},
"HabitList": {
"myTasks": "マイタスク",
"myHabits": "マイ習慣",
"addTaskButton": "タスクを追加",
"addHabitButton": "習慣を追加",
"searchTasksPlaceholder": "タスクを検索...",
"searchHabitsPlaceholder": "習慣を検索...",
"sortByLabel": "並び替え:",
"sortByName": "名前",
"sortByCoinReward": "コイン報酬",
"sortByDueDate": "締め切り",
"sortByFrequency": "頻度",
"toggleSortOrderAriaLabel": "並び順を切り替え",
"noTasksFoundMessage": "検索条件に一致するタスクはありません。",
"noHabitsFoundMessage": "検索条件に一致する習慣はありません。",
"emptyStateTasksTitle": "タスクがありません",
"emptyStateHabitsTitle": "習慣がありません",
"emptyStateTasksDescription": "最初のタスクを作成して進捗を追跡しましょう",
"emptyStateHabitsDescription": "最初の習慣を作成して進捗を追跡しましょう",
"archivedSectionTitle": "アーカイブ",
"deleteTaskDialogTitle": "タスクを削除",
"deleteHabitDialogTitle": "習慣を削除",
"deleteTaskDialogMessage": "このタスクを削除してもよろしいですか?この操作は元に戻せません。",
"deleteHabitDialogMessage": "この習慣を削除してもよろしいですか?この操作は元に戻せません。",
"deleteButton": "削除"
},
"DailyOverview": {
"addTaskButtonLabel": "タスクを追加",
"addHabitButtonLabel": "習慣を追加",
"todaysOverviewTitle": "今日の概要",
"dailyTasksTitle": "今日のタスク",
"noTasksDueTodayMessage": "今日のタスクはありません。タスクを追加して始めましょう!",
"dailyHabitsTitle": "今日の習慣",
"noHabitsDueTodayMessage": "今日の習慣はありません。習慣を追加して始めましょう!",
"wishlistGoalsTitle": "ウィッシュリスト目標",
"redeemableBadgeLabel": "{count}/{total} 使用可能",
"noWishlistItemsMessage": "ウィッシュリストにアイテムがありません。達成したい目標を追加しましょう!",
"readyToRedeemMessage": "使用可能です!",
"coinsToGoMessage": "あと{amount}コイン",
"showLessButton": "一部を表示",
"showAllButton": "すべて表示",
"viewButton": "表示",
"deleteTaskDialogTitle": "タスクを削除",
"deleteHabitDialogTitle": "習慣を削除",
"confirmDeleteDialogMessage": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
"deleteButton": "削除",
"overdueTooltip": "期限超過"
},
"HabitContextMenuItems": {
"startPomodoro": "ポモドーロを開始",
"moveToToday": "今日に移動",
"moveToTomorrow": "明日に移動",
"unpin": "ピン留めを解除",
"pin": "ピン留めする",
"edit": "編集",
"archive": "アーカイブ",
"unarchive": "アーカイブ解除",
"delete": "削除"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "毎日達成ストリーク",
"tooltipHabitsLabel": "習慣",
"tooltipTasksLabel": "タスク",
"tooltipCompletedLabel": "完了"
},
"CoinBalance": {
"coinBalanceTitle": "コイン残高"
},
"AddEditHabitModal": {
"editTaskTitle": "タスクを編集",
"editHabitTitle": "習慣を編集",
"addNewTaskTitle": "新しいタスクを追加",
"addNewHabitTitle": "新しい習慣を追加",
"nameLabel": "名前 *",
"descriptionLabel": "説明",
"whenLabel": "いつ *",
"completeLabel": "完了",
"timesSuffix": "回",
"rewardLabel": "報酬",
"coinsSuffix": "コイン",
"shareLabel": "共有",
"saveChangesButton": "変更を保存",
"addTaskButton": "タスクを追加",
"addHabitButton": "習慣を追加"
},
"ConfirmDialog": {
"confirmButton": "確認",
"cancelButton": "キャンセル"
},
"AddEditWishlistItemModal": {
"editTitle": "報酬を編集",
"addTitle": "新しい報酬を追加",
"nameLabel": "名前 *",
"descriptionLabel": "説明",
"costLabel": "コスト",
"coinsSuffix": "コイン",
"redeemableLabel": "使用可能",
"timesSuffix": "回",
"errorNameRequired": "名前は必須です",
"errorCoinCostMin": "コインコストは1以上である必要があります",
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
"errorInvalidUrl": "有効なURLを入力してください",
"linkLabel": "リンク",
"shareLabel": "共有",
"saveButton": "変更を保存",
"addButton": "報酬を追加"
},
"Navigation": {
"dashboard": "ダッシュボード",
"tasks": "タスク",
"habits": "習慣",
"calendar": "カレンダー",
"wishlist": "ウィッシュリスト",
"coins": "コイン"
},
"TodayEarnedCoins": {
"todaySuffix": "今日"
},
"WishlistItem": {
"usesLeftSingular": "使用可能回数: 1回",
"usesLeftPlural": "使用可能回数: {count}回",
"coinsSuffix": "コイン",
"redeem": "使用する",
"redeemedDone": "完了",
"redeemedExclamation": "使用しました!",
"editButton": "編集",
"archiveButton": "アーカイブ",
"unarchiveButton": "アーカイブ解除",
"deleteButton": "削除"
},
"WishlistManager": {
"title": "マイウィッシュリスト",
"addRewardButton": "報酬を追加",
"emptyStateTitle": "ウィッシュリストが空です",
"emptyStateDescription": "コインで獲得したい報酬を追加しましょう",
"archivedSectionTitle": "アーカイブ",
"popupBlockedTitle": "ポップアップがブロックされました",
"popupBlockedDescription": "リンクを開くためにポップアップを許可してください",
"deleteDialogTitle": "報酬を削除",
"deleteDialogMessage": "この報酬を削除してもよろしいですか?この操作は元に戻せません。",
"deleteButton": "削除"
},
"UserSelectModal": {
"addUserButton": "ユーザーを追加",
"createNewUserTitle": "新しいユーザーを作成",
"selectUserTitle": "ユーザーを選択",
"signInSuccessTitle": "サインインに成功しました",
"signInSuccessDescription": "おかえりなさい、{username}さん!",
"errorInvalidPassword": "パスワードが無効です",
"deleteUserConfirmation": "ユーザー {username} を削除してもよろしいですか?この操作は元に戻せません。",
"confirmDeleteButtonText": "削除",
"deletingButtonText": "削除中...",
"deleteUserSuccessTitle": "ユーザーが削除されました",
"deleteUserSuccessDescription": "ユーザー {username} は正常に削除されました。",
"deleteUserErrorTitle": "削除に失敗しました",
"genericError": "予期しないエラーが発生しました。",
"networkError": "ネットワークエラーが発生しました。もう一度お試しください。",
"editUserTooltip": "ユーザーを編集",
"deleteUserTooltip": "ユーザーを削除"
},
"CoinsManager": {
"title": "コイン管理",
"currentBalanceLabel": "現在の残高",
"coinsSuffix": "コイン",
"addCoinsButton": "コインを追加",
"removeCoinsButton": "コインを削除",
"statisticsTitle": "統計",
"totalEarnedLabel": "総獲得額",
"totalSpentLabel": "総支出額",
"totalTransactionsLabel": "総取引数",
"todaysEarnedLabel": "今日の獲得額",
"todaysSpentLabel": "今日の支出額",
"todaysTransactionsLabel": "今日の取引数",
"transactionHistoryTitle": "取引履歴",
"showLabel": "表示:",
"entriesSuffix": "件",
"showingEntries": "{from} から {to} 件(全 {total} 件)",
"noTransactionsTitle": "取引履歴がありません",
"noTransactionsDescription": "コインを獲得または使用すると、ここに取引履歴が表示されます",
"pageLabel": "ページ",
"ofLabel": "/",
"transactionTypeHabitCompletion": "習慣達成",
"transactionTypeTaskCompletion": "タスク達成",
"transactionTypeHabitUndo": "習慣取り消し",
"transactionTypeTaskUndo": "タスク取り消し",
"transactionTypeWishRedemption": "報酬使用",
"transactionTypeManualAdjustment": "手動調整",
"transactionTypeCoinReset": "コインリセット",
"transactionTypeInitialBalance": "初期残高"
},
"NotificationBell": {
"errorUpdateTimestamp": "通知の既読タイムスタンプの更新に失敗しました:"
},
"PomodoroTimer": {
"focusLabel1": "集中しよう",
"focusLabel2": "君ならできる",
"focusLabel3": "頑張れ",
"focusLabel4": "やり遂げろ",
"focusLabel5": "実現させよう",
"focusLabel6": "強く在れ",
"focusLabel7": "突破しよう",
"focusLabel8": "1歩ずつ進もう",
"focusLabel9": "君にはできる",
"focusLabel10": "集中して征服しよう",
"breakLabel1": "休憩しよう",
"breakLabel2": "リラックスして充電しよう",
"breakLabel3": "深呼吸しよう",
"breakLabel4": "ストレッチしよう",
"breakLabel5": "リフレッシュしよう",
"breakLabel6": "君ならできる",
"breakLabel7": "エネルギーを充電しよう",
"breakLabel8": "少し離れよう",
"breakLabel9": "心をクリアにしよう",
"breakLabel10": "休んで回復しよう",
"focusType": "集中",
"breakType": "休憩",
"pauseButton": "一時停止",
"startButton": "開始",
"resetButton": "リセット",
"skipButton": "スキップ",
"wakeLockNotSupported": "ブラウザがWake Lockをサポートしていません",
"wakeLockInUse": "Wake Lockは既に使用中です",
"wakeLockRequestError": "Wake Lockのリクエストエラー:",
"wakeLockReleaseError": "Wake Lockの解放エラー:"
},
"HabitCalendar": {
"title": "習慣カレンダー",
"calendarCardTitle": "カレンダー",
"selectDatePrompt": "日付を選択",
"tasksSectionTitle": "タスク",
"habitsSectionTitle": "習慣",
"errorCompletingPastHabit": "過去の習慣を完了する際にエラーが発生しました:"
},
"NotificationDropdown": {
"notLoggedIn": "ログインしていません。",
"userCompletedItem": "{username}さんが{itemName}を完了しました。",
"userRedeemedItem": "{username}さんが{itemName}を使用しました。",
"activityRelatedToItem": "{username}さんによる{itemName}に関連するアクティビティ。",
"defaultUsername": "誰か",
"defaultItemName": "共有アイテム",
"notificationsTitle": "通知",
"notificationsTooltip": "他のユーザーがあなたと共有した習慣やウィッシュリストの達成・使用を表示します(管理者のみ)",
"noNotificationsYet": "まだ通知はありません。"
},
"AboutModal": {
"dialogArisLabel": "概要",
"changelogButton": "変更履歴",
"createdByPrefix": "❤️で作成:",
"starOnGitHubButton": "GitHubでスターしよう"
},
"PermissionSelector": {
"permissionsTitle": "権限",
"adminAccessLabel": "管理者アクセス",
"adminAccessDescription": "管理者は全ユーザーの全データにアクセスできます",
"resourceHabitTask": "習慣 / タスク",
"resourceWishlist": "ウィッシュリスト",
"resourceCoins": "コイン",
"permissionWrite": "書き込み",
"permissionInteract": "操作"
},
"UserForm": {
"toastUserUpdatedTitle": "ユーザーを更新しました",
"toastUserUpdatedDescription": "{username}さんの情報を更新しました",
"toastUserCreatedTitle": "ユーザーを作成しました",
"toastUserCreatedDescription": "{username}さんを作成しました",
"actionUpdate": "更新",
"actionCreate": "作成",
"errorFailedUserAction": "ユーザーの{action}に失敗しました",
"toastDemoDeleteDisabled": "デモインスタンスでは削除が無効になっています",
"toastCannotDeleteSelf": "自分のアカウントは削除できません",
"confirmDeleteUser": "ユーザー {username} を削除してもよろしいですか?",
"toastUserDeletedTitle": "ユーザーが削除されました",
"toastUserDeletedDescription": "ユーザー {username} は正常に削除されました",
"toastDeleteUserFailed": "ユーザーの削除に失敗しました: {error}",
"errorTitle": "エラー",
"errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります",
"toastAvatarUploadedTitle": "アバターをアップロードしました",
"toastAvatarUploadedDescription": "アバターのアップロードに成功しました",
"errorFailedAvatarUpload": "アバターのアップロードに失敗しました",
"changeAvatarButton": "アバターを変更",
"uploadAvatarButton": "アバターをアップロード",
"usernameLabel": "ユーザー名",
"usernamePlaceholder": "ユーザー名",
"newPasswordLabel": "新しいパスワード",
"passwordLabel": "パスワード",
"passwordPlaceholderEdit": "現在のままにする場合は空欄",
"passwordPlaceholderCreate": "パスワードを入力",
"demoPasswordDisabledMessage": "デモインスタンスではパスワードは自動的に無効化されます",
"disablePasswordLabel": "パスワードを無効化",
"cancelButton": "キャンセル",
"saveChangesButton": "変更を保存",
"createUserButton": "ユーザーを作成",
"deleteAccountButton": "アカウントを削除",
"deletingButtonText": "削除中...",
"areYouSure": "本当によろしいですか?",
"deleteUserConfirmation": "ユーザー {username} を削除してもよろしいですか?",
"cancel": "キャンセル",
"confirmDeleteButtonText": "削除"
},
"ViewToggle": {
"habitsLabel": "習慣",
"tasksLabel": "タスク"
},
"HabitItem": {
"overdue": "期限超過",
"whenLabel": "いつ: {frequency}",
"coinsPerCompletion": "1回あたり{count}コイン",
"completedStatus": "完了",
"completedStatusCount": "完了({completed}/{target}",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "完了",
"completeButtonCount": "完了({completed}/{target}",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "取り消し",
"editButton": "編集"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "メモが長すぎます",
"noteTooLongDescription": "メモは200文字以内である必要があります",
"errorSavingNoteTitle": "メモの保存エラー",
"errorDeletingNoteTitle": "メモの削除エラー",
"pleaseTryAgainDescription": "再度お試しください",
"addNotePlaceholder": "メモを追加...",
"saveNoteTitle": "メモを保存",
"cancelButtonTitle": "キャンセル",
"deleteNoteTitle": "メモを削除",
"editNoteAriaLabel": "メモを編集"
},
"Profile": {
"guestUsername": "ゲスト",
"editProfileButton": "プロフィールを編集",
"signOutSuccessTitle": "サインアウトに成功しました",
"signOutSuccessDescription": "アカウントからサインアウトしました",
"signOutErrorTitle": "サインアウトエラー",
"signOutErrorDescription": "サインアウトに失敗しました",
"switchUserButton": "ユーザーを切り替え",
"settingsLink": "設定",
"aboutButton": "概要",
"themeLabel": "テーマ",
"editProfileModalTitle": "プロフィールを編集"
},
"PasswordEntryForm": {
"notYouButton": "違うユーザー?",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力",
"loginErrorToastTitle": "エラー",
"loginFailedErrorToastDescription": "ログインに失敗しました",
"cancelButton": "キャンセル",
"loginButton": "ログイン"
},
"CompletionCountBadge": {
"countCompleted": "完了 {completedCount}/{totalCount}"
},
"SettingsPage": {
"title": "設定",
"uiSettingsTitle": "UI設定",
"numberFormattingLabel": "数字のフォーマット",
"numberFormattingDescription": "大きな数字をフォーマットする1K、1M、1B",
"numberGroupingLabel": "数字のグループ化",
"numberGroupingDescription": "3桁区切りを使用する1,000 対 1000",
"systemSettingsTitle": "システム設定",
"timezoneLabel": "タイムゾーン",
"timezoneDescription": "正確な日付追跡のためにタイムゾーンを選択",
"weekStartDayLabel": "週の開始日",
"weekStartDayDescription": "週の最初の曜日を選択",
"weekdays": {
"sunday": "日曜日",
"monday": "月曜日",
"tuesday": "火曜日",
"wednesday": "水曜日",
"thursday": "木曜日",
"friday": "金曜日",
"saturday": "土曜日"
},
"autoBackupLabel": "自動バックアップ",
"autoBackupTooltip": "有効にすると、アプリケーションデータ習慣、コイン、設定などが毎日午前2時頃サーバー時間に自動的にバックアップされます。バックアップはプロジェクトルートの`backups/`ディレクトリにZIPファイルとして保存されます。最新の7つ分のバックアップのみ保持され、古いものは自動的に削除されます。",
"autoBackupDescription": "毎日データを自動バックアップ",
"languageLabel": "言語",
"languageDescription": "アプリケーションの表示言語を選択",
"languageChangedTitle": "言語が変更されました",
"languageChangedDescription": "変更を反映するにはページを更新してください",
"languageDisabledInDemoTooltip": "デモ版では言語の変更が無効になっています。"
},
"Common": {
"authenticationRequiredTitle": "認証が必要です",
"authenticationRequiredDescription": "続行するにはサインインしてください。",
"permissionDeniedTitle": "権限がありません",
"permissionDeniedDescription": "{resource}sに対する{action}権限がありません。",
"undoButton": "取り消し",
"redoButton": "やり直し",
"errorTitle": "エラー"
},
"useHabits": {
"alreadyCompletedTitle": "既に完了しています",
"alreadyCompletedDescription": "今日は既にこの習慣を完了しています。",
"completedTitle": "完了しました!",
"earnedCoinsDescription": "{coinReward}コインを獲得しました。",
"progressTitle": "進捗!",
"progressDescription": "今日は{count}/{target}回完了しました。",
"completionUndoneTitle": "完了を取り消しました",
"completionUndoneDescription": "今日は{count}/{target}回完了しています。",
"noCompletionsToUndoTitle": "取り消す完了がありません",
"noCompletionsToUndoDescription": "この習慣は今日まだ完了していません。",
"alreadyCompletedPastDateTitle": "既に完了しています",
"alreadyCompletedPastDateDescription": "この習慣は{dateKey}に既に完了しています。",
"earnedCoinsPastDateDescription": "{dateKey}に{coinReward}コインを獲得しました。",
"progressPastDateDescription": "{dateKey}に{count}/{target}回完了しました。"
},
"useWishlist": {
"redemptionLimitReachedTitle": "使用回数制限に達しました",
"redemptionLimitReachedDescription": "\"{itemName}\"の最大使用回数に達しました。",
"rewardRedeemedTitle": "🎉 報酬を使用しました!",
"rewardRedeemedDescription": "\"{itemName}\"を{itemCoinCost}コインで使用しました。",
"notEnoughCoinsTitle": "コインが不足しています",
"notEnoughCoinsDescription": "この報酬を使用するにはあと{coinsNeeded}コイン必要です。"
},
"Warning": {
"areYouSure": "本当によろしいですか?",
"cancel": "キャンセル"
},
"useCoins": {
"addedCoinsDescription": "{amount}コインを追加しました",
"invalidAmountTitle": "無効な値です",
"invalidAmountDescription": "有効な正の数を入力してください",
"successTitle": "成功しました",
"transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
"transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
}
}

435
messages/ru.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "Панель управления"
},
"HabitList": {
"myTasks": "Мои задачи",
"myHabits": "Мои привычки",
"addTaskButton": "Добавить задачу",
"addHabitButton": "Добавить привычку",
"searchTasksPlaceholder": "Поиск задач...",
"searchHabitsPlaceholder": "Поиск привычек...",
"sortByLabel": "Сортировать по:",
"sortByName": "Имени",
"sortByCoinReward": "Награде",
"sortByDueDate": "Сроку",
"sortByFrequency": "Частоте",
"toggleSortOrderAriaLabel": "Переключить порядок сортировки",
"noTasksFoundMessage": "Задачи не найдены.",
"noHabitsFoundMessage": "Привычки не найдены.",
"emptyStateTasksTitle": "Нет задач",
"emptyStateHabitsTitle": "Нет привычек",
"emptyStateTasksDescription": "Создайте свою первую задачу",
"emptyStateHabitsDescription": "Создайте свою первую привычку",
"archivedSectionTitle": "Архив",
"deleteTaskDialogTitle": "Удалить задачу",
"deleteHabitDialogTitle": "Удалить привычку",
"deleteTaskDialogMessage": "Вы уверены, что хотите удалить эту задачу? Это действие нельзя отменить.",
"deleteHabitDialogMessage": "Вы уверены, что хотите удалить эту привычку? Это действие нельзя отменить.",
"deleteButton": "Удалить"
},
"DailyOverview": {
"addTaskButtonLabel": "Добавить задачу",
"addHabitButtonLabel": "Добавить привычку",
"todaysOverviewTitle": "Сегодня",
"dailyTasksTitle": "Задачи на сегодня",
"noTasksDueTodayMessage": "Нет задач на сегодня.",
"dailyHabitsTitle": "Привычки на сегодня",
"noHabitsDueTodayMessage": "Нет привычек на сегодня.",
"wishlistGoalsTitle": "Цели",
"redeemableBadgeLabel": "{count}/{total} Доступно",
"noWishlistItemsMessage": "Нет целей.",
"readyToRedeemMessage": "Доступно!",
"coinsToGoMessage": "Осталось {amount} монет",
"showLessButton": "Свернуть",
"showAllButton": "Показать все",
"viewButton": "Просмотр",
"deleteTaskDialogTitle": "Удалить задачу",
"deleteHabitDialogTitle": "Удалить привычку",
"confirmDeleteDialogMessage": "Вы уверены, что хотите удалить \"{name}\"? Это действие нельзя отменить.",
"deleteButton": "Удалить",
"overdueTooltip": "Просрочено"
},
"HabitContextMenuItems": {
"startPomodoro": "Начать помидорку",
"moveToToday": "Перенести на сегодня",
"moveToTomorrow": "Перенести на завтра",
"unpin": "Открепить",
"pin": "Закрепить",
"edit": "Редактировать",
"archive": "В архив",
"unarchive": "Из архива",
"delete": "Удалить"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Ежедневный прогресс",
"tooltipHabitsLabel": "привычки",
"tooltipTasksLabel": "задачи",
"tooltipCompletedLabel": "Выполнено"
},
"CoinBalance": {
"coinBalanceTitle": "Баланс"
},
"AddEditHabitModal": {
"editTaskTitle": "Редактировать задачу",
"editHabitTitle": "Редактировать привычку",
"addNewTaskTitle": "Новая задача",
"addNewHabitTitle": "Новая привычка",
"nameLabel": "Название *",
"descriptionLabel": "Описание",
"whenLabel": "Когда *",
"completeLabel": "Выполнено",
"timesSuffix": "раз",
"rewardLabel": "Награда",
"coinsSuffix": "монет",
"shareLabel": "Поделиться",
"saveChangesButton": "Сохранить",
"addTaskButton": "Добавить задачу",
"addHabitButton": "Добавить привычку"
},
"ConfirmDialog": {
"confirmButton": "Подтвердить",
"cancelButton": "Отмена"
},
"AddEditWishlistItemModal": {
"editTitle": "Редактировать цель",
"addTitle": "Новая цель",
"nameLabel": "Название *",
"descriptionLabel": "Описание",
"costLabel": "Стоимость",
"coinsSuffix": "монет",
"redeemableLabel": "Доступно",
"timesSuffix": "раз",
"errorNameRequired": "Название обязательно",
"errorCoinCostMin": "Минимальная стоимость 1 монета",
"errorTargetCompletionsMin": "Минимум 1 выполнение",
"errorInvalidUrl": "Некорректная ссылка",
"linkLabel": "Ссылка",
"shareLabel": "Поделиться",
"saveButton": "Сохранить",
"addButton": "Добавить цель"
},
"Navigation": {
"dashboard": "Панель",
"tasks": "Задачи",
"habits": "Привычки",
"calendar": "Календарь",
"wishlist": "Цели",
"coins": "Монеты"
},
"TodayEarnedCoins": {
"todaySuffix": "сегодня"
},
"WishlistItem": {
"usesLeftSingular": "использование",
"usesLeftPlural": "использований",
"coinsSuffix": "монет",
"redeem": "Использовать",
"redeemedDone": "Готово",
"redeemedExclamation": "Использовано!",
"editButton": "Редактировать",
"archiveButton": "В архив",
"unarchiveButton": "Из архива",
"deleteButton": "Удалить"
},
"WishlistManager": {
"title": "Мои цели",
"addRewardButton": "Добавить цель",
"emptyStateTitle": "Нет целей",
"emptyStateDescription": "Добавьте цели, которые хотите достичь",
"archivedSectionTitle": "Архив",
"popupBlockedTitle": "Блокировка",
"popupBlockedDescription": "Разрешите всплывающие окна для открытия ссылки",
"deleteDialogTitle": "Удалить цель",
"deleteDialogMessage": "Вы уверены, что хотите удалить эту цель? Это действие нельзя отменить.",
"deleteButton": "Удалить"
},
"UserSelectModal": {
"addUserButton": "Добавить пользователя",
"createNewUserTitle": "Создать нового пользователя",
"selectUserTitle": "Выбрать пользователя",
"signInSuccessTitle": "Успешный вход",
"signInSuccessDescription": "Добро пожаловать, {username}!",
"errorInvalidPassword": "Неверный пароль",
"deleteUserConfirmation": "Вы уверены, что хотите удалить пользователя {username}? Это действие нельзя отменить.",
"confirmDeleteButtonText": "Удалить",
"deletingButtonText": "Удаление...",
"deleteUserSuccessTitle": "Пользователь удален",
"deleteUserSuccessDescription": "Пользователь {username} успешно удален.",
"deleteUserErrorTitle": "Ошибка удаления",
"genericError": "Произошла непредвиденная ошибка.",
"networkError": "Произошла сетевая ошибка. Пожалуйста, попробуйте еще раз.",
"editUserTooltip": "Редактировать пользователя",
"deleteUserTooltip": "Удалить пользователя"
},
"CoinsManager": {
"title": "Управление монетами",
"currentBalanceLabel": "Текущий баланс",
"coinsSuffix": "монет",
"addCoinsButton": "Добавить монеты",
"removeCoinsButton": "Удалить монеты",
"statisticsTitle": "Статистика",
"totalEarnedLabel": "Всего заработано",
"totalSpentLabel": "Всего потрачено",
"totalTransactionsLabel": "Всего транзакций",
"todaysEarnedLabel": "Заработано сегодня",
"todaysSpentLabel": "Потрачено сегодня",
"todaysTransactionsLabel": "Транзакций сегодня",
"transactionHistoryTitle": "История транзакций",
"showLabel": "Показать:",
"entriesSuffix": "записей",
"showingEntries": "Показано с {from} по {to} из {total} записей",
"noTransactionsTitle": "Нет транзакций",
"noTransactionsDescription": "История транзакций появится здесь, когда вы начнете зарабатывать или тратить монеты",
"pageLabel": "Страница",
"ofLabel": "из",
"transactionTypeHabitCompletion": "Выполнение привычки",
"transactionTypeTaskCompletion": "Выполнение задачи",
"transactionTypeHabitUndo": "Отмена привычки",
"transactionTypeTaskUndo": "Отмена задачи",
"transactionTypeWishRedemption": "Использование цели",
"transactionTypeManualAdjustment": "Ручная корректировка",
"transactionTypeCoinReset": "Сброс монет",
"transactionTypeInitialBalance": "Начальный баланс"
},
"NotificationBell": {
"errorUpdateTimestamp": "Не удалось обновить отметку времени прочтения уведомления:"
},
"PomodoroTimer": {
"focusLabel1": "Сосредоточьтесь",
"focusLabel2": "У вас получится",
"focusLabel3": "Продолжайте",
"focusLabel4": "Разгромите это",
"focusLabel5": "Воплотите это в жизнь",
"focusLabel6": "Оставайтесь сильными",
"focusLabel7": "Прорвитесь",
"focusLabel8": "Один шаг за раз",
"focusLabel9": "Вы можете это сделать",
"focusLabel10": "Сосредоточьтесь и побеждайте",
"breakLabel1": "Передохните",
"breakLabel2": "Расслабьтесь и перезагрузитесь",
"breakLabel3": "Дышите глубже",
"breakLabel4": "Потянитесь",
"breakLabel5": "Освежитесь",
"breakLabel6": "Вы этого заслуживаете",
"breakLabel7": "Восстановите энергию",
"breakLabel8": "Отойдите на немного",
"breakLabel9": "Очистите свой разум",
"breakLabel10": "Отдохните и восстановитесь",
"focusType": "Фокус",
"breakType": "Перерыв",
"pauseButton": "Пауза",
"startButton": "Старт",
"resetButton": "Сброс",
"skipButton": "Пропустить",
"wakeLockNotSupported": "Браузер не поддерживает блокировку экрана",
"wakeLockInUse": "Блокировка экрана уже используется",
"wakeLockRequestError": "Ошибка запроса блокировки экрана:",
"wakeLockReleaseError": "Ошибка освобождения блокировки экрана:"
},
"HabitCalendar": {
"title": "Календарь привычек",
"calendarCardTitle": "Календарь",
"selectDatePrompt": "Выберите дату",
"tasksSectionTitle": "Задачи",
"habitsSectionTitle": "Привычки",
"errorCompletingPastHabit": "Ошибка завершения прошлой привычки:"
},
"NotificationDropdown": {
"notLoggedIn": "Не выполнен вход.",
"userCompletedItem": "{username} выполнил(а) {itemName}.",
"userRedeemedItem": "{username} использовал(а) {itemName}.",
"activityRelatedToItem": "Действие, связанное с {itemName}, пользователем {username}.",
"defaultUsername": "Кто-то",
"defaultItemName": "общий элемент",
"notificationsTitle": "Уведомления",
"notificationsTooltip": "Показывает завершения или погашения другими пользователями для привычек или списка желаний, которыми вы поделились с ними (вы должны быть администратором)",
"noNotificationsYet": "Пока нет уведомлений."
},
"AboutModal": {
"dialogArisLabel": "о программе",
"changelogButton": "Список изменений",
"createdByPrefix": "Сделано с любовью ❤️ от",
"starOnGitHubButton": "Звезда на GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Разрешения",
"adminAccessLabel": "Доступ администратора",
"adminAccessDescription": "Администраторы имеют полный доступ ко всем данным для всех пользователей",
"resourceHabitTask": "Привычка / Задача",
"resourceWishlist": "Список желаний",
"resourceCoins": "Монеты",
"permissionWrite": "Запись",
"permissionInteract": "Взаимодействие"
},
"UserForm": {
"toastUserUpdatedTitle": "Пользователь обновлен",
"toastUserUpdatedDescription": "Пользователь {username} успешно обновлен",
"toastUserCreatedTitle": "Пользователь создан",
"toastUserCreatedDescription": "Пользователь {username} успешно создан",
"actionUpdate": "обновить",
"actionCreate": "создать",
"errorFailedUserAction": "Не удалось {action} пользователя",
"toastDemoDeleteDisabled": "Удаление отключено в демо-версии",
"toastCannotDeleteSelf": "Вы не можете удалить свою учетную запись",
"confirmDeleteUser": "Вы уверены, что хотите удалить пользователя {username}?",
"toastUserDeletedTitle": "Пользователь удален",
"toastUserDeletedDescription": "Пользователь {username} успешно удален",
"toastDeleteUserFailed": "Не удалось удалить пользователя: {error}",
"errorTitle": "Ошибка",
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
"toastAvatarUploadedTitle": "Аватар загружен",
"toastAvatarUploadedDescription": "Аватар успешно загружен",
"errorFailedAvatarUpload": "Не удалось загрузить аватар",
"changeAvatarButton": "Изменить аватар",
"uploadAvatarButton": "Загрузить аватар",
"usernameLabel": "Имя пользователя",
"usernamePlaceholder": "Имя пользователя",
"newPasswordLabel": "Новый пароль",
"passwordLabel": "Пароль",
"passwordPlaceholderEdit": "Оставьте пустым, чтобы сохранить текущий",
"passwordPlaceholderCreate": "Введите пароль",
"demoPasswordDisabledMessage": "Пароль автоматически отключен в демонстрационном экземпляре",
"disablePasswordLabel": "Отключить пароль",
"cancelButton": "Отмена",
"saveChangesButton": "Сохранить изменения",
"createUserButton": "Создать пользователя",
"deleteAccountButton": "Удалить аккаунт",
"deletingButtonText": "Удаление...",
"areYouSure": "Вы уверены?",
"deleteUserConfirmation": "Вы уверены, что хотите удалить пользователя {username}?",
"cancel": "Отмена",
"confirmDeleteButtonText": "Удалить"
},
"ViewToggle": {
"habitsLabel": "Привычки",
"tasksLabel": "Задачи"
},
"HabitItem": {
"overdue": "Просрочено",
"whenLabel": "Когда: {frequency}",
"coinsPerCompletion": "{count} монет за выполнение",
"completedStatus": "Выполнено",
"completedStatusCount": "Выполнено ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Выполнить",
"completeButtonCount": "Выполнить ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Отменить",
"editButton": "Редактировать"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Слишком длинная заметка",
"noteTooLongDescription": "Заметки должны быть менее 200 символов",
"errorSavingNoteTitle": "Ошибка сохранения заметки",
"errorDeletingNoteTitle": "Ошибка удаления заметки",
"pleaseTryAgainDescription": "Пожалуйста, попробуйте еще раз",
"addNotePlaceholder": "Добавить заметку...",
"saveNoteTitle": "Сохранить заметку",
"cancelButtonTitle": "Отмена",
"deleteNoteTitle": "Удалить заметку",
"editNoteAriaLabel": "Редактировать заметку"
},
"Profile": {
"guestUsername": "Гость",
"editProfileButton": "Редактировать профиль",
"signOutSuccessTitle": "Выход выполнен успешно",
"signOutSuccessDescription": "Вы вышли из своей учетной записи",
"signOutErrorTitle": "Ошибка выхода",
"signOutErrorDescription": "Не удалось выйти",
"switchUserButton": "Сменить пользователя",
"settingsLink": "Настройки",
"aboutButton": "О программе",
"themeLabel": "Тема",
"editProfileModalTitle": "Редактировать профиль"
},
"PasswordEntryForm": {
"notYouButton": "Не вы?",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите пароль",
"loginErrorToastTitle": "Ошибка",
"loginFailedErrorToastDescription": "Не удалось войти",
"cancelButton": "Отмена",
"loginButton": "Войти"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} выполнено"
},
"SettingsPage": {
"title": "Настройки",
"uiSettingsTitle": "Интерфейс",
"numberFormattingLabel": "Формат чисел",
"numberFormattingDescription": "Использовать сокращения (например, 1К, 1М, 1Млрд)",
"numberGroupingLabel": "Разделители",
"numberGroupingDescription": "Использовать разделители тысяч (например, 1 000 вместо 1000)",
"systemSettingsTitle": "Система",
"timezoneLabel": "Часовой пояс",
"timezoneDescription": "Выберите ваш часовой пояс",
"weekStartDayLabel": "Первый день недели",
"weekStartDayDescription": "Выберите первый день недели",
"weekdays": {
"sunday": "Воскресенье",
"monday": "Понедельник",
"tuesday": "Вторник",
"wednesday": "Среда",
"thursday": "Четверг",
"friday": "Пятница",
"saturday": "Суббота"
},
"autoBackupLabel": "Авто-бэкап",
"autoBackupTooltip": "При включении данные будут автоматически резервироваться ежедневно около 2:00 по времени сервера. Бэкапы хранятся в виде ZIP-файлов в директории `backups/`. Хранятся только последние 7 бэкапов.",
"autoBackupDescription": "Автоматическое резервное копирование данных",
"languageLabel": "Язык",
"languageDescription": "Выберите предпочитаемый язык интерфейса.",
"languageChangedTitle": "Язык изменен",
"languageChangedDescription": "Перезагрузите страницу для применения изменений",
"languageDisabledInDemoTooltip": "Смена языка недоступна в демо-версии."
},
"Common": {
"authenticationRequiredTitle": "Требуется аутентификация",
"authenticationRequiredDescription": "Пожалуйста, войдите, чтобы продолжить.",
"permissionDeniedTitle": "Отказано в доступе",
"permissionDeniedDescription": "У вас нет разрешения на {action} для {resource}.",
"undoButton": "Отменить",
"redoButton": "Повторить",
"errorTitle": "Ошибка"
},
"useHabits": {
"alreadyCompletedTitle": "Уже выполнено",
"alreadyCompletedDescription": "Вы уже выполнили эту привычку сегодня.",
"completedTitle": "Выполнено!",
"earnedCoinsDescription": "Вы заработали {coinReward} монет.",
"progressTitle": "Прогресс!",
"progressDescription": "Вы выполнили {count}/{target} раз сегодня.",
"completionUndoneTitle": "Выполнение отменено",
"completionUndoneDescription": "У вас {count}/{target} выполнений сегодня.",
"noCompletionsToUndoTitle": "Нет отмен",
"noCompletionsToUndoDescription": "Эта привычка не была выполнена сегодня.",
"alreadyCompletedPastDateTitle": "Уже выполнено",
"alreadyCompletedPastDateDescription": "Эта привычка уже была выполнена {dateKey}.",
"earnedCoinsPastDateDescription": "Вы заработали {coinReward} монет за {dateKey}.",
"progressPastDateDescription": "Вы выполнили {count}/{target} раз {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Достигнут лимит погашения",
"redemptionLimitReachedDescription": "Вы достигли максимального количества погашений для \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Награда получена!",
"rewardRedeemedDescription": "Вы получили \"{itemName}\" за {itemCoinCost} монет.",
"notEnoughCoinsTitle": "Недостаточно монет",
"notEnoughCoinsDescription": "Вам нужно еще {coinsNeeded} монет, чтобы получить эту награду."
},
"Warning": {
"areYouSure": "Вы уверены?",
"cancel": "Отмена"
},
"useCoins": {
"addedCoinsDescription": "Добавлено {amount} монет",
"invalidAmountTitle": "Неверная сумма",
"invalidAmountDescription": "Пожалуйста, введите положительное число",
"successTitle": "Успех",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}."
}
}

435
messages/zh.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "仪表板"
},
"HabitList": {
"myTasks": "我的任务",
"myHabits": "我的习惯",
"addTaskButton": "添加任务",
"addHabitButton": "添加习惯",
"searchTasksPlaceholder": "搜索任务...",
"searchHabitsPlaceholder": "搜索习惯...",
"sortByLabel": "排序方式:",
"sortByName": "名称",
"sortByCoinReward": "金币奖励",
"sortByDueDate": "截止日期",
"sortByFrequency": "频率",
"toggleSortOrderAriaLabel": "切换排序顺序",
"noTasksFoundMessage": "未找到符合搜索条件的任务。",
"noHabitsFoundMessage": "未找到符合搜索条件的习惯。",
"emptyStateTasksTitle": "暂无任务",
"emptyStateHabitsTitle": "暂无习惯",
"emptyStateTasksDescription": "创建第一个任务以开始跟踪进度",
"emptyStateHabitsDescription": "创建第一个习惯以开始跟踪进度",
"archivedSectionTitle": "已归档",
"deleteTaskDialogTitle": "删除任务",
"deleteHabitDialogTitle": "删除习惯",
"deleteTaskDialogMessage": "确定要删除此任务吗?此操作无法撤消。",
"deleteHabitDialogMessage": "确定要删除此习惯吗?此操作无法撤消。",
"deleteButton": "删除"
},
"DailyOverview": {
"addTaskButtonLabel": "添加任务",
"addHabitButtonLabel": "添加习惯",
"todaysOverviewTitle": "今日概览",
"dailyTasksTitle": "每日任务",
"noTasksDueTodayMessage": "今天没有任务。添加一些任务以开始!",
"dailyHabitsTitle": "每日习惯",
"noHabitsDueTodayMessage": "今天没有习惯。添加一些习惯以开始!",
"wishlistGoalsTitle": "愿望清单目标",
"redeemableBadgeLabel": "{count}/{total} 可兑换",
"noWishlistItemsMessage": "还没有愿望清单项目。添加一些目标来努力实现吧!",
"readyToRedeemMessage": "准备兑换!",
"coinsToGoMessage": "还需 {amount} 个金币",
"showLessButton": "显示更少",
"showAllButton": "显示全部",
"viewButton": "查看",
"deleteTaskDialogTitle": "删除任务",
"deleteHabitDialogTitle": "删除习惯",
"confirmDeleteDialogMessage": "确定要删除\"{name}\"吗?此操作无法撤消。",
"deleteButton": "删除",
"overdueTooltip": "逾期"
},
"HabitContextMenuItems": {
"startPomodoro": "开始番茄钟",
"moveToToday": "移动到今天",
"moveToTomorrow": "移动到明天",
"unpin": "取消固定",
"pin": "固定",
"edit": "编辑",
"archive": "归档",
"unarchive": "取消归档",
"delete": "删除"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "每日完成连胜",
"tooltipHabitsLabel": "习惯",
"tooltipTasksLabel": "任务",
"tooltipCompletedLabel": "已完成"
},
"CoinBalance": {
"coinBalanceTitle": "金币余额"
},
"AddEditHabitModal": {
"editTaskTitle": "编辑任务",
"editHabitTitle": "编辑习惯",
"addNewTaskTitle": "添加新任务",
"addNewHabitTitle": "添加新习惯",
"nameLabel": "名称 *",
"descriptionLabel": "描述",
"whenLabel": "时间 *",
"completeLabel": "完成",
"timesSuffix": "次",
"rewardLabel": "奖励",
"coinsSuffix": "金币",
"shareLabel": "分享",
"saveChangesButton": "保存更改",
"addTaskButton": "添加任务",
"addHabitButton": "添加习惯"
},
"ConfirmDialog": {
"confirmButton": "确认",
"cancelButton": "取消"
},
"AddEditWishlistItemModal": {
"editTitle": "编辑奖励",
"addTitle": "添加新奖励",
"nameLabel": "名称 *",
"descriptionLabel": "描述",
"costLabel": "成本",
"coinsSuffix": "金币",
"redeemableLabel": "可兑换",
"timesSuffix": "次",
"errorNameRequired": "名称是必填项",
"errorCoinCostMin": "金币成本至少为 1",
"errorTargetCompletionsMin": "目标完成次数至少为 1",
"errorInvalidUrl": "请输入有效的 URL",
"linkLabel": "链接",
"shareLabel": "分享",
"saveButton": "保存更改",
"addButton": "添加奖励"
},
"Navigation": {
"dashboard": "仪表板",
"tasks": "任务",
"habits": "习惯",
"calendar": "日历",
"wishlist": "愿望清单",
"coins": "金币"
},
"TodayEarnedCoins": {
"todaySuffix": "今天"
},
"WishlistItem": {
"usesLeftSingular": "剩余 1 次",
"usesLeftPlural": "剩余 {count} 次",
"coinsSuffix": "金币",
"redeem": "兑换",
"redeemedDone": "完成",
"redeemedExclamation": "已兑换!",
"editButton": "编辑",
"archiveButton": "归档",
"unarchiveButton": "取消归档",
"deleteButton": "删除"
},
"WishlistManager": {
"title": "我的愿望清单",
"addRewardButton": "添加奖励",
"emptyStateTitle": "您的愿望清单是空的",
"emptyStateDescription": "添加您想用金币兑换的奖励",
"archivedSectionTitle": "已归档",
"popupBlockedTitle": "弹出窗口被阻止",
"popupBlockedDescription": "请允许弹出窗口以打开链接",
"deleteDialogTitle": "删除奖励",
"deleteDialogMessage": "确定要删除此奖励吗?此操作无法撤消。",
"deleteButton": "删除"
},
"UserSelectModal": {
"addUserButton": "添加用户",
"createNewUserTitle": "创建新用户",
"selectUserTitle": "选择用户",
"signInSuccessTitle": "登录成功",
"signInSuccessDescription": "欢迎回来,{username}",
"errorInvalidPassword": "密码错误",
"deleteUserConfirmation": "您确定要删除用户 {username} 吗?此操作无法撤销。",
"confirmDeleteButtonText": "删除",
"deletingButtonText": "正在删除...",
"deleteUserSuccessTitle": "用户已删除",
"deleteUserSuccessDescription": "用户 {username} 已成功删除。",
"deleteUserErrorTitle": "删除失败",
"genericError": "发生意外错误。",
"networkError": "发生网络错误。请再试一次。",
"editUserTooltip": "编辑用户",
"deleteUserTooltip": "删除用户"
},
"CoinsManager": {
"title": "金币管理",
"currentBalanceLabel": "当前余额",
"coinsSuffix": "金币",
"addCoinsButton": "添加金币",
"removeCoinsButton": "移除金币",
"statisticsTitle": "统计",
"totalEarnedLabel": "总收入",
"totalSpentLabel": "总支出",
"totalTransactionsLabel": "总交易数",
"todaysEarnedLabel": "今日收入",
"todaysSpentLabel": "今日支出",
"todaysTransactionsLabel": "今日交易数",
"transactionHistoryTitle": "交易历史",
"showLabel": "显示:",
"entriesSuffix": "条",
"showingEntries": "显示 {from} 到 {to} 条,共 {total} 条",
"noTransactionsTitle": "尚无交易记录",
"noTransactionsDescription": "当您开始赚取或花费金币时,您的交易历史将在此显示",
"pageLabel": "第",
"ofLabel": "页,共",
"transactionTypeHabitCompletion": "习惯完成",
"transactionTypeTaskCompletion": "任务完成",
"transactionTypeHabitUndo": "习惯撤销",
"transactionTypeTaskUndo": "任务撤销",
"transactionTypeWishRedemption": "愿望兑换",
"transactionTypeManualAdjustment": "手动调整",
"transactionTypeCoinReset": "金币重置",
"transactionTypeInitialBalance": "初始余额"
},
"NotificationBell": {
"errorUpdateTimestamp": "更新通知阅读时间戳失败:"
},
"PomodoroTimer": {
"focusLabel1": "保持专注",
"focusLabel2": "你可以的",
"focusLabel3": "继续加油",
"focusLabel4": "全力以赴",
"focusLabel5": "让它实现",
"focusLabel6": "坚持下去",
"focusLabel7": "突破自我",
"focusLabel8": "一步一个脚印",
"focusLabel9": "你能做到",
"focusLabel10": "专注并征服",
"breakLabel1": "休息一下",
"breakLabel2": "放松充电",
"breakLabel3": "深呼吸",
"breakLabel4": "伸展身体",
"breakLabel5": "刷新自己",
"breakLabel6": "你值得拥有",
"breakLabel7": "补充能量",
"breakLabel8": "暂时离开一下",
"breakLabel9": "清空思绪",
"breakLabel10": "休息并恢复",
"focusType": "专注",
"breakType": "休息",
"pauseButton": "暂停",
"startButton": "开始",
"resetButton": "重置",
"skipButton": "跳过",
"wakeLockNotSupported": "浏览器不支持唤醒锁",
"wakeLockInUse": "唤醒锁已在使用中",
"wakeLockRequestError": "请求唤醒锁时发生错误:",
"wakeLockReleaseError": "释放唤醒锁时发生错误:"
},
"HabitCalendar": {
"title": "习惯日历",
"calendarCardTitle": "日历",
"selectDatePrompt": "选择一个日期",
"tasksSectionTitle": "任务",
"habitsSectionTitle": "习惯",
"errorCompletingPastHabit": "完成过去习惯时出错:"
},
"NotificationDropdown": {
"notLoggedIn": "未登录。",
"userCompletedItem": "{username} 完成了 {itemName}。",
"userRedeemedItem": "{username} 兑换了 {itemName}。",
"activityRelatedToItem": "{username} 对 {itemName} 的相关活动。",
"defaultUsername": "某人",
"defaultItemName": "一个共享项目",
"notificationsTitle": "通知",
"notificationsTooltip": "显示其他用户对您与他们共享的习惯或愿望清单的完成或兑换情况(您必须是管理员)",
"noNotificationsYet": "尚无通知。"
},
"AboutModal": {
"dialogArisLabel": "关于",
"changelogButton": "更新日志",
"createdByPrefix": "由 ❤️ 创建",
"starOnGitHubButton": "在 GitHub 上点赞"
},
"PermissionSelector": {
"permissionsTitle": "权限",
"adminAccessLabel": "管理员权限",
"adminAccessDescription": "管理员对所有用户的全部数据拥有完整权限",
"resourceHabitTask": "习惯/任务",
"resourceWishlist": "愿望清单",
"resourceCoins": "金币",
"permissionWrite": "写入",
"permissionInteract": "交互"
},
"UserForm": {
"toastUserUpdatedTitle": "用户已更新",
"toastUserUpdatedDescription": "成功更新用户 {username}",
"toastUserCreatedTitle": "用户已创建",
"toastUserCreatedDescription": "成功创建用户 {username}",
"actionUpdate": "更新",
"actionCreate": "创建",
"errorFailedUserAction": "用户 {action} 失败",
"toastDemoDeleteDisabled": "在演示实例中删除已禁用",
"toastCannotDeleteSelf": "您不能删除自己的帐户",
"confirmDeleteUser": "您确定要删除用户 {username} 吗?",
"toastUserDeletedTitle": "用户已删除",
"toastUserDeletedDescription": "用户 {username} 已成功删除",
"toastDeleteUserFailed": "删除用户失败: {error}",
"errorTitle": "错误",
"errorFileSizeLimit": "文件大小必须小于 5MB",
"toastAvatarUploadedTitle": "头像已上传",
"toastAvatarUploadedDescription": "成功上传头像",
"errorFailedAvatarUpload": "头像上传失败",
"changeAvatarButton": "更改头像",
"uploadAvatarButton": "上传头像",
"usernameLabel": "用户名",
"usernamePlaceholder": "用户名",
"newPasswordLabel": "新密码",
"passwordLabel": "密码",
"passwordPlaceholderEdit": "留空以保持当前密码",
"passwordPlaceholderCreate": "输入密码",
"demoPasswordDisabledMessage": "在演示实例中密码自动禁用",
"disablePasswordLabel": "禁用密码",
"cancelButton": "取消",
"saveChangesButton": "保存更改",
"createUserButton": "创建用户",
"deleteAccountButton": "删除账户",
"deletingButtonText": "正在删除...",
"areYouSure": "您确定吗?",
"deleteUserConfirmation": "您确定要删除用户 {username} 吗?",
"cancel": "取消",
"confirmDeleteButtonText": "删除"
},
"ViewToggle": {
"habitsLabel": "习惯",
"tasksLabel": "任务"
},
"HabitItem": {
"overdue": "逾期",
"whenLabel": "时间:{frequency}",
"coinsPerCompletion": "{count} 金币每次完成",
"completedStatus": "已完成",
"completedStatusCount": "已完成 ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "完成",
"completeButtonCount": "完成 ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "撤销",
"editButton": "编辑"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "备注太长",
"noteTooLongDescription": "备注必须少于200个字符",
"errorSavingNoteTitle": "保存备注出错",
"errorDeletingNoteTitle": "删除备注出错",
"pleaseTryAgainDescription": "请重试",
"addNotePlaceholder": "添加备注...",
"saveNoteTitle": "保存备注",
"cancelButtonTitle": "取消",
"deleteNoteTitle": "删除备注",
"editNoteAriaLabel": "编辑备注"
},
"Profile": {
"guestUsername": "游客",
"editProfileButton": "编辑资料",
"signOutSuccessTitle": "登出成功",
"signOutSuccessDescription": "您已从您的账户登出",
"signOutErrorTitle": "登出错误",
"signOutErrorDescription": "登出失败",
"switchUserButton": "切换用户",
"settingsLink": "设置",
"aboutButton": "关于",
"themeLabel": "主题",
"editProfileModalTitle": "编辑资料"
},
"PasswordEntryForm": {
"notYouButton": "不是您?",
"passwordLabel": "密码",
"passwordPlaceholder": "输入密码",
"loginErrorToastTitle": "错误",
"loginFailedErrorToastDescription": "登录失败",
"cancelButton": "取消",
"loginButton": "登录"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} 已完成"
},
"SettingsPage": {
"title": "设置",
"uiSettingsTitle": "界面设置",
"numberFormattingLabel": "数字格式化",
"numberFormattingDescription": "格式化大数字 (例如: 1K, 1M, 1B)",
"numberGroupingLabel": "数字分组",
"numberGroupingDescription": "使用千位分隔符 (例如: 1,000 vs 1000)",
"systemSettingsTitle": "系统设置",
"timezoneLabel": "时区",
"timezoneDescription": "选择您的时区以获得准确的日期跟踪",
"weekStartDayLabel": "周起始日",
"weekStartDayDescription": "选择您偏好的每周第一天",
"weekdays": {
"sunday": "周日",
"monday": "周一",
"tuesday": "周二",
"wednesday": "周三",
"thursday": "周四",
"friday": "周五",
"saturday": "周六"
},
"autoBackupLabel": "自动备份",
"autoBackupTooltip": "启用后应用程序数据习惯、金币、设置等将在每天凌晨2点左右自动备份。备份文件存储在项目根目录的`backups/`目录中仅保留最近7个备份旧的备份会被自动删除。",
"autoBackupDescription": "每天自动备份数据",
"languageLabel": "语言",
"languageDescription": "选择应用程序的首选显示语言。",
"languageChangedTitle": "语言已更改",
"languageChangedDescription": "请刷新页面以查看更改",
"languageDisabledInDemoTooltip": "在演示版本中禁用更改语言。"
},
"Common": {
"authenticationRequiredTitle": "需要身份验证",
"authenticationRequiredDescription": "请登录以继续。",
"permissionDeniedTitle": "权限被拒绝",
"permissionDeniedDescription": "您没有对{resource}的{action}权限。",
"undoButton": "撤销",
"redoButton": "重做",
"errorTitle": "错误"
},
"useHabits": {
"alreadyCompletedTitle": "已完成",
"alreadyCompletedDescription": "您今天已经完成了这个习惯。",
"completedTitle": "已完成!",
"earnedCoinsDescription": "您获得了{coinReward}金币。",
"progressTitle": "有进展!",
"progressDescription": "您今天已完成{count}/{target}次。",
"completionUndoneTitle": "完成已撤销",
"completionUndoneDescription": "您今天有{count}/{target}次完成。",
"noCompletionsToUndoTitle": "没有可撤销的完成",
"noCompletionsToUndoDescription": "这个习惯今天还没有完成过。",
"alreadyCompletedPastDateTitle": "已完成",
"alreadyCompletedPastDateDescription": "这个习惯已于{dateKey}完成。",
"earnedCoinsPastDateDescription": "您因{dateKey}获得了{coinReward}金币。",
"progressPastDateDescription": "您于{dateKey}完成了{count}/{target}次。"
},
"useWishlist": {
"redemptionLimitReachedTitle": "达到兑换限制",
"redemptionLimitReachedDescription": "您已达到\"{itemName}\"的最大兑换次数。",
"rewardRedeemedTitle": "🎉 奖励已兑换!",
"rewardRedeemedDescription": "您已用{itemCoinCost}金币兑换了\"{itemName}\"。",
"notEnoughCoinsTitle": "金币不足",
"notEnoughCoinsDescription": "您还需要{coinsNeeded}金币才能兑换此奖励。"
},
"Warning": {
"areYouSure": "您确定吗?",
"cancel": "取消"
},
"useCoins": {
"addedCoinsDescription": "已添加 {amount} 个金币",
"invalidAmountTitle": "无效金额",
"invalidAmountDescription": "请输入有效的正数",
"successTitle": "成功",
"transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。",
"transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。"
}
}

View File

@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = {
/* config options here */
@@ -51,4 +52,5 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

701
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "habittrove",
"version": "0.2.8",
"version": "0.2.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habittrove",
"version": "0.2.8",
"version": "0.2.13",
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@next/font": "^14.2.15",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.4",
@@ -21,7 +22,7 @@
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
@@ -41,6 +42,7 @@
"luxon": "^3.5.0",
"next": "15.2.3",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0",
"next-themes": "^0.4.4",
"node-cron": "^3.0.3",
"react": "^19.0.0",
@@ -306,6 +308,66 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz",
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig=="
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
"integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.1",
"decimal.js": "^10.4.3",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz",
"integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/fast-memoize": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz",
"integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==",
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.4",
"@formatjs/icu-skeleton-parser": "1.8.14",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "1.8.14",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz",
"integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==",
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.4",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
"integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
"license": "MIT",
"dependencies": {
"tslib": "2"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -999,6 +1061,93 @@
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
"integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
@@ -1071,6 +1220,24 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
@@ -1127,24 +1294,25 @@
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz",
"integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.3",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "^2.6.1"
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1161,6 +1329,265 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
"integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -1345,6 +1772,24 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz",
@@ -1381,6 +1826,24 @@
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
@@ -1480,6 +1943,24 @@
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz",
@@ -1758,6 +2239,24 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.3.tgz",
@@ -1838,11 +2337,12 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
@@ -1854,6 +2354,21 @@
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz",
@@ -1948,6 +2463,24 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@@ -1979,6 +2512,39 @@
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
@@ -2097,6 +2663,12 @@
"integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==",
"dev": true
},
"node_modules/@schummar/icu-type-parser": {
"version": "1.21.5",
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -3999,6 +4571,12 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -5490,6 +6068,18 @@
"node": ">=12"
}
},
"node_modules/intl-messageformat": {
"version": "10.7.16",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz",
"integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.4",
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/icu-messageformat-parser": "2.11.2",
"tslib": "^2.8.0"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -7068,6 +7658,15 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -7156,6 +7755,33 @@
}
}
},
"node_modules/next-intl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.1.0.tgz",
"integrity": "sha512-JNJRjc7sdnfUxhZmGcvzDszZ60tQKrygV/VLsgzXhnJDxQPn1cN2rVpc53adA1SvBJwPK2O6Sc6b4gYSILjCzw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"negotiator": "^1.0.0",
"use-intl": "^4.1.0"
},
"peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/next-themes": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz",
@@ -7866,15 +8492,16 @@
}
},
"node_modules/react-remove-scroll": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz",
"integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz",
"integrity": "sha512-sGsQtcjMqdQyijAHytfGEELB8FufGbfXIsvUTe+NLx1GDRJCXtCFLBLUI1eyZCKXXvbEU2C6gai0PZKoIE9Vbg==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.1",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.2"
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
@@ -9294,7 +9921,7 @@
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"dev": true,
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9468,6 +10095,20 @@
}
}
},
"node_modules/use-intl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.1.0.tgz",
"integrity": "sha512-mQvDYFvoGn+bm/PWvlQOtluKCknsQ5a9F1Cj0hMfBjMBVTwnOqLPd6srhjvVdEQEQFVyHM1PfyifKqKYb11M9Q==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "^2.2.0",
"@schummar/icu-type-parser": "1.21.5",
"intl-messageformat": "^10.5.14"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.2.11",
"version": "0.2.20",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -18,6 +18,7 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@next/font": "^14.2.15",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.4",
@@ -28,7 +29,7 @@
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
@@ -48,6 +49,7 @@
"luxon": "^3.5.0",
"next": "15.2.3",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0",
"next-themes": "^0.4.4",
"node-cron": "^3.0.3",
"react": "^19.0.0",

View File

@@ -8,6 +8,17 @@ export default {
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
screens: {
'3xs': '320px',
'2xs': '375px',
'xs': '480px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
'3xl': '1920px'
},
extend: {
keyframes: {
celebrate: {