Compare commits

...

68 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
9c2e3f7dec Merge Tag v0.2.14 2025-05-25 17:41:03 +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
Doh
a3d2b1ef96 support delete user (#139)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 21:24:30 -04:00
e93b1c1c57 fix: resolved linting errors 2025-05-21 15:05:12 +02:00
92d1462010 Merge Tag v0.2.13 2025-05-21 14:57:45 +02:00
Doh
ac71c94d53 fix responsive (#135) 2025-05-20 22:12:31 -04:00
eff14f3772 Merge Tag v0.2.12 2025-05-19 12:56:21 +02:00
Doh
91ffe46863 Added i18n support (#129) 2025-05-18 09:00:48 -04:00
d9fa0426ce fix: removed viewType from browser Settings Atom, converted to using path to identify pages 2025-05-18 02:03:54 +02:00
49a0ea8804 fix: refactored habit / task page 2025-05-18 01:34:28 +02:00
9bf24db477 fix: remove empty file 2025-05-18 01:16:39 +02:00
8530f703d9 fix: unified display of header 2025-05-18 01:16:17 +02:00
1a447e00bf fix: adapted notes to reflect fork 2025-05-17 17:27:26 +02:00
ac116e8322 feat: highlight selected navigation item 2025-05-17 17:16:09 +02:00
8c7a7a63d0 fix: refactored code on wishlist page 2025-05-17 16:46:54 +02:00
7c7d0e2f32 fix: switched docker-compose.yaml image remote 2025-05-17 16:29:54 +02:00
e908f1edec fix: resolved linting problems 2025-05-14 11:01:05 +02:00
8e6ddf0b9f fix: removed other build workflows 2025-05-14 10:38:06 +02:00
c5a8f403ef feat: added ghcr release workflow 2025-05-14 10:35:28 +02:00
33d36d0600 fix: refactored error display in add habit modal & disables button if
invalid
2025-05-12 18:11:25 +02:00
942356eaed fix: resolved navigator undefined error 2025-05-12 18:11:03 +02:00
e4a52657af fix: refactored error display in add habit modal & disables button if invalid 2025-05-12 18:00:04 +02:00
102 changed files with 9228 additions and 2374 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

View File

@@ -1,94 +0,0 @@
name: Docker Build and Publish
on:
push:
branches:
- main
- github-actions
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
EXISTS: ${{ steps.check-version.outputs.EXISTS }}
VERSION: ${{ steps.package-version.outputs.VERSION }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Get version from package.json
id: package-version
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Check if version exists
id: check-version
run: |
if docker pull dohsimpson/habittrove:v${{ steps.package-version.outputs.VERSION }} 2>/dev/null; then
echo "EXISTS=true" >> $GITHUB_OUTPUT
else
echo "EXISTS=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
dohsimpson/habittrove:demo
deploy-demo:
runs-on: ubuntu-latest
needs: build-and-push
# demo tracks the demo tag
if: needs.build-and-push.outputs.EXISTS == 'false'
steps:
- uses: actions/checkout@v4
- uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
with:
args: rollout restart -n ${{ secrets.KUBE_NAMESPACE }} deploy/${{ secrets.KUBE_DEPLOYMENT }}
create-release:
runs-on: ubuntu-latest
needs: build-and-push
if: needs.build-and-push.outputs.EXISTS == 'false'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
VERSION: ${{ needs.build-and-push.outputs.VERSION }}
run: |
# Extract release notes from CHANGELOG.md
notes=$(awk -v version="$VERSION" '
$0 ~ "## Version " version {flag=1;next}
$0 ~ "## Version " && flag {exit}
flag' CHANGELOG.md)
gh release create "v$VERSION" \
--repo="$GITHUB_REPOSITORY" \
--title="v$VERSION" \
--notes="$notes"

40
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Create and publish a Docker image to Github Container Registry
on:
push:
tags:
- v*
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
create-and-publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,28 +0,0 @@
name: Unit Tests
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run lint
run: bun run lint
- name: Run unit tests
run: bun test

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

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"i18n-ally.localesPaths": [
"i18n",
"messages"
]
}

View File

@@ -1,5 +1,148 @@
# Changelog
## 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
* support deleting user (#93)
## Version 0.2.13
### Fixed
* fix responsive design on mobile (#134)
* fix translation (#132)
* fix latest docker tag auto build (#131)
## Version 0.2.12
### Added
* 🌍 Added multi-language support! Users can now select their preferred language in settings.
* Supported languages: English, Español (Spanish), Deutsch (German), Français (French), Русский (Russian), 简体中文 (Simplified Chinese) and 日本語 (Japanese).
## Version 0.2.11
### Added

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,7 +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, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
- 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation
@@ -22,11 +34,8 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
## Usage
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
4. **Statistics**: View your progress through the heatmap and streak counters.
## Docker Deployment
@@ -63,7 +72,7 @@ docker run -d \
-v ./data:/app/data \
-v ./backups:/app/backups \ # Add this line to map the backups directory
-e AUTH_SECRET=$AUTH_SECRET \
dohsimpson/habittrove
ghcr.io/manindark/habittrove
```
Available image tags:
@@ -110,7 +119,7 @@ To contribute to HabitTrove, you'll need to set up a development environment. He
1. Clone the repository and navigate to the project directory:
```bash
git clone https://github.com/dohsimpson/habittrove.git
git clone https://github.com/ManInDark/HabitTrove.git
cd habittrove
```
@@ -163,7 +172,7 @@ Run these commands regularly during development to catch issues early.
## Contributing
We welcome feature requests and bug reports! Please [open an issue](https://github.com/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment.
We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
## License

View File

@@ -1,56 +1,38 @@
'use server'
import fs from 'fs/promises'
import path from 'path'
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import {
HabitsData,
CoinsData,
CoinTransaction,
TransactionType,
WishlistItemType,
WishlistData,
Settings,
DataType,
DATA_DEFAULTS,
getDefaultSettings,
UserData,
getDefaultUsersData,
User,
getDefaultWishlistData,
getDefaultHabitsData,
DataType,
getDefaultCoinsData,
getDefaultHabitsData,
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
HabitsData,
Permission,
ServerSettings
} from '@/lib/types'
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
import { verifyPassword } from "@/lib/server-helpers";
import { saltAndHashPassword } from "@/lib/server-helpers";
ServerSettings,
Settings,
TransactionType,
User,
UserData,
WishlistData,
WishlistItemType
} from '@/lib/types';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod';
import { auth } from '@/auth';
import fs from 'fs/promises';
import _ from 'lodash';
import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers'
import path from 'path';
import { PermissionError } from '@/lib/exceptions'
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;
}
@@ -94,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
}
@@ -123,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 {
@@ -141,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 => ({
@@ -164,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)
@@ -186,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,
@@ -202,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>()
}
}
@@ -249,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,
@@ -273,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()
@@ -304,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,
@@ -366,7 +370,7 @@ export async function loadUsersData(): Promise<UserData> {
try {
return await loadData<UserData>('auth')
} catch {
return getDefaultUsersData()
return getDefaultUsersData<UserData>()
}
}
@@ -410,7 +414,7 @@ export async function createUser(formData: FormData): Promise<User> {
const newUser: User = {
id: uuid(),
id: crypto.randomUUID(),
username,
password: hashedPassword,
permissions,
@@ -489,21 +493,80 @@ export async function updateUserPassword(userId: string, newPassword?: string):
}
export async function deleteUser(userId: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
// Load all necessary data
const wishlistData = await loadData<WishlistData>('wishlist')
const habitsData = await loadData<HabitsData>('habits')
const coinsData = await loadData<CoinsData>('coins')
const authData = await loadUsersData()
// Process Wishlist Data
const updatedWishlistItems = wishlistData.items.reduce((acc, item) => {
if (item.userIds?.includes(userId)) {
if (item.userIds.length === 1) {
// Remove item if this is the only user
return acc
} else {
// Remove userId from item's userIds
acc.push({
...item,
userIds: item.userIds.filter(id => id !== userId)
})
}
} else {
// Keep item as is
acc.push(item)
}
return acc
}, [] as WishlistItemType[])
wishlistData.items = updatedWishlistItems
await saveData('wishlist', wishlistData)
// Process Habits Data
const updatedHabits = habitsData.habits.reduce((acc, habit) => {
if (habit.userIds?.includes(userId)) {
if (habit.userIds.length === 1) {
// Remove habit if this is the only user
return acc
} else {
// Remove userId from habit's userIds
acc.push({
...habit,
userIds: habit.userIds.filter(id => id !== userId)
})
}
} else {
// Keep habit as is
acc.push(habit)
}
return acc
}, [] as HabitsData['habits'])
habitsData.habits = updatedHabits
await saveData('habits', habitsData)
// Process Coins Data
coinsData.transactions = coinsData.transactions.filter(
transaction => transaction.userId !== userId
)
// Recalculate balance
coinsData.balance = coinsData.transactions.reduce(
(sum, transaction) => sum + transaction.amount,
0
)
await saveData('coins', coinsData)
// Delete User from Auth Data
const userIndex = authData.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
...data.users.slice(userIndex + 1)
]
}
authData.users = [
...authData.users.slice(0, userIndex),
...authData.users.slice(userIndex + 1)
]
await saveUsersData(newData)
await saveUsersData(authData)
}
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
@@ -536,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

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

View File

@@ -1,14 +1,8 @@
import Layout from '@/components/Layout'
import HabitCalendar from '@/components/HabitCalendar'
import { ViewToggle } from '@/components/ViewToggle'
import CompletionCountBadge from '@/components/CompletionCountBadge'
export default function CalendarPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
{/* <ViewToggle /> */}
</div>
<div className="flex flex-col">
<HabitCalendar />
</div>
)

View File

@@ -1,4 +1,3 @@
import Layout from '@/components/Layout'
import CoinsManager from '@/components/CoinsManager'
export default function CoinsPage() {

View File

@@ -1,6 +1,5 @@
'use client'
import { useHabits } from "@/hooks/useHabits";
import { habitsAtom, settingsAtom } from "@/lib/atoms";
import { Habit } from "@/lib/types";
import { useAtom } from "jotai";

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,14 +1,9 @@
import Layout from '@/components/Layout'
import HabitList from '@/components/HabitList'
import { ViewToggle } from '@/components/ViewToggle'
export default function HabitsPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
{/* <ViewToggle /> */}
</div>
<HabitList />
<div className="flex flex-col">
<HabitList isTasksView={false} />
</div>
)
}

View File

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

View File

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

10
app/tasks/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import HabitList from '@/components/HabitList'
export default function TasksPage() {
return (
<div className="flex flex-col">
<HabitList isTasksView={true} />
</div>
)
}

View File

@@ -1,4 +1,3 @@
import Layout from '@/components/Layout'
import WishlistManager from '@/components/WishlistManager'
export default function WishlistPage() {

View File

@@ -5,24 +5,25 @@ import { Button } from "./ui/button"
import { Star, History } from "lucide-react"
import packageJson from '../package.json'
import { DialogTitle } from "@radix-ui/react-dialog"
import { useTranslations } from "next-intl"
import { Logo } from "./Logo"
import ChangelogModal from "./ChangelogModal"
import { useState } from "react"
interface AboutModalProps {
isOpen: boolean
onClose: () => void
}
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
export default function AboutModal({ onClose }: AboutModalProps) {
const t = useTranslations('AboutModal')
const version = packageJson.version
const [changelogOpen, setChangelogOpen] = useState(false)
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle aria-label="about"></DialogTitle>
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
</DialogHeader>
<div className="space-y-6 text-center py-4">
<div>
@@ -40,14 +41,14 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
onClick={() => setChangelogOpen(true)}
>
<History className="w-3 h-3 mr-1" />
Changelog
{t('changelogButton')}
</Button>
</div>
</div>
<div className="space-y-4">
<div className="text-sm">
Created with by{' '}
{t('createdByPrefix')}{' '}
<a
href="https://github.com/dohsimpson"
target="_blank"
@@ -56,17 +57,19 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
>
@dohsimpson
</a>
<br/>
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
</div>
<div className="flex justify-center">
<a
href="https://github.com/dohsimpson/habittrove"
href="https://github.com/ManInDark/HabitTrove"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<Star className="w-4 h-4 mr-2" />
Star on GitHub
{t('starOnGitHubButton')}
</Button>
</a>
</div>

View File

@@ -1,33 +1,27 @@
'use client'
import { useState } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Info, SmilePlus, Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Habit, SafeUser } from '@/lib/types'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
import * as chrono from 'chrono-node';
import { Textarea } from '@/components/ui/textarea'
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 { useAtom } from 'jotai'
import { Brush, Zap } from 'lucide-react'
import { DateTime } from 'luxon'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useHelpers } from '@/lib/client-helpers'
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,6 +31,7 @@ interface AddEditHabitModalProps {
}
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
const t = useTranslations('AddEditHabitModal');
const [settings] = useAtom(settingsAtom)
const [name, setName] = useState(habit?.name || '')
const [description, setDescription] = useState(habit?.description || '')
@@ -44,18 +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 [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom)
const users = usersData.users
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
function getFrequencyUpdate() {
if (ruleText === initialRuleText && habit?.frequency) {
@@ -90,259 +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 ? `Edit ${isTask ? 'Task' : 'Habit'}` : `Add New ${isTask ? 'Task' : 'Habit'}`}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name *
</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">
Description
</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">
When *
</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">
{(() => {
let displayText = '';
let errorMessage: string | null = null;
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
errorMessage = message;
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
return (
<>
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{displayText}
</span>
{errorMessage && (
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
)}
</>
);
})()}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
Complete
</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">
times
</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">
Reward
</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">
coins
</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">Share</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 ? 'Save Changes' : `Add ${isTask ? 'Task' : 'Habit'}`}</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,22 +1,22 @@
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
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 { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { SmilePlus, Info } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { WishlistItemType } from '@/lib/types'
import { useAtom } from 'jotai'
import { Brush } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
interface AddEditWishlistItemModalProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void
@@ -25,22 +25,24 @@ interface AddEditWishlistItemModalProps {
}
export default function AddEditWishlistItemModal({
isOpen,
setIsOpen,
editingItem,
setEditingItem,
addWishlistItem,
editWishlistItem
}: AddEditWishlistItemModalProps) {
const t = useTranslations('AddEditWishlistItemModal')
const [name, setName] = useState(editingItem?.name || '')
const [description, setDescription] = useState(editingItem?.description || '')
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '')
const { currentUser } = useHelpers()
const [currentUser] = useAtom(currentUserAtom)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
useEffect(() => {
if (editingItem) {
@@ -49,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])
@@ -62,16 +66,18 @@ export default function AddEditWishlistItemModal({
const validate = () => {
const newErrors: { [key: string]: string } = {}
if (!name.trim()) {
newErrors.name = 'Name is required'
newErrors.name = t('errorNameRequired')
}
if (coinCost < 1) {
newErrors.coinCost = 'Coin cost must be at least 1'
newErrors.coinCost = t('errorCoinCostMin')
} else if (coinCost > MAX_COIN_LIMIT) {
newErrors.coinCost = t('errorCoinCostMax', { max: MAX_COIN_LIMIT })
}
if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = 'Target completions must be at least 1'
newErrors.targetCompletions = t('errorTargetCompletionsMin')
}
if (link && !isValidUrl(link)) {
newErrors.link = 'Please enter a valid URL'
newErrors.link = t('errorInvalidUrl')
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
@@ -101,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) {
@@ -109,212 +116,248 @@ export default function AddEditWishlistItemModal({
} else {
addWishlistItem(itemData)
}
setIsOpen(false)
setEditingItem(null)
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSave}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name *
</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">
Description
</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">
Cost
<>
<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">
coins
</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">
Redeemable
<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">
times
</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">
Link
</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">Share</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 ? 'Save Changes' : 'Add Reward'}</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,34 +1,97 @@
'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
if (!currentUserId && !userSelect) {
setUserSelect(true)
}
}, [currentUserId, status, userSelect])
}, [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

@@ -2,17 +2,19 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Coins } from 'lucide-react'
import { FormattedNumber } from '@/components/FormattedNumber'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms'
import dynamic from 'next/dynamic'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
const t = useTranslations('CoinBalance');
const [settings] = useAtom(settingsAtom)
return (
<Card>
<CardHeader>
<CardTitle>Coin Balance</CardTitle>
<CardTitle>{t('coinBalanceTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">

View File

@@ -1,24 +1,28 @@
'use client'
import { useState, useEffect, useRef } from 'react' // Import useEffect, useRef
import { useSearchParams } from 'next/navigation' // Import useSearchParams
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { FormattedNumber } from '@/components/FormattedNumber'
import { History, Pencil } from 'lucide-react'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { settingsAtom, usersAtom } from '@/lib/atoms'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { Input } from '@/components/ui/input'
import { useCoins } from '@/hooks/useCoins'
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'
import { History } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
import EmptyState from './EmptyState'
import { TransactionNoteEditor } from './TransactionNoteEditor'
import { useHelpers } from '@/lib/client-helpers'
export default function CoinsManager() {
const { currentUser } = useHelpers()
const t = useTranslations('CoinsManager')
const [currentUser] = useAtom(currentUserAtom)
const [selectedUser, setSelectedUser] = useState<string>()
const {
add,
@@ -31,7 +35,7 @@ export default function CoinsManager() {
totalSpent,
coinsSpentToday,
transactionsToday
} = useCoins({selectedUser})
} = useCoins({ selectedUser })
const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom)
const DEFAULT_AMOUNT = '0'
@@ -43,6 +47,7 @@ export default function CoinsManager() {
const highlightId = searchParams.get('highlight')
const userIdFromQuery = searchParams.get('user') // Get user ID from query
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const PAGE_ENTRY_COUNTS = [10, 50, 100, 500];
// Effect to set selected user from query param if admin
useEffect(() => {
@@ -53,7 +58,7 @@ export default function CoinsManager() {
}
}
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
}, [userIdFromQuery, currentUser, usersData.users]);
}, [userIdFromQuery, currentUser, usersData.users, selectedUser]);
// Effect to scroll to highlighted transaction
useEffect(() => {
@@ -86,10 +91,21 @@ export default function CoinsManager() {
}
}
const getTransactionTypeLabel = (type: TransactionType) => {
switch (type) {
case 'HABIT_COMPLETION': return t('transactionTypeHabitCompletion');
case 'TASK_COMPLETION': return t('transactionTypeTaskCompletion');
case 'HABIT_UNDO': return t('transactionTypeHabitUndo');
case 'TASK_UNDO': return t('transactionTypeTaskUndo');
case 'WISH_REDEMPTION': return t('transactionTypeWishRedemption');
case 'MANUAL_ADJUSTMENT': return t('transactionTypeManualAdjustment');
}
}
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold mr-6">Coins Management</h1>
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
{currentUser?.isAdmin && (
<select
className="border rounded p-2"
@@ -111,8 +127,8 @@ export default function CoinsManager() {
<CardTitle className="flex items-center gap-2">
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
<div>
<div className="text-sm font-normal text-muted-foreground">Current Balance</div>
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> coins</div>
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div>
</div>
</CardTitle>
</CardHeader>
@@ -124,7 +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>
@@ -132,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">
@@ -143,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>
@@ -157,7 +196,7 @@ export default function CoinsManager() {
variant="default"
>
<div className="flex items-center gap-2">
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
{Number(amount) >= 0 ? t('addCoinsButton') : t('removeCoinsButton')}
</div>
</Button>
</div>
@@ -169,27 +208,27 @@ export default function CoinsManager() {
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
<CardTitle>{t('statisticsTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{/* Top Row - Totals */}
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
<div className="text-sm text-green-800 dark:text-green-100 mb-1">Total Earned</div>
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
</div>
</div>
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
<div className="text-sm text-red-800 dark:text-red-100 mb-1">Total Spent</div>
<div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div>
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
</div>
</div>
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900">
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">Total Transactions</div>
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">{t('totalTransactionsLabel')}</div>
<div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
{transactions.length} 📈
</div>
@@ -197,21 +236,21 @@ export default function CoinsManager() {
{/* Bottom Row - Today */}
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Earned</div>
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
</div>
</div>
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900">
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">Today's Spent</div>
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
</div>
</div>
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900">
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">Today's Transactions</div>
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div>
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
{transactionsToday} 📊
</div>
@@ -222,13 +261,13 @@ export default function CoinsManager() {
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Transaction History</CardTitle>
<CardTitle>{t('transactionHistoryTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
<span className="text-sm text-muted-foreground">{t('showLabel')}</span>
<select
className="border rounded p-1"
value={pageSize}
@@ -237,22 +276,20 @@ export default function CoinsManager() {
setCurrentPage(1) // Reset to first page when changing page size
}}
>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={500}>500</option>
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
</select>
<span className="text-sm text-muted-foreground">entries</span>
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
</div>
<div className="text-sm text-muted-foreground">
Showing {Math.min((currentPage - 1) * pageSize + 1, transactions.length)} to {Math.min(currentPage * pageSize, transactions.length)} of {transactions.length} entries
{t('showingEntries', { from: Math.min((currentPage - 1) * pageSize + 1, transactions.length), to: Math.min(currentPage * pageSize, transactions.length), total: transactions.length })}
</div>
</div>
{transactions.length === 0 ? (
<EmptyState
icon={History}
title="No transactions yet"
description="Your transaction history will appear here once you start earning or spending coins"
title={t('noTransactionsTitle')}
description={t('noTransactionsDescription')}
/>
) : (
<>
@@ -275,13 +312,13 @@ export default function CoinsManager() {
}
const isHighlighted = transaction.id === highlightId;
const transactionUser = usersData.users.find(u => u.id === transaction.userId);
return (
<div
key={transaction.id}
ref={(el) => { transactionRefs.current[transaction.id] = el; }} // Assign ref correctly
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
}`}
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
}`}
>
<div className="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
@@ -299,17 +336,17 @@ export default function CoinsManager() {
<span
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
>
{transaction.type.split('_').join(' ')}
{getTransactionTypeLabel(transaction.type as TransactionType)}
</span>
{transaction.userId && currentUser?.isAdmin && (
<Avatar className="h-6 w-6">
<AvatarImage
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` : undefined}
alt={usersData.users.find(u => u.id === transaction.userId)?.username}
src={transactionUser?.avatarPath ?
`/api/avatars/${transactionUser?.avatarPath?.split('/').pop()}` : undefined}
alt={transactionUser?.username}
/>
<AvatarFallback>
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
{transactionUser?.username?.[0] || '?'}
</AvatarFallback>
</Avatar>
)}
@@ -357,9 +394,9 @@ export default function CoinsManager() {
</Button>
<div className="flex items-center gap-1 px-4 py-2 rounded-md bg-muted">
<span className="text-sm font-medium">Page</span>
<span className="text-sm font-medium">{t('pageLabel')}</span>
<span className="text-sm font-bold">{currentPage}</span>
<span className="text-sm font-medium">of</span>
<span className="text-sm font-medium">{t('ofLabel')}</span>
<span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
</div>
<Button

View File

@@ -1,9 +1,8 @@
import { Badge } from "@/components/ui/badge"
import { useAtom } from 'jotai'
import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils'
import { useHabits } from '@/hooks/useHabits'
import { settingsAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
interface CompletionCountBadgeProps {
type: 'habits' | 'tasks'
@@ -14,6 +13,7 @@ export default function CompletionCountBadge({
type,
date
}: CompletionCountBadgeProps) {
const t = useTranslations('CompletionCountBadge');
const [settings] = useAtom(settingsAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const targetDate = date || getTodayInTimezone(settings.system.timezone)
@@ -29,7 +29,7 @@ export default function CompletionCountBadge({
return (
<Badge variant="secondary">
{`${completedCount}/${totalCount} Completed`}
{t('countCompleted', { completedCount, totalCount })}
</Badge>
)
}

View File

@@ -6,6 +6,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useTranslations } from 'next-intl'
interface ConfirmDialogProps {
isOpen: boolean
@@ -23,9 +24,13 @@ export default function ConfirmDialog({
onConfirm,
title,
message,
confirmText = "Confirm",
cancelText = "Cancel"
confirmText,
cancelText,
}: ConfirmDialogProps) {
const t = useTranslations('ConfirmDialog');
const finalConfirmText = confirmText || t('confirmButton');
const finalCancelText = cancelText || t('cancelButton');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
@@ -37,10 +42,10 @@ export default function ConfirmDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{cancelText}
{finalCancelText}
</Button>
<Button variant="destructive" onClick={onConfirm}>
{confirmText}
{finalConfirmText}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,35 +1,34 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Plus, Pin, AlertTriangle } from 'lucide-react' // Removed unused icons
import CompletionCountBadge from './CompletionCountBadge'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenuTrigger
} from "@/components/ui/context-menu"
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { useState } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Progress } from '@/components/ui/progress'
import { Settings, WishlistItemType } from '@/lib/types'
import { Habit } from '@/lib/types'
import Linkify from './linkify'
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'
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useState } from 'react'
import AddEditHabitModal from './AddEditHabitModal'
import CompletionCountBadge from './CompletionCountBadge'
import ConfirmDialog from './ConfirmDialog'
import { Button } from './ui/button'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import Linkify from './linkify'
import { Button } from './ui/button'
import DrawingDisplay from './DrawingDisplay'
interface UpcomingItemsProps {
habits: Habit[]
@@ -54,6 +53,7 @@ const ItemSection = ({
viewLink,
addNewItem,
}: ItemSectionProps) => {
const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits();
const [_, setPomo] = useAtom(pomodoroAtom);
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
@@ -105,7 +105,7 @@ const ItemSection = ({
onClick={addNewItem}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
@@ -130,7 +130,7 @@ const ItemSection = ({
onClick={addNewItem}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
</Button>
</div>
</div>
@@ -167,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 }))
@@ -224,12 +224,6 @@ const ItemSection = ({
<Link
href={`/habits?highlight=${habit.id}`}
className="flex items-center gap-1 hover:text-primary transition-colors"
onClick={() => {
const newViewType = isTask ? 'tasks' : 'habits';
if (browserSettings.viewType !== newViewType) {
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
}
}}
>
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
<TooltipProvider>
@@ -239,7 +233,7 @@ const ItemSection = ({
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-500" />
</TooltipTrigger>
<TooltipContent>
<p>Overdue</p>
<p>{t('overdueTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -253,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>
@@ -303,27 +307,21 @@ 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 ? (
<>
Show less
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
))}
</button>
<Link
href={viewLink}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => {
const newViewType = isTask ? 'tasks' : 'habits';
if (browserSettings.viewType !== newViewType) {
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
}
}}
>
View
<ArrowRight className="h-3 w-3" />
@@ -359,6 +357,7 @@ export default function DailyOverview({
wishlistItems,
coinBalance,
}: UpcomingItemsProps) {
const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
@@ -412,16 +411,16 @@ export default function DailyOverview({
<>
<Card>
<CardHeader>
<CardTitle>Today's Overview</CardTitle>
<CardTitle>{t('todaysOverviewTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Tasks Section */}
{hasTasks && (
<ItemSection
title="Daily Tasks"
title={t('dailyTasksTitle')}
items={dailyTasks}
emptyMessage="No tasks due today. Add some tasks to get started!"
emptyMessage={t('noTasksDueTodayMessage')}
isTask={true}
viewLink="/habits?view=tasks"
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
@@ -430,9 +429,9 @@ export default function DailyOverview({
{/* Habits Section */}
<ItemSection
title="Daily Habits"
title={t('dailyHabitsTitle')}
items={dailyHabits}
emptyMessage="No habits due today. Add some habits to get started!"
emptyMessage={t('noHabitsDueTodayMessage')}
isTask={false}
viewLink="/habits"
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
@@ -440,21 +439,24 @@ export default function DailyOverview({
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Wishlist Goals</h3>
<h3 className="font-semibold">{t('wishlistGoalsTitle')}</h3>
<Badge variant="secondary">
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
{t('redeemableBadgeLabel', {
count: wishlistItems.filter(item => item.coinCost <= coinBalance).length,
total: wishlistItems.length
})}
</Badge>
</div>
<div>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${browserSettings.expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards!
{t('noWishlistItemsMessage')}
</div>
) : (
<>
{sortedWishlistItems
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
@@ -467,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",
@@ -496,8 +508,8 @@ export default function DailyOverview({
/>
<p className="text-xs text-muted-foreground mt-2">
{isRedeemable
? "Ready to redeem!"
: `${item.coinCost - coinBalance} coins to go`
? t('readyToRedeemMessage')
: t('coinsToGoMessage', { amount: item.coinCost - coinBalance })
}
</p>
</Link>
@@ -511,23 +523,23 @@ 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 ? (
<>
Show less
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
))}
</button>
<Link
href="/wishlist"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
{t('viewButton')}
<ArrowRight className="h-3 w-3" />
</Link>
</div>

View File

@@ -1,25 +1,25 @@
'use client'
import { useCoins } from '@/hooks/useCoins'
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import CoinBalance from './CoinBalance'
import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits'
import { useCoins } from '@/hooks/useCoins'
export default function Dashboard() {
const t = useTranslations('Dashboard');
const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const { balance } = useCoins()
const [wishlist] = useAtom(wishlistAtom)
const wishlistItems = wishlist.items
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CoinBalance coinBalance={balance} />

View File

@@ -0,0 +1,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,29 +1,30 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Button } from '@/components/ui/button'
import { Check, Circle, CircleCheck } from 'lucide-react'
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms'
import { DateTime } from 'luxon'
import Linkify from './linkify'
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types'
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Circle, CircleCheck } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
import { useCallback, useMemo, useState } from 'react'
import Linkify from './linkify'
export default function HabitCalendar() {
const t = useTranslations('HabitCalendar')
const { completePastHabit } = useHabits()
const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => {
try {
await completePastHabit(habit, date)
} catch (error) {
console.error('Error completing past habit:', error)
console.error(t('errorCompletingPastHabit'), error)
}
}, [completePastHabit])
}, [completePastHabit, t])
const [settings] = useAtom(settingsAtom)
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
@@ -41,12 +42,12 @@ export default function HabitCalendar() {
}, [completedHabitsMap, settings.system.timezone])
return (
<div className="container mx-auto px-4 py-6">
<h1 className="text-2xl font-semibold mb-6">Habit Calendar</h1>
<div>
<h1 className="text-xl xs:text-3xl font-bold mb-6">{t('title')}</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Calendar</CardTitle>
<CardTitle>{t('calendarCardTitle')}</CardTitle>
</CardHeader>
<CardContent>
<Calendar
@@ -75,7 +76,7 @@ export default function HabitCalendar() {
{selectedDateTime ? (
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
) : (
'Select a date'
t('selectDatePrompt')
)}
</CardTitle>
</CardHeader>
@@ -85,7 +86,7 @@ export default function HabitCalendar() {
{hasTasks && (
<div className="pt-2 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3>
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
</div>
<ul className="space-y-3">
@@ -144,7 +145,7 @@ export default function HabitCalendar() {
)}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3>
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('habitsSectionTitle')}</h3>
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
</div>
<ul className="space-y-3">
@@ -155,49 +156,49 @@ export default function HabitCalendar() {
date: selectedDateTime
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
</ul>
</div>
</div>

View File

@@ -1,12 +1,12 @@
import { Habit } from '@/lib/types';
import { useHabits } from '@/hooks/useHabits';
import { useAtom } from 'jotai';
import { pomodoroAtom, settingsAtom } from '@/lib/atoms';
import { d2t, getNow, isHabitDueToday } from '@/lib/utils';
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
import { useTranslations } from 'next-intl';
interface HabitContextMenuItemsProps {
habit: Habit;
@@ -23,13 +23,14 @@ export function HabitContextMenuItems({
context = 'habit-item',
onClose,
}: HabitContextMenuItemsProps) {
const t = useTranslations('HabitContextMenuItems');
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
const [settings] = useAtom(settingsAtom);
const [, setPomo] = useAtom(pomodoroAtom);
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions
const [currentUser] = useAtom(currentUserAtom);
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions
const canInteract = hasPermission('habit', 'interact');
const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
const canInteract = hasPermission(currentUser, 'habit', 'interact');
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
@@ -55,7 +56,7 @@ export function HabitContextMenuItems({
})}
>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
<span>{t('startPomodoro')}</span>
</MenuItemComponent>
)}
@@ -69,7 +70,7 @@ export function HabitContextMenuItems({
})}
>
<Calendar className="mr-2 h-4 w-4" />
<span>Move to Today</span>
<span>{t('moveToToday')}</span>
</MenuItemComponent>
)}
@@ -83,7 +84,7 @@ export function HabitContextMenuItems({
})}
>
<Calendar className="mr-2 h-4 w-4" />
<span>Move to Tomorrow</span>
<span>{t('moveToTomorrow')}</span>
</MenuItemComponent>
)}
@@ -93,7 +94,7 @@ export function HabitContextMenuItems({
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
>
<Pin className="mr-2 h-4 w-4" />
<span>{habit.pinned ? 'Unpin' : 'Pin'}</span>
<span>{t(habit.pinned ? 'unpin' : 'pin')}</span>
</MenuItemComponent>
)}
@@ -104,7 +105,7 @@ export function HabitContextMenuItems({
disabled={!canWrite}
>
<Edit className="mr-2 h-4 w-4" />
<span>Edit</span>
<span>{t('edit')}</span>
</MenuItemComponent>
)}
@@ -114,7 +115,7 @@ export function HabitContextMenuItems({
disabled={!canWrite}
>
<Edit className="mr-2 h-4 w-4" />
<span>Edit</span>
<span>{t('edit')}</span>
</MenuItemComponent>
)}
@@ -125,7 +126,7 @@ export function HabitContextMenuItems({
onClick={() => handleAction(() => archiveHabit(habit.id))}
>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
<span>{t('archive')}</span>
</MenuItemComponent>
)}
@@ -135,7 +136,7 @@ export function HabitContextMenuItems({
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
<span>{t('unarchive')}</span>
</MenuItemComponent>
)}
@@ -150,7 +151,7 @@ export function HabitContextMenuItems({
disabled={!canWrite} // Assuming delete is a write operation
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
<span>{t('delete')}</span>
</MenuItemComponent>
</>
);

View File

@@ -1,24 +1,22 @@
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react' // Removed unused icons
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
import { DateTime } from 'luxon'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit, User } from '@/lib/types'
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Button } from './ui/button'
interface HabitItemProps {
habit: Habit
@@ -27,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()}` || ""} />
@@ -47,20 +45,18 @@ 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 [_, setPomo] = useAtom(pomodoroAtom)
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false)
const t = useTranslations('HabitItem');
const [usersData] = useAtom(usersAtom)
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('habit', 'write')
const canInteract = hasPermission('habit', 'interact')
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const isRecurRule = !isTasksView
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)
@@ -86,9 +82,9 @@ 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 ${isTasksView ? 'w-full' : ''} justify-between`}>
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
<div className="flex items-center gap-1">
{habit.pinned && (
<Pin className="h-4 w-4 text-yellow-500" />
@@ -97,32 +93,50 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</div>
{isTaskOverdue(habit, settings.system.timezone) && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
Overdue
{t('overdue')}
</span>
)}
</CardTitle>
{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'}`}>
When: {convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
<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
@@ -137,19 +151,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
{isCompletedToday ? (
target > 1 ? (
<>
<span className="sm:hidden">{completionsToday}/{target}</span>
<span className="hidden sm:inline">Completed ({completionsToday}/{target})</span>
<span className="sm:hidden">{t('completedStatusCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">{t('completedStatusCount', { completed: completionsToday, target })}</span>
</>
) : (
'Completed'
t('completedStatus')
)
) : (
target > 1 ? (
<>
<span className="sm:hidden">{completionsToday}/{target}</span>
<span className="hidden sm:inline">Complete ({completionsToday}/{target})</span>
<span className="sm:hidden">{t('completeButtonCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">{t('completeButtonCount', { completed: completionsToday, target })}</span>
</>
) : 'Complete'
) : t('completeButton')
)}
</span>
{habit.targetCompletions && habit.targetCompletions > 1 && (
@@ -171,7 +185,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
className="w-10 sm:w-auto"
>
<Undo2 className="h-4 w-4" />
<span className="hidden sm:inline ml-2">Undo</span>
<span className="hidden sm:inline ml-2">{t('undoButton')}</span>
</Button>
)}
</div>
@@ -185,10 +199,10 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
<span className="ml-2">{t('editButton')}</span>
</Button>
)}
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
@@ -208,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Card>
)
}

View File

@@ -1,30 +1,28 @@
'use client'
import { useState, useMemo, useEffect } from 'react' // Added useMemo, useEffect
import { Plus, ArrowUpNarrowWide, ArrowDownWideNarrow, Search } from 'lucide-react' // Added sort icons, Search icon
import { useAtom } from 'jotai'
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
import HabitItem from './HabitItem'
import { Input } from '@/components/ui/input'; // Added
import { Label } from '@/components/ui/label'; // Added
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom } from '@/lib/atoms'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Habit } from '@/lib/types'
import { getHabitFreq } from '@/lib/utils'; // Added
import { useAtom } from 'jotai'
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
import { DateTime } from 'luxon'; // Added
import { useTranslations } from 'next-intl'
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
import AddEditHabitModal from './AddEditHabitModal'
import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types'
import { useHabits } from '@/hooks/useHabits'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { ViewToggle } from './ViewToggle'
import { Input } from '@/components/ui/input' // Added
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' // Added
import { Label } from '@/components/ui/label' // Added
import { DateTime } from 'luxon' // Added
import { getHabitFreq } from '@/lib/utils' // Added
import EmptyState from './EmptyState'
import HabitItem from './HabitItem'
export default function HabitList() {
export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
const t = useTranslations('HabitList');
const { saveHabit, deleteHabit } = useHabits()
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
// const [settings] = useAtom(settingsAtom); // settingsAtom is not directly used in HabitList itself.
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
type SortOrder = 'asc' | 'desc';
@@ -122,23 +120,17 @@ export default function HabitList() {
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
<h1 className="text-xl xs:text-3xl font-bold">
{t(isTasksView ? 'myTasks' : 'myHabits')}
</h1>
<span>
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
</Button>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
<Plus className='mr-2 h-4 w-4' />{isTasksView ? t("addTaskButton") : t("addHabitButton")}
</Button>
</span>
</div>
<div className='py-4'>
<ViewToggle />
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
@@ -148,28 +140,28 @@ export default function HabitList() {
</div>
<Input
type="search"
placeholder={`Search ${isTasksView ? 'tasks' : 'habits'}...`}
placeholder={t(isTasksView ? 'searchTasksPlaceholder' : 'searchHabitsPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 w-full"
/>
</div>
<div className="flex items-center gap-2 self-start sm:self-center w-full sm:w-auto">
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">Sort by:</Label>
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">{t('sortByLabel')}</Label>
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
<SelectValue placeholder="Sort by" />
<SelectValue placeholder={t('sortByLabel')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="coinReward">Coin Reward</SelectItem>
{isTasksView && <SelectItem value="dueDate">Due Date</SelectItem>}
{!isTasksView && <SelectItem value="frequency">Frequency</SelectItem>}
<SelectItem value="name">{t('sortByName')}</SelectItem>
<SelectItem value="coinReward">{t('sortByCoinReward')}</SelectItem>
{isTasksView && <SelectItem value="dueDate">{t('sortByDueDate')}</SelectItem>}
{!isTasksView && <SelectItem value="frequency">{t('sortByFrequency')}</SelectItem>}
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
{sortOrder === 'asc' ? <ArrowUpNarrowWide className="h-4 w-4" /> : <ArrowDownWideNarrow className="h-4 w-4" />}
<span className="sr-only">Toggle sort order</span>
<span className="sr-only">{t('toggleSortOrderAriaLabel')}</span>
</Button>
</div>
</div>
@@ -177,35 +169,35 @@ export default function HabitList() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{activeHabits.length === 0 && searchTerm.trim() ? (
<div className="col-span-2 text-center text-muted-foreground py-8">
No {isTasksView ? 'tasks' : 'habits'} found matching your search.
{t(isTasksView ? 'noTasksFoundMessage' : 'noHabitsFoundMessage')}
</div>
) : activeHabits.length === 0 ? (
<div className="col-span-2">
<EmptyState
icon={isTasksView ? TaskIcon : HabitIcon}
title={isTasksView ? "No tasks yet" : "No habits yet"}
description={isTasksView ? "Create your first task to start tracking your progress" : "Create your first habit to start tracking your progress"}
title={t(isTasksView ? 'emptyStateTasksTitle' : 'emptyStateHabitsTitle')}
description={t(isTasksView ? 'emptyStateTasksDescription' : 'emptyStateHabitsDescription')}
/>
</div>
) : (
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
{archivedHabits.length > 0 && (
<>
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedHabits.map((habit: Habit) => (
@@ -246,9 +238,9 @@ export default function HabitList() {
}
setDeleteConfirmation({ isOpen: false, habitId: null })
}}
title={isTasksView ? "Delete Task" : "Delete Habit"}
message={isTasksView ? "Are you sure you want to delete this task? This action cannot be undone." : "Are you sure you want to delete this habit? This action cannot be undone."}
confirmText="Delete"
title={t(isTasksView ? 'deleteTaskDialogTitle' : 'deleteHabitDialogTitle')}
message={t(isTasksView ? 'deleteTaskDialogMessage' : 'deleteHabitDialogMessage')}
confirmText={t('deleteButton')}
/>
</div>
)

View File

@@ -1,17 +1,20 @@
'use client'
import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { d2s, getNow, t2d } from '@/lib/utils' // Removed getCompletedHabitsForDate
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { settingsAtom, hasTasksAtom, completedHabitsMapAtom } from '@/lib/atoms' // Added completedHabitsMapAtom
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
import { Habit } from '@/lib/types';
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
interface HabitStreakProps {
habits: Habit[]
}
export default function HabitStreak({ habits }: HabitStreakProps) {
const t = useTranslations('HabitStreak');
const [settings] = useAtom(settingsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
@@ -40,7 +43,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
return (
<Card>
<CardHeader>
<CardTitle>Daily Completion Streak</CardTitle>
<CardTitle>{t('dailyCompletionStreakTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full aspect-[2/1]">
@@ -56,11 +59,14 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(value, name) => [`${value} ${name}`, 'Completed']} />
<YAxis allowDecimals={false} />
<Tooltip formatter={(value, name) => {
const translatedName = name === 'habits' ? t('tooltipHabitsLabel') : t('tooltipTasksLabel');
return [`${value} ${translatedName}`, t('tooltipCompletedLabel')];
}} />
<Line
type="monotone"
name="habits"
name={t('tooltipHabitsLabel')}
dataKey="habits"
stroke="#14b8a6"
strokeWidth={2}
@@ -69,7 +75,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
{hasTasks && (
<Line
type="monotone"
name="tasks"
name={t('tooltipTasksLabel')}
dataKey="tasks"
stroke="#f59e0b"
strokeWidth={2}

View File

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

View File

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

View File

@@ -1,20 +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">
<ClientWrapper>
{children}
</ClientWrapper>
<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,9 +1,9 @@
import { Sparkles } from "lucide-react"
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,102 +1,40 @@
'use client'
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
import { useAtom } from 'jotai'
import { browserSettingsAtom } from '@/lib/atoms'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { useHelpers } from '@/lib/client-helpers'
import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { ElementType, useEffect, useState } from 'react'
import NavDisplay from './NavDisplay'
type ViewPort = 'main' | 'mobile'
const navItems = (isTasksView: boolean) => [
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
{
icon: isTasksView ? TaskIcon : HabitIcon,
label: isTasksView ? 'Tasks' : 'Habits',
href: '/habits',
position: 'main'
},
{ icon: Calendar, label: 'Calendar', href: '/calendar', position: 'main' },
{ icon: Gift, label: 'Wishlist', href: '/wishlist', position: 'main' },
{ icon: Coins, label: 'Coins', href: '/coins', position: 'main' },
]
interface NavigationProps {
className?: string
viewPort: ViewPort
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
}
export default function Navigation({ className, viewPort }: NavigationProps) {
const [showAbout, setShowAbout] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const { isIOS } = useHelpers()
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-5 w-full">
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
<Link
key={item.label}
href={item.href}
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
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(isTasksView).filter(item => item.position === 'main').map((item) => (
<Link
key={item.label}
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</div>
)
else {
return <></>
}
}

View File

@@ -2,9 +2,10 @@
import { useMemo } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl';
import NotificationDropdown from './NotificationDropdown';
import {
DropdownMenu,
@@ -13,11 +14,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
import { User, CoinTransaction } from '@/lib/types';
export default function NotificationBell() {
const { currentUser } = useHelpers();
const t = useTranslations('NotificationBell');
const [currentUser] = useAtom(currentUserAtom);
const [coinsData] = useAtom(coinsAtom)
const [habitsData] = useAtom(habitsAtom)
const [wishlistData] = useAtom(wishlistAtom)
@@ -99,7 +100,7 @@ export default function NotificationBell() {
const nowTimestamp = d2t({ dateTime: getNow({}) });
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
} catch (error) {
console.error("Failed to update notification read timestamp:", error);
console.error(t('errorUpdateTimestamp'), error);
}
};
@@ -120,7 +121,7 @@ export default function NotificationBell() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as useHelpers can return undefined initially
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down

View File

@@ -1,44 +1,28 @@
import React from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CoinsData, HabitsData, WishlistData, UserData, User, CoinTransaction } from '@/lib/types';
import { t2d } from '@/lib/utils';
import Link from 'next/link';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Info } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
import { t2d } from '@/lib/utils';
import { Info } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
interface NotificationDropdownProps {
currentUser: User | null;
unreadNotifications: CoinTransaction[];
displayedReadNotifications: CoinTransaction[];
habitsData: HabitsData; // Keep needed props
habitsData: HabitsData;
wishlistData: WishlistData;
usersData: UserData;
}
// Helper function to generate notification message
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
const username = triggeringUser?.username || 'Someone';
const itemName = relatedItemName || 'a shared item';
switch (tx.type) {
case 'HABIT_COMPLETION':
case 'TASK_COMPLETION':
return `${username} completed ${itemName}.`;
case 'WISH_REDEMPTION':
return `${username} redeemed ${itemName}.`;
// Add other relevant transaction types if needed
default:
return `Activity related to ${itemName} by ${username}.`; // Fallback message
}
};
// Helper function to get the name of the related item
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
if (!tx.relatedItemId) return undefined;
@@ -60,19 +44,33 @@ export default function NotificationDropdown({
wishlistData,
usersData,
}: NotificationDropdownProps) {
if (!currentUser) {
return <div className="p-4 text-sm text-gray-500">Not logged in.</div>;
}
const t = useTranslations('NotificationDropdown');
// Removed the useMemo block for calculating notifications
// Helper function to generate notification message, now using t
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
const username = triggeringUser?.username || t('defaultUsername');
const itemName = relatedItemName || t('defaultItemName');
switch (tx.type) {
case 'HABIT_COMPLETION':
case 'TASK_COMPLETION':
return t('userCompletedItem', { username, itemName });
case 'WISH_REDEMPTION':
return t('userRedeemedItem', { username, itemName });
default:
return t('activityRelatedToItem', { username, itemName });
}
};
if (!currentUser) {
return <div className="p-4 text-sm text-gray-500">{t('notLoggedIn')}</div>;
}
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
const message = getNotificationMessage(tx, triggeringUser, relatedItemName);
const message = getNotificationMessage(tx, triggeringUser, relatedItemName); // Uses the new t-aware helper
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
const timeAgo = txTimestamp.toRelative(); // e.g., "2 hours ago"
// Add the triggering user's ID to the query params if it exists
const timeAgo = txTimestamp.toRelative();
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
return (
@@ -99,21 +97,21 @@ export default function NotificationDropdown({
{/* Removed the outer div as width is now set on DropdownMenuContent in NotificationBell */}
<>
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<h4 className="text-sm font-medium">Notifications</h4>
<h4 className="text-sm font-medium">{t('notificationsTitle')}</h4>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">
Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)
{t('notificationsTooltip')}
</p>
</TooltipContent>
</Tooltip>
</div>
<ScrollArea className="h-[400px]">
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
<div className="p-4 text-center text-sm text-gray-500">No notifications yet.</div>
<div className="p-4 text-center text-sm text-gray-500">{t('noNotificationsYet')}</div>
)}
{unreadNotifications.length > 0 && (

View File

@@ -1,13 +1,14 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { User as UserIcon } from 'lucide-react';
import { Permission, User } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { User } from '@/lib/types';
import { User as UserIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
interface PasswordEntryFormProps {
user: User;
@@ -22,6 +23,7 @@ export default function PasswordEntryForm({
onSubmit,
error
}: PasswordEntryFormProps) {
const t = useTranslations('PasswordEntryForm');
const hasPassword = !!user.password;
const [password, setPassword] = useState('');
@@ -31,8 +33,8 @@ export default function PasswordEntryForm({
await onSubmit(password);
} catch (err) {
toast({
title: "Error",
description: err instanceof Error ? err.message : 'Login failed',
title: t('loginErrorToastTitle'),
description: err instanceof Error ? err.message : t('loginFailedErrorToastDescription'),
variant: "destructive"
});
}
@@ -58,18 +60,18 @@ export default function PasswordEntryForm({
onClick={onCancel}
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
>
Not you?
{t('notYouButton')}
</button>
</div>
</div>
{hasPassword && <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t('passwordLabel')}</Label>
<Input
id="password"
type="password"
placeholder="Enter password"
placeholder={t('passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
@@ -82,10 +84,10 @@ export default function PasswordEntryForm({
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
{t('cancelButton')}
</Button>
<Button type="submit" disabled={hasPassword && !password}>
Login
{t('loginButton')}
</Button>
</div>
</form>

View File

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

@@ -3,6 +3,7 @@
import { Switch } from './ui/switch';
import { Label } from './ui/label';
import { Permission } from '@/lib/types';
import { useTranslations } from 'next-intl';
interface PermissionSelectorProps {
permissions: Permission[];
@@ -11,18 +12,20 @@ interface PermissionSelectorProps {
onAdminChange: (isAdmin: boolean) => void;
}
const permissionLabels: { [key: string]: string } = {
habit: 'Habit / Task',
wishlist: 'Wishlist',
coins: 'Coins'
};
export function PermissionSelector({
permissions,
isAdmin,
onPermissionsChange,
onAdminChange,
}: PermissionSelectorProps) {
const t = useTranslations('PermissionSelector');
const permissionLabels: { [key: string]: string } = {
habit: t('resourceHabitTask'),
wishlist: t('resourceWishlist'),
coins: t('resourceCoins')
};
const currentPermissions = isAdmin ?
{
habit: { write: true, interact: true },
@@ -49,11 +52,11 @@ export function PermissionSelector({
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Permissions</Label>
<Label>{t('permissionsTitle')}</Label>
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
<div className="flex items-center gap-2">
<div className="font-medium text-sm">Admin Access</div>
<div className="font-medium text-sm">{t('adminAccessLabel')}</div>
</div>
<Switch
id="isAdmin"
@@ -65,7 +68,7 @@ export function PermissionSelector({
{isAdmin ? (
<p className="text-xs text-muted-foreground px-3">
Admins have full permission to all data for all users
{t('adminAccessDescription')}
</p>
) : (
<div className="grid grid-cols-3 gap-4">
@@ -74,7 +77,7 @@ export function PermissionSelector({
<div className="font-medium capitalize text-sm border-b pb-2">{permissionLabels[resource]}</div>
<div className="flex flex-col gap-2.5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">Write</Label>
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">{t('permissionWrite')}</Label>
<Switch
id={`${resource}-write`}
className="h-4 w-7"
@@ -85,7 +88,7 @@ export function PermissionSelector({
/>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">Interact</Label>
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">{t('permissionInteract')}</Label>
<Switch
id={`${resource}-interact`}
className="h-4 w-7"

View File

@@ -1,58 +1,43 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
import { cn, getCompletionsForToday } from '@/lib/utils'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils'
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useEffect, useRef, useState } from 'react'
interface PomoConfig {
labels: string[]
getLabels: () => string[]
duration: number
type: 'focus' | 'break'
}
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: {
labels: [
'Stay Focused',
'You Got This',
'Keep Going',
'Crush It',
'Make It Happen',
'Stay Strong',
'Push Through',
'One Step at a Time',
'You Can Do It',
'Focus and Conquer'
],
duration: 25 * 60,
type: 'focus',
},
break: {
labels: [
'Take a Break',
'Relax and Recharge',
'Breathe Deeply',
'Stretch It Out',
'Refresh Yourself',
'You Deserve This',
'Recharge Your Energy',
'Step Away for a Bit',
'Clear Your Mind',
'Rest and Rejuvenate'
],
duration: 5 * 60,
type: 'break',
},
}
export default function PomodoroTimer() {
const [settings] = useAtom(settingsAtom)
const t = useTranslations('PomodoroTimer')
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: {
getLabels: () => [
t('focusLabel1'), t('focusLabel2'), t('focusLabel3'), t('focusLabel4'), t('focusLabel5'),
t('focusLabel6'), t('focusLabel7'), t('focusLabel8'), t('focusLabel9'), t('focusLabel10')
],
duration: 25 * 60,
type: 'focus',
},
break: {
getLabels: () => [
t('breakLabel1'), t('breakLabel2'), t('breakLabel3'), t('breakLabel4'), t('breakLabel5'),
t('breakLabel6'), t('breakLabel7'), t('breakLabel8'), t('breakLabel9'), t('breakLabel10')
],
duration: 5 * 60,
type: 'break',
},
}
const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo
const [habitsData] = useAtom(habitsAtom)
@@ -62,21 +47,23 @@ export default function PomodoroTimer() {
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
const wakeLock = useRef<WakeLockSentinel | null>(null)
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(() => {
const labels = currentTimerRef.current.getLabels();
return labels[Math.floor(Math.random() * labels.length)];
});
// Handle wake lock
useEffect(() => {
const requestWakeLock = async () => {
try {
if (!('wakeLock' in navigator)) {
console.debug('Browser does not support wakelock')
console.debug(t('wakeLockNotSupported'))
return
}
if (wakeLock.current && !wakeLock.current.released) {
console.debug('Wake lock already in use')
console.debug(t('wakeLockInUse'))
return
}
if (state === 'started') {
@@ -85,7 +72,7 @@ export default function PomodoroTimer() {
return
}
} catch (err) {
console.error('Error requesting wake lock:', err)
console.error(t('wakeLockRequestError'), err)
}
}
@@ -96,7 +83,7 @@ export default function PomodoroTimer() {
wakeLock.current = null
}
} catch (err) {
console.error('Error releasing wake lock:', err)
console.error(t('wakeLockReleaseError'), err)
}
}
@@ -121,48 +108,48 @@ export default function PomodoroTimer() {
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock()
}
}, [state])
}, [state, t])
// Timer logic
useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null
const handleTimerEnd = async () => {
setState("stopped");
const currentTimerType = currentTimerRef.current.type;
currentTimerRef.current =
currentTimerType === "focus" ? PomoConfigs.break : PomoConfigs.focus;
setTimeLeft(currentTimerRef.current.duration);
const newLabels = currentTimerRef.current.getLabels();
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)]);
if (state === 'started') {
// update habits only after focus sessions
if (selectedHabit && currentTimerType === "focus") {
await completeHabit(selectedHabit);
// The atom will automatically update with the new completions
}
};
let interval: ReturnType<typeof setInterval> | null = null;
if (state === "started") {
// Calculate the target end time based on current timeLeft
const targetEndTime = Date.now() + timeLeft * 1000
const targetEndTime = Date.now() + timeLeft * 1000;
interval = setInterval(() => {
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
const remaining = Math.floor((targetEndTime - Date.now()) / 1000);
if (remaining <= 0) {
handleTimerEnd()
handleTimerEnd();
} else {
setTimeLeft(remaining)
setTimeLeft(remaining);
}
}, 1000)
}, 1000);
}
// return handles any other states
return () => {
if (interval) clearInterval(interval)
}
}, [state])
const handleTimerEnd = async () => {
setState("stopped")
const currentTimerType = currentTimer.current.type
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
setTimeLeft(currentTimer.current.duration)
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
// update habits only after focus sessions
if (selectedHabit && currentTimerType === 'focus') {
await completeHabit(selectedHabit)
// The atom will automatically update with the new completions
}
}
if (interval) clearInterval(interval);
};
}, [state, timeLeft, PomoConfigs.break, PomoConfigs.focus, completeHabit, selectedHabit]);
const toggleTimer = () => {
setState(prev => prev === 'started' ? 'paused' : 'started')
@@ -170,17 +157,16 @@ export default function PomodoroTimer() {
const resetTimer = () => {
setState("stopped")
setTimeLeft(currentTimer.current.duration)
setTimeLeft(currentTimerRef.current.duration)
}
const skipTimer = () => {
currentTimer.current = currentTimer.current.type === 'focus'
currentTimerRef.current = currentTimerRef.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
resetTimer() // This will also reset timeLeft to the new timer's duration
const newLabels = currentTimerRef.current.getLabels();
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
}
const formatTime = (seconds: number) => {
@@ -189,9 +175,9 @@ export default function PomodoroTimer() {
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
}
const progress = (timeLeft / currentTimer.current.duration) * 100
const progress = (timeLeft / currentTimerRef.current.duration) * 100
if (!show) return null
if (!show) return <></>
return (
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">
@@ -242,11 +228,11 @@ export default function PomodoroTimer() {
<div className={cn(
'w-2 h-2 rounded-full flex-none',
// order matters here
currentTimer.current.type === 'focus' && 'bg-green-500',
currentTimerRef.current.type === 'focus' && 'bg-green-500',
state === 'started' && 'animate-pulse',
state === 'paused' && 'bg-yellow-500',
state === 'stopped' && 'bg-red-500',
currentTimer.current.type === 'break' && 'bg-blue-500',
currentTimerRef.current.type === 'break' && 'bg-blue-500',
)} />
<div className="font-bold text-foreground">
{selectedHabit.name}
@@ -254,7 +240,9 @@ export default function PomodoroTimer() {
</div>
</div>
)}
<span>{currentTimer.current.type.charAt(0).toUpperCase() + currentTimer.current.type.slice(1)}: {currentLabel}</span>
<span>
{currentTimerRef.current.type === 'focus' ? t('focusType') : t('breakType')}: {currentLabel}
</span>
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
<div className="flex justify-center gap-1 mt-2">
{(() => {
@@ -293,12 +281,12 @@ export default function PomodoroTimer() {
{state === "started" ? (
<>
<Pause className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Pause</span>
<span className="hidden sm:inline">{t('pauseButton')}</span>
</>
) : (
<>
<Play className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Start</span>
<span className="hidden sm:inline">{t('startButton')}</span>
</>
)}
</Button>
@@ -309,7 +297,7 @@ export default function PomodoroTimer() {
className="sm:px-4"
>
<RotateCw className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Reset</span>
<span className="hidden sm:inline">{t('resetButton')}</span>
</Button>
<Button
variant="outline"
@@ -318,7 +306,7 @@ export default function PomodoroTimer() {
className="sm:px-4"
>
<SkipForward className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Skip</span>
<span className="hidden sm:inline">{t('skipButton')}</span>
</Button>
</div>
</div>

View File

@@ -1,42 +1,42 @@
'use client'
import { signOut } from "@/app/actions/user"
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 { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react"
import { toast } from "@/hooks/use-toast"
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm'
import Link from "next/link"
import { useAtom } from "jotai"
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
import AboutModal from "./AboutModal"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { signOut } from "@/app/actions/user"
import { toast } from "@/hooks/use-toast"
import { useHelpers } from "@/lib/client-helpers"
export function Profile() {
const t = useTranslations('Profile');
const [settings] = useAtom(settingsAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false)
const [showAbout, setShowAbout] = useState(false)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const { theme, setTheme } = useTheme()
const { currentUser: user } = useHelpers()
const [user] = useAtom(currentUserAtom)
const [open, setOpen] = useState(false)
const handleSignOut = async () => {
try {
await signOut()
toast({
title: "Signed out successfully",
description: "You have been logged out of your account",
title: t('signOutSuccessTitle'),
description: t('signOutSuccessDescription'),
})
setTimeout(() => window.location.reload(), 300);
} catch (error) {
toast({
title: "Error",
description: "Failed to sign out",
title: t('signOutErrorTitle'),
description: t('signOutErrorDescription'),
variant: "destructive",
})
}
@@ -65,8 +65,8 @@ export function Profile() {
</AvatarFallback>
</Avatar>
<div className="flex flex-col mr-4">
<span className="text-sm font-semibold flex items-center gap-1">
{user?.username || "Guest"}
<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>
{user && (
@@ -78,7 +78,7 @@ export function Profile() {
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
>
Edit profile
{t('editProfileButton')}
</button>
)}
</div>
@@ -104,34 +104,40 @@ export function Profile() {
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-4 w-4" />
<span>Switch user</span>
<span>{t('switchUserButton')}</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
<Link
href="/settings"
aria-label='settings'
className="flex items-center w-full gap-3"
aria-label={t('settingsLink')}
className="flex items-center justify-between w-full"
onClick={() => setOpen(false)} // Ensure dropdown closes on click
>
<Settings className="h-4 w-4" />
<span>Settings</span>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<span>{t('settingsLink')}</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
<button
onClick={() => setShowAbout(true)}
className="flex items-center w-full gap-3"
>
<Info className="h-4 w-4" />
<span>About</span>
</button>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
setOpen(false); // Close the dropdown
setAboutOpen(true); // Open the about modal
}}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" />
<span>{t('aboutButton')}</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
<div className="flex items-center justify-between w-full gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4" />
<span>Theme</span>
<span>{t('themeLabel')}</span>
</div>
<button
onClick={(e) => {
@@ -167,14 +173,12 @@ export function Profile() {
</DropdownMenuContent>
</DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* Add the UserForm dialog */}
{isEditing && user && (
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogTitle>{t('editProfileModalTitle')}</DialogTitle>
</DialogHeader>
<UserForm
userId={user.id}

View File

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

@@ -1,20 +1,22 @@
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
const t = useTranslations('TodayEarnedCoins')
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins()
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">
{"+"}
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
{longFormat ?
<span className="text-sm text-muted-foreground"> today</span>
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
: null}
</span>
)

View File

@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Check, Loader2, Pencil, Trash2, X } from 'lucide-react'
import { toast } from '@/hooks/use-toast'
import { useTranslations } from 'next-intl'
interface TransactionNoteEditorProps {
transactionId: string
@@ -19,6 +20,7 @@ export function TransactionNoteEditor({
onSave,
onDelete
}: TransactionNoteEditorProps) {
const t = useTranslations('TransactionNoteEditor');
const [isEditing, setIsEditing] = useState(false)
const [noteText, setNoteText] = useState(initialNote)
const [isSaving, setIsSaving] = useState(false)
@@ -27,8 +29,8 @@ export function TransactionNoteEditor({
const trimmedNote = noteText.trim()
if (trimmedNote.length > 200) {
toast({
title: 'Note too long',
description: 'Notes must be less than 200 characters',
title: t('noteTooLongTitle'),
description: t('noteTooLongDescription'),
variant: 'destructive'
})
return
@@ -40,8 +42,8 @@ export function TransactionNoteEditor({
setIsEditing(false)
} catch (error) {
toast({
title: 'Error saving note',
description: 'Please try again',
title: t('errorSavingNoteTitle'),
description: t('pleaseTryAgainDescription'),
variant: 'destructive'
})
// Revert to initial value on error
@@ -59,8 +61,8 @@ export function TransactionNoteEditor({
setIsEditing(false)
} catch (error) {
toast({
title: 'Error deleting note',
description: 'Please try again',
title: t('errorDeletingNoteTitle'),
description: t('pleaseTryAgainDescription'),
variant: 'destructive'
})
} finally {
@@ -74,7 +76,7 @@ export function TransactionNoteEditor({
<Input
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
placeholder="Add a note..."
placeholder={t('addNotePlaceholder')}
className="w-64"
maxLength={200}
/>
@@ -85,7 +87,7 @@ export function TransactionNoteEditor({
onClick={handleSave}
disabled={isSaving}
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400 transition-colors"
title="Save note"
title={t('saveNoteTitle')}
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
@@ -98,7 +100,7 @@ export function TransactionNoteEditor({
}}
disabled={isSaving}
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors"
title="Cancel"
title={t('cancelButtonTitle')}
>
<X className="h-4 w-4" />
</Button>
@@ -109,7 +111,7 @@ export function TransactionNoteEditor({
onClick={handleDelete}
disabled={isSaving}
className="text-gray-600 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 transition-colors"
title="Delete note"
title={t('deleteNoteTitle')}
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
@@ -129,7 +131,7 @@ export function TransactionNoteEditor({
<button
onClick={() => setIsEditing(true)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Edit note"
aria-label={t('editNoteAriaLabel')}
>
<Pencil className="h-4 w-4" />
</button>

View File

@@ -1,22 +1,33 @@
'use client';
import { useState } from 'react';
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 { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { Permission } from '@/lib/types';
import { passwordSchema, usernameSchema } from '@/lib/zod';
import { Input } from './ui/input';
import { useAtom, useAtomValue } from 'jotai';
import _ from 'lodash';
import { User as UserIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { PermissionSelector } from './PermissionSelector';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
import { Permission } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useAtom, useAtomValue } from 'jotai';
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import { SafeUser, User } from '@/lib/types';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { User as UserIcon } from 'lucide-react';
import _ from 'lodash';
import { PermissionSelector } from './PermissionSelector';
import { useHelpers } from '@/lib/client-helpers';
interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating
@@ -25,10 +36,11 @@ interface UserFormProps {
}
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
const t = useTranslations('UserForm');
const [users, setUsersData] = useAtom(usersAtom);
const serverSettings = useAtomValue(serverSettingsAtom)
const user = userId ? users.users.find(u => u.id === userId) : undefined;
const { currentUser } = useHelpers()
const [currentUser] = useAtom(currentUserAtom)
const getDefaultPermissions = (): Permission[] => [{
habit: {
write: true,
@@ -56,6 +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();
@@ -92,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
@@ -104,8 +179,8 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
}));
toast({
title: "User updated",
description: `Successfully updated user ${username}`,
title: t('toastUserUpdatedTitle'),
description: t('toastUserUpdatedDescription', { username }),
variant: 'default'
});
} else {
@@ -128,8 +203,8 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
}));
toast({
title: "User created",
description: `Successfully created user ${username}`,
title: t('toastUserCreatedTitle'),
description: t('toastUserCreatedDescription', { username }),
variant: 'default'
});
}
@@ -138,15 +213,16 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
setError('');
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} user`);
const action = isEditing ? t('actionUpdate') : t('actionCreate');
setError(err instanceof Error ? err.message : t('errorFailedUserAction', { action }));
}
};
const handleAvatarChange = async (file: File) => {
if (file.size > 5 * 1024 * 1024) {
if (file.size > 5 * 1024 * 1024) { // 5MB
toast({
title: "Error",
description: "File size must be less than 5MB",
title: t('errorTitle'),
description: t('errorFileSizeLimit'),
variant: 'destructive'
});
return;
@@ -160,14 +236,14 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
setAvatarPath(path);
setAvatarFile(null); // Clear the file since we've uploaded it
toast({
title: "Avatar uploaded",
description: "Successfully uploaded avatar",
title: t('toastAvatarUploadedTitle'),
description: t('toastAvatarUploadedDescription'),
variant: 'default'
});
} catch (err) {
toast({
title: "Error",
description: "Failed to upload avatar",
title: t('errorTitle'),
description: t('errorFailedAvatarUpload'),
variant: 'destructive'
});
}
@@ -209,18 +285,18 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
}}
className="w-full"
>
{isEditing ? 'Change Avatar' : 'Upload Avatar'}
{isEditing ? t('changeAvatarButton') : t('uploadAvatarButton')}
</Button>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t('usernameLabel')}</Label>
<Input
id="username"
type="text"
placeholder="Username"
placeholder={t('usernamePlaceholder')}
value={username}
onChange={(e) => setUsername(e.target.value)}
className={error ? 'border-red-500' : ''}
@@ -230,22 +306,22 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">
{isEditing ? 'New Password' : 'Password'}
{isEditing ? t('newPasswordLabel') : t('passwordLabel')}
</Label>
<Input
id="password"
type="password"
placeholder={isEditing ? "Leave blank to keep current" : "Enter password"}
placeholder={isEditing ? t('passwordPlaceholderEdit') : t('passwordPlaceholderCreate')}
value={password || ''}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
disabled={disablePassword}
/>
{serverSettings.isDemo && (
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
<p className="text-sm text-red-500">{t('demoPasswordDisabledMessage')}</p>
)}
</div>
<div className="flex items-center space-x-2">
<Switch
id="disable-password"
@@ -253,7 +329,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
onCheckedChange={setDisablePassword}
disabled={serverSettings.isDemo}
/>
<Label htmlFor="disable-password">Disable password</Label>
<Label htmlFor="disable-password">{t('disablePasswordLabel')}</Label>
</div>
</div>
@@ -261,7 +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}
@@ -272,15 +348,47 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
</div>
<div className="flex justify-end gap-2 pt-2">
{isEditing && (
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
className="mr-auto"
disabled={serverSettings.isDemo || isDeleting}
>
{isDeleting ? t('deletingButtonText') : t('deleteAccountButton')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteUserConfirmation', { username: user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button
type="button"
variant="outline"
onClick={onCancel}
>
Cancel
{t('cancelButton')}
</Button>
<Button type="submit" disabled={!username}>
{isEditing ? 'Save Changes' : 'Create User'}
{isEditing ? t('saveChangesButton') : t('createUserButton')}
</Button>
</div>
</form>

View File

@@ -1,36 +1,37 @@
'use client';
import { signIn } from '@/app/actions/user';
import { toast } from '@/hooks/use-toast';
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, User as UserIcon, UserRoundPen } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import PasswordEntryForm from './PasswordEntryForm';
import UserForm from './UserForm';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { useAtom } from 'jotai';
import { usersAtom } from '@/lib/atoms';
import { signIn } from '@/app/actions/user';
import { createUser } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast';
import { Description } from '@radix-ui/react-dialog';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
function UserCard({
user,
function UserCard({
user,
onSelect,
onEdit,
showEdit,
isCurrentUser
isCurrentUser,
}: {
user: User,
onSelect: () => void,
onEdit: () => void,
showEdit: boolean,
isCurrentUser: boolean
isCurrentUser: boolean,
}) {
const t = useTranslations('UserSelectModal');
return (
<div key={user.id} className="relative group">
<button
@@ -41,9 +42,9 @@ function UserCard({
)}
>
<Avatar className="h-16 w-16">
<AvatarImage
<AvatarImage
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
alt={user.username}
alt={user.username}
/>
<AvatarFallback>
<UserIcon className="h-8 w-8" />
@@ -55,21 +56,27 @@ function UserCard({
</span>
</button>
{showEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="absolute top-0 right-0 p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
>
<UserRoundPen className="h-4 w-4" />
</button>
<div className="absolute top-0 right-0 flex space-x-1">
{showEdit && (
<button
onClick={(e) => {
e.stopPropagation(); // Prevent card selection
onEdit();
}}
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
title={t('editUserTooltip')}
>
<UserRoundPen className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
}
function AddUserButton({ onClick }: { onClick: () => void }) {
const t = useTranslations('UserSelectModal');
return (
<button
onClick={onClick}
@@ -80,51 +87,53 @@ function AddUserButton({ onClick }: { onClick: () => void }) {
<Plus className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">Add User</span>
<span className="text-sm font-medium">{t('addUserButton')}</span>
</button>
);
}
function UserSelectionView({
users,
currentUser,
currentUserFromHook, // Renamed to avoid confusion with map variable
onUserSelect,
onEditUser,
onCreateUser
onCreateUser,
}: {
users: User[],
currentUser?: SafeUser,
currentUserFromHook?: SafeUser,
onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void,
onCreateUser: () => void
onCreateUser: () => void,
}) {
return (
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
{users
.filter(user => user.id !== currentUser?.id)
.filter(user => user.id !== currentUserFromHook?.id) // Show other users
.map((user) => (
<UserCard
key={user.id}
user={user}
onSelect={() => onUserSelect(user.id)}
onEdit={() => onEditUser(user.id)}
showEdit={!!currentUser?.isAdmin}
isCurrentUser={false}
showEdit={!!currentUserFromHook?.isAdmin}
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
/>
))}
{currentUser?.isAdmin && <AddUserButton onClick={onCreateUser} />}
))}
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
</div>
);
}
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
const t = useTranslations('UserSelectModal');
const [selectedUser, setSelectedUser] = useState<string>();
const [isCreating, setIsCreating] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [error, setError] = useState('');
const [usersData] = useAtom(usersAtom);
const [usersData, setUsersData] = useAtom(usersAtom);
const users = usersData.users;
const {currentUser} = useHelpers();
const [currentUser] = useAtom(currentUserAtom);
const handleUserSelect = (userId: string) => {
setSelectedUser(userId);
@@ -159,14 +168,14 @@ const {currentUser} = useHelpers();
<DialogContent className="sm:max-w-md">
<Description></Description>
<DialogHeader>
<DialogTitle>{isCreating ? 'Create New User' : 'Select User'}</DialogTitle>
<DialogTitle>{isCreating ? t('createNewUserTitle') : t('selectUserTitle')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{!selectedUser && !isCreating && !isEditing ? (
<UserSelectionView
users={users}
currentUser={currentUser}
currentUserFromHook={currentUser}
onUserSelect={handleUserSelect}
onEditUser={handleEditUser}
onCreateUser={handleCreateUser}
@@ -187,19 +196,19 @@ const {currentUser} = useHelpers();
const user = users.find(u => u.id === selectedUser);
if (!user) throw new Error("User not found");
await signIn(user.username, password);
setError('');
onClose();
toast({
title: "Signed in successfully",
description: `Welcome back, ${user.username}!`,
title: t('signInSuccessTitle'),
description: t('signInSuccessDescription', { username: user.username }),
variant: "default"
});
setTimeout(() => window.location.reload(), 300);
} catch (err) {
setError('invalid password');
setError(t('errorInvalidPassword'));
throw err;
}
}}

View File

@@ -1,33 +1,29 @@
'use client'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { CheckSquare, ListChecks } from 'lucide-react'
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import type { ViewType } from '@/lib/types'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { isHabitDueToday } from '@/lib/utils'
import { cn, isHabitDueToday } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { usePathname, useRouter } from 'next/navigation'
import { NotificationBadge } from './ui/notification-badge'
interface ViewToggleProps {
defaultView?: ViewType
className?: string
}
export function ViewToggle({
defaultView = 'habits',
className
}: ViewToggleProps) {
const t = useTranslations('ViewToggle')
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const [habits] = useAtom(habitsAtom)
const [settings] = useAtom(settingsAtom)
const pathname = usePathname();
const router = useRouter();
const handleViewChange = (checked: boolean) => {
const newView = checked ? 'tasks' : 'habits'
setBrowserSettings({
...browserSettings,
viewType: newView,
})
const handleViewChange = () => {
router.push(pathname.includes("habits") ? "/tasks" : "/habits");
}
// Calculate due tasks count
@@ -39,36 +35,36 @@ export function ViewToggle({
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
<button
onClick={() => handleViewChange(false)}
onClick={handleViewChange}
className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
browserSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
pathname.includes('habits') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<HabitIcon className="h-4 w-4" />
<span className="hidden sm:inline">Habits</span>
<span className="hidden sm:inline">{t('habitsLabel')}</span>
</button>
<NotificationBadge
<NotificationBadge
label={dueTasksCount}
show={dueTasksCount > 0}
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
className="shadow-md"
>
<button
onClick={() => handleViewChange(true)}
onClick={handleViewChange}
className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
pathname.includes('tasks') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<TaskIcon className="h-4 w-4" />
<span className="hidden sm:inline">Tasks</span>
<span className="hidden sm:inline">{t('tasksLabel')}</span>
</button>
</NotificationBadge>
<div
className={cn(
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
browserSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
pathname.includes('habits') ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
)}
/>
</div>

View File

@@ -1,12 +1,5 @@
import { WishlistItemType, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,6 +7,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { User, WishlistItemType } from '@/lib/types'
import { hasPermission } from '@/lib/utils'
import { useAtom } from 'jotai'
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()}` || ""} />
@@ -58,11 +59,13 @@ export default function WishlistItem({
isHighlighted,
isRecentlyRedeemed
}: WishlistItemProps) {
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('wishlist', 'write')
const canInteract = hasPermission('wishlist', 'interact')
const t = useTranslations('WishlistItem')
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
const [usersData] = useAtom(usersAtom)
return (
<Card
id={`wishlist-${item.id}`}
@@ -70,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} {item.targetCompletions === 1 ? 'use' : 'uses'} left)
</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} coins
</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"}
@@ -113,13 +130,13 @@ export default function WishlistItem({
<span>
{isRecentlyRedeemed ? (
<>
<span className="sm:hidden">Done</span>
<span className="hidden sm:inline">Redeemed!</span>
<span className="sm:hidden">{t('redeemedDone')}</span>
<span className="hidden sm:inline">{t('redeemedExclamation')}</span>
</>
) : (
<>
<span className="sm:hidden">Redeem</span>
<span className="hidden sm:inline">Redeem</span>
<span className="sm:hidden">{t('redeem')}</span>
<span className="hidden sm:inline">{t('redeem')}</span>
</>
)}
</span>
@@ -135,10 +152,10 @@ export default function WishlistItem({
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
<span className="ml-2">{t('editButton')}</span>
</Button>
)}
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
@@ -148,27 +165,27 @@ export default function WishlistItem({
{!item.archived && (
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
<span>{t('archiveButton')}</span>
</DropdownMenuItem>
)}
{item.archived && (
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
<span>{t('unarchiveButton')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
<Edit className="mr-2 h-4 w-4" />
Edit
{t('editButton')}
</DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onClick={onDelete}
disabled={!canWrite}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t('deleteButton')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -177,4 +194,3 @@ export default function WishlistItem({
</Card>
)
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react'
import { useWishlist } from '@/hooks/useWishlist'
import { useTranslations } from 'next-intl'
import { Plus, Gift } from 'lucide-react'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
@@ -13,6 +14,7 @@ import { openWindow } from '@/lib/utils'
import { toast } from '@/hooks/use-toast'
export default function WishlistManager() {
const t = useTranslations('WishlistManager')
const {
addWishlistItem,
editWishlistItem,
@@ -64,14 +66,14 @@ export default function WishlistManager() {
setTimeout(() => {
setRecentlyRedeemedId(null)
}, 3000)
if (item.link) {
setTimeout(() => {
const opened = openWindow(item.link!)
if (!opened) {
toast({
title: "Popup Blocked",
description: "Please allow popups to open the link",
title: t('popupBlockedTitle'),
description: t('popupBlockedDescription'),
variant: "destructive"
})
}
@@ -81,11 +83,11 @@ export default function WishlistManager() {
}
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">My Wishlist</h1>
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Reward
<Plus className="mr-2 h-4 w-4" /> {t('addRewardButton')}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
@@ -93,8 +95,8 @@ export default function WishlistManager() {
<div className="col-span-1 lg:col-span-2">
<EmptyState
icon={Gift}
title="Your wishlist is empty"
description="Add rewards that you'd like to earn with your coins"
title={t('emptyStateTitle')}
description={t('emptyStateDescription')}
/>
</div>
) : (
@@ -124,12 +126,12 @@ export default function WishlistManager() {
</div>
))
)}
{archivedItems.length > 0 && (
<>
<div className="col-span-1 lg:col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedItems.map((item) => (
@@ -150,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 })}
@@ -167,9 +170,9 @@ export default function WishlistManager() {
}
setDeleteConfirmation({ isOpen: false, itemId: null })
}}
title="Delete Reward"
message="Are you sure you want to delete this reward? This action cannot be undone."
confirmText="Delete"
title={t('deleteDialogTitle')}
message={t('deleteDialogMessage')}
confirmText={t('deleteButton')}
/>
</div>
)

View File

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

View File

@@ -1,7 +1,6 @@
"use client"
import * as React from "react"
import { Moon, MoonIcon, Sun } from "lucide-react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"

View File

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

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 (

View File

@@ -1,10 +1,11 @@
services:
habittrove:
image: ghcr.io/manindark/habittrove
container_name: habittrove
ports:
- "3000:3000"
volumes:
- "./data:/app/data"
- "./backups:/app/backups"
image: dohsimpson/habittrove
environment:
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret

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,77 +1,109 @@
import { useAtom } from 'jotai'
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'
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
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');
const tCommon = useTranslations('Common');
const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom)
const [users] = useAtom(usersAtom)
const { currentUser } = useHelpers()
let user: User | undefined;
if (!options?.selectedUser) {
user = currentUser;
} else {
user = users.users.find(u => u.id === options.selectedUser)
}
const [currentUser] = useAtom(currentUserAtom)
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
const [atomTotalEarned] = useAtom(totalEarnedAtom)
const [atomTotalSpent] = useAtom(totalSpentAtom)
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
const [atomTransactionsToday] = useAtom(transactionsTodayAtom);
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
const transactions = useMemo(() => {
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id);
}, [allCoinsData, targetUser?.id]);
// Filter transactions for the selectd user
const transactions = coins.transactions.filter(t => t.userId === user?.id)
const timezone = settings.system.timezone;
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
const [totalEarned, setTotalEarned] = useState(0);
const [totalSpent, setTotalSpent] = useState(0);
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
const [transactionsToday, setTransactionsToday] = useState<number>(0);
const [balance, setBalance] = useState(0);
const [balance] = useAtom(coinsBalanceAtom)
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
const [totalEarned] = useAtom(totalEarnedAtom)
const [totalSpent] = useAtom(totalSpentAtom)
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
const [transactionsToday] = useAtom(transactionsTodayAtom)
useEffect(() => {
// Calculate other metrics
if (targetUser?.id && targetUser.id === currentUser?.id) {
// If the target user is the currently logged-in user, use the derived atom's value
setCoinsEarnedToday(atomCoinsEarnedToday);
setTotalEarned(atomTotalEarned);
setTotalSpent(atomTotalSpent);
setCoinsSpentToday(atomCoinsSpentToday);
setTransactionsToday(atomTransactionsToday);
setBalance(loggedInUserBalance);
} else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually
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')) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
if (isNaN(amount) || amount <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid positive number"
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({
@@ -79,22 +111,29 @@ export function useCoins(options?: { selectedUser?: string }) {
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: user?.id
userId: targetUser?.id
})
setCoins(data)
toast({ title: "Success", description: `Added ${amount} coins` })
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
return data
}
const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid positive number"
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
return <></>;
}
if (numAmount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return <></>;
}
const data = await removeCoins({
@@ -102,22 +141,22 @@ export function useCoins(options?: { selectedUser?: string }) {
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: user?.id
userId: targetUser?.id
})
setCoins(data)
toast({ title: "Success", description: `Removed ${numAmount} coins` })
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
return data
}
const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) {
toast({
title: "Error",
description: "Transaction not found"
title: tCommon("errorTitle"),
description: t("transactionNotFoundDescription")
})
return null
return <></>;
}
const updatedTransaction = {

View File

@@ -1,63 +1,37 @@
import { useAtom, atom } from 'jotai'
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'
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
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)
const [habitFreqMap] = useAtom(habitFreqMapAtom)
const completeHabit = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone)
@@ -72,8 +46,8 @@ export function useHabits() {
// Check if already completed
if (completionsToday >= target) {
toast({
title: "Already completed",
description: `You've already completed this habit today.`,
title: t("alreadyCompletedTitle"),
description: t("alreadyCompletedDescription"),
variant: "destructive",
})
return
@@ -102,21 +76,21 @@ export function useHabits() {
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
isTargetReached && playSound()
playSound()
toast({
title: "Completed!",
description: `You earned ${habit.coinReward} coins.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
title: t("completedTitle"),
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
setCoins(updatedCoins)
} else {
toast({
title: "Progress!",
description: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
title: t("progressTitle"),
description: t("progressDescription", { count: completionsToday + 1, target }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
}
@@ -131,7 +105,7 @@ export function useHabits() {
}
const undoComplete = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
@@ -170,14 +144,17 @@ export function useHabits() {
}
toast({
title: "Completion undone",
description: `You have ${getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
})}/${target} completions today.`,
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />Redo
title: t("completionUndoneTitle"),
description: t("completionUndoneDescription", {
count: getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
}),
target
}),
action: <ToastAction altText={tCommon('redoButton')} onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('redoButton')}
</ToastAction>
})
@@ -188,8 +165,8 @@ export function useHabits() {
}
} else {
toast({
title: "No completions to undo",
description: "This habit hasn't been completed today.",
title: t("noCompletionsToUndoTitle"),
description: t("noCompletionsToUndoDescription"),
variant: "destructive",
})
return
@@ -197,10 +174,10 @@ export function useHabits() {
}
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
id: habit.id || crypto.randomUUID()
}
const updatedHabits = habit.id
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
@@ -212,7 +189,7 @@ export function useHabits() {
}
const deleteHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
@@ -220,7 +197,7 @@ export function useHabits() {
}
const completePastHabit = async (habit: Habit, date: DateTime) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const dateKey = getISODate({ dateTime: date, timezone })
@@ -232,8 +209,8 @@ export function useHabits() {
if (completionsOnDate >= target) {
toast({
title: "Already completed",
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`,
title: t("alreadyCompletedPastDateTitle"),
description: t("alreadyCompletedPastDateDescription", { dateKey: d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' }) }),
variant: "destructive",
})
return
@@ -273,12 +250,12 @@ export function useHabits() {
}
toast({
title: isTargetReached ? "Completed!" : "Progress!",
title: isTargetReached ? t("completedTitle") : t("progressTitle"),
description: isTargetReached
? `You earned ${habit.coinReward} coins for ${dateKey}.`
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
? t("earnedCoinsPastDateDescription", { coinReward: habit.coinReward, dateKey })
: t("progressPastDateDescription", { count: completionsOnDate + 1, target, dateKey }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
@@ -290,7 +267,7 @@ export function useHabits() {
}
const archiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: true } : h
)
@@ -299,7 +276,7 @@ export function useHabits() {
}
const unarchiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: false } : h
)

View File

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

13
i18n/request.ts Normal file
View File

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

View File

@@ -1,81 +1,86 @@
import { atom } from "jotai";
import {
getDefaultSettings,
getDefaultHabitsData,
getDefaultCoinsData,
getDefaultWishlistData,
Habit,
ViewType,
getDefaultUsersData,
CompletionCache,
getDefaultServerSettings,
User,
} from "./types";
import {
getTodayInTimezone,
isSameDate,
t2d,
calculateCoinsEarnedToday,
calculateCoinsSpentToday,
calculateTotalEarned,
calculateTotalSpent,
calculateCoinsSpentToday,
calculateTransactionsToday,
generateCryptoHash,
getCompletionsForToday,
getISODate,
isHabitDueToday,
getNow,
getHabitFreq,
isHabitDue,
getHabitFreq
prepareDataForHashing,
roundToInteger,
t2d
} from "@/lib/utils";
import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
import { Freq } from "./types";
import {
CoinsData,
CompletionCache,
Freq,
getDefaultCoinsData,
getDefaultHabitsData,
getDefaultServerSettings,
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
Habit,
HabitsData,
ServerSettings,
Settings,
UserData,
UserId,
WishlistData
} from "./types";
export interface BrowserSettings {
viewType: ViewType
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}
export const browserSettingsAtom = atomWithStorage('browserSettings', {
viewType: 'habits',
expandedHabits: false,
expandedTasks: false,
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
@@ -85,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 */
@@ -107,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) => {
@@ -195,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

@@ -1,11 +1,10 @@
// client helpers
'use-client'
import { useSession } from "next-auth/react"
import { User, UserId } from './types'
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()
@@ -14,7 +13,7 @@ export function useHelpers() {
const currentUser = usersData.users.find((u) => u.id === currentUserId)
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return [
return typeof navigator !== "undefined" && ([
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
@@ -23,7 +22,7 @@ export function useHelpers() {
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
}
return {
@@ -31,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,51 +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)
},
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,
@@ -163,6 +171,7 @@ export interface SystemSettings {
timezone: string;
weekStartDay: WeekDay;
autoBackupEnabled: boolean; // Add this line
language: string; // Add this line for language preference
}
export interface ProfileSettings {
@@ -181,8 +190,6 @@ export type CompletionCache = {
}
}
export type ViewType = 'habits' | 'tasks'
export interface JotaiHydrateInitialValues {
settings: Settings;
coins: CoinsData;

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

449
messages/de.json Normal file
View File

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

447
messages/en.json Normal file
View File

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

449
messages/es.json Normal file
View File

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

449
messages/fr.json Normal file
View File

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

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

449
messages/ru.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": "Некорректная ссылка",
"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": "Произошла сетевая ошибка. Пожалуйста, попробуйте еще раз.",
"editUserTooltip": "Редактировать пользователя",
"deleteUserTooltip": "Удалить пользователя"
},
"CoinsManager": {
"title": "Управление монетами",
"currentBalanceLabel": "Текущий баланс",
"coinsSuffix": "монет",
"addCoinsButton": "Добавить монеты",
"removeCoinsButton": "Удалить монеты",
"statisticsTitle": "Статистика",
"totalEarnedLabel": "Всего заработано",
"totalSpentLabel": "Всего потрачено",
"totalTransactionsLabel": "Всего транзакций",
"todaysEarnedLabel": "Заработано сегодня",
"todaysSpentLabel": "Потрачено сегодня",
"todaysTransactionsLabel": "Транзакций сегодня",
"transactionHistoryTitle": "История транзакций",
"showLabel": "Показать:",
"entriesSuffix": "записей",
"showingEntries": "Показано с {from} по {to} из {total} записей",
"noTransactionsTitle": "Нет транзакций",
"noTransactionsDescription": "История транзакций появится здесь, когда вы начнете зарабатывать или тратить монеты",
"pageLabel": "Страница",
"ofLabel": "из",
"transactionTypeHabitCompletion": "Выполнение привычки",
"transactionTypeTaskCompletion": "Выполнение задачи",
"transactionTypeHabitUndo": "Отмена привычки",
"transactionTypeTaskUndo": "Отмена задачи",
"transactionTypeWishRedemption": "Использование цели",
"transactionTypeManualAdjustment": "Ручная корректировка",
"transactionTypeCoinReset": "Сброс монет",
"transactionTypeInitialBalance": "Начальный баланс"
},
"NotificationBell": {
"errorUpdateTimestamp": "Не удалось обновить отметку времени прочтения уведомления:"
},
"PomodoroTimer": {
"focusLabel1": "Сосредоточьтесь",
"focusLabel2": "У вас получится",
"focusLabel3": "Продолжайте",
"focusLabel4": "Разгромите это",
"focusLabel5": "Воплотите это в жизнь",
"focusLabel6": "Оставайтесь сильными",
"focusLabel7": "Прорвитесь",
"focusLabel8": "Один шаг за раз",
"focusLabel9": "Вы можете это сделать",
"focusLabel10": "Сосредоточьтесь и побеждайте",
"breakLabel1": "Передохните",
"breakLabel2": "Расслабьтесь и перезагрузитесь",
"breakLabel3": "Дышите глубже",
"breakLabel4": "Потянитесь",
"breakLabel5": "Освежитесь",
"breakLabel6": "Вы этого заслуживаете",
"breakLabel7": "Восстановите энергию",
"breakLabel8": "Отойдите на немного",
"breakLabel9": "Очистите свой разум",
"breakLabel10": "Отдохните и восстановитесь",
"focusType": "Фокус",
"breakType": "Перерыв",
"pauseButton": "Пауза",
"startButton": "Старт",
"resetButton": "Сброс",
"skipButton": "Пропустить",
"wakeLockNotSupported": "Браузер не поддерживает блокировку экрана",
"wakeLockInUse": "Блокировка экрана уже используется",
"wakeLockRequestError": "Ошибка запроса блокировки экрана:",
"wakeLockReleaseError": "Ошибка освобождения блокировки экрана:"
},
"HabitCalendar": {
"title": "Календарь привычек",
"calendarCardTitle": "Календарь",
"selectDatePrompt": "Выберите дату",
"tasksSectionTitle": "Задачи",
"habitsSectionTitle": "Привычки",
"errorCompletingPastHabit": "Ошибка завершения прошлой привычки:"
},
"NotificationDropdown": {
"notLoggedIn": "Не выполнен вход.",
"userCompletedItem": "{username} выполнил(а) {itemName}.",
"userRedeemedItem": "{username} использовал(а) {itemName}.",
"activityRelatedToItem": "Действие, связанное с {itemName}, пользователем {username}.",
"defaultUsername": "Кто-то",
"defaultItemName": "общий элемент",
"notificationsTitle": "Уведомления",
"notificationsTooltip": "Показывает завершения или погашения другими пользователями для привычек или списка желаний, которыми вы поделились с ними (вы должны быть администратором)",
"noNotificationsYet": "Пока нет уведомлений."
},
"AboutModal": {
"dialogArisLabel": "о программе",
"changelogButton": "Список изменений",
"createdByPrefix": "Сделано с любовью ❤️ от",
"starOnGitHubButton": "Звезда на GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Разрешения",
"adminAccessLabel": "Доступ администратора",
"adminAccessDescription": "Администраторы имеют полный доступ ко всем данным для всех пользователей",
"resourceHabitTask": "Привычка / Задача",
"resourceWishlist": "Список желаний",
"resourceCoins": "Монеты",
"permissionWrite": "Запись",
"permissionInteract": "Взаимодействие"
},
"UserForm": {
"toastUserUpdatedTitle": "Пользователь обновлен",
"toastUserUpdatedDescription": "Пользователь {username} успешно обновлен",
"toastUserCreatedTitle": "Пользователь создан",
"toastUserCreatedDescription": "Пользователь {username} успешно создан",
"actionUpdate": "обновить",
"actionCreate": "создать",
"errorFailedUserAction": "Не удалось {action} пользователя",
"toastDemoDeleteDisabled": "Удаление отключено в демо-версии",
"toastCannotDeleteSelf": "Вы не можете удалить свою учетную запись",
"confirmDeleteUser": "Вы уверены, что хотите удалить пользователя {username}?",
"toastUserDeletedTitle": "Пользователь удален",
"toastUserDeletedDescription": "Пользователь {username} успешно удален",
"toastDeleteUserFailed": "Не удалось удалить пользователя: {error}",
"errorTitle": "Ошибка",
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
"toastAvatarUploadedTitle": "Аватар загружен",
"toastAvatarUploadedDescription": "Аватар успешно загружен",
"errorFailedAvatarUpload": "Не удалось загрузить аватар",
"changeAvatarButton": "Изменить аватар",
"uploadAvatarButton": "Загрузить аватар",
"usernameLabel": "Имя пользователя",
"usernamePlaceholder": "Имя пользователя",
"newPasswordLabel": "Новый пароль",
"passwordLabel": "Пароль",
"passwordPlaceholderEdit": "Оставьте пустым, чтобы сохранить текущий",
"passwordPlaceholderCreate": "Введите пароль",
"demoPasswordDisabledMessage": "Пароль автоматически отключен в демонстрационном экземпляре",
"disablePasswordLabel": "Отключить пароль",
"cancelButton": "Отмена",
"saveChangesButton": "Сохранить изменения",
"createUserButton": "Создать пользователя",
"deleteAccountButton": "Удалить аккаунт",
"deletingButtonText": "Удаление...",
"areYouSure": "Вы уверены?",
"deleteUserConfirmation": "Вы уверены, что хотите удалить пользователя {username}?",
"cancel": "Отмена",
"confirmDeleteButtonText": "Удалить"
},
"ViewToggle": {
"habitsLabel": "Привычки",
"tasksLabel": "Задачи"
},
"HabitItem": {
"overdue": "Просрочено",
"whenLabel": "Когда: {frequency}",
"coinsPerCompletion": "{count} монет за выполнение",
"completedStatus": "Выполнено",
"completedStatusCount": "Выполнено ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Выполнить",
"completeButtonCount": "Выполнить ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Отменить",
"editButton": "Редактировать"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Слишком длинная заметка",
"noteTooLongDescription": "Заметки должны быть менее 200 символов",
"errorSavingNoteTitle": "Ошибка сохранения заметки",
"errorDeletingNoteTitle": "Ошибка удаления заметки",
"pleaseTryAgainDescription": "Пожалуйста, попробуйте еще раз",
"addNotePlaceholder": "Добавить заметку...",
"saveNoteTitle": "Сохранить заметку",
"cancelButtonTitle": "Отмена",
"deleteNoteTitle": "Удалить заметку",
"editNoteAriaLabel": "Редактировать заметку"
},
"Profile": {
"guestUsername": "Гость",
"editProfileButton": "Редактировать профиль",
"signOutSuccessTitle": "Выход выполнен успешно",
"signOutSuccessDescription": "Вы вышли из своей учетной записи",
"signOutErrorTitle": "Ошибка выхода",
"signOutErrorDescription": "Не удалось выйти",
"switchUserButton": "Сменить пользователя",
"settingsLink": "Настройки",
"aboutButton": "О программе",
"themeLabel": "Тема",
"editProfileModalTitle": "Редактировать профиль"
},
"PasswordEntryForm": {
"notYouButton": "Не вы?",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите пароль",
"loginErrorToastTitle": "Ошибка",
"loginFailedErrorToastDescription": "Не удалось войти",
"cancelButton": "Отмена",
"loginButton": "Войти"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} выполнено"
},
"SettingsPage": {
"title": "Настройки",
"uiSettingsTitle": "Интерфейс",
"numberFormattingLabel": "Формат чисел",
"numberFormattingDescription": "Использовать сокращения (например, 1К, 1М, 1Млрд)",
"numberGroupingLabel": "Разделители",
"numberGroupingDescription": "Использовать разделители тысяч (например, 1 000 вместо 1000)",
"systemSettingsTitle": "Система",
"timezoneLabel": "Часовой пояс",
"timezoneDescription": "Выберите ваш часовой пояс",
"weekStartDayLabel": "Первый день недели",
"weekStartDayDescription": "Выберите первый день недели",
"weekdays": {
"sunday": "Воскресенье",
"monday": "Понедельник",
"tuesday": "Вторник",
"wednesday": "Среда",
"thursday": "Четверг",
"friday": "Пятница",
"saturday": "Суббота"
},
"autoBackupLabel": "Авто-бэкап",
"autoBackupTooltip": "При включении данные будут автоматически резервироваться ежедневно около 2:00 по времени сервера. Бэкапы хранятся в виде ZIP-файлов в директории `backups/`. Хранятся только последние 7 бэкапов.",
"autoBackupDescription": "Автоматическое резервное копирование данных",
"languageLabel": "Язык",
"languageDescription": "Выберите предпочитаемый язык интерфейса.",
"languageChangedTitle": "Язык изменен",
"languageChangedDescription": "Перезагрузите страницу для применения изменений",
"languageDisabledInDemoTooltip": "Смена языка недоступна в демо-версии."
},
"Common": {
"authenticationRequiredTitle": "Требуется аутентификация",
"authenticationRequiredDescription": "Пожалуйста, войдите, чтобы продолжить.",
"permissionDeniedTitle": "Отказано в доступе",
"permissionDeniedDescription": "У вас нет разрешения на {action} для {resource}.",
"undoButton": "Отменить",
"redoButton": "Повторить",
"errorTitle": "Ошибка"
},
"useHabits": {
"alreadyCompletedTitle": "Уже выполнено",
"alreadyCompletedDescription": "Вы уже выполнили эту привычку сегодня.",
"completedTitle": "Выполнено!",
"earnedCoinsDescription": "Вы заработали {coinReward} монет.",
"progressTitle": "Прогресс!",
"progressDescription": "Вы выполнили {count}/{target} раз сегодня.",
"completionUndoneTitle": "Выполнение отменено",
"completionUndoneDescription": "У вас {count}/{target} выполнений сегодня.",
"noCompletionsToUndoTitle": "Нет отмен",
"noCompletionsToUndoDescription": "Эта привычка не была выполнена сегодня.",
"alreadyCompletedPastDateTitle": "Уже выполнено",
"alreadyCompletedPastDateDescription": "Эта привычка уже была выполнена {dateKey}.",
"earnedCoinsPastDateDescription": "Вы заработали {coinReward} монет за {dateKey}.",
"progressPastDateDescription": "Вы выполнили {count}/{target} раз {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Достигнут лимит погашения",
"redemptionLimitReachedDescription": "Вы достигли максимального количества погашений для \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Награда получена!",
"rewardRedeemedDescription": "Вы получили \"{itemName}\" за {itemCoinCost} монет.",
"notEnoughCoinsTitle": "Недостаточно монет",
"notEnoughCoinsDescription": "Вам нужно еще {coinsNeeded} монет, чтобы получить эту награду."
},
"Warning": {
"areYouSure": "Вы уверены?",
"cancel": "Отмена"
},
"useCoins": {
"addedCoinsDescription": "Добавлено {amount} монет",
"invalidAmountTitle": "Неверная сумма",
"invalidAmountDescription": "Пожалуйста, введите положительное число",
"successTitle": "Успех",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}."
},
"DrawingModal": {
"colorLabel": "Цвет:",
"thicknessLabel": "Толщина:",
"undoButton": "Отменить",
"clearButton": "Очистить",
"saveDrawingButton": "Сохранить рисунок",
"cancelButton": "Отмена"
}
}

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

View File

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

1398
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.11",
"version": "0.2.30",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -18,6 +18,7 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@next/font": "^14.2.15",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.4",
@@ -28,7 +29,7 @@
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
@@ -47,8 +48,9 @@
"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",
"node-cron": "^3.0.3",
"react": "^19.0.0",
@@ -60,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

Some files were not shown because too many files have changed in this diff Show More