Compare commits

..

47 Commits

Author SHA1 Message Date
bb2e4be41b Merge Tag 'v0.2.30' 2025-12-04 23:21:16 +01:00
dohsimpson
b01c5dcd6a feat: update Next.js to v15.5.7 to address CVE-2025-55182 2025-12-03 12:48:53 -05:00
3f9cd87c4d fix: added missing i18n en entries 2025-09-02 22:52:25 +02:00
083fae020a fix: imports 2025-09-02 22:47:24 +02:00
38da61c6c2 Merge Tag 'v0.2.29' 2025-09-02 22:46:45 +02:00
0689a5827f fix: replace return null with empty tags 2025-09-02 22:35:36 +02:00
ab0c5e3e99 Merge Tag 'v0.2.28' 2025-09-02 22:31:39 +02:00
3cc8543067 Merge Tag 'v0.2.27' 2025-09-02 22:29:16 +02:00
06aa27af63 Merge Tag 'v0.2.26' 2025-09-02 22:28:48 +02:00
c5bacb719c Merge Tag 'v0.2.25' 2025-09-02 22:28:15 +02:00
3ae2a3cb79 Merge Tag 'v0.2.24' 2025-09-02 22:27:46 +02:00
Doh
3e6b4b75ec feat: freehand drawing capability and card layout improvements and v0.2.29 release (#180) 2025-08-21 23:04:50 -04:00
Doh
31700c9a45 feat: server permission checking and v0.2.28 release (#178) 2025-08-20 17:27:50 -04:00
Doh
e05b982307 feat: mobile navigation text centering and v0.2.27 release (#177) 2025-08-20 17:14:32 -04:00
Doh
ee2821b2bf feat: optimize Docker build performance and add version validation (#176) 2025-08-20 10:04:23 -04:00
Doh
8fb7cd1810 Add project documentation and translation guide (#174) 2025-08-20 09:26:29 -04:00
1b17d6b50a fix: add TS types 2025-08-17 19:49:11 +02:00
Doh
a6f5bf1baa Update README.md
updated tasktrove banner
2025-08-17 09:40:10 -04:00
Doh
8dda60b9b1 Add Korean translation (#169) 2025-08-14 14:56:09 -04:00
8269f3adad fix: refactored code & removed unused parts 2025-08-09 18:57:04 +02:00
Doh
ad2504dc7f Update README.md 2025-07-11 22:12:00 -04:00
4cadf4cea7 fix: added upstream differences to README 2025-07-10 00:45:45 +02:00
06e802f2f5 fix: resolved mobile display errors 2025-06-17 23:45:55 +02:00
6c0b196de2 fix: only display 'show all' if there are more than 4 entries 2025-06-17 23:20:39 +02:00
0f073760ee fix: unify NavDisplays 2025-06-17 22:30:18 +02:00
55c2e3577d Merge Tag v0.2.23 2025-06-13 21:59:16 +02:00
043201217f Merge Tag v0.2.22 2025-06-13 21:57:27 +02:00
4e11f17729 Merge Tag v0.2.21 2025-06-13 21:52:24 +02:00
faa6f4cb76 Merge Tag v0.2.20 2025-06-13 21:43:44 +02:00
84d6321153 Merge Tag v0.2.19 2025-06-13 21:26:19 +02:00
1af98fb233 Merge Tag v0.2.18 2025-06-13 21:22:11 +02:00
Doh
8d2bfaf62c update PWA icon, fix floating number balance (#159) 2025-06-04 18:40:48 -04:00
9046d40a7a Merge Tag v0.2.17.0 2025-06-04 16:08:11 +02:00
be0a5c48b3 Merge Tag v0.2.16.0 2025-06-04 16:02:37 +02:00
Doh
98b5d5eebb Added logo to README 2025-05-31 10:53:04 -04:00
Doh
276e8a8a7b refresh stale data (#156) 2025-05-30 18:04:03 -04:00
dohsimpson
1967d154ed bump version 2025-05-29 08:58:46 -04:00
Doh
9e0ae1e0da Fix emojipicker (#152) 2025-05-29 08:46:08 -04:00
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
3d78a00c66 Merge Tag v0.2.15.0 2025-05-25 17:46:20 +02: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
73 changed files with 4472 additions and 1584 deletions

View File

@@ -7,3 +7,17 @@ Dockerfile
node_modules
npm-debug.log
data
CLAUDE.md
docs/
Budfile
PLAN.md
/backups/
/data.bak/
/coverage/
*.md
!README.md
!CHANGELOG.md
tags
tsconfig.tsbuildinfo
.env.local
.env.*.local

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ next-env.d.ts
Budfile
certificates
/backups/*
CLAUDE.md
CHANGELOG.md.tmp

10
.husky/pre-commit Normal file → Executable file
View File

@@ -1 +1,11 @@
#!/bin/sh
# Check that package.json version exists in CHANGELOG.md
VERSION=$(node -p "require('./package.json').version")
if ! grep -q "## Version $VERSION" CHANGELOG.md; then
echo "❌ Error: Version $VERSION from package.json not found in CHANGELOG.md"
echo "Please add an entry for version $VERSION in CHANGELOG.md"
exit 1
fi
echo "✅ Version $VERSION found in CHANGELOG.md"
npm run typecheck && npm run lint && npm run test

View File

@@ -1,6 +1,128 @@
# Changelog
## Version 0.2.13
## Version 0.2.30
### Fixed
* Security: Updated Next.js from 15.2.3 to 15.5.7 to address CVE-2025-55182 (https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp)
## Version 0.2.29
### Added
* ✏️ Freehand drawing capability for habits and wishlist items
### Fixed
* Wishlist and Habit card layout - time and rewards sections now stay at bottom regardless of description length
* Wishlist card user avatars now appear on same row as title for consistency with habit cards
## Version 0.2.28
### Added
* Server permission checking system to validate data directory access on startup
* Permission error display with troubleshooting guidance and recheck functionality
## Version 0.2.27
### Fixed
* Mobile navigation text centering and sizing for multi-word translations
## Version 0.2.26
### Improved
* Docker build performance optimization with cache mounts
## Version 0.2.25
### Added
* 🌍 Added Catalan language support (Català)
### Fixed
* Translation files consistency: Added missing UserForm keys to English and Korean translations
## Version 0.2.24
### Added
* 🌍 Added Korean language support (한국어)
## Version 0.2.23
### Fixed
* floating number coin balance (#155)
* disable freshness check if browser does not support web crypto (#161)
### Improved
* use transparent background PWA icon with correct text (#103)
* display icon in logo
## Version 0.2.22
### Added
* auto check data freshness on interval (#138)
* warn about out-of-sync data
## Version 0.2.21
### Fixed
* emoji picker overlay issue (#150)
## 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

100
CLAUDE.md Normal file
View File

@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
HabitTrove is a gamified habit tracking PWA built with Next.js 15, TypeScript, and Jotai state management. Users earn coins for completing habits and can redeem them for rewards. Features multi-user support with admin capabilities and shared ownership of habits/wishlist items.
## Essential Commands
### Development
- `npm run dev` - Start development server with Turbopack
- `npm run setup:dev` - Full setup: installs bun, dependencies, runs typecheck and lint
- `npm install --force` - Install dependencies (force flag required)
### Quality Assurance (Run these before committing)
- `npm run typecheck` - TypeScript type checking (required)
- `npm run lint` - ESLint code linting (required)
- `npm test` - Run tests with Bun
- `npm run build` - Build production version
### Docker Deployment
- `npm run docker-build` - Build Docker image locally
- `docker compose up -d` - Run with docker-compose (recommended)
- Requires `AUTH_SECRET` environment variable: `openssl rand -base64 32`
## Version Management
### Creating a New Version
1. Update version in `package.json`
2. Update `CHANGELOG.md` with new version and changes (follow existing patterns in the file, keep entries concise - ideally 1 line per change)
3. Run `npm run typecheck && npm run lint` to ensure code quality
4. Commit changes: `git add . && git commit -m "feat: description"`
* Follow Conventional Commits Standard: `<type>[scope]: <description>` (e.g., `feat(auth): add OAuth integration`, `fix: resolve memory leak in task loader`).
## Architecture Overview
### State Management (Jotai)
- **Central atoms**: `habitsAtom`, `coinsAtom`, `wishlistAtom`, `usersAtom` in `lib/atoms.ts`
- **Derived atoms**: Computed values like `dailyHabitsAtom`, `coinsBalanceAtom`
- **Business logic hooks**: `useHabits`, `useCoins`, `useWishlist` in `/hooks`
### Data Models & Ownership
- **Individual ownership**: `CoinTransaction` has single `userId`
- **Shared ownership**: `Habit` and `WishlistItemType` have `userIds: string[]` array
- **Admin features**: Admin users can view/manage any user's data via dropdown selectors
- **Data persistence**: JSON files in `/data` directory with automatic `/backups`
### Key Components Structure
- **Feature components**: `HabitList`, `CoinsManager`, `WishlistManager` - main page components
- **Modal components**: `AddEditHabitModal`, `AddEditWishlistItemModal`, `UserSelectModal`
- **UI components**: `/components/ui` - shadcn/ui based components
### Authentication & Users
- NextAuth.js v5 with multi-user support
- User permissions: regular users vs admin users
- Admin dropdown patterns: Similar implementation across Habits/Wishlist pages (reference CoinsManager for pattern)
### Internationalization
- `next-intl` with messages in `/messages/*.json`
- Supported languages: English, Spanish, German, French, Russian, Chinese, Japanese
## Code Patterns
### Component Structure
```typescript
// Standard component pattern:
export default function ComponentName() {
const [data, setData] = useAtom(dataAtom)
const { businessLogicFunction } = useCustomHook()
// Component logic
}
```
### Hook Patterns
- Custom hooks accept options: `useHabits({ selectedUser?: string })`
- Return destructured functions and computed values
- Handle both individual and shared ownership models
### Shared Ownership Pattern
```typescript
// Filtering for shared ownership:
const userItems = allItems.filter(item =>
item.userIds && item.userIds.includes(targetUserId)
)
```
### Admin Dropdown Pattern
Reference `CoinsManager.tsx:107-119` for admin user selection implementation. Similar pattern should be applied to Habits and Wishlist pages.
## Data Safety
- Always backup `/data` before major changes
- Test with existing data files to prevent data loss
- Validate user permissions for all data operations
- Handle migration scripts carefully (see PLAN.md for shared ownership migration)
## Performance Considerations
- State updates use immutable patterns
- Large dataset filtering happens at hook level
- Derived atoms prevent unnecessary re-renders

View File

@@ -1,18 +1,17 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# Use build platform for base images to avoid emulation
FROM --platform=$BUILDPLATFORM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
# Use cache mounts for npm cache
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
RUN --mount=type=cache,target=/root/.npm \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
# use --force flag until all deps supports react19
elif [ -f package-lock.json ]; then npm ci --force; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
@@ -26,32 +25,28 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
# Use cache mount for Next.js cache
RUN --mount=type=cache,target=/app/.next/cache \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
# Production image - use target platform
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Create data and backups directories and set permissions
RUN mkdir -p /app/data /app/backups \
&& chown nextjs:nodejs /app/data /app/backups
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
mkdir -p /app/data /app/backups && \
chown nextjs:nodejs /app/data /app/backups
COPY --from=builder /app/public ./public
COPY --from=builder /app/CHANGELOG.md ./
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

View File

@@ -1,12 +1,22 @@
# HabitTrove
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
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.
## Differences to Upstream
## Try the Demo
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
Want to try HabitTrove before installing? Visit the public [demo instance](https://demo.habittrove.com) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
Differences (as of writing) are:
- resolved linting problems so you can actually commit things
- added missing dependency
- refactored adding habit modal to cause less errors
- resolved undefined error
- replaced dockerhub release flow with github
- miscellaneous refactorings
- split habits & tasks page into two different pages
- only display "show all" if there are more than 4 entries
## Features
@@ -14,8 +24,9 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
- 🏆 Earn coins for completing habits
- 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics
- ✏️ Add freehand drawings to habits and wishlist items for visual reminders
- 📅 Calendar heatmap to visualize your progress (WIP)
- 🌍 Multi-language support (English, Español, Deutsch, Français, Русский, 简体中文, 日本語)
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
- 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation

View File

@@ -21,32 +21,18 @@ import {
WishlistData,
WishlistItemType
} from '@/lib/types';
import { d2t, getNow, uuid } from '@/lib/utils';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod';
import fs from 'fs/promises';
import _ from 'lodash';
import path from 'path';
type ResourceType = 'habit' | 'wishlist' | 'coins'
type ActionType = 'write' | 'interact'
async function verifyPermission(
resource: ResourceType,
action: ActionType
): Promise<void> {
// const user = await getCurrentUser()
// if (!user) throw new PermissionError('User not authenticated')
// if (user.isAdmin) return // Admins bypass permission checks
// if (!checkPermission(user.permissions, resource, action)) {
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
// }
return
}
function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T;
}
@@ -90,7 +76,7 @@ async function loadData<T>(type: DataType): Promise<T> {
await fs.access(filePath)
} catch {
// File doesn't exist, create it with default data
const initialData = getDefaultData(type)
const initialData = getDefaultData<T>(type)
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
return initialData as T
}
@@ -119,10 +105,38 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
}
}
/**
* Calculates the server's global freshness token based on all core data files.
* This is an expensive operation as it reads all data files.
*/
async function calculateServerFreshnessToken(): Promise<string | null> {
try {
const [settings, habits, coins, wishlist, users] = await Promise.all([
loadSettings(),
loadHabitsData(),
loadCoinsData(),
loadWishlistData(),
loadUsersData()
]);
const dataString = prepareDataForHashing(
settings,
habits,
coins,
wishlist,
users
);
return generateCryptoHash(dataString);
} catch (error) {
console.error("Error calculating server freshness token:", error);
throw error;
}
}
// Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> {
const user = await getCurrentUser()
if (!user) return getDefaultWishlistData()
if (!user) return getDefaultWishlistData<WishlistData>()
const data = await loadData<WishlistData>('wishlist')
return {
@@ -137,7 +151,6 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
}
export async function saveWishlistItems(data: WishlistData): Promise<void> {
await verifyPermission('wishlist', 'write')
const user = await getCurrentUser()
data.items = data.items.map(wishlist => ({
@@ -160,17 +173,14 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
// Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> {
const user = await getCurrentUser()
if (!user) return getDefaultHabitsData()
if (!user) return getDefaultHabitsData<HabitsData>()
const data = await loadData<HabitsData>('habits')
return {
...data,
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
}
}
export async function saveHabitsData(data: HabitsData): Promise<void> {
await verifyPermission('habit', 'write')
const user = await getCurrentUser()
// Create clone of input data
const newData = _.cloneDeep(data)
@@ -182,7 +192,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
}))
if (!user?.isAdmin) {
const existingData = await loadData<HabitsData>('habits')
const existingData = await loadHabitsData();
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
newData.habits = [
...existingHabits,
@@ -198,14 +208,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
export async function loadCoinsData(): Promise<CoinsData> {
try {
const user = await getCurrentUser()
if (!user) return getDefaultCoinsData()
if (!user) return getDefaultCoinsData<CoinsData>()
const data = await loadData<CoinsData>('coins')
return {
...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
}
} catch {
return getDefaultCoinsData()
return getDefaultCoinsData<CoinsData>()
}
}
@@ -245,11 +255,10 @@ export async function addCoins({
note?: string
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: uuid(),
id: crypto.randomUUID(),
amount,
type,
description,
@@ -269,7 +278,7 @@ export async function addCoins({
}
export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings()
const defaultSettings = getDefaultSettings<Settings>()
try {
const user = await getCurrentUser()
@@ -300,11 +309,10 @@ export async function removeCoins({
note?: string
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: uuid(),
id: crypto.randomUUID(),
amount: -amount,
type,
description,
@@ -362,7 +370,7 @@ export async function loadUsersData(): Promise<UserData> {
try {
return await loadData<UserData>('auth')
} catch {
return getDefaultUsersData()
return getDefaultUsersData<UserData>()
}
}
@@ -406,7 +414,7 @@ export async function createUser(formData: FormData): Promise<User> {
const newUser: User = {
id: uuid(),
id: crypto.randomUUID(),
username,
password: hashedPassword,
permissions,
@@ -591,3 +599,24 @@ export async function loadServerSettings(): Promise<ServerSettings> {
isDemo: !!process.env.DEMO,
}
}
/**
* Checks if the client's data is fresh by comparing its token with the server's token.
* @param clientToken The freshness token calculated by the client.
* @returns A promise that resolves to an object { isFresh: boolean }.
*/
export async function checkDataFreshness(clientToken: string): Promise<{ isFresh: boolean }> {
try {
const serverToken = await calculateServerFreshnessToken();
const isFresh = clientToken === serverToken;
if (!isFresh) {
console.log(`Data freshness check: Stale. Client token: ${clientToken}, Server token: ${serverToken}`);
}
return { isFresh };
} catch (error) {
console.error("Error in checkDataFreshness:", error);
// If server fails to determine its token, assume client might be stale to be safe,
// or handle error reporting differently.
return { isFresh: false };
}
}

View File

@@ -1,7 +1,7 @@
import { ReactNode } from "react";
export default function Debug({children}: {children: ReactNode}) {
if (process.env.NODE_ENV !== 'development') return null
if (process.env.NODE_ENV !== 'development') return <></>
return (
<div className="debug">
{children}

View File

@@ -1,30 +1,23 @@
import { JotaiHydrate } from '@/components/jotai-hydrate'
import { JotaiProvider } from '@/components/jotai-providers'
import Layout from '@/components/Layout'
import LoadingSpinner from '@/components/LoadingSpinner'
import { ThemeProvider } from "@/components/theme-provider"
import { Toaster } from '@/components/ui/toaster'
import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google'
import { Suspense } from 'react'
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
import './globals.css'
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
// Inter (clean, modern, excellent readability)
// const inter = Inter({
// subsets: ['latin'],
// weight: ['400', '500', '600', '700']
// })
// Clean and contemporary
const dmSans = DM_Sans({
const activeFont = DM_Sans({
subsets: ['latin'],
weight: ['400', '500', '600', '700']
})
const activeFont = dmSans
export const metadata = {
title: 'HabitTrove',
description: 'Track your habits and get rewarded',
@@ -73,7 +66,7 @@ export default async function RootLayout({
}}
/>
<JotaiProvider>
<Suspense fallback="loading">
<Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate
initialValues={{
settings: initialSettings,

View File

@@ -14,11 +14,14 @@ import { toast } from '@/hooks/use-toast';
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
import { Settings, WeekDay } from '@/lib/types';
import { useAtom } from 'jotai';
import { Info } from 'lucide-react'; // Import Info icon
import { useSession } from 'next-auth/react'; // signOut removed
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { saveSettings } from '../actions/data';
import { useSession } from 'next-auth/react'; // signOut removed
import { useRouter } from 'next/navigation';
// AlertDialog components and useState removed
// Trash2 icon removed
@@ -38,7 +41,7 @@ export default function SettingsPage() {
// handleDeleteAccount function removed
if (!settings) return null
if (!settings) return <></>
return (
<>
@@ -227,10 +230,12 @@ export default function SettingsPage() {
{/* Add more languages as needed */}
<option value="en">English</option>
<option value="es">Español</option>
<option value="ca">Català</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ko"></option>
<option value="ja"></option>
</select>
</div>

View File

@@ -11,17 +11,16 @@ 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={t('dialogArisLabel')}></DialogTitle>

View File

@@ -7,19 +7,21 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Textarea } from '@/components/ui/textarea'
import { settingsAtom, usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
import { Habit } from '@/lib/types'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { useAtom } from 'jotai'
import { SmilePlus, Zap } from 'lucide-react'
import { Brush, Zap } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { RRule } from 'rrule'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'; // Import the new component
interface AddEditHabitModalProps {
onClose: () => void
@@ -37,17 +39,19 @@ 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 [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom)
const users = usersData.users
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
function getFrequencyUpdate() {
if (ruleText === initialRuleText && habit?.frequency) {
@@ -82,254 +86,286 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [],
frequency: getFrequencyUpdate(),
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
})
}
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<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">
{t('nameLabel')}
</Label>
<div className='flex col-span-3 gap-2'>
<Input
id="name"
value={name}
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>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
{t('whenLabel')}
</Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2">
<div className="flex gap-2">
<>
<ModalOverlay />
<Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
onClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader>
<DialogTitle>
{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">
{t('nameLabel')}
</Label>
<div className='flex col-span-3 gap-2'>
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
{isTask && (
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</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>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('completeLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions}
onChange={(e) => {
const value = parseInt(e.target.value)
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
}}
min={1}
max={10}
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={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
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">
{t('timesSuffix')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('rewardLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
min={0}
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)}
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">
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
{t('whenLabel')}
</Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2">
<div className="flex gap-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
/>
{isTask && (
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</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>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('completeLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions}
onChange={(e) => {
const value = parseInt(e.target.value)
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
}}
min={1}
max={10}
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={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
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">
{t('timesSuffix')}
</span>
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">
{habit
? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('rewardLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinReward}
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 => 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">
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
{t('drawingLabel')}
</Label>
<div className="col-span-3">
<div className="flex gap-4 items-center">
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
className="flex-1 justify-start"
>
<Brush className="h-4 w-4 mr-2" />
{drawing ? t('editDrawing') : t('addDrawing')}
</Button>
{drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={drawing}
width={80}
height={53}
className=""
/>
</div>
)}
</div>
</div>
</div>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit" disabled={!!errorMessage}>
{habit
? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
)
}

View File

@@ -1,23 +1,22 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Textarea } from '@/components/ui/textarea'
import { usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { WishlistItemType } from '@/lib/types'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { useAtom } from 'jotai'
import { SmilePlus } from 'lucide-react'
import { Brush } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
interface AddEditWishlistItemModalProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void
@@ -26,7 +25,6 @@ interface AddEditWishlistItemModalProps {
}
export default function AddEditWishlistItemModal({
isOpen,
setIsOpen,
editingItem,
setEditingItem,
@@ -39,10 +37,12 @@ export default function AddEditWishlistItemModal({
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)
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
useEffect(() => {
if (editingItem) {
@@ -51,12 +51,14 @@ export default function AddEditWishlistItemModal({
setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '')
setDrawing(editingItem.drawing || '')
} else {
setName('')
setDescription('')
setCoinCost(1)
setTargetCompletions(undefined)
setLink('')
setDrawing('')
}
setErrors({})
}, [editingItem])
@@ -68,6 +70,8 @@ export default function AddEditWishlistItemModal({
}
if (coinCost < 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 = t('errorTargetCompletionsMin')
@@ -103,7 +107,8 @@ export default function AddEditWishlistItemModal({
coinCost,
targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
}
if (editingItem) {
@@ -111,212 +116,248 @@ export default function AddEditWishlistItemModal({
} else {
addWishlistItem(itemData)
}
setIsOpen(false)
setEditingItem(null)
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<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">
{t('nameLabel')}
</Label>
<div className="col-span-3 flex gap-2">
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
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>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('costLabel')}
<>
<ModalOverlay />
<Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
handleClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader>
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
</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">
{t('nameLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
min={0}
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)}
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">
{t('coinsSuffix')}
</span>
<div className="col-span-3 flex gap-2">
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1"
required
/>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('redeemableLabel')}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions || ''}
onChange={(e) => {
const value = e.target.value
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
}}
min={0}
placeholder="∞"
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={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
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">
{t('timesSuffix')}
</span>
</div>
{errors.targetCompletions && (
<div className="text-sm text-red-500">
{errors.targetCompletions}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
{t('linkLabel')}
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('costLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
-
</button>
<Input
id="coinReward"
type="number"
value={coinCost}
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 => 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">
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('redeemableLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions || ''}
onChange={(e) => {
const value = e.target.value
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
}}
min={0}
placeholder="∞"
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={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
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">
{t('timesSuffix')}
</span>
</div>
{errors.targetCompletions && (
<div className="text-sm text-red-500">
{errors.targetCompletions}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
{t('linkLabel')}
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
className="col-span-3"
/>
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
{t('drawingLabel')}
</Label>
<div className="col-span-3">
<div className="flex gap-4 items-center">
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
className="flex-1 justify-start"
>
<Brush className="h-4 w-4 mr-2" />
{drawing ? t('editDrawing') : t('addDrawing')}
</Button>
{drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={drawing}
width={80}
height={53}
className=""
/>
</div>
)}
</div>
</div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
)
}

View File

@@ -1,17 +1,29 @@
'use client'
import { ReactNode, useEffect } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
import PomodoroTimer from './PomodoroTimer'
import UserSelectModal from './UserSelectModal'
import { useSession } from 'next-auth/react'
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useSession } from 'next-auth/react';
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import AboutModal from './AboutModal';
import LoadingSpinner from './LoadingSpinner';
import PomodoroTimer from './PomodoroTimer';
import RefreshBanner from './RefreshBanner';
import UserSelectModal from './UserSelectModal';
export default function ClientWrapper({ children }: { children: ReactNode }) {
function ClientWrapperContent({ 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 [showRefreshBanner, setShowRefreshBanner] = useState(false);
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
const clientToken = useAtomValue(clientFreshnessTokenAtom);
useEffect(() => {
if (status === 'loading') return
@@ -20,15 +32,66 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
}
}, [currentUserId, status, userSelect, setUserSelect])
useEffect(() => {
setCurrentUserIdAtom(currentUserId)
}, [currentUserId, setCurrentUserIdAtom])
const performFreshnessCheck = useCallback(async () => {
if (!clientToken || status !== 'authenticated') return;
try {
const result = await checkServerDataFreshness(clientToken);
if (!result.isFresh) {
setShowRefreshBanner(true);
}
} catch (error) {
console.error("Failed to check data freshness with server:", error);
}
}, [clientToken, status]);
useEffect(() => {
// Interval for polling data freshness
if (clientToken && !showRefreshBanner && status === 'authenticated') {
const intervalId = setInterval(() => {
performFreshnessCheck();
}, 30000); // Check every 30 seconds
return () => clearInterval(intervalId);
}
}, [clientToken, performFreshnessCheck, showRefreshBanner, status]);
const handleRefresh = () => {
setShowRefreshBanner(false);
window.location.reload();
};
return (
<>
{children}
{pomo.show && (
<PomodoroTimer />
)}
{userSelect && (
<UserSelectModal onClose={() => setUserSelect(false)}/>
)}
{pomo.show && <PomodoroTimer />}
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
</>
)
);
}
export default function ClientWrapper({ children }: { children: ReactNode }) {
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);
}, []);
if (!isMounted) {
return <LoadingSpinner />;
}
return (
<Suspense fallback={<LoadingSpinner />}>
<ClientWrapperContent>{children}</ClientWrapperContent>
</Suspense>
);
}

View File

@@ -3,11 +3,12 @@
import { FormattedNumber } from '@/components/FormattedNumber'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useCoins } from '@/hooks/useCoins'
import { settingsAtom, usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { TransactionType } from '@/lib/types'
import { d2s, t2d } from '@/lib/utils'
import { useAtom } from 'jotai'
@@ -21,7 +22,7 @@ import { TransactionNoteEditor } from './TransactionNoteEditor'
export default function CoinsManager() {
const t = useTranslations('CoinsManager')
const { currentUser } = useHelpers()
const [currentUser] = useAtom(currentUserAtom)
const [selectedUser, setSelectedUser] = useState<string>()
const {
add,
@@ -139,7 +140,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>
@@ -147,7 +152,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">
@@ -158,7 +178,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>

View File

@@ -14,6 +14,7 @@ import {
} from "@/components/ui/tooltip"
import { useHabits } from '@/hooks/useHabits'
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
import { Habit, WishlistItemType } from '@/lib/types'
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
import { useAtom } from 'jotai'
@@ -27,6 +28,7 @@ import ConfirmDialog from './ConfirmDialog'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import Linkify from './linkify'
import { Button } from './ui/button'
import DrawingDisplay from './DrawingDisplay'
interface UpcomingItemsProps {
habits: Habit[]
@@ -165,7 +167,7 @@ const ItemSection = ({
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, currentExpanded ? undefined : 5)
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((habit) => {
const completionsToday = habit.completions.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
@@ -245,6 +247,16 @@ const ItemSection = ({
{habit.name}
</span>
</Link>
{habit.drawing && (
<div className="ml-2 pr-2">
<DrawingDisplay
drawingData={habit.drawing}
width={40}
height={26}
className="border-0"
/>
</div>
)}
</span>
</div>
</ContextMenuTrigger>
@@ -295,7 +307,7 @@ const ItemSection = ({
onClick={() => setCurrentExpanded(!currentExpanded)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{currentExpanded ? (
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
<>
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
@@ -305,7 +317,7 @@ const ItemSection = ({
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
))}
</button>
<Link
href={viewLink}
@@ -444,7 +456,7 @@ export default function DailyOverview({
) : (
<>
{sortedWishlistItems
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
@@ -457,9 +469,19 @@ export default function DailyOverview({
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
{item.drawing && (
<DrawingDisplay
drawingData={item.drawing}
width={40}
height={26}
className="border-0"
/>
)}
</div>
<span className="text-xs flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
@@ -501,7 +523,7 @@ export default function DailyOverview({
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{browserSettings.expandedWishlist ? (
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
<>
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
@@ -511,7 +533,7 @@ export default function DailyOverview({
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
))}
</button>
<Link
href="/wishlist"

View File

@@ -0,0 +1,241 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Undo2, Trash2, Palette } from 'lucide-react'
import { useTranslations } from 'next-intl'
interface DrawingCanvasProps {
initialDrawing?: string
onSave: (drawingData: string) => void
onClear?: () => void
}
export default function DrawingCanvas({ initialDrawing, onSave, onClear }: DrawingCanvasProps) {
const t = useTranslations('DrawingModal')
const [drawingHistory, setDrawingHistory] = useState<Array<{
color: string
thickness: number
points: Array<{ x: number; y: number }>
}>>([])
const [isDrawing, setIsDrawing] = useState(false)
const [color, setColor] = useState('#000000')
const [thickness, setThickness] = useState(4)
const canvasRef = useRef<HTMLCanvasElement>(null)
const contextRef = useRef<CanvasRenderingContext2D | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const context = canvas.getContext('2d')
if (!context) return
context.lineCap = 'round'
context.lineJoin = 'round'
contextRef.current = context
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
}
window.addEventListener('resize', resizeCanvas)
resizeCanvas()
return () => {
window.removeEventListener('resize', resizeCanvas)
}
}, [])
useEffect(() => {
if (initialDrawing) {
try {
const loadedData = JSON.parse(initialDrawing)
if (Array.isArray(loadedData)) {
setDrawingHistory(loadedData)
}
} catch (e) {
console.warn('Failed to load initial drawing data')
}
}
}, [initialDrawing])
useEffect(() => {
redrawCanvas()
}, [drawingHistory])
const getMousePos = (event: React.MouseEvent) => {
const canvas = canvasRef.current
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
}
}
const startDrawing = (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const { x, y } = getMousePos(event)
setIsDrawing(true)
contextRef.current?.beginPath()
contextRef.current?.moveTo(x, y)
setDrawingHistory(prevHistory => [
...prevHistory,
{ color, thickness, points: [{ x, y }] }
])
}
const draw = (event: React.MouseEvent) => {
if (!isDrawing || !contextRef.current) return
event.preventDefault()
event.stopPropagation()
const { x, y } = getMousePos(event)
contextRef.current.lineTo(x, y)
contextRef.current.strokeStyle = color
contextRef.current.lineWidth = thickness
contextRef.current.stroke()
setDrawingHistory(prevHistory => {
const lastStroke = prevHistory[prevHistory.length - 1]
if (lastStroke) {
lastStroke.points.push({ x, y })
}
return [...prevHistory]
})
}
const stopDrawing = (event?: React.MouseEvent) => {
if (event) {
event.preventDefault()
event.stopPropagation()
}
setIsDrawing(false)
contextRef.current?.closePath()
}
const redrawCanvas = () => {
const canvas = canvasRef.current
if (!canvas || !contextRef.current) return
const context = contextRef.current
context.clearRect(0, 0, canvas.width, canvas.height)
drawingHistory.forEach(stroke => {
if (stroke.points.length === 0) return
context.beginPath()
context.strokeStyle = stroke.color
context.lineWidth = stroke.thickness
context.moveTo(stroke.points[0].x, stroke.points[0].y)
stroke.points.forEach(point => {
context.lineTo(point.x, point.y)
})
context.stroke()
})
}
const handleUndo = () => {
setDrawingHistory(prevHistory => {
const newHistory = [...prevHistory]
newHistory.pop()
return newHistory
})
}
const handleClear = () => {
setDrawingHistory([])
onClear?.()
}
const handleSave = () => {
const jsonString = drawingHistory.length > 0 ? JSON.stringify(drawingHistory) : ''
onSave(jsonString)
}
return (
<div className="flex flex-col space-y-4">
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={(e) => stopDrawing(e)}
onMouseLeave={(e) => stopDrawing(e)}
className="border border-gray-300 rounded-lg bg-white touch-none w-full h-80 cursor-crosshair"
/>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="colorPicker" className="text-sm font-medium">
{t('colorLabel')}
</Label>
<div className="flex items-center gap-1">
<Palette className="h-4 w-4 text-muted-foreground" />
<Input
type="color"
id="colorPicker"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 border-2 border-gray-300 rounded cursor-pointer p-0"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="lineThickness" className="text-sm font-medium">
{t('thicknessLabel')}
</Label>
<Input
type="range"
id="lineThickness"
min="1"
max="20"
value={thickness}
onChange={(e) => setThickness(Number(e.target.value))}
className="w-20"
/>
<span className="text-xs text-muted-foreground w-6">{thickness}</span>
</div>
<div className="flex gap-2 ml-auto">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={drawingHistory.length === 0}
>
<Undo2 className="h-4 w-4 mr-1" />
{t('undoButton')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClear}
disabled={drawingHistory.length === 0}
>
<Trash2 className="h-4 w-4 mr-1" />
{t('clearButton')}
</Button>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSave}>
{t('saveDrawingButton')}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useEffect, useRef } from 'react'
interface DrawingDisplayProps {
drawingData?: string
width?: number
height?: number
className?: string
}
interface DrawingStroke {
color: string
thickness: number
points: Array<{ x: number; y: number }>
}
export default function DrawingDisplay({
drawingData,
width = 120,
height = 80,
className = ''
}: DrawingDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !drawingData) return
const context = canvas.getContext('2d')
if (!context) return
try {
const strokes: DrawingStroke[] = JSON.parse(drawingData)
// Clear canvas
context.clearRect(0, 0, canvas.width, canvas.height)
// Set up context for drawing
context.lineCap = 'round'
context.lineJoin = 'round'
// Calculate scaling to fit the drawing in the small canvas
if (strokes.length === 0) return
// Find bounds of the drawing
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
strokes.forEach(stroke => {
stroke.points.forEach(point => {
minX = Math.min(minX, point.x)
minY = Math.min(minY, point.y)
maxX = Math.max(maxX, point.x)
maxY = Math.max(maxY, point.y)
})
})
// Add padding
const padding = 10
const drawingWidth = maxX - minX + padding * 2
const drawingHeight = maxY - minY + padding * 2
// Calculate scale to fit in canvas
const scaleX = canvas.width / drawingWidth
const scaleY = canvas.height / drawingHeight
const scale = Math.min(scaleX, scaleY, 1) // Don't scale up
// Center the drawing
const offsetX = (canvas.width - drawingWidth * scale) / 2 - (minX - padding) * scale
const offsetY = (canvas.height - drawingHeight * scale) / 2 - (minY - padding) * scale
// Draw each stroke
strokes.forEach(stroke => {
if (stroke.points.length === 0) return
context.beginPath()
context.strokeStyle = stroke.color
context.lineWidth = Math.max(1, stroke.thickness * scale) // Ensure minimum line width
const firstPoint = stroke.points[0]
context.moveTo(
firstPoint.x * scale + offsetX,
firstPoint.y * scale + offsetY
)
stroke.points.forEach(point => {
context.lineTo(
point.x * scale + offsetX,
point.y * scale + offsetY
)
})
context.stroke()
})
} catch (error) {
console.warn('Failed to render drawing:', error)
}
}, [drawingData, width, height])
if (!drawingData) {
return null
}
return (
<canvas
ref={canvasRef}
width={width}
height={height}
className={`border-2 border-muted-foreground rounded bg-white ${className}`}
style={{ width: `${width}px`, height: `${height}px` }}
/>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import DrawingCanvas from './DrawingCanvas'
import { useTranslations } from 'next-intl'
interface DrawingModalProps {
isOpen: boolean
onClose: () => void
onSave: (drawingData: string) => void
initialDrawing?: string
title?: string
}
export default function DrawingModal({
isOpen,
onClose,
onSave,
initialDrawing,
title = 'Drawing'
}: DrawingModalProps) {
const t = useTranslations('DrawingModal')
const [currentDrawing, setCurrentDrawing] = useState<string>(initialDrawing || '')
const handleSave = (drawingData: string) => {
setCurrentDrawing(drawingData)
onSave(drawingData)
onClose()
}
const handleClear = () => {
setCurrentDrawing('')
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div
className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold">{title}</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-6 w-6"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="p-6" onClick={(e) => e.stopPropagation()}>
<DrawingCanvas
initialDrawing={currentDrawing}
onSave={handleSave}
onClear={handleClear}
/>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-6 border-t">
<Button variant="outline" onClick={onClose}>
{t('cancelButton')}
</Button>
</div>
</div>
</div>
)
}

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={false} 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

@@ -1,12 +1,11 @@
import { Habit } 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 {
@@ -28,10 +27,10 @@ export function 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;

View File

@@ -1,22 +1,22 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useHabits } from '@/hooks/useHabits'
import { settingsAtom, usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit, User } from '@/lib/types'
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'; // Removed unused icons
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Button } from './ui/button'
interface HabitItemProps {
habit: Habit
@@ -25,13 +25,13 @@ interface HabitItemProps {
}
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return null;
if (!habit.userIds || habit.userIds.length <= 1) return <></>;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return null
if (!user) return <></>;
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
@@ -45,7 +45,7 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: {
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1
@@ -53,10 +53,10 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
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 pathname = usePathname();
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'habit', 'write')
const canInteract = hasPermission(currentUser, 'habit', 'interact')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -82,7 +82,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
id={`habit-${habit.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<CardHeader className="flex-shrink-0">
<div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
<div className="flex items-center gap-1">
@@ -99,26 +99,44 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</CardTitle>
{renderUserAvatars(habit, currentUser as User, usersData)}
</div>
{habit.description && (
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
{(habit.description || habit.drawing) && (
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
{habit.description && (
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
)}
{habit.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={habit.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
{t('whenLabel', { frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule: pathname.includes("habits"),
timezone: settings.system.timezone
})})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
<CardContent className="flex-grow flex flex-col justify-end">
<div className="mt-auto">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
{t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule: pathname.includes("habits"),
timezone: settings.system.timezone
})
})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
<div className="flex gap-2">
<div className="relative">
<Button
@@ -184,7 +202,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<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" />
@@ -204,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Card>
)
}

View File

@@ -8,6 +8,7 @@ import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
interface HabitStreakProps {
habits: Habit[]
}

View File

@@ -1,25 +1,13 @@
'use client'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Logo } from '@/components/Logo'
import { useCoins } from '@/hooks/useCoins'
import { settingsAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { Coins } from 'lucide-react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import NotificationBell from './NotificationBell'
import { Profile } from './Profile'
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 { balance } = useCoins()
return (
<>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -28,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,23 +1,26 @@
import ClientWrapper from './ClientWrapper'
import Header from './Header'
import Navigation from './Navigation'
import PermissionError from './PermissionError'
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">
{/* responsive container (optimized for mobile) */}
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
<Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden">
<Navigation position='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">
<ClientWrapper>
<PermissionError />
{children}
</ClientWrapper>
</div>
</main>
<Navigation viewPort='mobile' />
<Navigation position='mobile' />
</div>
</div>
</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

@@ -1,8 +1,9 @@
import Image from "next/image"
export function Logo() {
return (
<div className="flex items-center gap-2">
{/* <Sparkles className="h-6 w-6 text-primary" /> */}
<Image src="/icons/icon.png" alt="HabitTrove Logo" width={96} height={96} className="h-12 w-12 hidden xs:inline" />
<span className="font-bold text-xl">HabitTrove</span>
</div>
)

View File

@@ -0,0 +1,9 @@
/**
* ModalOverlay provides a dimming effect for non-modal dialogs or popovers
* that need to appear modal (e.g., to prevent interaction with background elements).
* It should be rendered alongside the dialog/popover it's intended to overlay for.
* Ensure the dialog/popover has a z-index higher than this overlay (default z-40).
*/
export default function ModalOverlay() {
return <div className="fixed inset-0 z-50 bg-black/80" />
}

61
components/NavDisplay.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { useHelpers } from '@/lib/client-helpers';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { NavItemType } from './Navigation';
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) {
const pathname = usePathname();
const { isIOS } = useHelpers()
if (isMobile) {
return (
<>
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)}
<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-6 w-full">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
(pathname === (item.href) ?
"text-blue-500 dark:text-blue-500" :
"text-gray-300 dark:text-gray-300")
}
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
</>
);
} else {
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.map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex items-center px-2 py-2 font-medium rounded-md " +
(pathname === (item.href) ?
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
"text-gray-300 hover:text-white hover:bg-gray-700")}
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -1,104 +1,40 @@
'use client'
import { useHelpers } from '@/lib/client-helpers'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
import { ElementType, useEffect, useState } from 'react'
import NavDisplay from './NavDisplay'
type ViewPort = 'main' | 'mobile'
interface NavigationProps {
viewPort: ViewPort
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
}
export default function Navigation({ viewPort }: NavigationProps) {
const t = useTranslations('Navigation')
const [showAbout, setShowAbout] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
const { isIOS } = useHelpers()
const pathname = usePathname();
const navItems = () => [
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
{ icon: HabitIcon, label: t('habits'), href: '/habits', position: 'main' },
{ icon: TaskIcon, label: t('tasks'), href: '/tasks', position: 'main' },
{ icon: Calendar, label: t('calendar'), href: '/calendar', position: 'main' },
{ icon: Gift, label: t('wishlist'), href: '/wishlist', position: 'main' },
{ icon: Coins, label: t('coins'), href: '/coins', position: 'main' },
]
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
const t = useTranslations('Navigation');
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
useEffect(() => {
const handleResize = () => {
setIsMobileView(window.innerWidth < 1024)
}
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [setIsMobile]);
// Set initial value
handleResize()
const currentNavItems: NavItemType[] = [
{ icon: Home, label: t('dashboard'), href: '/' },
{ icon: HabitIcon, label: t('habits'), href: '/habits' },
{ icon: TaskIcon, label: t('tasks'), href: '/tasks' },
{ icon: Calendar, label: t('calendar'), href: '/calendar' },
{ icon: Gift, label: t('wishlist'), href: '/wishlist' },
{ icon: Coins, label: t('coins'), href: '/coins' },
]
// Add event listener
window.addEventListener('resize', handleResize)
// Cleanup
return () => window.removeEventListener('resize', handleResize)
}, [])
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-6 w-full">
{...navItems().map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
(pathname === (item.href) ?
"text-blue-500 dark:text-blue-500" :
"text-gray-300 dark:text-gray-300")
}
>
<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)} />
</>
)
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
}
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().filter(item => item.position === 'main').map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex items-center px-2 py-2 font-medium rounded-md " +
(pathname === (item.href) ?
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
"text-gray-300 hover:text-white hover:bg-gray-700")}
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</div>
)
else {
return <></>
}
}

View File

@@ -2,7 +2,7 @@
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';
@@ -14,12 +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 t = useTranslations('NotificationBell');
const { currentUser } = useHelpers();
const [currentUser] = useAtom(currentUserAtom);
const [coinsData] = useAtom(coinsAtom)
const [habitsData] = useAtom(habitsAtom)
const [wishlistData] = useAtom(wishlistAtom)
@@ -122,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

@@ -0,0 +1,43 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertTriangle } from 'lucide-react'
import { checkStartupPermissions } from '@/lib/startup-checks'
import RecheckButton from './RecheckButton'
export default async function PermissionError() {
const permissionResult = await checkStartupPermissions()
// If everything is fine, render nothing
if (permissionResult.success) {
return <></>
}
// Get error message
const getErrorMessage = () => {
return permissionResult.error?.message || 'Unknown permission error occurred.'
}
return (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-bold">Permission Error</AlertTitle>
<AlertDescription className="mt-2 flex flex-col">
<div className="space-y-3">
<span className="text-sm">
{getErrorMessage()}{" "}
<a
href="https://docs.habittrove.com/troubleshooting"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-red-300"
>
Troubleshooting Guide
</a>
</span>
<div>
<RecheckButton />
</div>
</div>
</AlertDescription>
</Alert>
)
}

View File

@@ -177,7 +177,7 @@ export default function PomodoroTimer() {
const progress = (timeLeft / currentTimerRef.current.duration) * 100
if (!show) return null
if (!show) return <></>
return (
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">

View File

@@ -5,15 +5,13 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { toast } from "@/hooks/use-toast"
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
import { useHelpers } from "@/lib/client-helpers"
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
import { useAtom } from "jotai"
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
import { useTranslations } from 'next-intl'
import { useTheme } from "next-themes"
import Link from "next/link"
import { useState } from "react"
import AboutModal from "./AboutModal"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm'
@@ -22,9 +20,9 @@ export function 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 () => {
@@ -67,7 +65,7 @@ export function Profile() {
</AvatarFallback>
</Avatar>
<div className="flex flex-col mr-4">
<span className="text-sm font-semibold flex items-center gap-1">
<span className="text-sm font-semibold flex items-center gap-1 break-all">
{user?.username || t('guestUsername')}
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
</span>
@@ -111,27 +109,33 @@ export function Profile() {
</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={t('settingsLink')}
className="flex items-center w-full gap-3"
className="flex items-center justify-between w-full"
onClick={() => setOpen(false)} // Ensure dropdown closes on click
>
<Settings className="h-4 w-4" />
<span>{t('settingsLink')}</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>{t('aboutButton')}</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>{t('themeLabel')}</span>
</div>
@@ -169,8 +173,6 @@ export function Profile() {
</DropdownMenuContent>
</DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* Add the UserForm dialog */}
{isEditing && user && (
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>

View File

@@ -0,0 +1,22 @@
'use client'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
export default function RecheckButton() {
const handleRecheck = () => {
window.location.reload()
}
return (
<Button
onClick={handleRecheck}
variant="outline"
size="sm"
className="bg-red-50 border-red-300 text-red-700 hover:bg-red-100"
>
<RefreshCw className="h-4 w-4 mr-2" />
Recheck
</Button>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import { Button } from "@/components/ui/button"
import { AlertTriangle } from "lucide-react"
interface RefreshBannerProps {
onRefresh: () => void;
}
export default function RefreshBanner({ onRefresh }: RefreshBannerProps) {
return (
<div className="fixed bottom-4 right-4 z-[100] bg-yellow-400 dark:bg-yellow-500 text-black dark:text-gray-900 p-4 rounded-lg shadow-lg flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
<div>
<p className="font-semibold">Data out of sync</p>
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
</div>
<Button
onClick={onRefresh}
variant="outline"
className="ml-auto bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700 border-yellow-600 dark:border-yellow-700 text-white dark:text-gray-900"
>
Refresh
</Button>
</div>
)
}

View File

@@ -9,7 +9,7 @@ export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins()
if (coinsEarnedToday <= 0) return null
if (coinsEarnedToday <= 0) return <></>;
return (
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">

View File

@@ -1,9 +1,19 @@
'use client';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from '@/hooks/use-toast';
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { useHelpers } from '@/lib/client-helpers';
import { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { Permission } from '@/lib/types';
import { passwordSchema, usernameSchema } from '@/lib/zod';
import { useAtom, useAtomValue } from 'jotai';
@@ -18,6 +28,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating
onCancel: () => void;
@@ -29,7 +40,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
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,
@@ -57,6 +68,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();
@@ -93,11 +167,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
@@ -247,7 +321,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
<p className="text-sm text-red-500">{t('demoPasswordDisabledMessage')}</p>
)}
</div>
<div className="flex items-center space-x-2">
<Switch
id="disable-password"
@@ -263,7 +337,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}
@@ -274,6 +348,38 @@ 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"

View File

@@ -1,25 +1,13 @@
'use client';
import { signIn } from '@/app/actions/user';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from '@/hooks/use-toast';
import { usersAtom } from '@/lib/atoms';
import { useHelpers } from '@/lib/client-helpers';
import { currentUserAtom, usersAtom } from '@/lib/atoms';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Description } from '@radix-ui/react-dialog';
import { useAtom } from 'jotai';
import { Crown, Plus, Trash2, User as UserIcon, UserRoundPen } from 'lucide-react';
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import PasswordEntryForm from './PasswordEntryForm';
@@ -27,68 +15,27 @@ import UserForm from './UserForm';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
function UserCard({
user,
onSelect,
onEdit,
showEdit,
isCurrentUser,
currentLoggedInUserId, // For "don't delete self" check
onUserDeleted // Callback to update usersAtom
}: {
user: User,
onSelect: () => void,
onEdit: () => void,
showEdit: boolean,
isCurrentUser: boolean,
currentLoggedInUserId?: string,
onUserDeleted: (userId: string) => void,
}) {
const t = useTranslations('UserSelectModal');
const tWarning = useTranslations('Warning');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteUser = async () => {
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) {
toast({
title: t('deleteUserSuccessTitle'),
description: t('deleteUserSuccessDescription', { username: user.username }),
});
onUserDeleted(user.id);
} else {
const errorData = await response.json();
toast({
title: t('deleteUserErrorTitle'),
description: errorData.error || t('genericError'),
variant: 'destructive',
});
}
} catch (error) {
toast({
title: t('deleteUserErrorTitle'),
description: t('networkError'),
variant: 'destructive',
});
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
return (
<div key={user.id} className="relative group">
<button
onClick={onSelect}
disabled={isDeleting} // Disable main button while deleting this user
className={cn(
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
isCurrentUser && "ring-2 ring-primary"
@@ -116,48 +63,12 @@ function UserCard({
e.stopPropagation(); // Prevent card selection
onEdit();
}}
disabled={isDeleting}
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>
)}
{showEdit && user.id !== currentLoggedInUserId && (
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation(); // Prevent card selection
setShowDeleteConfirm(true);
}}
disabled={isDeleting}
className="p-1 rounded-full bg-red-200 hover:bg-red-300 dark:bg-red-700 dark:hover:bg-red-600 transition-colors text-red-600 dark:text-red-300"
title={t('deleteUserTooltip')}
>
<Trash2 className="h-4 w-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{tWarning('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteUserConfirmation', { username: user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(false);}} disabled={isDeleting}>{tWarning('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.stopPropagation(); handleDeleteUser();}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)}
</div>
@@ -187,14 +98,12 @@ function UserSelectionView({
onUserSelect,
onEditUser,
onCreateUser,
onUserDeleted, // Pass through the delete handler
}: {
users: User[],
currentUserFromHook?: SafeUser,
onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void,
onCreateUser: () => void,
onUserDeleted: (userId: string) => void,
}) {
return (
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
@@ -208,8 +117,6 @@ function UserSelectionView({
onEdit={() => onEditUser(user.id)}
showEdit={!!currentUserFromHook?.isAdmin}
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
currentLoggedInUserId={currentUserFromHook?.id} // For the "don't delete self" check
onUserDeleted={onUserDeleted}
/>
))}
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
@@ -225,14 +132,8 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
const [error, setError] = useState('');
const [usersData, setUsersData] = useAtom(usersAtom);
const users = usersData.users;
const { currentUser } = useHelpers();
const [currentUser] = useAtom(currentUserAtom);
const handleUserDeleted = (userIdToDelete: string) => {
setUsersData(prevData => ({
...prevData,
users: prevData.users.filter(u => u.id !== userIdToDelete)
}));
};
const handleUserSelect = (userId: string) => {
setSelectedUser(userId);
@@ -278,7 +179,6 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
onUserSelect={handleUserSelect}
onEditUser={handleEditUser}
onCreateUser={handleCreateUser}
onUserDeleted={handleUserDeleted}
/>
) : isCreating || isEditing ? (
<UserForm

View File

@@ -7,13 +7,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { User, WishlistItemType } from '@/lib/types'
import { hasPermission } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import DrawingDisplay from './DrawingDisplay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
interface WishlistItemProps {
item: WishlistItemType
@@ -29,13 +30,13 @@ interface WishlistItemProps {
}
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
if (!item.userIds || item.userIds.length <= 1) return null;
if (!item.userIds || item.userIds.length <= 1) return <></>;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return null
if (!user) return <></>;
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
@@ -59,9 +60,9 @@ export default function WishlistItem({
isRecentlyRedeemed
}: WishlistItemProps) {
const t = useTranslations('WishlistItem')
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('wishlist', 'write')
const canInteract = hasPermission('wishlist', 'interact')
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
const [usersData] = useAtom(usersAtom)
@@ -72,37 +73,51 @@ export default function WishlistItem({
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
} ${item.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<div className="flex items-center gap-2">
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name}
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400">
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
</span>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex-1">
{item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
<CardHeader className="flex-shrink-0">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2 flex-1 min-w-0">
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name}
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
</span>
)}
</div>
{renderUserAvatars(item, currentUser as User, usersData)}
</div>
{(item.description || item.drawing) && (
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
{item.description && (
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
{item.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={item.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center gap-2">
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} {t('coinsSuffix')}
</span>
<CardContent className="flex-grow flex flex-col justify-end">
<div className="mt-auto">
<div className="flex items-center gap-2">
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} {t('coinsSuffix')}
</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
<div className="flex gap-2">
<Button
variant={canRedeem ? "default" : "secondary"}
@@ -140,7 +155,7 @@ export default function WishlistItem({
<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" />
@@ -165,7 +180,7 @@ export default function WishlistItem({
</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}
>
@@ -179,4 +194,3 @@ export default function WishlistItem({
</Card>
)
}

View File

@@ -152,14 +152,15 @@ export default function WishlistManager() {
</>
)}
</div>
<AddEditWishlistItemModal
isOpen={isModalOpen}
setIsOpen={setIsModalOpen}
editingItem={editingItem}
setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem}
/>
{isModalOpen &&
<AddEditWishlistItemModal
setIsOpen={setIsModalOpen}
editingItem={editingItem}
setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem}
/>
}
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}

View File

@@ -14,7 +14,7 @@ export function JotaiHydrate({
[coinsAtom, initialValues.coins],
[wishlistAtom, initialValues.wishlist],
[usersAtom, initialValues.users],
[serverSettingsAtom, initialValues.serverSettings]
[serverSettingsAtom, initialValues.serverSettings],
])
return children
}

61
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,61 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning:
"border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -73,7 +73,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
)
if (!colorConfig.length) {
return null
return <></>;
}
return (
@@ -135,7 +135,7 @@ const ChartTooltipContent = React.forwardRef<
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
return <></>;
}
const [item] = payload
@@ -155,7 +155,7 @@ const ChartTooltipContent = React.forwardRef<
}
if (!value) {
return null
return <></>;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
@@ -170,7 +170,7 @@ const ChartTooltipContent = React.forwardRef<
])
if (!active || !payload?.length) {
return null
return <></>;
}
const nestLabel = payload.length === 1 && indicator !== "dot"
@@ -273,7 +273,7 @@ const ChartLegendContent = React.forwardRef<
const { config } = useChart()
if (!payload?.length) {
return null
return <></>;
}
return (

77
docs/translation-guide.md Normal file
View File

@@ -0,0 +1,77 @@
# Language Guide
## Adding/Updating Translations
### Adding a New Language
To add a new language translation to HabitTrove:
1. **Create translation file**:
- Copy `messages/en.json` as a template
- Save as `messages/{language-code}.json` (e.g., `ko.json` for Korean)
- Translate all values while preserving keys and placeholder variables like `{username}`, `{count}`, etc.
2. **Validate translation structure**:
```bash
# Ensure JSON is valid
jq empty messages/{language-code}.json
# Compare structure with English (should show no differences)
diff <(jq -S . messages/en.json | jq -r 'keys | sort | .[]') <(jq -S . messages/{language-code}.json | jq -r 'keys | sort | .[]')
```
3. **Add language option to UI**:
- Edit `app/settings/page.tsx`
- Add new `<option value="{language-code}">{Language Name}</option>` in alphabetical order
4. **Update documentation**:
- Add language to README.md supported languages list
- Create new changelog entry with version bump
- Update package.json version
### Example: Adding Korean (한국어)
```bash
# 1. Copy translation file
cp /path/to/ko.json messages/ko.json
# 2. Add to settings page
# Add: <option value="ko">한국어</option>
# 3. Update README.md
# Change: 简体中文, 日본語
# To: 简체中文, 한국어, 日본語
# 4. Add changelog entry
# Create new version section with language addition
# 5. Bump package version
# Update version in package.json
```
### Translation Quality Guidelines
- Use natural, contextually appropriate expressions
- Maintain consistent terminology throughout
- Preserve all placeholder variables exactly: `{username}`, `{count}`, `{target}`, etc.
- Use appropriate formality level for the target language
- Ensure JSON structure matches English file exactly (385 total keys)
### Validation Commands
```bash
# Check JSON validity
jq empty messages/{lang}.json
# Compare key structure
node -e "
const en = require('./messages/en.json');
const target = require('./messages/{lang}.json');
// ... deep key comparison script
"
# Verify placeholder consistency
grep -o '{[^}]*}' messages/en.json | sort | uniq > en_vars.txt
grep -o '{[^}]*}' messages/{lang}.json | sort | uniq > {lang}_vars.txt
diff en_vars.txt {lang}_vars.txt
```

View File

@@ -1,48 +1,23 @@
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast';
import {
coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom,
coinsSpentTodayAtom,
currentUserAtom,
settingsAtom,
totalEarnedAtom,
totalSpentAtom,
coinsSpentTodayAtom,
transactionsTodayAtom,
coinsBalanceAtom,
settingsAtom,
usersAtom,
} 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'
function handlePermissionCheck(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
} from '@/lib/atoms';
import { MAX_COIN_LIMIT } from '@/lib/constants';
import { CoinsData } from '@/lib/types';
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
export function useCoins(options?: { selectedUser?: string }) {
const t = useTranslations('useCoins');
@@ -50,32 +25,85 @@ export function useCoins(options?: { selectedUser?: string }) {
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
const earnedToday = calculateCoinsEarnedToday(transactions, timezone);
setCoinsEarnedToday(roundToInteger(earnedToday));
const totalEarnedVal = calculateTotalEarned(transactions);
setTotalEarned(roundToInteger(totalEarnedVal));
const totalSpentVal = calculateTotalSpent(transactions);
setTotalSpent(roundToInteger(totalSpentVal));
const spentToday = calculateCoinsSpentToday(transactions, timezone);
setCoinsSpentToday(roundToInteger(spentToday));
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
setBalance(roundToInteger(calculatedBalance));
}
}, [
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', tCommon)) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
if (isNaN(amount) || amount <= 0) {
toast({
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
return <></>;
}
if (amount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return <></>;
}
const data = await addCoins({
@@ -83,7 +111,7 @@ export function useCoins(options?: { selectedUser?: string }) {
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: user?.id
userId: targetUser?.id
})
setCoins(data)
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
@@ -91,14 +119,21 @@ export function useCoins(options?: { selectedUser?: string }) {
}
const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) {
toast({
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
return <></>;
}
if (numAmount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return <></>;
}
const data = await removeCoins({
@@ -106,7 +141,7 @@ export function useCoins(options?: { selectedUser?: string }) {
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: user?.id
userId: targetUser?.id
})
setCoins(data)
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
@@ -114,14 +149,14 @@ export function useCoins(options?: { selectedUser?: string }) {
}
const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) {
toast({
title: tCommon("errorTitle"),
description: t("transactionNotFoundDescription")
})
return null
return <></>;
}
const updatedTransaction = {

View File

@@ -1,60 +1,30 @@
import { useAtom, atom } from 'jotai'
import { useTranslations } from 'next-intl'
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit, Permission, SafeUser, User } from '@/lib/types'
import { ToastAction } from '@/components/ui/toast'
import { toast } from '@/hooks/use-toast'
import { DateTime } from 'luxon'
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types'
import {
getNowInMilliseconds,
getTodayInTimezone,
isSameDate,
t2d,
d2s,
d2t,
getNow,
getCompletionsForDate,
getISODate,
d2s,
getNow,
getTodayInTimezone,
handlePermissionCheck,
isSameDate,
playSound,
checkPermission
t2d
} from '@/lib/utils'
import { ToastAction } from '@/components/ui/toast'
import { useAtom } from 'jotai'
import { Undo2 } from 'lucide-react'
import { useHelpers } from '@/lib/client-helpers'
function handlePermissionCheck(
user: SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
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)
@@ -106,7 +76,7 @@ export function useHabits() {
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
isTargetReached && playSound()
playSound()
toast({
title: t("completedTitle"),
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
@@ -207,7 +177,7 @@ export function useHabits() {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
id: habit.id || crypto.randomUUID()
}
const updatedHabits = habit.id
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)

View File

@@ -1,45 +1,17 @@
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
import { WishlistItemType } from '@/lib/types'
import { handlePermissionCheck } from '@/lib/utils'
import { celebrations } from '@/utils/celebrations'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { WishlistItemType } 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, // Consider using a more specific type like SafeUser | User | undefined
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
export function useWishlist() {
const t = useTranslations('useWishlist');
const tCommon = useTranslations('Common');
const { currentUser: user } = useHelpers()
const [user] = useAtom(currentUserAtom)
const [wishlist, setWishlist] = useAtom(wishlistAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const { balance } = useCoins()

View File

@@ -4,16 +4,19 @@ import {
calculateTotalEarned,
calculateTotalSpent,
calculateTransactionsToday,
generateCryptoHash,
getCompletionsForToday,
getHabitFreq,
getTodayInTimezone,
isHabitDue,
prepareDataForHashing,
roundToInteger,
t2d
} from "@/lib/utils";
import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
import {
CoinsData,
CompletionCache,
Freq,
getDefaultCoinsData,
@@ -22,7 +25,13 @@ import {
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
Habit
Habit,
HabitsData,
ServerSettings,
Settings,
UserData,
UserId,
WishlistData
} from "./types";
export interface BrowserSettings {
@@ -37,37 +46,41 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', {
expandedWishlist: false
} as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData())
export const settingsAtom = atom(getDefaultSettings());
export const habitsAtom = atom(getDefaultHabitsData());
export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings());
export const usersAtom = atom(getDefaultUsersData<UserData>())
export const settingsAtom = atom(getDefaultSettings<Settings>());
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
// Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
});
// Derived atom for total earned
export const totalEarnedAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalEarned(coins.transactions);
const value = calculateTotalEarned(coins.transactions);
return roundToInteger(value);
});
// Derived atom for total spent
export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalSpent(coins.transactions);
const value = calculateTotalSpent(coins.transactions);
return roundToInteger(value);
});
// Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
});
// Derived atom for transactions today
@@ -77,10 +90,27 @@ 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);
const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
});
/* transient atoms */
@@ -99,6 +129,23 @@ export const pomodoroAtom = atom<PomodoroAtom>({
})
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
/**
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
* This token can be compared with a server-generated token to detect data discrepancies.
*/
export const clientFreshnessTokenAtom = atom(async (get) => {
const settings = get(settingsAtom);
const habits = get(habitsAtom);
const coins = get(coinsAtom);
const wishlist = get(wishlistAtom);
const users = get(usersAtom);
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
const hash = await generateCryptoHash(dataString);
return hash;
});
// Derived atom for completion cache
export const completionCacheAtom = atom((get) => {
@@ -187,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
})
);
// Derived atom for daily habits
export const dailyHabitsAtom = atom((get) => {
const settings = get(settingsAtom);
const today = getTodayInTimezone(settings.system.timezone);
return get(habitsByDateFamily(today));
});

View File

@@ -4,7 +4,7 @@
import { useAtom } from 'jotai'
import { useSession } from "next-auth/react"
import { usersAtom } from './atoms'
import { checkPermission } from './utils'
import { hasPermission } from './utils'
export function useHelpers() {
const { data: session, status } = useSession()
@@ -30,8 +30,7 @@ export function useHelpers() {
currentUser,
usersData,
status,
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
checkPermission(currentUser?.permissions, resource, action),
hasPermission,
isIOS: iOS(),
}
}

View File

@@ -29,4 +29,8 @@ 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
export const DESKTOP_DISPLAY_ITEM_COUNT = 4

View File

@@ -37,4 +37,4 @@ export function verifyPassword(password?: string, storedHash?: string): boolean
const newHash = saltAndHashPassword(password, salt).split(':')[1]
// Compare the new hash with the stored hash
return newHash === hash
}
}

205
lib/startup-checks.test.ts Normal file
View File

@@ -0,0 +1,205 @@
import { describe, expect, test, beforeEach, mock } from 'bun:test'
import { checkStartupPermissions } from './startup-checks'
// Mock the fs promises module
const mockStat = mock()
const mockWriteFile = mock()
const mockReadFile = mock()
const mockUnlink = mock()
mock.module('fs', () => ({
promises: {
stat: mockStat,
writeFile: mockWriteFile,
readFile: mockReadFile,
unlink: mockUnlink,
},
}))
describe('checkStartupPermissions', () => {
beforeEach(() => {
// Reset all mocks before each test
mockStat.mockReset()
mockWriteFile.mockReset()
mockReadFile.mockReset()
mockUnlink.mockReset()
})
test('should return success when directory exists and has proper permissions', async () => {
// Mock successful directory stat
mockStat.mockResolvedValue({
isDirectory: () => true,
})
// Mock successful file operations
mockWriteFile.mockResolvedValue(undefined)
mockReadFile.mockResolvedValue('permission-test')
mockUnlink.mockResolvedValue(undefined)
const result = await checkStartupPermissions()
expect(result).toEqual({ success: true })
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
})
test('should return error when directory does not exist', async () => {
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'))
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).not.toHaveBeenCalled()
})
test('should return error when path exists but is not a directory', async () => {
// Mock path exists but is a file, not directory
mockStat.mockResolvedValue({
isDirectory: () => false,
})
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).not.toHaveBeenCalled()
})
test('should return error when write permission fails', async () => {
// Mock successful directory stat
mockStat.mockResolvedValue({
isDirectory: () => true,
})
// Mock write failure
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'))
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).not.toHaveBeenCalled()
})
test('should return error when read permission fails', async () => {
// Mock successful directory stat and write
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
// Mock read failure
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'))
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
})
test('should return error when read content does not match written content', async () => {
// Mock successful directory stat and write
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
// Mock read with different content
mockReadFile.mockResolvedValue('different-content')
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).not.toHaveBeenCalled()
})
test('should return error when cleanup (unlink) fails', async () => {
// Mock successful directory stat, write, and read
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
mockReadFile.mockResolvedValue('permission-test')
// Mock cleanup failure
mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'))
const result = await checkStartupPermissions()
// Should return error since cleanup failed and is part of the try-catch block
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
})
test('should use correct file paths', async () => {
// Mock successful operations
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
mockReadFile.mockResolvedValue('permission-test')
mockUnlink.mockResolvedValue(undefined)
await checkStartupPermissions()
// Verify the correct paths are used
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
})
})

73
lib/startup-checks.ts Normal file
View File

@@ -0,0 +1,73 @@
import { promises as fs } from 'fs'
import { join } from 'path'
const DEFAULT_DATA_DIR = 'data'
/**
* Checks startup permissions for the data directory
*/
interface StartupPermissionResult {
success: boolean
error?: { path: string; message: string; type?: 'writable_data_dir' }
}
export async function checkStartupPermissions(): Promise<StartupPermissionResult> {
const dirPath = DEFAULT_DATA_DIR
// Check if directory exists and is accessible
try {
const stats = await fs.stat(dirPath)
if (!stats.isDirectory()) {
return {
success: false,
error: {
path: dirPath,
message: `Path '${dirPath}' exists but is not a directory. Please ensure the data directory is properly configured.`,
type: 'writable_data_dir'
}
}
}
} catch (statError) {
return {
success: false,
error: {
path: dirPath,
message: `Data directory '${dirPath}' does not exist or is not accessible. Check volume mounts and permissions.`,
type: 'writable_data_dir'
}
}
}
// Test read/write permissions with a temporary file
const testFilePath = join(dirPath, '.habittrove-permission-test')
const testContent = 'permission-test'
try {
await fs.writeFile(testFilePath, testContent)
const readContent = await fs.readFile(testFilePath, 'utf8')
if (readContent !== testContent) {
return {
success: false,
error: {
path: dirPath,
message: `Data integrity check failed in '${dirPath}'. File system may be corrupted or have inconsistent behavior.`,
type: 'writable_data_dir'
}
}
}
await fs.unlink(testFilePath)
return { success: true }
} catch (rwError) {
return {
success: false,
error: {
path: dirPath,
message: `Insufficient read/write permissions for data directory '${dirPath}'. Check file permissions and ownership.`,
type: 'writable_data_dir'
}
}
}
}

View File

@@ -1,5 +1,4 @@
import { RRule } from "rrule"
import { uuid } from "./utils"
import { DateTime } from "luxon"
export type UserId = string
@@ -47,6 +46,7 @@ export type Habit = {
archived?: boolean // mark the habit as archived
pinned?: boolean // mark the habit as pinned
userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
}
@@ -61,6 +61,7 @@ export type WishlistItemType = {
targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource
userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
}
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
@@ -97,52 +98,58 @@ export interface WishlistData {
}
// Default value functions
export const getDefaultUsersData = (): UserData => ({
users: [
{
id: uuid(),
username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined
}
]
});
export function getDefaultUsersData<UserData>(): UserData {
return {
users: [
{
id: crypto.randomUUID(),
username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined
}
]
} as UserData;
};
export const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
export function getDefaultHabitsData<HabitsData>(): HabitsData {
return { habits: [] } as HabitsData;
}
export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData;
};
export const getDefaultCoinsData = (): CoinsData => ({
balance: 0,
transactions: []
});
export function getDefaultCoinsData<CoinsData>(): CoinsData {
return { balance: 0, transactions: [] } as CoinsData;
};
export const getDefaultWishlistData = (): WishlistData => ({
items: []
});
export function getDefaultWishlistData<WishlistData>(): WishlistData {
return { items: [] } as WishlistData;
}
export const getDefaultSettings = (): Settings => ({
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
},
profile: {}
});
export function getDefaultSettings<Settings>(): Settings {
return {
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
},
profile: {}
} as Settings;
};
export const getDefaultServerSettings = (): ServerSettings => ({
isDemo: false
})
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
return { isDemo: false } as ServerSettings;
}
// Map of data types to their default values
export const DATA_DEFAULTS = {
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData,
coins: getDefaultCoinsData,

View File

@@ -3,12 +3,9 @@ import {
cn,
getTodayInTimezone,
getNow,
getNowInMilliseconds,
t2d,
d2t,
d2s,
d2sDate,
d2n,
isSameDate,
calculateCoinsEarnedToday,
calculateTotalEarned,
@@ -16,16 +13,19 @@ import {
calculateCoinsSpentToday,
isHabitDueToday,
isHabitDue,
uuid,
isTaskOverdue,
deserializeRRule,
serializeRRule,
convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable,
getUnsupportedRRuleReason
prepareDataForHashing,
getUnsupportedRRuleReason,
roundToInteger,
generateCryptoHash
} from './utils'
import { CoinTransaction, ParsedResultType } from './types'
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
import { DateTime } from "luxon";
import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
import { RRule, Weekday } from 'rrule';
import { Habit } from '@/lib/types';
import { INITIAL_DUE } from './constants';
@@ -39,6 +39,33 @@ describe('cn utility', () => {
})
})
describe('roundToInteger', () => {
test('should round positive numbers correctly', () => {
expect(roundToInteger(10.123)).toBe(10);
expect(roundToInteger(10.5)).toBe(11);
expect(roundToInteger(10.75)).toBe(11);
expect(roundToInteger(10.49)).toBe(10);
});
test('should round negative numbers correctly', () => {
expect(roundToInteger(-10.123)).toBe(-10);
expect(roundToInteger(-10.5)).toBe(-10); // Math.round rounds -x.5 to -(x-1) e.g. -10.5 to -10
expect(roundToInteger(-10.75)).toBe(-11);
expect(roundToInteger(-10.49)).toBe(-10);
});
test('should handle zero correctly', () => {
expect(roundToInteger(0)).toBe(0);
expect(roundToInteger(0.0)).toBe(0);
expect(roundToInteger(-0.0)).toBe(-0);
});
test('should handle integers correctly', () => {
expect(roundToInteger(15)).toBe(15);
expect(roundToInteger(-15)).toBe(-15);
});
});
describe('getUnsupportedRRuleReason', () => {
test('should return message for HOURLY frequency', () => {
const rrule = new RRule({ freq: RRule.HOURLY });
@@ -139,7 +166,7 @@ describe('isTaskOverdue', () => {
// Create a task due "tomorrow" in UTC
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow)
// Test in various timezones
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
@@ -147,32 +174,6 @@ describe('isTaskOverdue', () => {
})
})
describe('uuid', () => {
test('should generate valid UUIDs', () => {
const id = uuid()
// UUID v4 format: 8-4-4-4-12 hex digits
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
test('should generate unique UUIDs', () => {
const ids = new Set()
for (let i = 0; i < 1000; i++) {
ids.add(uuid())
}
// All 1000 UUIDs should be unique
expect(ids.size).toBe(1000)
})
test('should generate v4 UUIDs', () => {
const id = uuid()
// Version 4 UUID has specific bits set:
// - 13th character is '4'
// - 17th character is '8', '9', 'a', or 'b'
expect(id.charAt(14)).toBe('4')
expect('89ab').toContain(id.charAt(19))
})
})
describe('datetime utilities', () => {
let fixedNow: DateTime;
let currentDateIndex = 0;
@@ -290,13 +291,6 @@ describe('getNow', () => {
})
})
describe('getNowInMilliseconds', () => {
test('should return current time in milliseconds', () => {
const now = DateTime.now().setZone('UTC')
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
})
})
describe('timestamp conversion utilities', () => {
const testTimestamp = '2024-01-01T00:00:00.000Z';
const testDateTime = DateTime.fromISO(testTimestamp);
@@ -320,16 +314,6 @@ describe('timestamp conversion utilities', () => {
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
expect(customFormat).toBe('2024-01-01')
})
test('d2sDate should format DateTime as date string', () => {
const result = d2sDate({ dateTime: testDateTime });
expect(result).toBeString()
})
test('d2n should convert DateTime to milliseconds string', () => {
const result = d2n({ dateTime: testDateTime });
expect(result).toBe('1704067200000')
})
})
describe('isSameDate', () => {
@@ -594,7 +578,7 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
})
})
@@ -707,7 +691,7 @@ describe('isHabitDue', () => {
test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE')
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
})
})
@@ -956,3 +940,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
expect(humanReadable).toBe('invalid')
})
})
describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings<Settings>();
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
const mockUsers: UserData = getDefaultUsersData<UserData>();
// Add a user to mockUsers for more realistic testing
mockUsers.users.push({
id: 'user-123',
username: 'testuser',
isAdmin: false,
});
mockHabits.habits.push({
id: 'habit-123',
name: 'Test Habit',
description: 'A habit for testing',
frequency: 'FREQ=DAILY',
coinReward: 10,
completions: [],
userIds: ['user-123']
});
describe('prepareDataForHashing', () => {
test('should produce a consistent string for the same data', () => {
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
expect(string1).toBe(string2);
});
test('should produce a different string if settings data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should produce a different string if habits data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings<Settings>();
const emptyHabits = getDefaultHabitsData<HabitsData>();
const emptyCoins = getDefaultCoinsData<CoinsData>();
const emptyWishlist = getDefaultWishlistData<WishlistData>();
const emptyUsers = getDefaultUsersData<UserData>();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
expect(string1).toBe(string2);
expect(string1).toBeDefined();
});
});
describe('generateCryptoHash', () => {
test('should generate a SHA-256 hex string', async () => {
const dataString = 'test string';
const hash = await generateCryptoHash(dataString);
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
});
test('should generate different hashes for different strings', async () => {
const hash1 = await generateCryptoHash('test string 1');
const hash2 = await generateCryptoHash('test string 2');
expect(hash1).not.toBe(hash2);
});
test('should generate the same hash for the same string', async () => {
const hash1 = await generateCryptoHash('consistent string');
const hash2 = await generateCryptoHash('consistent string');
expect(hash1).toBe(hash2);
});
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
test('should generate correct hash for a known string', async () => {
const knownString = "hello world";
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
const actualHash = await generateCryptoHash(knownString);
expect(actualHash).toBe(expectedHash);
});
});
})

View File

@@ -1,12 +1,12 @@
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 { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
import { toast } from "@/hooks/use-toast"
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
import * as chrono from 'chrono-node'
import _ from "lodash"
import { v4 as uuidv4 } from 'uuid'
import { clsx, type ClassValue } from "clsx"
import { DateTime, DateTimeFormatOptions } from "luxon"
import { Formats } from "next-intl"
import { datetime, RRule } from 'rrule'
import { twMerge } from "tailwind-merge"
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -18,6 +18,11 @@ export function getTodayInTimezone(timezone: string): string {
return getISODate({ dateTime: now, timezone });
}
// round a number to the nearest integer
export function roundToInteger(value: number): number {
return Math.round(value);
}
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
return dateTime.setZone(timezone).toISODate()!;
}
@@ -27,12 +32,6 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
return DateTime.now().setZone(timezone, { keepLocalTime });
}
// get current time in epoch milliseconds
export function getNowInMilliseconds() {
const now = getNow({});
return d2n({ dateTime: now });
}
// iso timestamp to datetime object, most for storage read
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
return DateTime.fromISO(timestamp).setZone(timezone);
@@ -55,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
}
// convert datetime object to date string, mostly for display
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
return dateTime.toLocaleString(DateTime.DATE_MED);
}
// convert datetime object to epoch milliseconds string, mostly for storage write
export function d2n({ dateTime }: { dateTime: DateTime }) {
return dateTime.toMillis().toString();
}
// compare the date portion of two datetime objects (i.e. same year, month, day)
export function isSameDate(a: DateTime, b: DateTime) {
return a.hasSame(b, 'day');
}
export function normalizeCompletionDate(date: string, timezone: string): string {
// If already in ISO format, return as is
if (date.includes('T')) {
return date;
}
// Convert from yyyy-MM-dd to ISO format
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
}
export function getCompletionsForDate({
habit,
date,
@@ -432,22 +412,20 @@ export const openWindow = (url: string): boolean => {
return true
}
export function deepMerge<T>(a: T, b: T) {
return _.merge(a, b, (x: unknown, y: unknown) => {
if (_.isArray(a)) {
return a.concat(b)
}
})
}
export function checkPermission(
permissions: Permission[] | undefined,
export function hasPermission(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!permissions) return false
return permissions.some(permission => {
if (!user || !user.permissions) {
return false;
}
// If user is admin, they have all permissions.
if (user.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return user.permissions.some(permission => {
switch (resource) {
case 'habit':
return permission.habit[action]
@@ -461,6 +439,73 @@ export function checkPermission(
})
}
export function uuid() {
return uuidv4()
/**
* Prepares a consistent string representation of the data for hashing.
* It combines all relevant data pieces into a single object and then stringifies it stably.
*/
export function prepareDataForHashing(
settings: Settings,
habits: HabitsData,
coins: CoinsData,
wishlist: WishlistData,
users: UserData
): string {
return JSON.stringify({
settings,
habits,
coins,
wishlist,
users,
});
}
/**
* Generates a SHA-256 hash for a given string using the Web Crypto API.
* This function is suitable for both client-side and server-side (Node.js 19+) environments.
* @param dataString The string to hash.
* @returns A promise that resolves to the hex string of the hash.
*/
export async function generateCryptoHash(dataString: string): Promise<string | null> {
try {
const encoder = new TextEncoder();
const data = encoder.encode(dataString);
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Convert buffer to hex string
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch (error) {
console.error(`Failed to generate hash: ${error}`);
return null;
}
}
export function handlePermissionCheck(
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!hasPermission(user, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}

447
messages/ca.json Normal file
View File

@@ -0,0 +1,447 @@
{
"Dashboard": {
"title": "Tauler"
},
"HabitList": {
"myTasks": "Les meves tasques",
"myHabits": "Els meus hàbits",
"addTaskButton": "Afegeix tasca",
"addHabitButton": "Afegeix hàbit",
"searchTasksPlaceholder": "Cerca tasques...",
"searchHabitsPlaceholder": "Cerca hàbits...",
"sortByLabel": "Ordena per:",
"sortByName": "Nom",
"sortByCoinReward": "Recompensa de monedes",
"sortByDueDate": "Data límit",
"sortByFrequency": "Freqüència",
"toggleSortOrderAriaLabel": "Canvia l'ordre de classificació",
"noTasksFoundMessage": "No s'han trobat tasques que coincideixin amb la teva cerca.",
"noHabitsFoundMessage": "No s'han trobat hàbits que coincideixin amb la teva cerca.",
"emptyStateTasksTitle": "Encara no hi ha tasques",
"emptyStateHabitsTitle": "Encara no hi ha hàbits",
"emptyStateTasksDescription": "Crea la teva primera tasca per començar a seguir el teu progrés",
"emptyStateHabitsDescription": "Crea el teu primer hàbit per començar a seguir el teu progrés",
"archivedSectionTitle": "Arxivat",
"deleteTaskDialogTitle": "Elimina tasca",
"deleteHabitDialogTitle": "Elimina hàbit",
"deleteTaskDialogMessage": "Estàs segur que vols eliminar aquesta tasca? Aquesta acció no es pot desfer.",
"deleteHabitDialogMessage": "Estàs segur que vols eliminar aquest hàbit? Aquesta acció no es pot desfer.",
"deleteButton": "Elimina"
},
"DailyOverview": {
"addTaskButtonLabel": "Afegeix tasca",
"addHabitButtonLabel": "Afegeix hàbit",
"todaysOverviewTitle": "Resum d'avui",
"dailyTasksTitle": "Tasques diàries",
"noTasksDueTodayMessage": "No hi ha tasques per avui. Afegeix-ne algunes per començar!",
"dailyHabitsTitle": "Hàbits diaris",
"noHabitsDueTodayMessage": "No hi ha hàbits per avui. Afegeix-ne alguns per començar!",
"wishlistGoalsTitle": "Objectius de la llista de desitjos",
"redeemableBadgeLabel": "{count}/{total} bescanviable",
"noWishlistItemsMessage": "Encara no hi ha elements a la llista de desitjos. Afegeix algunes metes per treballar-hi!",
"readyToRedeemMessage": "Llest per bescanviar!",
"coinsToGoMessage": "Falten {amount} monedes",
"showLessButton": "Mostra menys",
"showAllButton": "Mostra tot",
"viewButton": "Visualitza",
"deleteTaskDialogTitle": "Elimina tasca",
"deleteHabitDialogTitle": "Elimina hàbit",
"confirmDeleteDialogMessage": "Estàs segur que vols eliminar \"{name}\"? Aquesta acció no es pot desfer.",
"deleteButton": "Elimina",
"overdueTooltip": "Vençut"
},
"HabitContextMenuItems": {
"startPomodoro": "Inicia Pomodoro",
"moveToToday": "Mou a avui",
"moveToTomorrow": "Mou a demà",
"unpin": "Desfixa",
"pin": "Fixa",
"edit": "Edita",
"archive": "Arxiva",
"unarchive": "Desarxiva",
"delete": "Elimina"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Ratxa de finalització diària",
"tooltipHabitsLabel": "hàbits",
"tooltipTasksLabel": "tasques",
"tooltipCompletedLabel": "Completat"
},
"CoinBalance": {
"coinBalanceTitle": "Saldo de monedes"
},
"AddEditHabitModal": {
"editTaskTitle": "Edita tasca",
"editHabitTitle": "Edita hàbit",
"addNewTaskTitle": "Afegeix nova tasca",
"addNewHabitTitle": "Afegeix nou hàbit",
"nameLabel": "Nom *",
"descriptionLabel": "Descripció",
"whenLabel": "Quan *",
"completeLabel": "Completa",
"timesSuffix": "vegades",
"rewardLabel": "Recompensa",
"coinsSuffix": "monedes",
"drawingLabel": "Dibuix",
"addDrawing": "Afegeix Dibuix",
"editDrawing": "Edita Dibuix",
"shareLabel": "Comparteix",
"saveChangesButton": "Desa els canvis",
"addTaskButton": "Afegeix tasca",
"addHabitButton": "Afegeix hàbit"
},
"ConfirmDialog": {
"confirmButton": "Confirma",
"cancelButton": "Cancel·la"
},
"AddEditWishlistItemModal": {
"editTitle": "Edita recompensa",
"addTitle": "Afegeix nova recompensa",
"nameLabel": "Nom *",
"descriptionLabel": "Descripció",
"costLabel": "Cost",
"coinsSuffix": "monedes",
"redeemableLabel": "Bescanviable",
"timesSuffix": "vegades",
"errorNameRequired": "El nom és obligatori",
"errorCoinCostMin": "El cost en monedes ha de ser com a mínim 1",
"errorTargetCompletionsMin": "El nombre de finalitzacions objectiu ha de ser com a mínim 1",
"errorInvalidUrl": "Si us plau, introdueix una URL vàlida",
"linkLabel": "Enllaç",
"drawingLabel": "Dibuix",
"addDrawing": "Afegeix Dibuix",
"editDrawing": "Edita Dibuix",
"shareLabel": "Comparteix",
"saveButton": "Desa els canvis",
"addButton": "Afegeix recompensa"
},
"Navigation": {
"dashboard": "Tauler",
"tasks": "Tasques",
"habits": "Hàbits",
"calendar": "Calendari",
"wishlist": "Llista de desitjos",
"coins": "Monedes"
},
"TodayEarnedCoins": {
"todaySuffix": "avui"
},
"WishlistItem": {
"usesLeftSingular": "ús restant",
"usesLeftPlural": "usos restants",
"coinsSuffix": "monedes",
"redeem": "Bescanvia",
"redeemedDone": "Fet",
"redeemedExclamation": "Bescanviat!",
"editButton": "Edita",
"archiveButton": "Arxiva",
"unarchiveButton": "Desarxiva",
"deleteButton": "Elimina"
},
"WishlistManager": {
"title": "La meva llista de desitjos",
"addRewardButton": "Afegeix recompensa",
"emptyStateTitle": "La teva llista de desitjos està buida",
"emptyStateDescription": "Afegeix recompenses que t'agradaria guanyar amb les teves monedes",
"archivedSectionTitle": "Arxivat",
"popupBlockedTitle": "Finestra emergent bloquejada",
"popupBlockedDescription": "Si us plau, permet les finestres emergents per obrir l'enllaç",
"deleteDialogTitle": "Elimina recompensa",
"deleteDialogMessage": "Estàs segur que vols eliminar aquesta recompensa? Aquesta acció no es pot desfer.",
"deleteButton": "Elimina"
},
"UserSelectModal": {
"addUserButton": "Afegeix usuari",
"createNewUserTitle": "Crea nou usuari",
"selectUserTitle": "Selecciona usuari",
"signInSuccessTitle": "Inici de sessió correcte",
"signInSuccessDescription": "Benvingut de nou, {username}!",
"errorInvalidPassword": "contrasenya no vàlida",
"deleteUserConfirmation": "Estàs segur que vols eliminar l'usuari {username}? Aquesta acció no es pot desfer.",
"confirmDeleteButtonText": "Elimina",
"deletingButtonText": "Eliminant...",
"deleteUserSuccessTitle": "Usuari eliminat",
"deleteUserSuccessDescription": "L'usuari {username} s'ha eliminat correctament.",
"deleteUserErrorTitle": "Error en eliminar",
"genericError": "S'ha produït un error inesperat.",
"networkError": "S'ha produït un error de xarxa. Si us plau, torna-ho a intentar.",
"editUserTooltip": "Edita usuari",
"deleteUserTooltip": "Elimina usuari"
},
"CoinsManager": {
"title": "Gestió de monedes",
"currentBalanceLabel": "Saldo actual",
"coinsSuffix": "monedes",
"addCoinsButton": "Afegeix monedes",
"removeCoinsButton": "Elimina monedes",
"statisticsTitle": "Estadístiques",
"totalEarnedLabel": "Total guanyat",
"totalSpentLabel": "Total gastat",
"totalTransactionsLabel": "Transaccions totals",
"todaysEarnedLabel": "Guanyat avui",
"todaysSpentLabel": "Gastat avui",
"todaysTransactionsLabel": "Transaccions d'avui",
"transactionHistoryTitle": "Historial de transaccions",
"showLabel": "Mostra:",
"entriesSuffix": "entrades",
"showingEntries": "Mostrant {from} a {to} de {total} entrades",
"noTransactionsTitle": "Encara no hi ha transaccions",
"noTransactionsDescription": "El teu historial de transaccions apareixerà aquí un cop comencis a guanyar o gastar monedes",
"pageLabel": "Pàgina",
"ofLabel": "de",
"transactionTypeHabitCompletion": "Finalització d'hàbit",
"transactionTypeTaskCompletion": "Finalització de tasca",
"transactionTypeHabitUndo": "Desfer hàbit",
"transactionTypeTaskUndo": "Desfer tasca",
"transactionTypeWishRedemption": "Bescanvi de desig",
"transactionTypeManualAdjustment": "Ajust manual",
"transactionTypeCoinReset": "Reinici de monedes",
"transactionTypeInitialBalance": "Saldo inicial"
},
"NotificationBell": {
"errorUpdateTimestamp": "Error en actualitzar la marca de temps de notificació llegida:"
},
"PomodoroTimer": {
"focusLabel1": "Mantén-te concentrat",
"focusLabel2": "Tu pots",
"focusLabel3": "Continua endavant",
"focusLabel4": "Fes-ho",
"focusLabel5": "Fes que passi",
"focusLabel6": "Mantén-te fort",
"focusLabel7": "Esforça't",
"focusLabel8": "Un pas cada vegada",
"focusLabel9": "Tu pots fer-ho",
"focusLabel10": "Concentra't i conquereix",
"breakLabel1": "Fes un descans",
"breakLabel2": "Relaxa't i recarrega",
"breakLabel3": "Respira profundament",
"breakLabel4": "Estira't",
"breakLabel5": "Refresca't",
"breakLabel6": "T'ho mereixes",
"breakLabel7": "Recarrega la teva energia",
"breakLabel8": "Allunya't un moment",
"breakLabel9": "Neteja la teva ment",
"breakLabel10": "Descansa i recupera't",
"focusType": "Concentració",
"breakType": "Descans",
"pauseButton": "Pausa",
"startButton": "Inicia",
"resetButton": "Reinicia",
"skipButton": "Omet",
"wakeLockNotSupported": "El navegador no suporta wake lock",
"wakeLockInUse": "Wake lock ja està en ús",
"wakeLockRequestError": "Error en sol·licitar wake lock:",
"wakeLockReleaseError": "Error en alliberar wake lock:"
},
"HabitCalendar": {
"title": "Calendari d'hàbits",
"calendarCardTitle": "Calendari",
"selectDatePrompt": "Selecciona una data",
"tasksSectionTitle": "Tasques",
"habitsSectionTitle": "Hàbits",
"errorCompletingPastHabit": "Error en completar hàbit passat:"
},
"NotificationDropdown": {
"notLoggedIn": "No has iniciat sessió.",
"userCompletedItem": "{username} ha completat {itemName}.",
"userRedeemedItem": "{username} ha bescanviat {itemName}.",
"activityRelatedToItem": "Activitat relacionada amb {itemName} per {username}.",
"defaultUsername": "Algú",
"defaultItemName": "un element compartit",
"notificationsTitle": "Notificacions",
"notificationsTooltip": "Mostra finalitzacions o bescanvis d'altres usuaris per hàbits o llista de desitjos que has compartit amb ells (has de ser administrador)",
"noNotificationsYet": "Encara no hi ha notificacions."
},
"AboutModal": {
"dialogArisLabel": "quant a",
"changelogButton": "Registre de canvis",
"createdByPrefix": "Creat amb ❤️ per",
"starOnGitHubButton": "Dona una estrella a GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permisos",
"adminAccessLabel": "Accés d'administrador",
"adminAccessDescription": "Els administradors tenen permís complet sobre totes les dades de tots els usuaris",
"resourceHabitTask": "Hàbit / Tasca",
"resourceWishlist": "Llista de desitjos",
"resourceCoins": "Monedes",
"permissionWrite": "Escriptura",
"permissionInteract": "Interactua"
},
"UserForm": {
"toastUserUpdatedTitle": "Usuari actualitzat",
"toastUserUpdatedDescription": "Usuari {username} actualitzat amb èxit",
"toastUserCreatedTitle": "Usuari creat",
"toastUserCreatedDescription": "Usuari {username} creat amb èxit",
"actionUpdate": "actualitzar",
"actionCreate": "crear",
"errorFailedUserAction": "Error en {action} usuari",
"toastDemoDeleteDisabled": "L'eliminació està deshabilitada a la instància de demostració",
"toastCannotDeleteSelf": "No pots eliminar el teu propi compte",
"confirmDeleteUser": "Estàs segur que vols eliminar l'usuari {username}?",
"toastUserDeletedTitle": "Usuari eliminat",
"toastUserDeletedDescription": "L'usuari {username} s'ha eliminat correctament",
"toastDeleteUserFailed": "Error en eliminar l'usuari: {error}",
"errorTitle": "Error",
"errorFileSizeLimit": "La mida de l'arxiu ha de ser inferior a 5MB",
"toastAvatarUploadedTitle": "Avatar pujat",
"toastAvatarUploadedDescription": "Avatar pujat amb èxit",
"errorFailedAvatarUpload": "Error en pujar l'avatar",
"changeAvatarButton": "Canvia avatar",
"uploadAvatarButton": "Puja avatar",
"usernameLabel": "Nom d'usuari",
"usernamePlaceholder": "Nom d'usuari",
"newPasswordLabel": "Nova contrasenya",
"passwordLabel": "Contrasenya",
"passwordPlaceholderEdit": "Deixa en blanc per mantenir l'actual",
"passwordPlaceholderCreate": "Introdueix contrasenya",
"demoPasswordDisabledMessage": "La contrasenya està automàticament desactivada a la instància de demostració",
"disablePasswordLabel": "Desactiva contrasenya",
"cancelButton": "Cancel·la",
"saveChangesButton": "Desa els canvis",
"createUserButton": "Crea usuari",
"deleteAccountButton": "Elimina compte",
"deletingButtonText": "Eliminant...",
"areYouSure": "Estàs segur?",
"deleteUserConfirmation": "Estàs segur que vols eliminar l'usuari {username}?",
"cancel": "Cancel·la",
"confirmDeleteButtonText": "Elimina"
},
"ViewToggle": {
"habitsLabel": "Hàbits",
"tasksLabel": "Tasques"
},
"HabitItem": {
"overdue": "Vençut",
"whenLabel": "Quan: {frequency}",
"coinsPerCompletion": "{count} monedes per finalització",
"completedStatus": "Completat",
"completedStatusCount": "Completat ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Completa",
"completeButtonCount": "Completa ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Desfés",
"editButton": "Edita"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Nota massa llarga",
"noteTooLongDescription": "Les notes han de tenir menys de 200 caràcters",
"errorSavingNoteTitle": "Error en desar la nota",
"errorDeletingNoteTitle": "Error en eliminar la nota",
"pleaseTryAgainDescription": "Si us plau, torna-ho a intentar",
"addNotePlaceholder": "Afegeix una nota...",
"saveNoteTitle": "Desa nota",
"cancelButtonTitle": "Cancel·la",
"deleteNoteTitle": "Elimina nota",
"editNoteAriaLabel": "Edita nota"
},
"Profile": {
"guestUsername": "Convidat",
"editProfileButton": "Edita perfil",
"signOutSuccessTitle": "Tancament de sessió correcte",
"signOutSuccessDescription": "Has tancat sessió del teu compte",
"signOutErrorTitle": "Error en tancar sessió",
"signOutErrorDescription": "Error en tancar sessió",
"switchUserButton": "Canvia usuari",
"settingsLink": "Configuració",
"aboutButton": "Quant a",
"themeLabel": "Tema",
"editProfileModalTitle": "Edita perfil"
},
"PasswordEntryForm": {
"notYouButton": "No ets tu?",
"passwordLabel": "Contrasenya",
"passwordPlaceholder": "Introdueix contrasenya",
"loginErrorToastTitle": "Error",
"loginFailedErrorToastDescription": "Error en iniciar sessió",
"cancelButton": "Cancel·la",
"loginButton": "Inicia sessió"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} completat"
},
"SettingsPage": {
"title": "Configuració",
"uiSettingsTitle": "Configuració d'interfície",
"numberFormattingLabel": "Format numèric",
"numberFormattingDescription": "Formateja nombres grans (ex: 1K, 1M, 1B)",
"numberGroupingLabel": "Agrupació numèrica",
"numberGroupingDescription": "Usa separadors de milers (ex: 1.000 vs 1000)",
"systemSettingsTitle": "Configuració del sistema",
"timezoneLabel": "Zona horària",
"timezoneDescription": "Selecciona la teva zona horària per a un seguiment precís de dates",
"weekStartDayLabel": "Dia d'inici de setmana",
"weekStartDayDescription": "Selecciona el teu dia preferit per iniciar la setmana",
"weekdays": {
"sunday": "Diumenge",
"monday": "Dilluns",
"tuesday": "Dimarts",
"wednesday": "Dimecres",
"thursday": "Dijous",
"friday": "Divendres",
"saturday": "Dissabte"
},
"autoBackupLabel": "Còpia de seguretat automàtica",
"autoBackupTooltip": "Quan està habilitat, les dades de l'aplicació (hàbits, monedes, configuracions, etc.) es copien automàticament cada dia al voltant de les 2 AM hora del servidor. Les còpies de seguretat s'emmagatzemen com a fitxers ZIP al directori `backups/` a l'arrel del projecte. Només es conserven les últimes 7 còpies de seguretat; les més antigues s'eliminen automàticament.",
"autoBackupDescription": "Realitza còpia de seguretat automàtica diària",
"languageLabel": "Idioma",
"languageDescription": "Tria el teu idioma preferit per mostrar a l'aplicació.",
"languageChangedTitle": "Idioma canviat",
"languageChangedDescription": "Si us plau, actualitza la pàgina per veure els canvis",
"languageDisabledInDemoTooltip": "Canviar l'idioma està deshabilitat a la versió de demostració."
},
"Common": {
"authenticationRequiredTitle": "Autenticació requerida",
"authenticationRequiredDescription": "Si us plau, inicia sessió per continuar.",
"permissionDeniedTitle": "Permís denegat",
"permissionDeniedDescription": "No tens permís de {action} per a {resource}.",
"undoButton": "Desfés",
"redoButton": "Refés",
"errorTitle": "Error"
},
"useHabits": {
"alreadyCompletedTitle": "Ja completat",
"alreadyCompletedDescription": "Ja has completat aquest hàbit avui.",
"completedTitle": "Completat!",
"earnedCoinsDescription": "Has guanyat {coinReward} monedes.",
"progressTitle": "Progrés!",
"progressDescription": "Has completat {count}/{target} vegades avui.",
"completionUndoneTitle": "Finalització desfeta",
"completionUndoneDescription": "Tens {count}/{target} finalitzacions avui.",
"noCompletionsToUndoTitle": "No hi ha finalitzacions per desfer",
"noCompletionsToUndoDescription": "Aquest hàbit no s'ha completat avui.",
"alreadyCompletedPastDateTitle": "Ja completat",
"alreadyCompletedPastDateDescription": "Aquest hàbit ja va ser completat el {dateKey}.",
"earnedCoinsPastDateDescription": "Vas guanyar {coinReward} monedes per {dateKey}.",
"progressPastDateDescription": "Has completat {count}/{target} vegades el {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Límit de bescanvis assolit",
"redemptionLimitReachedDescription": "Has assolit el màxim de bescanvis per \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Recompensa bescanviada!",
"rewardRedeemedDescription": "Has bescanviat \"{itemName}\" per {itemCoinCost} monedes.",
"notEnoughCoinsTitle": "No hi ha prou monedes",
"notEnoughCoinsDescription": "Necessites {coinsNeeded} monedes més per bescanviar aquesta recompensa."
},
"Warning": {
"areYouSure": "Estàs segur?",
"cancel": "Cancel·la"
},
"useCoins": {
"addedCoinsDescription": "S'han afegit {amount} monedes",
"invalidAmountTitle": "Quantitat no vàlida",
"invalidAmountDescription": "Si us plau, introdueix un nombre positiu vàlid",
"successTitle": "Èxit",
"transactionNotFoundDescription": "Transacció no trobada",
"maxAmountExceededDescription": "La quantitat no pot excedir {max}."
},
"DrawingModal": {
"colorLabel": "Color:",
"thicknessLabel": "Gruix:",
"undoButton": "Desfés",
"clearButton": "Neteja",
"saveDrawingButton": "Desa el dibuix",
"cancelButton": "Cancel·la"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "mal",
"rewardLabel": "Belohnung",
"coinsSuffix": "Münzen",
"drawingLabel": "Zeichnung",
"addDrawing": "Zeichnung hinzufügen",
"editDrawing": "Zeichnung bearbeiten",
"shareLabel": "Teilen",
"saveChangesButton": "Änderungen speichern",
"addTaskButton": "Aufgabe hinzufügen",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
"linkLabel": "Link",
"drawingLabel": "Zeichnung",
"addDrawing": "Zeichnung hinzufügen",
"editDrawing": "Zeichnung bearbeiten",
"shareLabel": "Teilen",
"saveButton": "Änderungen speichern",
"addButton": "Belohnung hinzufügen"
@@ -270,6 +276,12 @@
"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",
@@ -287,7 +299,13 @@
"disablePasswordLabel": "Passwort deaktivieren",
"cancelButton": "Abbrechen",
"saveChangesButton": "Änderungen speichern",
"createUserButton": "Benutzer erstellen"
"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",
@@ -406,16 +424,26 @@
"notEnoughCoinsTitle": "Nicht genug Münzen",
"notEnoughCoinsDescription": "Sie benötigen {coinsNeeded} Münzen mehr, um diese Belohnung einzulösen."
},
"useCoins": {
"invalidAmountTitle": "Ungültiger Betrag",
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
"successTitle": "Erfolg",
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
"removedCoinsDescription": "{amount} Münzen entfernt",
"transactionNotFoundDescription": "Transaktion nicht gefunden"
},
"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."
},
"DrawingModal": {
"colorLabel": "Farbe:",
"thicknessLabel": "Dicke:",
"undoButton": "Rückgängig",
"clearButton": "Löschen",
"saveDrawingButton": "Zeichnung speichern",
"cancelButton": "Abbrechen"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "times",
"rewardLabel": "Reward",
"coinsSuffix": "coins",
"drawingLabel": "Drawing",
"addDrawing": "Add Drawing",
"editDrawing": "Edit Drawing",
"shareLabel": "Share",
"saveChangesButton": "Save Changes",
"addTaskButton": "Add Task",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Target completions must be at least 1",
"errorInvalidUrl": "Please enter a valid URL",
"linkLabel": "Link",
"drawingLabel": "Drawing",
"addDrawing": "Add Drawing",
"editDrawing": "Edit Drawing",
"shareLabel": "Share",
"saveButton": "Save Changes",
"addButton": "Add Reward"
@@ -287,7 +293,19 @@
"disablePasswordLabel": "Disable password",
"cancelButton": "Cancel",
"saveChangesButton": "Save Changes",
"createUserButton": "Create User"
"createUserButton": "Create User",
"deleteAccountButton": "Delete Account",
"toastDemoDeleteDisabled": "Deletion is disabled in demo instance",
"toastCannotDeleteSelf": "You cannot delete your own account",
"confirmDeleteUser": "Are you sure you want to delete user {username}?",
"toastUserDeletedTitle": "User deleted",
"toastUserDeletedDescription": "User {username} has been deleted successfully",
"toastDeleteUserFailed": "Failed to delete user: {error}",
"deletingButtonText": "Deleting...",
"areYouSure": "Are you sure?",
"deleteUserConfirmation": "Are you sure you want to delete user {username}?",
"cancel": "Cancel",
"confirmDeleteButtonText": "Delete"
},
"ViewToggle": {
"habitsLabel": "Habits",
@@ -407,15 +425,23 @@
"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",
"addedCoinsDescription": "Added {amount} coins",
"removedCoinsDescription": "Removed {amount} coins",
"transactionNotFoundDescription": "Transaction not found"
"transactionNotFoundDescription": "Transaction not found",
"maxAmountExceededDescription": "The amount cannot exceed {max}."
},
"Warning": {
"areYouSure": "Are you sure?",
"cancel": "Cancel"
},
"DrawingModal": {
"saveDrawingButton": "Save",
"cancelButton": "Cancel",
"clearButton": "Clear",
"undoButton": "Undo",
"colorLabel": "Color",
"thicknessLabel": "Thickness"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "veces",
"rewardLabel": "Recompensa",
"coinsSuffix": "monedas",
"drawingLabel": "Dibujo",
"addDrawing": "Añadir Dibujo",
"editDrawing": "Editar Dibujo",
"shareLabel": "Compartir",
"saveChangesButton": "Guardar cambios",
"addTaskButton": "Añadir tarea",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
"errorInvalidUrl": "Por favor ingresa una URL válida",
"linkLabel": "Enlace",
"drawingLabel": "Dibujo",
"addDrawing": "Añadir Dibujo",
"editDrawing": "Editar Dibujo",
"shareLabel": "Compartir",
"saveButton": "Guardar cambios",
"addButton": "Añadir recompensa"
@@ -270,6 +276,12 @@
"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",
@@ -287,7 +299,13 @@
"disablePasswordLabel": "Desactivar contraseña",
"cancelButton": "Cancelar",
"saveChangesButton": "Guardar cambios",
"createUserButton": "Crear usuario"
"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",
@@ -373,6 +391,14 @@
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
},
"DrawingModal": {
"colorLabel": "Color:",
"thicknessLabel": "Grosor:",
"undoButton": "Deshacer",
"clearButton": "Limpiar",
"saveDrawingButton": "Guardar Dibujo",
"cancelButton": "Cancelar"
},
"Common": {
"authenticationRequiredTitle": "Autenticación requerida",
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",
@@ -406,16 +432,18 @@
"notEnoughCoinsTitle": "No hay suficientes monedas",
"notEnoughCoinsDescription": "Necesitas {coinsNeeded} monedas más para canjear esta recompensa."
},
"useCoins": {
"invalidAmountTitle": "Cantidad inválida",
"invalidAmountDescription": "Por favor ingresa un número positivo válido",
"successTitle": "Éxito",
"addedCoinsDescription": "Añadidas {amount} monedas",
"removedCoinsDescription": "Quitadas {amount} monedas",
"transactionNotFoundDescription": "Transacción no encontrada"
},
"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}."
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "fois",
"rewardLabel": "Récompense",
"coinsSuffix": "pièces",
"drawingLabel": "Dessin",
"addDrawing": "Ajouter un Dessin",
"editDrawing": "Modifier le Dessin",
"shareLabel": "Partager",
"saveChangesButton": "Sauvegarder les modifications",
"addTaskButton": "Ajouter une tâche",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
"errorInvalidUrl": "Veuillez entrer une URL valide",
"linkLabel": "Lien",
"drawingLabel": "Dessin",
"addDrawing": "Ajouter un Dessin",
"editDrawing": "Modifier le Dessin",
"shareLabel": "Partager",
"saveButton": "Sauvegarder les modifications",
"addButton": "Ajouter une récompense"
@@ -270,6 +276,12 @@
"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é",
@@ -287,7 +299,13 @@
"disablePasswordLabel": "Désactiver le mot de passe",
"cancelButton": "Annuler",
"saveChangesButton": "Sauvegarder les modifications",
"createUserButton": "Créer un utilisateur"
"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",
@@ -406,16 +424,26 @@
"notEnoughCoinsTitle": "Pas assez de pièces",
"notEnoughCoinsDescription": "Il vous manque {coinsNeeded} pièces pour échanger cette récompense."
},
"useCoins": {
"invalidAmountTitle": "Montant invalide",
"invalidAmountDescription": "Veuillez entrer un nombre positif valide",
"successTitle": "Succès",
"addedCoinsDescription": "Ajouté {amount} pièces",
"removedCoinsDescription": "Retiré {amount} pièces",
"transactionNotFoundDescription": "Transaction non trouvée"
},
"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}."
},
"DrawingModal": {
"colorLabel": "Couleur :",
"thicknessLabel": "Épaisseur :",
"undoButton": "Annuler",
"clearButton": "Effacer",
"saveDrawingButton": "Enregistrer le dessin",
"cancelButton": "Annuler"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "回",
"rewardLabel": "報酬",
"coinsSuffix": "コイン",
"drawingLabel": "描画",
"addDrawing": "描画を追加",
"editDrawing": "描画を編集",
"shareLabel": "共有",
"saveChangesButton": "変更を保存",
"addTaskButton": "タスクを追加",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
"errorInvalidUrl": "有効なURLを入力してください",
"linkLabel": "リンク",
"drawingLabel": "描画",
"addDrawing": "描画を追加",
"editDrawing": "描画を編集",
"shareLabel": "共有",
"saveButton": "変更を保存",
"addButton": "報酬を追加"
@@ -270,6 +276,12 @@
"actionUpdate": "更新",
"actionCreate": "作成",
"errorFailedUserAction": "ユーザーの{action}に失敗しました",
"toastDemoDeleteDisabled": "デモインスタンスでは削除が無効になっています",
"toastCannotDeleteSelf": "自分のアカウントは削除できません",
"confirmDeleteUser": "ユーザー {username} を削除してもよろしいですか?",
"toastUserDeletedTitle": "ユーザーが削除されました",
"toastUserDeletedDescription": "ユーザー {username} は正常に削除されました",
"toastDeleteUserFailed": "ユーザーの削除に失敗しました: {error}",
"errorTitle": "エラー",
"errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります",
"toastAvatarUploadedTitle": "アバターをアップロードしました",
@@ -287,7 +299,13 @@
"disablePasswordLabel": "パスワードを無効化",
"cancelButton": "キャンセル",
"saveChangesButton": "変更を保存",
"createUserButton": "ユーザーを作成"
"createUserButton": "ユーザーを作成",
"deleteAccountButton": "アカウントを削除",
"deletingButtonText": "削除中...",
"areYouSure": "本当によろしいですか?",
"deleteUserConfirmation": "ユーザー {username} を削除してもよろしいですか?",
"cancel": "キャンセル",
"confirmDeleteButtonText": "削除"
},
"ViewToggle": {
"habitsLabel": "習慣",
@@ -406,16 +424,26 @@
"notEnoughCoinsTitle": "コインが不足しています",
"notEnoughCoinsDescription": "この報酬を使用するにはあと{coinsNeeded}コイン必要です。"
},
"useCoins": {
"invalidAmountTitle": "無効な値です",
"invalidAmountDescription": "有効な正の数を入力してください",
"successTitle": "成功しました",
"addedCoinsDescription": "{amount}コインを追加しました",
"removedCoinsDescription": "{amount}コインを削除しました",
"transactionNotFoundDescription": "取引が見つかりません"
},
"Warning": {
"areYouSure": "本当によろしいですか?",
"cancel": "キャンセル"
},
"useCoins": {
"addedCoinsDescription": "{amount}コインを追加しました",
"invalidAmountTitle": "無効な値です",
"invalidAmountDescription": "有効な正の数を入力してください",
"successTitle": "成功しました",
"transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
"transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
},
"DrawingModal": {
"colorLabel": "色:",
"thicknessLabel": "太さ:",
"undoButton": "元に戻す",
"clearButton": "クリア",
"saveDrawingButton": "描画を保存",
"cancelButton": "キャンセル"
}
}

449
messages/ko.json Normal file
View File

@@ -0,0 +1,449 @@
{
"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": "코인",
"drawingLabel": "그림",
"addDrawing": "그림 추가",
"editDrawing": "그림 편집",
"shareLabel": "공유",
"saveChangesButton": "변경 사항 저장",
"addTaskButton": "할 일 추가",
"addHabitButton": "습관 추가"
},
"ConfirmDialog": {
"confirmButton": "확인",
"cancelButton": "취소"
},
"AddEditWishlistItemModal": {
"editTitle": "보상 수정",
"addTitle": "새 보상 추가",
"nameLabel": "이름 *",
"descriptionLabel": "설명",
"costLabel": "비용",
"coinsSuffix": "코인",
"redeemableLabel": "교환 가능",
"timesSuffix": "회",
"errorNameRequired": "이름은 필수입니다",
"errorCoinCostMin": "코인 비용은 최소 1이어야 합니다",
"errorTargetCompletionsMin": "목표 완료 횟수는 최소 1이어야 합니다",
"errorInvalidUrl": "유효한 URL을 입력하세요",
"linkLabel": "링크",
"drawingLabel": "그림",
"addDrawing": "그림 추가",
"editDrawing": "그림 편집",
"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": "네트워크 오류가 발생했습니다. 다시 시도해주세요.",
"deleteUserTooltip": "사용자 삭제",
"editUserTooltip": "사용자 수정"
},
"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": "브라우저가 wakelock을 지원하지 않습니다",
"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} 실패",
"errorTitle": "오류",
"errorFileSizeLimit": "파일 크기는 5MB 미만이어야 합니다",
"toastAvatarUploadedTitle": "아바타 업로드됨",
"toastAvatarUploadedDescription": "아바타가 성공적으로 업로드되었습니다",
"errorFailedAvatarUpload": "아바타 업로드 실패",
"changeAvatarButton": "아바타 변경",
"uploadAvatarButton": "아바타 업로드",
"usernameLabel": "사용자 이름",
"usernamePlaceholder": "사용자 이름",
"newPasswordLabel": "새 비밀번호",
"passwordLabel": "비밀번호",
"passwordPlaceholderEdit": "현재 비밀번호를 유지하려면 비워두세요",
"passwordPlaceholderCreate": "비밀번호를 입력하세요",
"demoPasswordDisabledMessage": "데모 인스턴스에서는 비밀번호가 자동으로 비활성화됩니다",
"disablePasswordLabel": "비밀번호 비활성화",
"cancelButton": "취소",
"saveChangesButton": "변경 사항 저장",
"createUserButton": "사용자 생성",
"deleteAccountButton": "계정 삭제",
"toastDemoDeleteDisabled": "데모 인스턴스에서는 삭제가 비활성화되어 있습니다",
"toastCannotDeleteSelf": "본인의 계정을 삭제할 수 없습니다",
"confirmDeleteUser": "사용자 {username}을(를) 삭제하시겠습니까?",
"toastUserDeletedTitle": "사용자 삭제됨",
"toastUserDeletedDescription": "사용자 {username}이(가) 성공적으로 삭제되었습니다",
"toastDeleteUserFailed": "사용자 삭제 실패: {error}",
"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": "UI 설정",
"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/` 디렉터리에 ZIP 파일로 저장됩니다. 최근 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": "보상 \"{itemName}\"을(를) {itemCoinCost} 코인으로 교환했습니다.",
"notEnoughCoinsTitle": "코인 부족",
"notEnoughCoinsDescription": "이 보상을 교환하려면 {coinsNeeded} 코인이 더 필요합니다."
},
"useCoins": {
"addedCoinsDescription": "{amount} 코인 추가됨",
"invalidAmountTitle": "유효하지 않은 금액",
"invalidAmountDescription": "유효한 양의 숫자를 입력하세요",
"successTitle": "성공",
"transactionNotFoundDescription": "거래를 찾을 수 없습니다",
"maxAmountExceededDescription": "금액은 {max}을(를) 초과할 수 없습니다.",
"transactionNotFoundDescription": "거래를 찾을 수 없습니다",
"maxAmountExceededDescription": "금액은 {max}을(를) 초과할 수 없습니다."
},
"Warning": {
"areYouSure": "확실합니까?",
"cancel": "취소"
},
"DrawingModal": {
"colorLabel": "색상:",
"thicknessLabel": "두께:",
"undoButton": "실행 취소",
"clearButton": "지우기",
"saveDrawingButton": "그림 저장",
"cancelButton": "취소"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "раз",
"rewardLabel": "Награда",
"coinsSuffix": "монет",
"drawingLabel": "Рисунок",
"addDrawing": "Добавить Рисунок",
"editDrawing": "Редактировать Рисунок",
"shareLabel": "Поделиться",
"saveChangesButton": "Сохранить",
"addTaskButton": "Добавить задачу",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Минимум 1 выполнение",
"errorInvalidUrl": "Некорректная ссылка",
"linkLabel": "Ссылка",
"drawingLabel": "Рисунок",
"addDrawing": "Добавить Рисунок",
"editDrawing": "Редактировать Рисунок",
"shareLabel": "Поделиться",
"saveButton": "Сохранить",
"addButton": "Добавить цель"
@@ -270,6 +276,12 @@
"actionUpdate": "обновить",
"actionCreate": "создать",
"errorFailedUserAction": "Не удалось {action} пользователя",
"toastDemoDeleteDisabled": "Удаление отключено в демо-версии",
"toastCannotDeleteSelf": "Вы не можете удалить свою учетную запись",
"confirmDeleteUser": "Вы уверены, что хотите удалить пользователя {username}?",
"toastUserDeletedTitle": "Пользователь удален",
"toastUserDeletedDescription": "Пользователь {username} успешно удален",
"toastDeleteUserFailed": "Не удалось удалить пользователя: {error}",
"errorTitle": "Ошибка",
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
"toastAvatarUploadedTitle": "Аватар загружен",
@@ -287,7 +299,13 @@
"disablePasswordLabel": "Отключить пароль",
"cancelButton": "Отмена",
"saveChangesButton": "Сохранить изменения",
"createUserButton": "Создать пользователя"
"createUserButton": "Создать пользователя",
"deleteAccountButton": "Удалить аккаунт",
"deletingButtonText": "Удаление...",
"areYouSure": "Вы уверены?",
"deleteUserConfirmation": "Вы уверены, что хотите удалить пользователя {username}?",
"cancel": "Отмена",
"confirmDeleteButtonText": "Удалить"
},
"ViewToggle": {
"habitsLabel": "Привычки",
@@ -406,16 +424,26 @@
"notEnoughCoinsTitle": "Недостаточно монет",
"notEnoughCoinsDescription": "Вам нужно еще {coinsNeeded} монет, чтобы получить эту награду."
},
"useCoins": {
"invalidAmountTitle": "Неверная сумма",
"invalidAmountDescription": "Пожалуйста, введите положительное число",
"successTitle": "Успех",
"addedCoinsDescription": "Добавлено {amount} монет",
"removedCoinsDescription": "Удалено {amount} монет",
"transactionNotFoundDescription": "Транзакция не найдена"
},
"Warning": {
"areYouSure": "Вы уверены?",
"cancel": "Отмена"
},
"useCoins": {
"addedCoinsDescription": "Добавлено {amount} монет",
"invalidAmountTitle": "Неверная сумма",
"invalidAmountDescription": "Пожалуйста, введите положительное число",
"successTitle": "Успех",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}."
},
"DrawingModal": {
"colorLabel": "Цвет:",
"thicknessLabel": "Толщина:",
"undoButton": "Отменить",
"clearButton": "Очистить",
"saveDrawingButton": "Сохранить рисунок",
"cancelButton": "Отмена"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "次",
"rewardLabel": "奖励",
"coinsSuffix": "金币",
"drawingLabel": "绘图",
"addDrawing": "添加绘图",
"editDrawing": "编辑绘图",
"shareLabel": "分享",
"saveChangesButton": "保存更改",
"addTaskButton": "添加任务",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "目标完成次数至少为 1",
"errorInvalidUrl": "请输入有效的 URL",
"linkLabel": "链接",
"drawingLabel": "绘图",
"addDrawing": "添加绘图",
"editDrawing": "编辑绘图",
"shareLabel": "分享",
"saveButton": "保存更改",
"addButton": "添加奖励"
@@ -270,6 +276,12 @@
"actionUpdate": "更新",
"actionCreate": "创建",
"errorFailedUserAction": "用户 {action} 失败",
"toastDemoDeleteDisabled": "在演示实例中删除已禁用",
"toastCannotDeleteSelf": "您不能删除自己的帐户",
"confirmDeleteUser": "您确定要删除用户 {username} 吗?",
"toastUserDeletedTitle": "用户已删除",
"toastUserDeletedDescription": "用户 {username} 已成功删除",
"toastDeleteUserFailed": "删除用户失败: {error}",
"errorTitle": "错误",
"errorFileSizeLimit": "文件大小必须小于 5MB",
"toastAvatarUploadedTitle": "头像已上传",
@@ -287,7 +299,13 @@
"disablePasswordLabel": "禁用密码",
"cancelButton": "取消",
"saveChangesButton": "保存更改",
"createUserButton": "创建用户"
"createUserButton": "创建用户",
"deleteAccountButton": "删除账户",
"deletingButtonText": "正在删除...",
"areYouSure": "您确定吗?",
"deleteUserConfirmation": "您确定要删除用户 {username} 吗?",
"cancel": "取消",
"confirmDeleteButtonText": "删除"
},
"ViewToggle": {
"habitsLabel": "习惯",
@@ -406,16 +424,26 @@
"notEnoughCoinsTitle": "金币不足",
"notEnoughCoinsDescription": "您还需要{coinsNeeded}金币才能兑换此奖励。"
},
"useCoins": {
"invalidAmountTitle": "无效金额",
"invalidAmountDescription": "请输入有效的正数",
"successTitle": "成功",
"addedCoinsDescription": "添加了{amount}金币",
"removedCoinsDescription": "移除了{amount}金币",
"transactionNotFoundDescription": "未找到交易记录"
},
"Warning": {
"areYouSure": "您确定吗?",
"cancel": "取消"
},
"useCoins": {
"addedCoinsDescription": "已添加 {amount} 个金币",
"invalidAmountTitle": "无效金额",
"invalidAmountDescription": "请输入有效的正数",
"successTitle": "成功",
"transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。",
"transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。"
},
"DrawingModal": {
"colorLabel": "颜色:",
"thicknessLabel": "粗细:",
"undoButton": "撤销",
"clearButton": "清除",
"saveDrawingButton": "保存绘图",
"cancelButton": "取消"
}
}

703
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.2.14",
"version": "0.2.30",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -48,7 +48,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"next": "15.2.3",
"next": "^v15.5.7",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0",
"next-themes": "^0.4.4",
@@ -62,7 +62,6 @@
"rrule": "^2.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 101 KiB