mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-10 12:29:50 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6934432fb5
|
@@ -7,17 +7,3 @@ Dockerfile
|
|||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
data
|
data
|
||||||
CLAUDE.md
|
|
||||||
docs/
|
|
||||||
Budfile
|
|
||||||
PLAN.md
|
|
||||||
/backups/
|
|
||||||
/data.bak/
|
|
||||||
/coverage/
|
|
||||||
*.md
|
|
||||||
!README.md
|
|
||||||
!CHANGELOG.md
|
|
||||||
tags
|
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|||||||
94
.github/workflows/docker-publish.yml
vendored
Normal file
94
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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
40
.github/workflows/release.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
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 }}
|
|
||||||
28
.github/workflows/test.yml
vendored
Normal file
28
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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
3
.gitignore
vendored
@@ -46,6 +46,3 @@ next-env.d.ts
|
|||||||
Budfile
|
Budfile
|
||||||
certificates
|
certificates
|
||||||
/backups/*
|
/backups/*
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
CHANGELOG.md.tmp
|
|
||||||
|
|||||||
10
.husky/pre-commit
Executable file → Normal file
10
.husky/pre-commit
Executable file → Normal file
@@ -1,11 +1 @@
|
|||||||
#!/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
|
npm run typecheck && npm run lint && npm run test
|
||||||
|
|||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"i18n-ally.localesPaths": [
|
|
||||||
"i18n",
|
|
||||||
"messages"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
143
CHANGELOG.md
143
CHANGELOG.md
@@ -1,148 +1,5 @@
|
|||||||
# Changelog
|
# 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
|
## Version 0.2.11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
100
CLAUDE.md
100
CLAUDE.md
@@ -1,100 +0,0 @@
|
|||||||
# 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
|
|
||||||
29
Dockerfile
29
Dockerfile
@@ -1,17 +1,18 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
# Use build platform for base images to avoid emulation
|
FROM node:18-alpine AS base
|
||||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS base
|
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
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
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Use cache mounts for npm cache
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN \
|
||||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
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 package-lock.json ]; then npm ci --force; \
|
||||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
@@ -25,28 +26,32 @@ COPY . .
|
|||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Use cache mount for Next.js cache
|
RUN \
|
||||||
RUN --mount=type=cache,target=/app/.next/cache \
|
|
||||||
if [ -f yarn.lock ]; then yarn run build; \
|
if [ -f yarn.lock ]; then yarn run build; \
|
||||||
elif [ -f package-lock.json ]; then npm run build; \
|
elif [ -f package-lock.json ]; then npm run build; \
|
||||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Production image - use target platform
|
# Production image, copy all the files and run next
|
||||||
FROM node:22-alpine AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
adduser --system --uid 1001 nextjs && \
|
RUN adduser --system --uid 1001 nextjs
|
||||||
mkdir -p /app/data /app/backups && \
|
|
||||||
chown nextjs:nodejs /app/data /app/backups
|
# Create data and backups directories and set permissions
|
||||||
|
RUN mkdir -p /app/data /app/backups \
|
||||||
|
&& chown nextjs:nodejs /app/data /app/backups
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/CHANGELOG.md ./
|
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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -1,22 +1,12 @@
|
|||||||
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
|
# 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.
|
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.
|
||||||
|
|
||||||
## Differences to Upstream
|
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
||||||
|
|
||||||
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
|
## Try the Demo
|
||||||
|
|
||||||
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.
|
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)
|
||||||
|
|
||||||
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
|
## Features
|
||||||
|
|
||||||
@@ -24,9 +14,7 @@ Differences (as of writing) are:
|
|||||||
- 🏆 Earn coins for completing habits
|
- 🏆 Earn coins for completing habits
|
||||||
- 💰 Create a wishlist of rewards to redeem with earned coins
|
- 💰 Create a wishlist of rewards to redeem with earned coins
|
||||||
- 📊 View your habit completion streaks and statistics
|
- 📊 View your habit completion streaks and statistics
|
||||||
- ✏️ Add freehand drawings to habits and wishlist items for visual reminders
|
|
||||||
- 📅 Calendar heatmap to visualize your progress (WIP)
|
- 📅 Calendar heatmap to visualize your progress (WIP)
|
||||||
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
|
|
||||||
- 🌙 Dark mode support
|
- 🌙 Dark mode support
|
||||||
- 📲 Progressive Web App (PWA) support
|
- 📲 Progressive Web App (PWA) support
|
||||||
- 💾 Automatic daily backups with rotation
|
- 💾 Automatic daily backups with rotation
|
||||||
@@ -34,8 +22,11 @@ Differences (as of writing) are:
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
|
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.
|
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.
|
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.
|
4. **Statistics**: View your progress through the heatmap and streak counters.
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
@@ -72,7 +63,7 @@ docker run -d \
|
|||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
-v ./backups:/app/backups \ # Add this line to map the backups directory
|
-v ./backups:/app/backups \ # Add this line to map the backups directory
|
||||||
-e AUTH_SECRET=$AUTH_SECRET \
|
-e AUTH_SECRET=$AUTH_SECRET \
|
||||||
ghcr.io/manindark/habittrove
|
dohsimpson/habittrove
|
||||||
```
|
```
|
||||||
|
|
||||||
Available image tags:
|
Available image tags:
|
||||||
@@ -119,7 +110,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:
|
1. Clone the repository and navigate to the project directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ManInDark/HabitTrove.git
|
git clone https://github.com/dohsimpson/habittrove.git
|
||||||
cd habittrove
|
cd habittrove
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -172,7 +163,7 @@ Run these commands regularly during development to catch issues early.
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
|
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.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -21,18 +21,32 @@ import {
|
|||||||
WishlistData,
|
WishlistData,
|
||||||
WishlistItemType
|
WishlistItemType
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
import { d2t, getNow, uuid } from '@/lib/utils';
|
||||||
import { signInSchema } from '@/lib/zod';
|
import { signInSchema } from '@/lib/zod';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||||
type ActionType = 'write' | 'interact'
|
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 {
|
function getDefaultData<T>(type: DataType): T {
|
||||||
return DATA_DEFAULTS[type]() as T;
|
return DATA_DEFAULTS[type]() as T;
|
||||||
}
|
}
|
||||||
@@ -76,7 +90,7 @@ async function loadData<T>(type: DataType): Promise<T> {
|
|||||||
await fs.access(filePath)
|
await fs.access(filePath)
|
||||||
} catch {
|
} catch {
|
||||||
// File doesn't exist, create it with default data
|
// File doesn't exist, create it with default data
|
||||||
const initialData = getDefaultData<T>(type)
|
const initialData = getDefaultData(type)
|
||||||
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
||||||
return initialData as T
|
return initialData as T
|
||||||
}
|
}
|
||||||
@@ -105,38 +119,10 @@ 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
|
// Wishlist specific functions
|
||||||
export async function loadWishlistData(): Promise<WishlistData> {
|
export async function loadWishlistData(): Promise<WishlistData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultWishlistData<WishlistData>()
|
if (!user) return getDefaultWishlistData()
|
||||||
|
|
||||||
const data = await loadData<WishlistData>('wishlist')
|
const data = await loadData<WishlistData>('wishlist')
|
||||||
return {
|
return {
|
||||||
@@ -151,6 +137,7 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
||||||
|
await verifyPermission('wishlist', 'write')
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
|
|
||||||
data.items = data.items.map(wishlist => ({
|
data.items = data.items.map(wishlist => ({
|
||||||
@@ -173,14 +160,17 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
|||||||
// Habits specific functions
|
// Habits specific functions
|
||||||
export async function loadHabitsData(): Promise<HabitsData> {
|
export async function loadHabitsData(): Promise<HabitsData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultHabitsData<HabitsData>()
|
if (!user) return getDefaultHabitsData()
|
||||||
const data = await loadData<HabitsData>('habits')
|
const data = await loadData<HabitsData>('habits')
|
||||||
return {
|
return {
|
||||||
|
...data,
|
||||||
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||||
|
await verifyPermission('habit', 'write')
|
||||||
|
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
// Create clone of input data
|
// Create clone of input data
|
||||||
const newData = _.cloneDeep(data)
|
const newData = _.cloneDeep(data)
|
||||||
@@ -192,7 +182,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
if (!user?.isAdmin) {
|
if (!user?.isAdmin) {
|
||||||
const existingData = await loadHabitsData();
|
const existingData = await loadData<HabitsData>('habits')
|
||||||
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
||||||
newData.habits = [
|
newData.habits = [
|
||||||
...existingHabits,
|
...existingHabits,
|
||||||
@@ -208,14 +198,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
|||||||
export async function loadCoinsData(): Promise<CoinsData> {
|
export async function loadCoinsData(): Promise<CoinsData> {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultCoinsData<CoinsData>()
|
if (!user) return getDefaultCoinsData()
|
||||||
const data = await loadData<CoinsData>('coins')
|
const data = await loadData<CoinsData>('coins')
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultCoinsData<CoinsData>()
|
return getDefaultCoinsData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +245,11 @@ export async function addCoins({
|
|||||||
note?: string
|
note?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
|
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||||
const currentUser = await getCurrentUser()
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: crypto.randomUUID(),
|
id: uuid(),
|
||||||
amount,
|
amount,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
@@ -278,7 +269,7 @@ export async function addCoins({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
const defaultSettings = getDefaultSettings<Settings>()
|
const defaultSettings = getDefaultSettings()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
@@ -309,10 +300,11 @@ export async function removeCoins({
|
|||||||
note?: string
|
note?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
|
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||||
const currentUser = await getCurrentUser()
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: crypto.randomUUID(),
|
id: uuid(),
|
||||||
amount: -amount,
|
amount: -amount,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
@@ -370,7 +362,7 @@ export async function loadUsersData(): Promise<UserData> {
|
|||||||
try {
|
try {
|
||||||
return await loadData<UserData>('auth')
|
return await loadData<UserData>('auth')
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultUsersData<UserData>()
|
return getDefaultUsersData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +406,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
|
|
||||||
|
|
||||||
const newUser: User = {
|
const newUser: User = {
|
||||||
id: crypto.randomUUID(),
|
id: uuid(),
|
||||||
username,
|
username,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
permissions,
|
permissions,
|
||||||
@@ -493,80 +485,21 @@ export async function updateUserPassword(userId: string, newPassword?: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(userId: string): Promise<void> {
|
export async function deleteUser(userId: string): Promise<void> {
|
||||||
// Load all necessary data
|
const data = await loadUsersData()
|
||||||
const wishlistData = await loadData<WishlistData>('wishlist')
|
const userIndex = data.users.findIndex(user => user.id === userId)
|
||||||
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) {
|
if (userIndex === -1) {
|
||||||
throw new Error('User not found')
|
throw new Error('User not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
authData.users = [
|
const newData: UserData = {
|
||||||
...authData.users.slice(0, userIndex),
|
users: [
|
||||||
...authData.users.slice(userIndex + 1)
|
...data.users.slice(0, userIndex),
|
||||||
]
|
...data.users.slice(userIndex + 1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
await saveUsersData(authData)
|
await saveUsersData(newData)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
|
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
|
||||||
@@ -599,24 +532,3 @@ export async function loadServerSettings(): Promise<ServerSettings> {
|
|||||||
isDemo: !!process.env.DEMO,
|
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,10 @@ import HabitCalendar from '@/components/HabitCalendar'
|
|||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* <ViewToggle /> */}
|
||||||
|
</div>
|
||||||
<HabitCalendar />
|
<HabitCalendar />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export default function Debug({children}: {children: ReactNode}) {
|
export default function Debug({children}: {children: ReactNode}) {
|
||||||
if (process.env.NODE_ENV !== 'development') return <></>
|
if (process.env.NODE_ENV !== 'development') return null
|
||||||
return (
|
return (
|
||||||
<div className="debug">
|
<div className="debug">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import HabitList from '@/components/HabitList'
|
|||||||
|
|
||||||
export default function HabitsPage() {
|
export default function HabitsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-4">
|
||||||
<HabitList isTasksView={false} />
|
<div className="flex justify-end">
|
||||||
|
{/* <ViewToggle /> */}
|
||||||
|
</div>
|
||||||
|
<HabitList />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||||
import { JotaiProvider } from '@/components/jotai-providers'
|
import { JotaiProvider } from '@/components/jotai-providers'
|
||||||
import Layout from '@/components/Layout'
|
import Layout from '@/components/Layout'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { SessionProvider } from 'next-auth/react'
|
import { SessionProvider } from 'next-auth/react'
|
||||||
import { NextIntlClientProvider } from 'next-intl'
|
|
||||||
import { getLocale, getMessages } from 'next-intl/server'
|
|
||||||
import { DM_Sans } from 'next/font/google'
|
import { DM_Sans } from 'next/font/google'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
|
|
||||||
|
// Inter (clean, modern, excellent readability)
|
||||||
|
// const inter = Inter({
|
||||||
|
// subsets: ['latin'],
|
||||||
|
// weight: ['400', '500', '600', '700']
|
||||||
|
// })
|
||||||
|
|
||||||
// Clean and contemporary
|
// Clean and contemporary
|
||||||
const activeFont = DM_Sans({
|
const dmSans = DM_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '500', '600', '700']
|
weight: ['400', '500', '600', '700']
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeFont = dmSans
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'HabitTrove',
|
title: 'HabitTrove',
|
||||||
description: 'Track your habits and get rewarded',
|
description: 'Track your habits and get rewarded',
|
||||||
@@ -30,11 +36,6 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
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([
|
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
|
||||||
loadSettings(),
|
loadSettings(),
|
||||||
loadHabitsData(),
|
loadHabitsData(),
|
||||||
@@ -46,7 +47,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
|
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={activeFont.className}>
|
<body className={activeFont.className}>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
@@ -66,7 +67,7 @@ export default async function RootLayout({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback="loading">
|
||||||
<JotaiHydrate
|
<JotaiHydrate
|
||||||
initialValues={{
|
initialValues={{
|
||||||
settings: initialSettings,
|
settings: initialSettings,
|
||||||
@@ -77,20 +78,18 @@ export default async function RootLayout({
|
|||||||
serverSettings: initialServerSettings,
|
serverSettings: initialServerSettings,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<ThemeProvider
|
||||||
<ThemeProvider
|
attribute="class"
|
||||||
attribute="class"
|
defaultTheme="system"
|
||||||
defaultTheme="system"
|
enableSystem
|
||||||
enableSystem
|
disableTransitionOnChange
|
||||||
disableTransitionOnChange
|
>
|
||||||
>
|
<SessionProvider>
|
||||||
<SessionProvider>
|
<Layout>
|
||||||
<Layout>
|
{children}
|
||||||
{children}
|
</Layout>
|
||||||
</Layout>
|
</SessionProvider>
|
||||||
</SessionProvider>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
</JotaiHydrate>
|
</JotaiHydrate>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
|
|||||||
@@ -10,53 +10,37 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { settingsAtom } from '@/lib/atoms';
|
||||||
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
|
|
||||||
import { Settings, WeekDay } from '@/lib/types';
|
import { Settings, WeekDay } from '@/lib/types';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
|
|
||||||
import { Info } from 'lucide-react'; // Import Info icon
|
import { Info } from 'lucide-react'; // Import Info icon
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { saveSettings } from '../actions/data';
|
import { saveSettings } from '../actions/data';
|
||||||
|
|
||||||
import { useSession } from 'next-auth/react'; // signOut removed
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
// AlertDialog components and useState removed
|
|
||||||
// Trash2 icon removed
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const t = useTranslations('SettingsPage');
|
|
||||||
// tWarning removed
|
|
||||||
const [settings, setSettings] = useAtom(settingsAtom);
|
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) => {
|
const updateSettings = async (newSettings: Settings) => {
|
||||||
await saveSettings(newSettings)
|
await saveSettings(newSettings)
|
||||||
setSettings(newSettings)
|
setSettings(newSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeleteAccount function removed
|
|
||||||
|
|
||||||
if (!settings) return <></>
|
if (!settings) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-3xl font-bold mb-6">{t('title')}</h1>
|
<h1 className="text-3xl font-bold mb-6">Settings</h1>
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('uiSettingsTitle')}</CardTitle>
|
<CardTitle>UI Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="number-formatting">{t('numberFormattingLabel')}</Label>
|
<Label htmlFor="number-formatting">Number Formatting</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t('numberFormattingDescription')}
|
Format large numbers (e.g., 1K, 1M, 1B)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -73,9 +57,9 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="number-grouping">{t('numberGroupingLabel')}</Label>
|
<Label htmlFor="number-grouping">Number Grouping</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t('numberGroupingDescription')}
|
Use thousand separators (e.g., 1,000 vs 1000)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -94,14 +78,14 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('systemSettingsTitle')}</CardTitle>
|
<CardTitle>System Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="timezone">{t('timezoneLabel')}</Label>
|
<Label htmlFor="timezone">Timezone</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t('timezoneDescription')}
|
Select your timezone for accurate date tracking
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
@@ -114,7 +98,7 @@ export default function SettingsPage() {
|
|||||||
system: { ...settings.system, timezone: e.target.value }
|
system: { ...settings.system, timezone: e.target.value }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
|
className="w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
|
||||||
>
|
>
|
||||||
{Intl.supportedValuesOf('timeZone').map((tz) => (
|
{Intl.supportedValuesOf('timeZone').map((tz) => (
|
||||||
<option key={tz} value={tz}>
|
<option key={tz} value={tz}>
|
||||||
@@ -127,9 +111,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="weekStartDay">{t('weekStartDayLabel')}</Label>
|
<Label htmlFor="timezone">Week Start Day</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t('weekStartDayDescription')}
|
Select your preferred first day of the week
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
@@ -142,7 +126,7 @@ export default function SettingsPage() {
|
|||||||
system: { ...settings.system, weekStartDay: Number(e.target.value) as WeekDay }
|
system: { ...settings.system, weekStartDay: Number(e.target.value) as WeekDay }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2"
|
className="w-[200px] rounded-md border border-input bg-background px-3 py-2"
|
||||||
>
|
>
|
||||||
{([
|
{([
|
||||||
['sunday', 0],
|
['sunday', 0],
|
||||||
@@ -152,9 +136,9 @@ export default function SettingsPage() {
|
|||||||
['thursday', 4],
|
['thursday', 4],
|
||||||
['friday', 5],
|
['friday', 5],
|
||||||
['saturday', 6]
|
['saturday', 6]
|
||||||
] as Array<["sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday", WeekDay]>).map(([dayName, dayNumber]) => (
|
] as Array<[string, WeekDay]>).map(([dayName, dayNumber]) => (
|
||||||
<option key={dayNumber} value={dayNumber}>
|
<option key={dayNumber} value={dayNumber}>
|
||||||
{t(`weekdays.${dayName}`)}
|
{dayName.charAt(0).toUpperCase() + dayName.slice(1)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -165,7 +149,7 @@ export default function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Label htmlFor="auto-backup">{t('autoBackupLabel')}</Label>
|
<Label htmlFor="auto-backup">Auto Backup</Label>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -173,14 +157,18 @@ export default function SettingsPage() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="start">
|
<TooltipContent side="top" align="start">
|
||||||
<p className="max-w-xs text-sm">
|
<p className="max-w-xs text-sm">
|
||||||
{t('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.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t('autoBackupDescription')}
|
Automatically back up data daily
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -196,54 +184,8 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* End of Auto Backup section */}
|
{/* 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Danger Zone Card Removed */}
|
|
||||||
</div >
|
</div >
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import HabitList from '@/components/HabitList'
|
|
||||||
|
|
||||||
export default function TasksPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<HabitList isTasksView={true} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -5,25 +5,24 @@ import { Button } from "./ui/button"
|
|||||||
import { Star, History } from "lucide-react"
|
import { Star, History } from "lucide-react"
|
||||||
import packageJson from '../package.json'
|
import packageJson from '../package.json'
|
||||||
import { DialogTitle } from "@radix-ui/react-dialog"
|
import { DialogTitle } from "@radix-ui/react-dialog"
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { Logo } from "./Logo"
|
import { Logo } from "./Logo"
|
||||||
import ChangelogModal from "./ChangelogModal"
|
import ChangelogModal from "./ChangelogModal"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
interface AboutModalProps {
|
interface AboutModalProps {
|
||||||
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutModal({ onClose }: AboutModalProps) {
|
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||||
const t = useTranslations('AboutModal')
|
|
||||||
const version = packageJson.version
|
const version = packageJson.version
|
||||||
const [changelogOpen, setChangelogOpen] = useState(false)
|
const [changelogOpen, setChangelogOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
|
<DialogTitle aria-label="about"></DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-6 text-center py-4">
|
<div className="space-y-6 text-center py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -41,14 +40,14 @@ export default function AboutModal({ onClose }: AboutModalProps) {
|
|||||||
onClick={() => setChangelogOpen(true)}
|
onClick={() => setChangelogOpen(true)}
|
||||||
>
|
>
|
||||||
<History className="w-3 h-3 mr-1" />
|
<History className="w-3 h-3 mr-1" />
|
||||||
{t('changelogButton')}
|
Changelog
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{t('createdByPrefix')}{' '}
|
Created with ❤️ by{' '}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/dohsimpson"
|
href="https://github.com/dohsimpson"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -57,19 +56,17 @@ export default function AboutModal({ onClose }: AboutModalProps) {
|
|||||||
>
|
>
|
||||||
@dohsimpson
|
@dohsimpson
|
||||||
</a>
|
</a>
|
||||||
<br/>
|
|
||||||
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/ManInDark/HabitTrove"
|
href="https://github.com/dohsimpson/habittrove"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Star className="w-4 h-4 mr-2" />
|
<Star className="w-4 h-4 mr-2" />
|
||||||
{t('starOnGitHubButton')}
|
Star on GitHub
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,21 +7,18 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
|
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
||||||
|
import data from '@emoji-mart/data'
|
||||||
|
import Picker from '@emoji-mart/react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Brush, Zap } from 'lucide-react'
|
import { SmilePlus, Zap } from 'lucide-react'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { RRule } from 'rrule'
|
import { RRule } from 'rrule'
|
||||||
import DrawingDisplay from './DrawingDisplay'
|
|
||||||
import DrawingModal from './DrawingModal'
|
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
|
||||||
import ModalOverlay from './ModalOverlay'; // Import the new component
|
|
||||||
|
|
||||||
|
|
||||||
interface AddEditHabitModalProps {
|
interface AddEditHabitModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -31,7 +28,6 @@ interface AddEditHabitModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
|
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
|
||||||
const t = useTranslations('AddEditHabitModal');
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [name, setName] = useState(habit?.name || '')
|
const [name, setName] = useState(habit?.name || '')
|
||||||
const [description, setDescription] = useState(habit?.description || '')
|
const [description, setDescription] = useState(habit?.description || '')
|
||||||
@@ -39,19 +35,18 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||||
const isRecurRule = !isTask
|
const isRecurRule = !isTask
|
||||||
// Initialize ruleText with the actual frequency string or default, not the display text
|
// 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,
|
frequency: habit.frequency,
|
||||||
isRecurRule,
|
isRecurRule,
|
||||||
timezone: settings.system.timezone
|
timezone: settings.system.timezone
|
||||||
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||||
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const { currentUser } = useHelpers()
|
||||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
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 [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const users = usersData.users
|
const users = usersData.users
|
||||||
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
|
|
||||||
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
|
||||||
|
|
||||||
function getFrequencyUpdate() {
|
function getFrequencyUpdate() {
|
||||||
if (ruleText === initialRuleText && habit?.frequency) {
|
if (ruleText === initialRuleText && habit?.frequency) {
|
||||||
@@ -86,286 +81,259 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||||
completions: habit?.completions || [],
|
completions: habit?.completions || [],
|
||||||
frequency: getFrequencyUpdate(),
|
frequency: getFrequencyUpdate(),
|
||||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
|
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
|
||||||
drawing: drawing && drawing !== '[]' ? drawing : undefined
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<ModalOverlay />
|
<DialogContent>
|
||||||
<Dialog open={true} onOpenChange={(open) => {
|
<DialogHeader>
|
||||||
if (!open && !isDrawingModalOpen) {
|
<DialogTitle>{habit ? `Edit ${isTask ? 'Task' : 'Habit'}` : `Add New ${isTask ? 'Task' : 'Habit'}`}</DialogTitle>
|
||||||
onClose()
|
</DialogHeader>
|
||||||
}
|
<form onSubmit={handleSubmit}>
|
||||||
}} modal={false}>
|
<div className="grid gap-4 py-4">
|
||||||
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<DialogHeader>
|
<Label htmlFor="name" className="text-right">
|
||||||
<DialogTitle>
|
Name *
|
||||||
{habit
|
</Label>
|
||||||
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
|
<div className='flex col-span-3 gap-2'>
|
||||||
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
|
<Input
|
||||||
</DialogTitle>
|
id="name"
|
||||||
</DialogHeader>
|
value={name}
|
||||||
<form onSubmit={handleSubmit}>
|
onChange={(e) => setName(e.target.value)}
|
||||||
<div className="grid gap-4 py-4">
|
required
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
/>
|
||||||
<Label htmlFor="name" className="text-right">
|
<Popover>
|
||||||
{t('nameLabel')}
|
<PopoverTrigger asChild>
|
||||||
</Label>
|
<Button
|
||||||
<div className='flex col-span-3 gap-2'>
|
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">
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="recurrence"
|
||||||
value={name}
|
value={ruleText}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setRuleText(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<EmojiPickerButton
|
{isTask && (
|
||||||
inputIdToFocus="name"
|
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
|
||||||
onEmojiSelect={(emoji) => {
|
<PopoverTrigger asChild>
|
||||||
setName(prev => {
|
<Button
|
||||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
type="button"
|
||||||
return `${prev}${space}${emoji}`;
|
variant="ghost"
|
||||||
})
|
size="icon"
|
||||||
}}
|
className="h-8 w-8"
|
||||||
/>
|
>
|
||||||
</div>
|
<Zap className="h-4 w-4" />
|
||||||
</div>
|
</Button>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
</PopoverTrigger>
|
||||||
<Label htmlFor="description" className="text-right">
|
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
|
||||||
{t('descriptionLabel')}
|
<div className="space-y-1">
|
||||||
</Label>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Textarea
|
{QUICK_DATES.map((date) => (
|
||||||
id="description"
|
<Button
|
||||||
value={description}
|
key={date.value}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
variant="outline"
|
||||||
className="col-span-3"
|
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
/>
|
onClick={() => {
|
||||||
</div>
|
setRuleText(date.value);
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
setIsQuickDatesOpen(false);
|
||||||
<Label htmlFor="recurrence" className="text-right">
|
}}
|
||||||
{t('whenLabel')}
|
>
|
||||||
</Label>
|
{date.label}
|
||||||
{/* date input (task) */}
|
</Button>
|
||||||
<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>
|
</div>
|
||||||
</PopoverContent>
|
</div>
|
||||||
</Popover>
|
</PopoverContent>
|
||||||
)}
|
</Popover>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* rrule input (habit) */}
|
</div>
|
||||||
<div className="col-start-2 col-span-3 text-sm">
|
{/* rrule input (habit) */}
|
||||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
<div className="col-start-2 col-span-3 text-sm">
|
||||||
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
|
{(() => {
|
||||||
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<Label htmlFor="targetCompletions">
|
|
||||||
{t('completeLabel')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
|
|
||||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<Input
|
|
||||||
id="targetCompletions"
|
|
||||||
type="number"
|
|
||||||
value={targetCompletions}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value)
|
|
||||||
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
|
|
||||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t('timesSuffix')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<Label htmlFor="coinReward">
|
|
||||||
{t('rewardLabel')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
|
|
||||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<Input
|
|
||||||
id="coinReward"
|
|
||||||
type="number"
|
|
||||||
value={coinReward}
|
|
||||||
onChange={(e) => {
|
|
||||||
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>
|
</div>
|
||||||
<DialogFooter>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Button type="submit" disabled={!!errorMessage}>
|
<div className="flex items-center gap-2 justify-end">
|
||||||
{habit
|
<Label htmlFor="coinReward">
|
||||||
? t('saveChangesButton')
|
Reward
|
||||||
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
</Label>
|
||||||
</Button>
|
</div>
|
||||||
</DialogFooter>
|
<div className="col-span-3">
|
||||||
</form>
|
<div className="flex items-center gap-4">
|
||||||
</DialogContent>
|
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||||
</Dialog>
|
<button
|
||||||
<DrawingModal
|
type="button"
|
||||||
isOpen={isDrawingModalOpen}
|
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
|
||||||
onClose={() => setIsDrawingModalOpen(false)}
|
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||||
onSave={(drawingData) => setDrawing(drawingData)}
|
>
|
||||||
initialDrawing={drawing}
|
-
|
||||||
title={name}
|
</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>
|
||||||
|
</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">{habit ? 'Save Changes' : `Add ${isTask ? 'Task' : 'Habit'}`}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,20 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
import { usersAtom } from '@/lib/atoms'
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import data from '@emoji-mart/data'
|
||||||
|
import Picker from '@emoji-mart/react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Brush } from 'lucide-react'
|
import { SmilePlus } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import DrawingDisplay from './DrawingDisplay'
|
|
||||||
import DrawingModal from './DrawingModal'
|
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
|
||||||
import ModalOverlay from './ModalOverlay'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
|
||||||
interface AddEditWishlistItemModalProps {
|
interface AddEditWishlistItemModalProps {
|
||||||
|
isOpen: boolean
|
||||||
setIsOpen: (isOpen: boolean) => void
|
setIsOpen: (isOpen: boolean) => void
|
||||||
editingItem: WishlistItemType | null
|
editingItem: WishlistItemType | null
|
||||||
setEditingItem: (item: WishlistItemType | null) => void
|
setEditingItem: (item: WishlistItemType | null) => void
|
||||||
@@ -25,24 +24,22 @@ interface AddEditWishlistItemModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddEditWishlistItemModal({
|
export default function AddEditWishlistItemModal({
|
||||||
|
isOpen,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
editingItem,
|
editingItem,
|
||||||
setEditingItem,
|
setEditingItem,
|
||||||
addWishlistItem,
|
addWishlistItem,
|
||||||
editWishlistItem
|
editWishlistItem
|
||||||
}: AddEditWishlistItemModalProps) {
|
}: AddEditWishlistItemModalProps) {
|
||||||
const t = useTranslations('AddEditWishlistItemModal')
|
|
||||||
const [name, setName] = useState(editingItem?.name || '')
|
const [name, setName] = useState(editingItem?.name || '')
|
||||||
const [description, setDescription] = useState(editingItem?.description || '')
|
const [description, setDescription] = useState(editingItem?.description || '')
|
||||||
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
|
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
|
||||||
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
|
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
|
||||||
const [link, setLink] = useState(editingItem?.link || '')
|
const [link, setLink] = useState(editingItem?.link || '')
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const { currentUser } = useHelpers()
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
|
|
||||||
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
@@ -51,14 +48,12 @@ export default function AddEditWishlistItemModal({
|
|||||||
setCoinCost(editingItem.coinCost)
|
setCoinCost(editingItem.coinCost)
|
||||||
setTargetCompletions(editingItem.targetCompletions)
|
setTargetCompletions(editingItem.targetCompletions)
|
||||||
setLink(editingItem.link || '')
|
setLink(editingItem.link || '')
|
||||||
setDrawing(editingItem.drawing || '')
|
|
||||||
} else {
|
} else {
|
||||||
setName('')
|
setName('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setCoinCost(1)
|
setCoinCost(1)
|
||||||
setTargetCompletions(undefined)
|
setTargetCompletions(undefined)
|
||||||
setLink('')
|
setLink('')
|
||||||
setDrawing('')
|
|
||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
}, [editingItem])
|
}, [editingItem])
|
||||||
@@ -66,18 +61,16 @@ export default function AddEditWishlistItemModal({
|
|||||||
const validate = () => {
|
const validate = () => {
|
||||||
const newErrors: { [key: string]: string } = {}
|
const newErrors: { [key: string]: string } = {}
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
newErrors.name = t('errorNameRequired')
|
newErrors.name = 'Name is required'
|
||||||
}
|
}
|
||||||
if (coinCost < 1) {
|
if (coinCost < 1) {
|
||||||
newErrors.coinCost = t('errorCoinCostMin')
|
newErrors.coinCost = 'Coin cost must be at least 1'
|
||||||
} else if (coinCost > MAX_COIN_LIMIT) {
|
|
||||||
newErrors.coinCost = t('errorCoinCostMax', { max: MAX_COIN_LIMIT })
|
|
||||||
}
|
}
|
||||||
if (targetCompletions !== undefined && targetCompletions < 1) {
|
if (targetCompletions !== undefined && targetCompletions < 1) {
|
||||||
newErrors.targetCompletions = t('errorTargetCompletionsMin')
|
newErrors.targetCompletions = 'Target completions must be at least 1'
|
||||||
}
|
}
|
||||||
if (link && !isValidUrl(link)) {
|
if (link && !isValidUrl(link)) {
|
||||||
newErrors.link = t('errorInvalidUrl')
|
newErrors.link = 'Please enter a valid URL'
|
||||||
}
|
}
|
||||||
setErrors(newErrors)
|
setErrors(newErrors)
|
||||||
return Object.keys(newErrors).length === 0
|
return Object.keys(newErrors).length === 0
|
||||||
@@ -107,8 +100,7 @@ export default function AddEditWishlistItemModal({
|
|||||||
coinCost,
|
coinCost,
|
||||||
targetCompletions: targetCompletions || undefined,
|
targetCompletions: targetCompletions || undefined,
|
||||||
link: link.trim() || undefined,
|
link: link.trim() || undefined,
|
||||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
|
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
|
||||||
drawing: drawing && drawing !== '[]' ? drawing : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
@@ -116,248 +108,212 @@ export default function AddEditWishlistItemModal({
|
|||||||
} else {
|
} else {
|
||||||
addWishlistItem(itemData)
|
addWishlistItem(itemData)
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
setEditingItem(null)
|
setEditingItem(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ModalOverlay />
|
<DialogContent>
|
||||||
<Dialog open={true} onOpenChange={(open) => {
|
<DialogHeader>
|
||||||
if (!open && !isDrawingModalOpen) {
|
<DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
|
||||||
handleClose()
|
</DialogHeader>
|
||||||
}
|
<form onSubmit={handleSave}>
|
||||||
}} modal={false}>
|
<div className="grid gap-4 py-4">
|
||||||
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<DialogHeader>
|
<Label htmlFor="name" className="text-right">
|
||||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
Name *
|
||||||
</DialogHeader>
|
</Label>
|
||||||
<form onSubmit={handleSave}>
|
<div className="col-span-3 flex gap-2">
|
||||||
<div className="grid gap-4 py-4">
|
<Input
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
id="name"
|
||||||
<Label htmlFor="name" className="text-right">
|
value={name}
|
||||||
{t('nameLabel')}
|
onChange={(e) => setName(e.target.value)}
|
||||||
</Label>
|
className="flex-1"
|
||||||
<div className="col-span-3 flex gap-2">
|
required
|
||||||
<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 className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="description" className="text-right">
|
|
||||||
{t('descriptionLabel')}
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
className="col-span-3"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Popover>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<PopoverTrigger asChild>
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<Label htmlFor="coinReward">
|
|
||||||
{t('costLabel')}
|
|
||||||
</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) => {
|
|
||||||
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 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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
onClick={(e) => {
|
size="icon"
|
||||||
e.preventDefault()
|
className="h-8 w-8"
|
||||||
e.stopPropagation()
|
|
||||||
setIsDrawingModalOpen(true)
|
|
||||||
}}
|
|
||||||
className="flex-1 justify-start"
|
|
||||||
>
|
>
|
||||||
<Brush className="h-4 w-4 mr-2" />
|
<SmilePlus className="h-4 w-4" />
|
||||||
{drawing ? t('editDrawing') : t('addDrawing')}
|
|
||||||
</Button>
|
</Button>
|
||||||
{drawing && (
|
</PopoverTrigger>
|
||||||
<div className="flex-shrink-0">
|
<PopoverContent className="w-[300px] p-0">
|
||||||
<DrawingDisplay
|
<Picker
|
||||||
drawingData={drawing}
|
data={data}
|
||||||
width={80}
|
onEmojiSelect={(emoji: { native: string }) => {
|
||||||
height={53}
|
setName(prev => `${prev}${emoji.native}`)
|
||||||
className=""
|
// Focus back on input after selection
|
||||||
/>
|
const input = document.getElementById('name') as HTMLInputElement
|
||||||
</div>
|
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
|
||||||
|
</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>
|
||||||
|
</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
|
||||||
|
</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)}
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{usersData.users && usersData.users.length > 1 && (
|
)}
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<DialogFooter>
|
||||||
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
|
<Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
<div className="col-span-3">
|
</form>
|
||||||
<div className="flex flex-wrap gap-2">
|
</DialogContent>
|
||||||
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
|
</Dialog>
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
|
import { ReactNode, useEffect } from 'react'
|
||||||
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
|
import { useAtom } from 'jotai'
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
|
||||||
import { useSession } from 'next-auth/react';
|
import PomodoroTimer from './PomodoroTimer'
|
||||||
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
|
import UserSelectModal from './UserSelectModal'
|
||||||
import AboutModal from './AboutModal';
|
import { useSession } from 'next-auth/react'
|
||||||
import LoadingSpinner from './LoadingSpinner';
|
|
||||||
import PomodoroTimer from './PomodoroTimer';
|
|
||||||
import RefreshBanner from './RefreshBanner';
|
|
||||||
import UserSelectModal from './UserSelectModal';
|
|
||||||
|
|
||||||
function ClientWrapperContent({ children }: { children: ReactNode }) {
|
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||||
const [pomo] = useAtom(pomodoroAtom)
|
const [pomo] = useAtom(pomodoroAtom)
|
||||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||||
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
|
||||||
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
|
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const currentUserId = session?.user.id
|
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(() => {
|
useEffect(() => {
|
||||||
if (status === 'loading') return
|
if (status === 'loading') return
|
||||||
@@ -32,66 +20,15 @@ function ClientWrapperContent({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [currentUserId, status, userSelect, setUserSelect])
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{pomo.show && <PomodoroTimer />}
|
{pomo.show && (
|
||||||
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
|
<PomodoroTimer />
|
||||||
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
)}
|
||||||
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
|
{userSelect && (
|
||||||
|
<UserSelectModal onClose={() => setUserSelect(false)}/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
0
components/CoinBalance.test.tsx
Normal file
0
components/CoinBalance.test.tsx
Normal file
@@ -2,19 +2,17 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Coins } from 'lucide-react'
|
import { Coins } from 'lucide-react'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||||
|
|
||||||
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
|
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
|
||||||
const t = useTranslations('CoinBalance');
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('coinBalanceTitle')}</CardTitle>
|
<CardTitle>Coin Balance</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -3,17 +3,14 @@
|
|||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { TransactionType } from '@/lib/types'
|
|
||||||
import { d2s, t2d } from '@/lib/utils'
|
import { d2s, t2d } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { History } from 'lucide-react'
|
import { History } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
|
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
|
||||||
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
|
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
|
||||||
@@ -21,8 +18,7 @@ import EmptyState from './EmptyState'
|
|||||||
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||||
|
|
||||||
export default function CoinsManager() {
|
export default function CoinsManager() {
|
||||||
const t = useTranslations('CoinsManager')
|
const { currentUser } = useHelpers()
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
|
||||||
const [selectedUser, setSelectedUser] = useState<string>()
|
const [selectedUser, setSelectedUser] = useState<string>()
|
||||||
const {
|
const {
|
||||||
add,
|
add,
|
||||||
@@ -35,7 +31,7 @@ export default function CoinsManager() {
|
|||||||
totalSpent,
|
totalSpent,
|
||||||
coinsSpentToday,
|
coinsSpentToday,
|
||||||
transactionsToday
|
transactionsToday
|
||||||
} = useCoins({ selectedUser })
|
} = useCoins({selectedUser})
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const DEFAULT_AMOUNT = '0'
|
const DEFAULT_AMOUNT = '0'
|
||||||
@@ -47,7 +43,6 @@ export default function CoinsManager() {
|
|||||||
const highlightId = searchParams.get('highlight')
|
const highlightId = searchParams.get('highlight')
|
||||||
const userIdFromQuery = searchParams.get('user') // Get user ID from query
|
const userIdFromQuery = searchParams.get('user') // Get user ID from query
|
||||||
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
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
|
// Effect to set selected user from query param if admin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,21 +86,10 @@ 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 (
|
return (
|
||||||
<div>
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
|
<h1 className="text-3xl font-bold mr-6">Coins Management</h1>
|
||||||
{currentUser?.isAdmin && (
|
{currentUser?.isAdmin && (
|
||||||
<select
|
<select
|
||||||
className="border rounded p-2"
|
className="border rounded p-2"
|
||||||
@@ -127,8 +111,8 @@ export default function CoinsManager() {
|
|||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
|
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
|
<div className="text-sm font-normal text-muted-foreground">Current Balance</div>
|
||||||
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div>
|
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> coins</div>
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -140,11 +124,7 @@ export default function CoinsManager() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-10 w-10 text-lg"
|
className="h-10 w-10 text-lg"
|
||||||
onClick={() => setAmount(prev => {
|
onClick={() => setAmount(prev => (Number(prev) - 1).toString())}
|
||||||
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>
|
</Button>
|
||||||
@@ -152,22 +132,7 @@ export default function CoinsManager() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => {
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
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"
|
className="text-center text-xl font-medium h-12"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||||
@@ -178,11 +143,7 @@ export default function CoinsManager() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-10 w-10 text-lg"
|
className="h-10 w-10 text-lg"
|
||||||
onClick={() => setAmount(prev => {
|
onClick={() => setAmount(prev => (Number(prev) + 1).toString())}
|
||||||
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>
|
</Button>
|
||||||
@@ -196,7 +157,7 @@ export default function CoinsManager() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{Number(amount) >= 0 ? t('addCoinsButton') : t('removeCoinsButton')}
|
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,27 +169,27 @@ export default function CoinsManager() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('statisticsTitle')}</CardTitle>
|
<CardTitle>Statistics</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||||
{/* Top Row - Totals */}
|
{/* Top Row - Totals */}
|
||||||
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
|
<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">{t('totalEarnedLabel')}</div>
|
<div className="text-sm text-green-800 dark:text-green-100 mb-1">Total Earned</div>
|
||||||
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
|
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
|
||||||
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
|
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
|
<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">{t('totalSpentLabel')}</div>
|
<div className="text-sm text-red-800 dark:text-red-100 mb-1">Total Spent</div>
|
||||||
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
|
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
|
||||||
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
|
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900">
|
<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">{t('totalTransactionsLabel')}</div>
|
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">Total Transactions</div>
|
||||||
<div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
|
<div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
|
||||||
{transactions.length} 📈
|
{transactions.length} 📈
|
||||||
</div>
|
</div>
|
||||||
@@ -236,21 +197,21 @@ export default function CoinsManager() {
|
|||||||
|
|
||||||
{/* Bottom Row - Today */}
|
{/* Bottom Row - Today */}
|
||||||
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
|
<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">{t('todaysEarnedLabel')}</div>
|
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Earned</div>
|
||||||
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
|
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
|
||||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
|
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900">
|
<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">{t('todaysSpentLabel')}</div>
|
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">Today's Spent</div>
|
||||||
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
|
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
|
||||||
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
|
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900">
|
<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">{t('todaysTransactionsLabel')}</div>
|
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">Today's Transactions</div>
|
||||||
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
|
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
|
||||||
{transactionsToday} 📊
|
{transactionsToday} 📊
|
||||||
</div>
|
</div>
|
||||||
@@ -261,13 +222,13 @@ export default function CoinsManager() {
|
|||||||
|
|
||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('transactionHistoryTitle')}</CardTitle>
|
<CardTitle>Transaction History</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">{t('showLabel')}</span>
|
<span className="text-sm text-muted-foreground">Show:</span>
|
||||||
<select
|
<select
|
||||||
className="border rounded p-1"
|
className="border rounded p-1"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
@@ -276,20 +237,22 @@ export default function CoinsManager() {
|
|||||||
setCurrentPage(1) // Reset to first page when changing page size
|
setCurrentPage(1) // Reset to first page when changing page size
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={500}>500</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
|
<span className="text-sm text-muted-foreground">entries</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t('showingEntries', { from: Math.min((currentPage - 1) * pageSize + 1, transactions.length), to: Math.min(currentPage * pageSize, transactions.length), total: transactions.length })}
|
Showing {Math.min((currentPage - 1) * pageSize + 1, transactions.length)} to {Math.min(currentPage * pageSize, transactions.length)} of {transactions.length} entries
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={History}
|
icon={History}
|
||||||
title={t('noTransactionsTitle')}
|
title="No transactions yet"
|
||||||
description={t('noTransactionsDescription')}
|
description="Your transaction history will appear here once you start earning or spending coins"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -312,13 +275,13 @@ export default function CoinsManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isHighlighted = transaction.id === highlightId;
|
const isHighlighted = transaction.id === highlightId;
|
||||||
const transactionUser = usersData.users.find(u => u.id === transaction.userId);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={transaction.id}
|
key={transaction.id}
|
||||||
ref={(el) => { transactionRefs.current[transaction.id] = el; }} // Assign ref correctly
|
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="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
|
||||||
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
|
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
|
||||||
@@ -336,17 +299,17 @@ export default function CoinsManager() {
|
|||||||
<span
|
<span
|
||||||
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
|
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
|
||||||
>
|
>
|
||||||
{getTransactionTypeLabel(transaction.type as TransactionType)}
|
{transaction.type.split('_').join(' ')}
|
||||||
</span>
|
</span>
|
||||||
{transaction.userId && currentUser?.isAdmin && (
|
{transaction.userId && currentUser?.isAdmin && (
|
||||||
<Avatar className="h-6 w-6">
|
<Avatar className="h-6 w-6">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={transactionUser?.avatarPath ?
|
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
|
||||||
`/api/avatars/${transactionUser?.avatarPath?.split('/').pop()}` : undefined}
|
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` : undefined}
|
||||||
alt={transactionUser?.username}
|
alt={usersData.users.find(u => u.id === transaction.userId)?.username}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{transactionUser?.username?.[0] || '?'}
|
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
@@ -394,9 +357,9 @@ export default function CoinsManager() {
|
|||||||
‹
|
‹
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-1 px-4 py-2 rounded-md bg-muted">
|
<div className="flex items-center gap-1 px-4 py-2 rounded-md bg-muted">
|
||||||
<span className="text-sm font-medium">{t('pageLabel')}</span>
|
<span className="text-sm font-medium">Page</span>
|
||||||
<span className="text-sm font-bold">{currentPage}</span>
|
<span className="text-sm font-bold">{currentPage}</span>
|
||||||
<span className="text-sm font-medium">{t('ofLabel')}</span>
|
<span className="text-sm font-medium">of</span>
|
||||||
<span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
|
<span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
|
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone } from '@/lib/utils'
|
import { getTodayInTimezone } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
interface CompletionCountBadgeProps {
|
interface CompletionCountBadgeProps {
|
||||||
type: 'habits' | 'tasks'
|
type: 'habits' | 'tasks'
|
||||||
@@ -13,7 +12,6 @@ export default function CompletionCountBadge({
|
|||||||
type,
|
type,
|
||||||
date
|
date
|
||||||
}: CompletionCountBadgeProps) {
|
}: CompletionCountBadgeProps) {
|
||||||
const t = useTranslations('CompletionCountBadge');
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||||
const targetDate = date || getTodayInTimezone(settings.system.timezone)
|
const targetDate = date || getTodayInTimezone(settings.system.timezone)
|
||||||
@@ -29,7 +27,7 @@ export default function CompletionCountBadge({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{t('countCompleted', { completedCount, totalCount })}
|
{`${completedCount}/${totalCount} Completed`}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -24,13 +23,9 @@ export default function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
confirmText,
|
confirmText = "Confirm",
|
||||||
cancelText,
|
cancelText = "Cancel"
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const t = useTranslations('ConfirmDialog');
|
|
||||||
const finalConfirmText = confirmText || t('confirmButton');
|
|
||||||
const finalCancelText = cancelText || t('cancelButton');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -42,10 +37,10 @@ export default function ConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{finalCancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={onConfirm}>
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
{finalConfirmText}
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -14,12 +14,10 @@ import {
|
|||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
|
||||||
import { Habit, WishlistItemType } from '@/lib/types'
|
import { Habit, WishlistItemType } from '@/lib/types'
|
||||||
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons
|
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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import AddEditHabitModal from './AddEditHabitModal'
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
@@ -28,7 +26,6 @@ import ConfirmDialog from './ConfirmDialog'
|
|||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import DrawingDisplay from './DrawingDisplay'
|
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -53,7 +50,6 @@ const ItemSection = ({
|
|||||||
viewLink,
|
viewLink,
|
||||||
addNewItem,
|
addNewItem,
|
||||||
}: ItemSectionProps) => {
|
}: ItemSectionProps) => {
|
||||||
const t = useTranslations('DailyOverview');
|
|
||||||
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits();
|
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits();
|
||||||
const [_, setPomo] = useAtom(pomodoroAtom);
|
const [_, setPomo] = useAtom(pomodoroAtom);
|
||||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
|
||||||
@@ -105,7 +101,7 @@ const ItemSection = ({
|
|||||||
onClick={addNewItem}
|
onClick={addNewItem}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
|
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center text-muted-foreground text-sm py-4">
|
<div className="text-center text-muted-foreground text-sm py-4">
|
||||||
@@ -130,7 +126,7 @@ const ItemSection = ({
|
|||||||
onClick={addNewItem}
|
onClick={addNewItem}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
|
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,7 +163,7 @@ const ItemSection = ({
|
|||||||
const bTarget = b.targetCompletions || 1;
|
const bTarget = b.targetCompletions || 1;
|
||||||
return bTarget - aTarget;
|
return bTarget - aTarget;
|
||||||
})
|
})
|
||||||
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
.slice(0, currentExpanded ? undefined : 5)
|
||||||
.map((habit) => {
|
.map((habit) => {
|
||||||
const completionsToday = habit.completions.filter(completion =>
|
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 }))
|
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||||
@@ -224,6 +220,12 @@ const ItemSection = ({
|
|||||||
<Link
|
<Link
|
||||||
href={`/habits?highlight=${habit.id}`}
|
href={`/habits?highlight=${habit.id}`}
|
||||||
className="flex items-center gap-1 hover:text-primary transition-colors"
|
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 && (
|
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -233,7 +235,7 @@ const ItemSection = ({
|
|||||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-500" />
|
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-500" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t('overdueTooltip')}</p>
|
<p>Overdue</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -247,16 +249,6 @@ const ItemSection = ({
|
|||||||
{habit.name}
|
{habit.name}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
{habit.drawing && (
|
|
||||||
<div className="ml-2 pr-2">
|
|
||||||
<DrawingDisplay
|
|
||||||
drawingData={habit.drawing}
|
|
||||||
width={40}
|
|
||||||
height={26}
|
|
||||||
className="border-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -307,21 +299,27 @@ const ItemSection = ({
|
|||||||
onClick={() => setCurrentExpanded(!currentExpanded)}
|
onClick={() => setCurrentExpanded(!currentExpanded)}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
|
{currentExpanded ? (
|
||||||
<>
|
<>
|
||||||
{t('showLessButton')}
|
Show less
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{t('showAllButton')}
|
Show all
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
))}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={viewLink}
|
href={viewLink}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
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
|
View
|
||||||
<ArrowRight className="h-3 w-3" />
|
<ArrowRight className="h-3 w-3" />
|
||||||
@@ -357,7 +355,6 @@ export default function DailyOverview({
|
|||||||
wishlistItems,
|
wishlistItems,
|
||||||
coinBalance,
|
coinBalance,
|
||||||
}: UpcomingItemsProps) {
|
}: UpcomingItemsProps) {
|
||||||
const t = useTranslations('DailyOverview');
|
|
||||||
const { completeHabit, undoComplete } = useHabits()
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||||
@@ -411,16 +408,16 @@ export default function DailyOverview({
|
|||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('todaysOverviewTitle')}</CardTitle>
|
<CardTitle>Today's Overview</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tasks Section */}
|
{/* Tasks Section */}
|
||||||
{hasTasks && (
|
{hasTasks && (
|
||||||
<ItemSection
|
<ItemSection
|
||||||
title={t('dailyTasksTitle')}
|
title="Daily Tasks"
|
||||||
items={dailyTasks}
|
items={dailyTasks}
|
||||||
emptyMessage={t('noTasksDueTodayMessage')}
|
emptyMessage="No tasks due today. Add some tasks to get started!"
|
||||||
isTask={true}
|
isTask={true}
|
||||||
viewLink="/habits?view=tasks"
|
viewLink="/habits?view=tasks"
|
||||||
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
|
||||||
@@ -429,9 +426,9 @@ export default function DailyOverview({
|
|||||||
|
|
||||||
{/* Habits Section */}
|
{/* Habits Section */}
|
||||||
<ItemSection
|
<ItemSection
|
||||||
title={t('dailyHabitsTitle')}
|
title="Daily Habits"
|
||||||
items={dailyHabits}
|
items={dailyHabits}
|
||||||
emptyMessage={t('noHabitsDueTodayMessage')}
|
emptyMessage="No habits due today. Add some habits to get started!"
|
||||||
isTask={false}
|
isTask={false}
|
||||||
viewLink="/habits"
|
viewLink="/habits"
|
||||||
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
|
||||||
@@ -439,24 +436,21 @@ export default function DailyOverview({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-semibold">{t('wishlistGoalsTitle')}</h3>
|
<h3 className="font-semibold">Wishlist Goals</h3>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{t('redeemableBadgeLabel', {
|
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
|
||||||
count: wishlistItems.filter(item => item.coinCost <= coinBalance).length,
|
|
||||||
total: wishlistItems.length
|
|
||||||
})}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${browserSettings.expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
<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 ? (
|
{sortedWishlistItems.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground text-sm py-4">
|
<div className="text-center text-muted-foreground text-sm py-4">
|
||||||
{t('noWishlistItemsMessage')}
|
No wishlist items yet. Add some goals to work towards!
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sortedWishlistItems
|
{sortedWishlistItems
|
||||||
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const isRedeemable = item.coinCost <= coinBalance
|
const isRedeemable = item.coinCost <= coinBalance
|
||||||
return (
|
return (
|
||||||
@@ -469,19 +463,9 @@ export default function DailyOverview({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm">
|
||||||
<span className="text-sm">
|
<Linkify>{item.name}</Linkify>
|
||||||
<Linkify>{item.name}</Linkify>
|
</span>
|
||||||
</span>
|
|
||||||
{item.drawing && (
|
|
||||||
<DrawingDisplay
|
|
||||||
drawingData={item.drawing}
|
|
||||||
width={40}
|
|
||||||
height={26}
|
|
||||||
className="border-0"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs flex items-center">
|
<span className="text-xs flex items-center">
|
||||||
<Coins className={cn(
|
<Coins className={cn(
|
||||||
"h-3 w-3 mr-1 transition-all",
|
"h-3 w-3 mr-1 transition-all",
|
||||||
@@ -508,8 +492,8 @@ export default function DailyOverview({
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
{isRedeemable
|
{isRedeemable
|
||||||
? t('readyToRedeemMessage')
|
? "Ready to redeem!"
|
||||||
: t('coinsToGoMessage', { amount: item.coinCost - coinBalance })
|
: `${item.coinCost - coinBalance} coins to go`
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -523,23 +507,23 @@ export default function DailyOverview({
|
|||||||
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
|
{browserSettings.expandedWishlist ? (
|
||||||
<>
|
<>
|
||||||
{t('showLessButton')}
|
Show less
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{t('showAllButton')}
|
Show all
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
))}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/wishlist"
|
href="/wishlist"
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{t('viewButton')}
|
View
|
||||||
<ArrowRight className="h-3 w-3" />
|
<ArrowRight className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
|
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import CoinBalance from './CoinBalance'
|
import CoinBalance from './CoinBalance'
|
||||||
import DailyOverview from './DailyOverview'
|
import DailyOverview from './DailyOverview'
|
||||||
import HabitStreak from './HabitStreak'
|
import HabitStreak from './HabitStreak'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const t = useTranslations('Dashboard');
|
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
const habits = habitsData.habits
|
const habits = habitsData.habits
|
||||||
const { balance } = useCoins()
|
const { balance } = useCoins()
|
||||||
@@ -17,9 +15,9 @@ export default function Dashboard() {
|
|||||||
const wishlistItems = wishlist.items
|
const wishlistItems = wishlist.items
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
|
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<CoinBalance coinBalance={balance} />
|
<CoinBalance coinBalance={balance} />
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
'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` }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import DrawingCanvas from './DrawingCanvas'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
interface DrawingModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onSave: (drawingData: string) => void
|
|
||||||
initialDrawing?: string
|
|
||||||
title?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DrawingModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
initialDrawing,
|
|
||||||
title = 'Drawing'
|
|
||||||
}: DrawingModalProps) {
|
|
||||||
const t = useTranslations('DrawingModal')
|
|
||||||
const [currentDrawing, setCurrentDrawing] = useState<string>(initialDrawing || '')
|
|
||||||
|
|
||||||
const handleSave = (drawingData: string) => {
|
|
||||||
setCurrentDrawing(drawingData)
|
|
||||||
onSave(drawingData)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
setCurrentDrawing('')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div
|
|
||||||
className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
className="h-6 w-6"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<DrawingCanvas
|
|
||||||
initialDrawing={currentDrawing}
|
|
||||||
onSave={handleSave}
|
|
||||||
onClear={handleClear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex justify-end gap-2 p-6 border-t">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
{t('cancelButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,21 +10,19 @@ import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/li
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Circle, CircleCheck } from 'lucide-react'
|
import { Circle, CircleCheck } from 'lucide-react'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
|
|
||||||
export default function HabitCalendar() {
|
export default function HabitCalendar() {
|
||||||
const t = useTranslations('HabitCalendar')
|
|
||||||
const { completePastHabit } = useHabits()
|
const { completePastHabit } = useHabits()
|
||||||
|
|
||||||
const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => {
|
const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => {
|
||||||
try {
|
try {
|
||||||
await completePastHabit(habit, date)
|
await completePastHabit(habit, date)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t('errorCompletingPastHabit'), error)
|
console.error('Error completing past habit:', error)
|
||||||
}
|
}
|
||||||
}, [completePastHabit, t])
|
}, [completePastHabit])
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
||||||
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
|
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
|
||||||
@@ -42,12 +40,12 @@ export default function HabitCalendar() {
|
|||||||
}, [completedHabitsMap, settings.system.timezone])
|
}, [completedHabitsMap, settings.system.timezone])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="container mx-auto px-4 py-6">
|
||||||
<h1 className="text-xl xs:text-3xl font-bold mb-6">{t('title')}</h1>
|
<h1 className="text-2xl font-semibold mb-6">Habit Calendar</h1>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('calendarCardTitle')}</CardTitle>
|
<CardTitle>Calendar</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Calendar
|
<Calendar
|
||||||
@@ -76,7 +74,7 @@ export default function HabitCalendar() {
|
|||||||
{selectedDateTime ? (
|
{selectedDateTime ? (
|
||||||
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
|
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
|
||||||
) : (
|
) : (
|
||||||
t('selectDatePrompt')
|
'Select a date'
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -86,7 +84,7 @@ export default function HabitCalendar() {
|
|||||||
{hasTasks && (
|
{hasTasks && (
|
||||||
<div className="pt-2 border-t">
|
<div className="pt-2 border-t">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3>
|
||||||
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
|
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
@@ -145,7 +143,7 @@ export default function HabitCalendar() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('habitsSectionTitle')}</h3>
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3>
|
||||||
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
|
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
@@ -156,49 +154,49 @@ export default function HabitCalendar() {
|
|||||||
date: selectedDateTime
|
date: selectedDateTime
|
||||||
}))
|
}))
|
||||||
.map((habit) => {
|
.map((habit) => {
|
||||||
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
|
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
|
||||||
const isCompleted = completions >= (habit.targetCompletions || 1)
|
const isCompleted = completions >= (habit.targetCompletions || 1)
|
||||||
return (
|
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">
|
<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">
|
<span className="flex items-center gap-2">
|
||||||
<Linkify>{habit.name}</Linkify>
|
<Linkify>{habit.name}</Linkify>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{habit.targetCompletions && (
|
{habit.targetCompletions && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{completions}/{habit.targetCompletions}
|
{completions}/{habit.targetCompletions}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
|
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
|
||||||
disabled={isCompleted}
|
disabled={isCompleted}
|
||||||
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-4 w-4">
|
<div className="relative h-4 w-4">
|
||||||
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
||||||
<div
|
<div
|
||||||
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
background: `conic-gradient(
|
background: `conic-gradient(
|
||||||
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
|
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
|
||||||
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
|
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
|
||||||
)`,
|
)`,
|
||||||
mask: 'radial-gradient(transparent 50%, black 51%)',
|
mask: 'radial-gradient(transparent 50%, black 51%)',
|
||||||
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
import { useHabits } from '@/hooks/useHabits';
|
import { useHabits } from '@/hooks/useHabits';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
|
import { pomodoroAtom, settingsAtom } from '@/lib/atoms';
|
||||||
import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
|
import { d2t, getNow, isHabitDueToday } from '@/lib/utils';
|
||||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||||
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
|
||||||
|
|
||||||
interface HabitContextMenuItemsProps {
|
interface HabitContextMenuItemsProps {
|
||||||
habit: Habit;
|
habit: Habit;
|
||||||
@@ -23,14 +23,13 @@ export function HabitContextMenuItems({
|
|||||||
context = 'habit-item',
|
context = 'habit-item',
|
||||||
onClose,
|
onClose,
|
||||||
}: HabitContextMenuItemsProps) {
|
}: HabitContextMenuItemsProps) {
|
||||||
const t = useTranslations('HabitContextMenuItems');
|
|
||||||
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
||||||
const [settings] = useAtom(settingsAtom);
|
const [settings] = useAtom(settingsAtom);
|
||||||
const [, setPomo] = useAtom(pomodoroAtom);
|
const [, setPomo] = useAtom(pomodoroAtom);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions
|
||||||
|
|
||||||
const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
||||||
const canInteract = hasPermission(currentUser, 'habit', 'interact');
|
const canInteract = hasPermission('habit', 'interact');
|
||||||
|
|
||||||
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
|
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
|
||||||
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
|
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
|
||||||
@@ -56,7 +55,7 @@ export function HabitContextMenuItems({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Timer className="mr-2 h-4 w-4" />
|
<Timer className="mr-2 h-4 w-4" />
|
||||||
<span>{t('startPomodoro')}</span>
|
<span>Start Pomodoro</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ export function HabitContextMenuItems({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
<span>{t('moveToToday')}</span>
|
<span>Move to Today</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -84,7 +83,7 @@ export function HabitContextMenuItems({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
<span>{t('moveToTomorrow')}</span>
|
<span>Move to Tomorrow</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ export function HabitContextMenuItems({
|
|||||||
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
|
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
|
||||||
>
|
>
|
||||||
<Pin className="mr-2 h-4 w-4" />
|
<Pin className="mr-2 h-4 w-4" />
|
||||||
<span>{t(habit.pinned ? 'unpin' : 'pin')}</span>
|
<span>{habit.pinned ? 'Unpin' : 'Pin'}</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -105,7 +104,7 @@ export function HabitContextMenuItems({
|
|||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
>
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<span>{t('edit')}</span>
|
<span>Edit</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ export function HabitContextMenuItems({
|
|||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
>
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<span>{t('edit')}</span>
|
<span>Edit</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -126,7 +125,7 @@ export function HabitContextMenuItems({
|
|||||||
onClick={() => handleAction(() => archiveHabit(habit.id))}
|
onClick={() => handleAction(() => archiveHabit(habit.id))}
|
||||||
>
|
>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
<span>{t('archive')}</span>
|
<span>Archive</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ export function HabitContextMenuItems({
|
|||||||
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
|
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
|
||||||
>
|
>
|
||||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||||
<span>{t('unarchive')}</span>
|
<span>Unarchive</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -151,7 +150,7 @@ export function HabitContextMenuItems({
|
|||||||
disabled={!canWrite} // Assuming delete is a write operation
|
disabled={!canWrite} // Assuming delete is a write operation
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t('delete')}</span>
|
<span>Delete</span>
|
||||||
</MenuItemComponent>
|
</MenuItemComponent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { browserSettingsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { Habit, User } from '@/lib/types'
|
import { Habit, User } from '@/lib/types'
|
||||||
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
|
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
|
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'; // Removed unused icons
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import DrawingDisplay from './DrawingDisplay'
|
|
||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
import { Button } from './ui/button'
|
|
||||||
|
|
||||||
interface HabitItemProps {
|
interface HabitItemProps {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
@@ -25,13 +23,13 @@ interface HabitItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
|
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
|
||||||
if (!habit.userIds || habit.userIds.length <= 1) return <></>;
|
if (!habit.userIds || habit.userIds.length <= 1) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||||
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||||
const user = usersData.users.find(u => u.id === userId)
|
const user = usersData.users.find(u => u.id === userId)
|
||||||
if (!user) return <></>;
|
if (!user) return null
|
||||||
return (
|
return (
|
||||||
<Avatar key={user.id} className="h-6 w-6">
|
<Avatar key={user.id} className="h-6 w-6">
|
||||||
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
@@ -45,18 +43,19 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: {
|
|||||||
|
|
||||||
|
|
||||||
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||||
const { completeHabit, undoComplete } = useHabits()
|
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
||||||
const target = habit.targetCompletions || 1
|
const target = habit.targetCompletions || 1
|
||||||
const isCompletedToday = completionsToday >= target
|
const isCompletedToday = completionsToday >= target
|
||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
const t = useTranslations('HabitItem');
|
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const pathname = usePathname();
|
const { currentUser, hasPermission } = useHelpers()
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const canWrite = hasPermission('habit', 'write')
|
||||||
const canWrite = hasPermission(currentUser, 'habit', 'write')
|
const canInteract = hasPermission('habit', 'interact')
|
||||||
const canInteract = hasPermission(currentUser, 'habit', 'interact')
|
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||||
|
const isTasksView = browserSettings.viewType === 'tasks'
|
||||||
|
const isRecurRule = !isTasksView
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
@@ -82,9 +81,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
id={`habit-${habit.id}`}
|
id={`habit-${habit.id}`}
|
||||||
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
|
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-shrink-0">
|
<CardHeader className="flex-none">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
|
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{habit.pinned && (
|
{habit.pinned && (
|
||||||
<Pin className="h-4 w-4 text-yellow-500" />
|
<Pin className="h-4 w-4 text-yellow-500" />
|
||||||
@@ -93,50 +92,32 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
{isTaskOverdue(habit, settings.system.timezone) && (
|
{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">
|
<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">
|
||||||
{t('overdue')}
|
Overdue
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{renderUserAvatars(habit, currentUser as User, usersData)}
|
{renderUserAvatars(habit, currentUser as User, usersData)}
|
||||||
</div>
|
</div>
|
||||||
{(habit.description || habit.drawing) && (
|
{habit.description && (
|
||||||
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
|
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||||
{habit.description && (
|
{habit.description}
|
||||||
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
</CardDescription>
|
||||||
{habit.description}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
{habit.drawing && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<DrawingDisplay
|
|
||||||
drawingData={habit.drawing}
|
|
||||||
width={120}
|
|
||||||
height={80}
|
|
||||||
className=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-grow flex flex-col justify-end">
|
<CardContent className="flex-1">
|
||||||
<div className="mt-auto">
|
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
When: {convertMachineReadableFrequencyToHumanReadable({
|
||||||
{t('whenLabel', {
|
frequency: habit.frequency,
|
||||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
isRecurRule,
|
||||||
frequency: habit.frequency,
|
timezone: settings.system.timezone
|
||||||
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'}`} />
|
||||||
</p>
|
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
|
||||||
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
<CardFooter className="flex justify-between gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
@@ -151,19 +132,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
{isCompletedToday ? (
|
{isCompletedToday ? (
|
||||||
target > 1 ? (
|
target > 1 ? (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">{t('completedStatusCountMobile', { completed: completionsToday, target })}</span>
|
<span className="sm:hidden">{completionsToday}/{target}</span>
|
||||||
<span className="hidden sm:inline">{t('completedStatusCount', { completed: completionsToday, target })}</span>
|
<span className="hidden sm:inline">Completed ({completionsToday}/{target})</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t('completedStatus')
|
'Completed'
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
target > 1 ? (
|
target > 1 ? (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">{t('completeButtonCountMobile', { completed: completionsToday, target })}</span>
|
<span className="sm:hidden">{completionsToday}/{target}</span>
|
||||||
<span className="hidden sm:inline">{t('completeButtonCount', { completed: completionsToday, target })}</span>
|
<span className="hidden sm:inline">Complete ({completionsToday}/{target})</span>
|
||||||
</>
|
</>
|
||||||
) : t('completeButton')
|
) : 'Complete'
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
||||||
@@ -185,7 +166,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
className="w-10 sm:w-auto"
|
className="w-10 sm:w-auto"
|
||||||
>
|
>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline ml-2">{t('undoButton')}</span>
|
<span className="hidden sm:inline ml-2">Undo</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,10 +180,10 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
className="hidden sm:flex"
|
className="hidden sm:flex"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
<span className="ml-2">{t('editButton')}</span>
|
<span className="ml-2">Edit</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
@@ -222,3 +203,4 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,26 @@ import { Input } from '@/components/ui/input'; // Added
|
|||||||
import { Label } from '@/components/ui/label'; // Added
|
import { Label } from '@/components/ui/label'; // Added
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { habitsAtom } from '@/lib/atoms'
|
import { browserSettingsAtom, habitsAtom } from '@/lib/atoms'
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { getHabitFreq } from '@/lib/utils'; // Added
|
import { getHabitFreq } from '@/lib/utils'; // Added
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
|
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
|
||||||
import { DateTime } from 'luxon'; // Added
|
import { DateTime } from 'luxon'; // Added
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
|
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
|
||||||
import AddEditHabitModal from './AddEditHabitModal'
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
import EmptyState from './EmptyState'
|
import EmptyState from './EmptyState'
|
||||||
import HabitItem from './HabitItem'
|
import HabitItem from './HabitItem'
|
||||||
|
import { ViewToggle } from './ViewToggle'
|
||||||
|
|
||||||
export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
export default function HabitList() {
|
||||||
const t = useTranslations('HabitList');
|
|
||||||
const { saveHabit, deleteHabit } = useHabits()
|
const { saveHabit, deleteHabit } = useHabits()
|
||||||
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
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 SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
@@ -120,17 +122,23 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-xl xs:text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
{t(isTasksView ? 'myTasks' : 'myHabits')}
|
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||||
</h1>
|
</h1>
|
||||||
<span>
|
<span>
|
||||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
|
||||||
<Plus className='mr-2 h-4 w-4' />{isTasksView ? t("addTaskButton") : t("addHabitButton")}
|
<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>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='py-4'>
|
||||||
|
<ViewToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search and Sort Controls */}
|
{/* Search and Sort Controls */}
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
|
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
|
||||||
@@ -140,28 +148,28 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={t(isTasksView ? 'searchTasksPlaceholder' : 'searchHabitsPlaceholder')}
|
placeholder={`Search ${isTasksView ? 'tasks' : 'habits'}...`}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10 w-full"
|
className="pl-10 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 self-start sm:self-center w-full sm:w-auto">
|
<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">{t('sortByLabel')}</Label>
|
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">Sort by:</Label>
|
||||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
|
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
|
||||||
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
|
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
|
||||||
<SelectValue placeholder={t('sortByLabel')} />
|
<SelectValue placeholder="Sort by" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="name">{t('sortByName')}</SelectItem>
|
<SelectItem value="name">Name</SelectItem>
|
||||||
<SelectItem value="coinReward">{t('sortByCoinReward')}</SelectItem>
|
<SelectItem value="coinReward">Coin Reward</SelectItem>
|
||||||
{isTasksView && <SelectItem value="dueDate">{t('sortByDueDate')}</SelectItem>}
|
{isTasksView && <SelectItem value="dueDate">Due Date</SelectItem>}
|
||||||
{!isTasksView && <SelectItem value="frequency">{t('sortByFrequency')}</SelectItem>}
|
{!isTasksView && <SelectItem value="frequency">Frequency</SelectItem>}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button variant="outline" size="icon" onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
|
<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" />}
|
{sortOrder === 'asc' ? <ArrowUpNarrowWide className="h-4 w-4" /> : <ArrowDownWideNarrow className="h-4 w-4" />}
|
||||||
<span className="sr-only">{t('toggleSortOrderAriaLabel')}</span>
|
<span className="sr-only">Toggle sort order</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,35 +177,35 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||||
{activeHabits.length === 0 && searchTerm.trim() ? (
|
{activeHabits.length === 0 && searchTerm.trim() ? (
|
||||||
<div className="col-span-2 text-center text-muted-foreground py-8">
|
<div className="col-span-2 text-center text-muted-foreground py-8">
|
||||||
{t(isTasksView ? 'noTasksFoundMessage' : 'noHabitsFoundMessage')}
|
No {isTasksView ? 'tasks' : 'habits'} found matching your search.
|
||||||
</div>
|
</div>
|
||||||
) : activeHabits.length === 0 ? (
|
) : activeHabits.length === 0 ? (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={isTasksView ? TaskIcon : HabitIcon}
|
icon={isTasksView ? TaskIcon : HabitIcon}
|
||||||
title={t(isTasksView ? 'emptyStateTasksTitle' : 'emptyStateHabitsTitle')}
|
title={isTasksView ? "No tasks yet" : "No habits yet"}
|
||||||
description={t(isTasksView ? 'emptyStateTasksDescription' : 'emptyStateHabitsDescription')}
|
description={isTasksView ? "Create your first task to start tracking your progress" : "Create your first habit to start tracking your progress"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activeHabits.map((habit: Habit) => (
|
activeHabits.map((habit: Habit) => (
|
||||||
<HabitItem
|
<HabitItem
|
||||||
key={habit.id}
|
key={habit.id}
|
||||||
habit={habit}
|
habit={habit}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingHabit(habit)
|
setEditingHabit(habit)
|
||||||
setModalConfig({ isOpen: true, isTask: isTasksView })
|
setModalConfig({ isOpen: true, isTask: isTasksView })
|
||||||
}}
|
}}
|
||||||
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{archivedHabits.length > 0 && (
|
{archivedHabits.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
|
<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" />
|
<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">{t('archivedSectionTitle')}</span>
|
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
|
||||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
{archivedHabits.map((habit: Habit) => (
|
{archivedHabits.map((habit: Habit) => (
|
||||||
@@ -238,9 +246,9 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
|||||||
}
|
}
|
||||||
setDeleteConfirmation({ isOpen: false, habitId: null })
|
setDeleteConfirmation({ isOpen: false, habitId: null })
|
||||||
}}
|
}}
|
||||||
title={t(isTasksView ? 'deleteTaskDialogTitle' : 'deleteHabitDialogTitle')}
|
title={isTasksView ? "Delete Task" : "Delete Habit"}
|
||||||
message={t(isTasksView ? 'deleteTaskDialogMessage' : 'deleteHabitDialogMessage')}
|
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={t('deleteButton')}
|
confirmText="Delete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
|
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types'
|
||||||
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
|
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl';
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
|
||||||
|
|
||||||
|
|
||||||
interface HabitStreakProps {
|
interface HabitStreakProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HabitStreak({ habits }: HabitStreakProps) {
|
export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||||
const t = useTranslations('HabitStreak');
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [hasTasks] = useAtom(hasTasksAtom)
|
const [hasTasks] = useAtom(hasTasksAtom)
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
||||||
@@ -43,7 +40,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('dailyCompletionStreakTitle')}</CardTitle>
|
<CardTitle>Daily Completion Streak</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="w-full aspect-[2/1]">
|
<div className="w-full aspect-[2/1]">
|
||||||
@@ -59,14 +56,11 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
<YAxis allowDecimals={false} />
|
<YAxis />
|
||||||
<Tooltip formatter={(value, name) => {
|
<Tooltip formatter={(value, name) => [`${value} ${name}`, 'Completed']} />
|
||||||
const translatedName = name === 'habits' ? t('tooltipHabitsLabel') : t('tooltipTasksLabel');
|
|
||||||
return [`${value} ${translatedName}`, t('tooltipCompletedLabel')];
|
|
||||||
}} />
|
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
name={t('tooltipHabitsLabel')}
|
name="habits"
|
||||||
dataKey="habits"
|
dataKey="habits"
|
||||||
stroke="#14b8a6"
|
stroke="#14b8a6"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@@ -75,7 +69,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
{hasTasks && (
|
{hasTasks && (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
name={t('tooltipTasksLabel')}
|
name="tasks"
|
||||||
dataKey="tasks"
|
dataKey="tasks"
|
||||||
stroke="#f59e0b"
|
stroke="#f59e0b"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Coins } from 'lucide-react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import HeaderActions from './HeaderActions'
|
import NotificationBell from './NotificationBell'
|
||||||
|
import { Profile } from './Profile'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||||
|
|
||||||
export default function Header({ className }: HeaderProps) {
|
export default function Header({ className }: HeaderProps) {
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const { balance } = useCoins()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
||||||
@@ -16,7 +28,23 @@ export default function Header({ className }: HeaderProps) {
|
|||||||
<Link href="/" className="mr-3 sm:mr-4">
|
<Link href="/" className="mr-3 sm:mr-4">
|
||||||
<Logo />
|
<Logo />
|
||||||
</Link>
|
</Link>
|
||||||
<HeaderActions />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
import ClientWrapper from './ClientWrapper'
|
import ClientWrapper from './ClientWrapper'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import Navigation from './Navigation'
|
import Navigation from './Navigation'
|
||||||
import PermissionError from './PermissionError'
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||||
|
<Header className="sticky top-0 z-50" />
|
||||||
<Header className="sticky top-0 z-50" />
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<Navigation viewPort='main' />
|
||||||
<Navigation position='main' />
|
<div className="flex-1 flex flex-col">
|
||||||
<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">
|
||||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
<ClientWrapper>
|
||||||
{/* responsive container (optimized for mobile) */}
|
{children}
|
||||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
</ClientWrapper>
|
||||||
<ClientWrapper>
|
|
||||||
<PermissionError />
|
|
||||||
{children}
|
|
||||||
</ClientWrapper>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
<Navigation position='mobile' />
|
<Navigation viewPort='mobile' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
'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;
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import Image from "next/image"
|
|
||||||
|
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Image src="/icons/icon.png" alt="HabitTrove Logo" width={96} height={96} className="h-12 w-12 hidden xs:inline" />
|
{/* <Sparkles className="h-6 w-6 text-primary" /> */}
|
||||||
<span className="font-bold text-xl">HabitTrove</span>
|
<span className="font-bold text-xl">HabitTrove</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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" />
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,102 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { browserSettingsAtom } from '@/lib/atoms'
|
||||||
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import Link from 'next/link'
|
||||||
import { ElementType, useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import NavDisplay from './NavDisplay'
|
import AboutModal from './AboutModal'
|
||||||
|
|
||||||
export interface NavItemType {
|
type ViewPort = 'main' | 'mobile'
|
||||||
icon: ElementType;
|
|
||||||
label: string;
|
const navItems = (isTasksView: boolean) => [
|
||||||
href: string;
|
{ 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 default function Navigation({ position }: { position: 'main' | 'mobile' }) {
|
export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||||
const t = useTranslations('Navigation');
|
const [showAbout, setShowAbout] = useState(false)
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
|
const [isMobileView, setIsMobileView] = useState(false)
|
||||||
|
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||||
|
const isTasksView = browserSettings.viewType === 'tasks'
|
||||||
|
const { isIOS } = useHelpers()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
|
const handleResize = () => {
|
||||||
window.addEventListener("resize", handleResize);
|
setIsMobileView(window.innerWidth < 1024)
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
}
|
||||||
}, [setIsMobile]);
|
|
||||||
|
|
||||||
const currentNavItems: NavItemType[] = [
|
// Set initial value
|
||||||
{ icon: Home, label: t('dashboard'), href: '/' },
|
handleResize()
|
||||||
{ 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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
|
// Add event listener
|
||||||
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
|
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)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
return <></>
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { Bell } from 'lucide-react';
|
import { Bell } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import NotificationDropdown from './NotificationDropdown';
|
import NotificationDropdown from './NotificationDropdown';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -14,11 +13,11 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
||||||
import { d2t, getNow, t2d } from '@/lib/utils';
|
import { d2t, getNow, t2d } from '@/lib/utils';
|
||||||
|
import { useHelpers } from '@/lib/client-helpers';
|
||||||
import { User, CoinTransaction } from '@/lib/types';
|
import { User, CoinTransaction } from '@/lib/types';
|
||||||
|
|
||||||
export default function NotificationBell() {
|
export default function NotificationBell() {
|
||||||
const t = useTranslations('NotificationBell');
|
const { currentUser } = useHelpers();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
const [coinsData] = useAtom(coinsAtom)
|
const [coinsData] = useAtom(coinsAtom)
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
const [wishlistData] = useAtom(wishlistAtom)
|
const [wishlistData] = useAtom(wishlistAtom)
|
||||||
@@ -100,7 +99,7 @@ export default function NotificationBell() {
|
|||||||
const nowTimestamp = d2t({ dateTime: getNow({}) });
|
const nowTimestamp = d2t({ dateTime: getNow({}) });
|
||||||
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
|
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t('errorUpdateTimestamp'), error);
|
console.error("Failed to update notification read timestamp:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ export default function NotificationBell() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
|
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
|
||||||
<NotificationDropdown
|
<NotificationDropdown
|
||||||
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
|
currentUser={currentUser as User | null} // Cast needed as useHelpers can return undefined initially
|
||||||
unreadNotifications={unreadNotifications}
|
unreadNotifications={unreadNotifications}
|
||||||
displayedReadNotifications={displayedReadNotifications}
|
displayedReadNotifications={displayedReadNotifications}
|
||||||
habitsData={habitsData} // Pass necessary data down
|
habitsData={habitsData} // Pass necessary data down
|
||||||
|
|||||||
@@ -11,18 +11,33 @@ import {
|
|||||||
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
|
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
|
||||||
import { t2d } from '@/lib/utils';
|
import { t2d } from '@/lib/utils';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface NotificationDropdownProps {
|
interface NotificationDropdownProps {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
unreadNotifications: CoinTransaction[];
|
unreadNotifications: CoinTransaction[];
|
||||||
displayedReadNotifications: CoinTransaction[];
|
displayedReadNotifications: CoinTransaction[];
|
||||||
habitsData: HabitsData;
|
habitsData: HabitsData; // Keep needed props
|
||||||
wishlistData: WishlistData;
|
wishlistData: WishlistData;
|
||||||
usersData: UserData;
|
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
|
// Helper function to get the name of the related item
|
||||||
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
|
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
|
||||||
if (!tx.relatedItemId) return undefined;
|
if (!tx.relatedItemId) return undefined;
|
||||||
@@ -44,33 +59,19 @@ export default function NotificationDropdown({
|
|||||||
wishlistData,
|
wishlistData,
|
||||||
usersData,
|
usersData,
|
||||||
}: NotificationDropdownProps) {
|
}: NotificationDropdownProps) {
|
||||||
const t = useTranslations('NotificationDropdown');
|
|
||||||
|
|
||||||
// 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) {
|
if (!currentUser) {
|
||||||
return <div className="p-4 text-sm text-gray-500">{t('notLoggedIn')}</div>;
|
return <div className="p-4 text-sm text-gray-500">Not logged in.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removed the useMemo block for calculating notifications
|
||||||
|
|
||||||
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
|
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
|
||||||
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
|
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
|
||||||
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
|
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
|
||||||
const message = getNotificationMessage(tx, triggeringUser, relatedItemName); // Uses the new t-aware helper
|
const message = getNotificationMessage(tx, triggeringUser, relatedItemName);
|
||||||
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
|
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
|
||||||
const timeAgo = txTimestamp.toRelative();
|
const timeAgo = txTimestamp.toRelative(); // e.g., "2 hours ago"
|
||||||
|
// Add the triggering user's ID to the query params if it exists
|
||||||
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
|
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,21 +98,21 @@ export default function NotificationDropdown({
|
|||||||
{/* Removed the outer div as width is now set on DropdownMenuContent in NotificationBell */}
|
{/* 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">
|
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
<h4 className="text-sm font-medium">{t('notificationsTitle')}</h4>
|
<h4 className="text-sm font-medium">Notifications</h4>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" className="max-w-xs">
|
<TooltipContent side="left" className="max-w-xs">
|
||||||
<p className="text-xs">
|
<p className="text-xs">
|
||||||
{t('notificationsTooltip')}
|
Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-[400px]">
|
<ScrollArea className="h-[400px]">
|
||||||
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
|
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
|
||||||
<div className="p-4 text-center text-sm text-gray-500">{t('noNotificationsYet')}</div>
|
<div className="p-4 text-center text-sm text-gray-500">No notifications yet.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{unreadNotifications.length > 0 && (
|
{unreadNotifications.length > 0 && (
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { User } from '@/lib/types';
|
import { User } from '@/lib/types';
|
||||||
import { User as UserIcon } from 'lucide-react';
|
import { User as UserIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
@@ -23,7 +22,6 @@ export default function PasswordEntryForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
error
|
error
|
||||||
}: PasswordEntryFormProps) {
|
}: PasswordEntryFormProps) {
|
||||||
const t = useTranslations('PasswordEntryForm');
|
|
||||||
const hasPassword = !!user.password;
|
const hasPassword = !!user.password;
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
@@ -33,8 +31,8 @@ export default function PasswordEntryForm({
|
|||||||
await onSubmit(password);
|
await onSubmit(password);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: t('loginErrorToastTitle'),
|
title: "Error",
|
||||||
description: err instanceof Error ? err.message : t('loginFailedErrorToastDescription'),
|
description: err instanceof Error ? err.message : 'Login failed',
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -60,18 +58,18 @@ export default function PasswordEntryForm({
|
|||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
|
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
|
||||||
>
|
>
|
||||||
{t('notYouButton')}
|
Not you?
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasPassword && <div className="space-y-4">
|
{hasPassword && <div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t('passwordPlaceholder')}
|
placeholder="Enter password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className={error ? 'border-red-500' : ''}
|
className={error ? 'border-red-500' : ''}
|
||||||
@@ -84,10 +82,10 @@ export default function PasswordEntryForm({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
{t('cancelButton')}
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={hasPassword && !password}>
|
<Button type="submit" disabled={hasPassword && !password}>
|
||||||
{t('loginButton')}
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { Switch } from './ui/switch';
|
import { Switch } from './ui/switch';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Permission } from '@/lib/types';
|
import { Permission } from '@/lib/types';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
interface PermissionSelectorProps {
|
interface PermissionSelectorProps {
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
@@ -12,20 +11,18 @@ interface PermissionSelectorProps {
|
|||||||
onAdminChange: (isAdmin: boolean) => void;
|
onAdminChange: (isAdmin: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permissionLabels: { [key: string]: string } = {
|
||||||
|
habit: 'Habit / Task',
|
||||||
|
wishlist: 'Wishlist',
|
||||||
|
coins: 'Coins'
|
||||||
|
};
|
||||||
|
|
||||||
export function PermissionSelector({
|
export function PermissionSelector({
|
||||||
permissions,
|
permissions,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onPermissionsChange,
|
onPermissionsChange,
|
||||||
onAdminChange,
|
onAdminChange,
|
||||||
}: PermissionSelectorProps) {
|
}: PermissionSelectorProps) {
|
||||||
const t = useTranslations('PermissionSelector');
|
|
||||||
|
|
||||||
const permissionLabels: { [key: string]: string } = {
|
|
||||||
habit: t('resourceHabitTask'),
|
|
||||||
wishlist: t('resourceWishlist'),
|
|
||||||
coins: t('resourceCoins')
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentPermissions = isAdmin ?
|
const currentPermissions = isAdmin ?
|
||||||
{
|
{
|
||||||
habit: { write: true, interact: true },
|
habit: { write: true, interact: true },
|
||||||
@@ -52,11 +49,11 @@ export function PermissionSelector({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('permissionsTitle')}</Label>
|
<Label>Permissions</Label>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<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 justify-between p-3 rounded-lg border bg-muted/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium text-sm">{t('adminAccessLabel')}</div>
|
<div className="font-medium text-sm">Admin Access</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="isAdmin"
|
id="isAdmin"
|
||||||
@@ -68,7 +65,7 @@ export function PermissionSelector({
|
|||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<p className="text-xs text-muted-foreground px-3">
|
<p className="text-xs text-muted-foreground px-3">
|
||||||
{t('adminAccessDescription')}
|
Admins have full permission to all data for all users
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
@@ -77,7 +74,7 @@ export function PermissionSelector({
|
|||||||
<div className="font-medium capitalize text-sm border-b pb-2">{permissionLabels[resource]}</div>
|
<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 gap-2.5">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
<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">{t('permissionWrite')}</Label>
|
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">Write</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id={`${resource}-write`}
|
id={`${resource}-write`}
|
||||||
className="h-4 w-7"
|
className="h-4 w-7"
|
||||||
@@ -88,7 +85,7 @@ export function PermissionSelector({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
<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">{t('permissionInteract')}</Label>
|
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">Interact</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id={`${resource}-interact`}
|
id={`${resource}-interact`}
|
||||||
className="h-4 w-7"
|
className="h-4 w-7"
|
||||||
|
|||||||
@@ -3,41 +3,55 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
|
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface PomoConfig {
|
interface PomoConfig {
|
||||||
getLabels: () => string[]
|
labels: string[]
|
||||||
duration: number
|
duration: number
|
||||||
type: 'focus' | 'break'
|
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() {
|
export default function PomodoroTimer() {
|
||||||
const t = useTranslations('PomodoroTimer')
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
|
||||||
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 [pomo, setPomo] = useAtom(pomodoroAtom)
|
||||||
const { show, selectedHabitId, autoStart, minimized } = pomo
|
const { show, selectedHabitId, autoStart, minimized } = pomo
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
@@ -47,23 +61,21 @@ export default function PomodoroTimer() {
|
|||||||
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
|
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
|
||||||
const wakeLock = useRef<WakeLockSentinel | null>(null)
|
const wakeLock = useRef<WakeLockSentinel | null>(null)
|
||||||
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
|
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
|
||||||
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
|
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus)
|
||||||
const [currentLabel, setCurrentLabel] = useState(() => {
|
const [currentLabel, setCurrentLabel] = useState(
|
||||||
const labels = currentTimerRef.current.getLabels();
|
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||||
return labels[Math.floor(Math.random() * labels.length)];
|
)
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Handle wake lock
|
// Handle wake lock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestWakeLock = async () => {
|
const requestWakeLock = async () => {
|
||||||
try {
|
try {
|
||||||
if (!('wakeLock' in navigator)) {
|
if (!('wakeLock' in navigator)) {
|
||||||
console.debug(t('wakeLockNotSupported'))
|
console.debug('Browser does not support wakelock')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (wakeLock.current && !wakeLock.current.released) {
|
if (wakeLock.current && !wakeLock.current.released) {
|
||||||
console.debug(t('wakeLockInUse'))
|
console.debug('Wake lock already in use')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state === 'started') {
|
if (state === 'started') {
|
||||||
@@ -72,7 +84,7 @@ export default function PomodoroTimer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(t('wakeLockRequestError'), err)
|
console.error('Error requesting wake lock:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +95,7 @@ export default function PomodoroTimer() {
|
|||||||
wakeLock.current = null
|
wakeLock.current = null
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(t('wakeLockReleaseError'), err)
|
console.error('Error releasing wake lock:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,48 +120,44 @@ export default function PomodoroTimer() {
|
|||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
}
|
}
|
||||||
}, [state, t])
|
}, [state])
|
||||||
|
|
||||||
// Timer logic
|
// Timer logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTimerEnd = async () => {
|
let interval: ReturnType<typeof setInterval> | null = null
|
||||||
setState("stopped");
|
|
||||||
const currentTimerType = currentTimerRef.current.type;
|
|
||||||
currentTimerRef.current =
|
|
||||||
currentTimerType === "focus" ? PomoConfigs.break : PomoConfigs.focus;
|
|
||||||
setTimeLeft(currentTimerRef.current.duration);
|
|
||||||
const newLabels = currentTimerRef.current.getLabels();
|
|
||||||
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)]);
|
|
||||||
|
|
||||||
// update habits only after focus sessions
|
if (state === 'started') {
|
||||||
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
|
// Calculate the target end time based on current timeLeft
|
||||||
const targetEndTime = Date.now() + timeLeft * 1000;
|
const targetEndTime = Date.now() + timeLeft * 1000
|
||||||
|
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
const remaining = Math.floor((targetEndTime - Date.now()) / 1000);
|
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
|
||||||
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
handleTimerEnd();
|
setState("stopped")
|
||||||
|
const currentTimerType = currentTimer.current.type
|
||||||
|
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
|
||||||
|
setTimeLeft(currentTimer.current.duration)
|
||||||
|
setCurrentLabel(
|
||||||
|
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||||
|
)
|
||||||
|
|
||||||
|
// update habits only after focus sessions
|
||||||
|
if (selectedHabit && currentTimerType === 'focus') {
|
||||||
|
completeHabit(selectedHabit)
|
||||||
|
// The atom will automatically update with the new completions
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setTimeLeft(remaining);
|
setTimeLeft(remaining)
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// return handles any other states
|
// return handles any other states
|
||||||
return () => {
|
return () => {
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval)
|
||||||
};
|
}
|
||||||
}, [state, timeLeft, PomoConfigs.break, PomoConfigs.focus, completeHabit, selectedHabit]);
|
}, [state, timeLeft, completeHabit, selectedHabit])
|
||||||
|
|
||||||
const toggleTimer = () => {
|
const toggleTimer = () => {
|
||||||
setState(prev => prev === 'started' ? 'paused' : 'started')
|
setState(prev => prev === 'started' ? 'paused' : 'started')
|
||||||
@@ -157,16 +165,17 @@ export default function PomodoroTimer() {
|
|||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
setState("stopped")
|
setState("stopped")
|
||||||
setTimeLeft(currentTimerRef.current.duration)
|
setTimeLeft(currentTimer.current.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipTimer = () => {
|
const skipTimer = () => {
|
||||||
currentTimerRef.current = currentTimerRef.current.type === 'focus'
|
currentTimer.current = currentTimer.current.type === 'focus'
|
||||||
? PomoConfigs.break
|
? PomoConfigs.break
|
||||||
: PomoConfigs.focus
|
: PomoConfigs.focus
|
||||||
resetTimer() // This will also reset timeLeft to the new timer's duration
|
resetTimer()
|
||||||
const newLabels = currentTimerRef.current.getLabels();
|
setCurrentLabel(
|
||||||
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
|
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
@@ -175,9 +184,9 @@ export default function PomodoroTimer() {
|
|||||||
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
|
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = (timeLeft / currentTimerRef.current.duration) * 100
|
const progress = (timeLeft / currentTimer.current.duration) * 100
|
||||||
|
|
||||||
if (!show) return <></>
|
if (!show) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">
|
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">
|
||||||
@@ -228,11 +237,11 @@ export default function PomodoroTimer() {
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-2 h-2 rounded-full flex-none',
|
'w-2 h-2 rounded-full flex-none',
|
||||||
// order matters here
|
// order matters here
|
||||||
currentTimerRef.current.type === 'focus' && 'bg-green-500',
|
currentTimer.current.type === 'focus' && 'bg-green-500',
|
||||||
state === 'started' && 'animate-pulse',
|
state === 'started' && 'animate-pulse',
|
||||||
state === 'paused' && 'bg-yellow-500',
|
state === 'paused' && 'bg-yellow-500',
|
||||||
state === 'stopped' && 'bg-red-500',
|
state === 'stopped' && 'bg-red-500',
|
||||||
currentTimerRef.current.type === 'break' && 'bg-blue-500',
|
currentTimer.current.type === 'break' && 'bg-blue-500',
|
||||||
)} />
|
)} />
|
||||||
<div className="font-bold text-foreground">
|
<div className="font-bold text-foreground">
|
||||||
{selectedHabit.name}
|
{selectedHabit.name}
|
||||||
@@ -240,9 +249,7 @@ export default function PomodoroTimer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>{currentTimer.current.type.charAt(0).toUpperCase() + currentTimer.current.type.slice(1)}: {currentLabel}</span>
|
||||||
{currentTimerRef.current.type === 'focus' ? t('focusType') : t('breakType')}: {currentLabel}
|
|
||||||
</span>
|
|
||||||
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
|
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
|
||||||
<div className="flex justify-center gap-1 mt-2">
|
<div className="flex justify-center gap-1 mt-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -281,12 +288,12 @@ export default function PomodoroTimer() {
|
|||||||
{state === "started" ? (
|
{state === "started" ? (
|
||||||
<>
|
<>
|
||||||
<Pause className="h-4 w-4 sm:mr-2" />
|
<Pause className="h-4 w-4 sm:mr-2" />
|
||||||
<span className="hidden sm:inline">{t('pauseButton')}</span>
|
<span className="hidden sm:inline">Pause</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="h-4 w-4 sm:mr-2" />
|
<Play className="h-4 w-4 sm:mr-2" />
|
||||||
<span className="hidden sm:inline">{t('startButton')}</span>
|
<span className="hidden sm:inline">Start</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -297,7 +304,7 @@ export default function PomodoroTimer() {
|
|||||||
className="sm:px-4"
|
className="sm:px-4"
|
||||||
>
|
>
|
||||||
<RotateCw className="h-4 w-4 sm:mr-2" />
|
<RotateCw className="h-4 w-4 sm:mr-2" />
|
||||||
<span className="hidden sm:inline">{t('resetButton')}</span>
|
<span className="hidden sm:inline">Reset</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -306,7 +313,7 @@ export default function PomodoroTimer() {
|
|||||||
className="sm:px-4"
|
className="sm:px-4"
|
||||||
>
|
>
|
||||||
<SkipForward className="h-4 w-4 sm:mr-2" />
|
<SkipForward className="h-4 w-4 sm:mr-2" />
|
||||||
<span className="hidden sm:inline">{t('skipButton')}</span>
|
<span className="hidden sm:inline">Skip</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,38 +5,38 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { toast } from "@/hooks/use-toast"
|
import { toast } from "@/hooks/use-toast"
|
||||||
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
|
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||||
|
import { useHelpers } from "@/lib/client-helpers"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import AboutModal from "./AboutModal"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||||
import UserForm from './UserForm'
|
import UserForm from './UserForm'
|
||||||
|
|
||||||
export function Profile() {
|
export function Profile() {
|
||||||
const t = useTranslations('Profile');
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
const [showAbout, setShowAbout] = useState(false)
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [user] = useAtom(currentUserAtom)
|
const { currentUser: user } = useHelpers()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
await signOut()
|
await signOut()
|
||||||
toast({
|
toast({
|
||||||
title: t('signOutSuccessTitle'),
|
title: "Signed out successfully",
|
||||||
description: t('signOutSuccessDescription'),
|
description: "You have been logged out of your account",
|
||||||
})
|
})
|
||||||
setTimeout(() => window.location.reload(), 300);
|
setTimeout(() => window.location.reload(), 300);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t('signOutErrorTitle'),
|
title: "Error",
|
||||||
description: t('signOutErrorDescription'),
|
description: "Failed to sign out",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -65,8 +65,8 @@ export function Profile() {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col mr-4">
|
<div className="flex flex-col mr-4">
|
||||||
<span className="text-sm font-semibold flex items-center gap-1 break-all">
|
<span className="text-sm font-semibold flex items-center gap-1">
|
||||||
{user?.username || t('guestUsername')}
|
{user?.username || "Guest"}
|
||||||
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
||||||
</span>
|
</span>
|
||||||
{user && (
|
{user && (
|
||||||
@@ -78,7 +78,7 @@ export function Profile() {
|
|||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
|
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
|
||||||
>
|
>
|
||||||
{t('editProfileButton')}
|
Edit profile
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -104,40 +104,34 @@ export function Profile() {
|
|||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ArrowRightLeft className="h-4 w-4" />
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
<span>{t('switchUserButton')}</span>
|
<span>Switch user</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
<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
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
aria-label={t('settingsLink')}
|
aria-label='settings'
|
||||||
className="flex items-center justify-between w-full"
|
className="flex items-center w-full gap-3"
|
||||||
onClick={() => setOpen(false)} // Ensure dropdown closes on click
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Settings className="h-4 w-4" />
|
||||||
<Settings className="h-4 w-4" />
|
<span>Settings</span>
|
||||||
<span>{t('settingsLink')}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||||
setOpen(false); // Close the dropdown
|
<button
|
||||||
setAboutOpen(true); // Open the about modal
|
onClick={() => setShowAbout(true)}
|
||||||
}}>
|
className="flex items-center w-full gap-3"
|
||||||
<div className="flex items-center justify-between w-full">
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Info className="h-4 w-4" />
|
||||||
<Info className="h-4 w-4" />
|
<span>About</span>
|
||||||
<span>{t('aboutButton')}</span>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Palette className="h-4 w-4" />
|
<Palette className="h-4 w-4" />
|
||||||
<span>{t('themeLabel')}</span>
|
<span>Theme</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -173,12 +167,14 @@ export function Profile() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||||
|
|
||||||
{/* Add the UserForm dialog */}
|
{/* Add the UserForm dialog */}
|
||||||
{isEditing && user && (
|
{isEditing && user && (
|
||||||
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('editProfileModalTitle')}</DialogTitle>
|
<DialogTitle>Edit Profile</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<UserForm
|
<UserForm
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
|
|
||||||
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
|
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
|
||||||
const t = useTranslations('TodayEarnedCoins')
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const { coinsEarnedToday } = useCoins()
|
const { coinsEarnedToday } = useCoins()
|
||||||
|
|
||||||
if (coinsEarnedToday <= 0) return <></>;
|
if (coinsEarnedToday <= 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
|
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
|
||||||
{"+"}
|
{"+"}
|
||||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
|
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
|
||||||
{longFormat ?
|
{longFormat ?
|
||||||
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
|
<span className="text-sm text-muted-foreground"> today</span>
|
||||||
: null}
|
: null}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Check, Loader2, Pencil, Trash2, X } from 'lucide-react'
|
import { Check, Loader2, Pencil, Trash2, X } from 'lucide-react'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
interface TransactionNoteEditorProps {
|
interface TransactionNoteEditorProps {
|
||||||
transactionId: string
|
transactionId: string
|
||||||
@@ -20,7 +19,6 @@ export function TransactionNoteEditor({
|
|||||||
onSave,
|
onSave,
|
||||||
onDelete
|
onDelete
|
||||||
}: TransactionNoteEditorProps) {
|
}: TransactionNoteEditorProps) {
|
||||||
const t = useTranslations('TransactionNoteEditor');
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [noteText, setNoteText] = useState(initialNote)
|
const [noteText, setNoteText] = useState(initialNote)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
@@ -29,8 +27,8 @@ export function TransactionNoteEditor({
|
|||||||
const trimmedNote = noteText.trim()
|
const trimmedNote = noteText.trim()
|
||||||
if (trimmedNote.length > 200) {
|
if (trimmedNote.length > 200) {
|
||||||
toast({
|
toast({
|
||||||
title: t('noteTooLongTitle'),
|
title: 'Note too long',
|
||||||
description: t('noteTooLongDescription'),
|
description: 'Notes must be less than 200 characters',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -42,8 +40,8 @@ export function TransactionNoteEditor({
|
|||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t('errorSavingNoteTitle'),
|
title: 'Error saving note',
|
||||||
description: t('pleaseTryAgainDescription'),
|
description: 'Please try again',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
// Revert to initial value on error
|
// Revert to initial value on error
|
||||||
@@ -61,8 +59,8 @@ export function TransactionNoteEditor({
|
|||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t('errorDeletingNoteTitle'),
|
title: 'Error deleting note',
|
||||||
description: t('pleaseTryAgainDescription'),
|
description: 'Please try again',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -76,7 +74,7 @@ export function TransactionNoteEditor({
|
|||||||
<Input
|
<Input
|
||||||
value={noteText}
|
value={noteText}
|
||||||
onChange={(e) => setNoteText(e.target.value)}
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
placeholder={t('addNotePlaceholder')}
|
placeholder="Add a note..."
|
||||||
className="w-64"
|
className="w-64"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
/>
|
/>
|
||||||
@@ -87,7 +85,7 @@ export function TransactionNoteEditor({
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400 transition-colors"
|
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400 transition-colors"
|
||||||
title={t('saveNoteTitle')}
|
title="Save note"
|
||||||
>
|
>
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -100,7 +98,7 @@ export function TransactionNoteEditor({
|
|||||||
}}
|
}}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors"
|
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors"
|
||||||
title={t('cancelButtonTitle')}
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -111,7 +109,7 @@ export function TransactionNoteEditor({
|
|||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="text-gray-600 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 transition-colors"
|
className="text-gray-600 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 transition-colors"
|
||||||
title={t('deleteNoteTitle')}
|
title="Delete note"
|
||||||
>
|
>
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -131,7 +129,7 @@ export function TransactionNoteEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
aria-label={t('editNoteAriaLabel')}
|
aria-label="Edit note"
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
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 { toast } from '@/hooks/use-toast';
|
||||||
import { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||||
|
import { useHelpers } from '@/lib/client-helpers';
|
||||||
import { Permission } from '@/lib/types';
|
import { Permission } from '@/lib/types';
|
||||||
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { User as UserIcon } from 'lucide-react';
|
import { User as UserIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PermissionSelector } from './PermissionSelector';
|
import { PermissionSelector } from './PermissionSelector';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
@@ -28,7 +17,6 @@ import { Input } from './ui/input';
|
|||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Switch } from './ui/switch';
|
import { Switch } from './ui/switch';
|
||||||
|
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
userId?: string; // if provided, we're editing; if not, we're creating
|
userId?: string; // if provided, we're editing; if not, we're creating
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -36,11 +24,10 @@ interface UserFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
|
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
|
||||||
const t = useTranslations('UserForm');
|
|
||||||
const [users, setUsersData] = useAtom(usersAtom);
|
const [users, setUsersData] = useAtom(usersAtom);
|
||||||
const serverSettings = useAtomValue(serverSettingsAtom)
|
const serverSettings = useAtomValue(serverSettingsAtom)
|
||||||
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const { currentUser } = useHelpers()
|
||||||
const getDefaultPermissions = (): Permission[] => [{
|
const getDefaultPermissions = (): Permission[] => [{
|
||||||
habit: {
|
habit: {
|
||||||
write: true,
|
write: true,
|
||||||
@@ -68,69 +55,6 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
);
|
);
|
||||||
const isEditing = !!user;
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -167,11 +91,11 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
setUsersData(prev => ({
|
setUsersData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
users: prev.users.map(u =>
|
users: prev.users.map(u =>
|
||||||
u.id === user.id ? {
|
u.id === user.id ? {
|
||||||
...u,
|
...u,
|
||||||
username,
|
username,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
permissions,
|
permissions,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
|
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
|
||||||
} : u
|
} : u
|
||||||
@@ -179,8 +103,8 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('toastUserUpdatedTitle'),
|
title: "User updated",
|
||||||
description: t('toastUserUpdatedDescription', { username }),
|
description: `Successfully updated user ${username}`,
|
||||||
variant: 'default'
|
variant: 'default'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -203,8 +127,8 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('toastUserCreatedTitle'),
|
title: "User created",
|
||||||
description: t('toastUserCreatedDescription', { username }),
|
description: `Successfully created user ${username}`,
|
||||||
variant: 'default'
|
variant: 'default'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -213,16 +137,15 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
setError('');
|
setError('');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const action = isEditing ? t('actionUpdate') : t('actionCreate');
|
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} user`);
|
||||||
setError(err instanceof Error ? err.message : t('errorFailedUserAction', { action }));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAvatarChange = async (file: File) => {
|
const handleAvatarChange = async (file: File) => {
|
||||||
if (file.size > 5 * 1024 * 1024) { // 5MB
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
toast({
|
toast({
|
||||||
title: t('errorTitle'),
|
title: "Error",
|
||||||
description: t('errorFileSizeLimit'),
|
description: "File size must be less than 5MB",
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -236,14 +159,14 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
setAvatarPath(path);
|
setAvatarPath(path);
|
||||||
setAvatarFile(null); // Clear the file since we've uploaded it
|
setAvatarFile(null); // Clear the file since we've uploaded it
|
||||||
toast({
|
toast({
|
||||||
title: t('toastAvatarUploadedTitle'),
|
title: "Avatar uploaded",
|
||||||
description: t('toastAvatarUploadedDescription'),
|
description: "Successfully uploaded avatar",
|
||||||
variant: 'default'
|
variant: 'default'
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: t('errorTitle'),
|
title: "Error",
|
||||||
description: t('errorFailedAvatarUpload'),
|
description: "Failed to upload avatar",
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -285,18 +208,18 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isEditing ? t('changeAvatarButton') : t('uploadAvatarButton')}
|
{isEditing ? 'Change Avatar' : 'Upload Avatar'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">{t('usernameLabel')}</Label>
|
<Label htmlFor="username">Username</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('usernamePlaceholder')}
|
placeholder="Username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className={error ? 'border-red-500' : ''}
|
className={error ? 'border-red-500' : ''}
|
||||||
@@ -306,22 +229,22 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
{isEditing ? t('newPasswordLabel') : t('passwordLabel')}
|
{isEditing ? 'New Password' : 'Password'}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={isEditing ? t('passwordPlaceholderEdit') : t('passwordPlaceholderCreate')}
|
placeholder={isEditing ? "Leave blank to keep current" : "Enter password"}
|
||||||
value={password || ''}
|
value={password || ''}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className={error ? 'border-red-500' : ''}
|
className={error ? 'border-red-500' : ''}
|
||||||
disabled={disablePassword}
|
disabled={disablePassword}
|
||||||
/>
|
/>
|
||||||
{serverSettings.isDemo && (
|
{serverSettings.isDemo && (
|
||||||
<p className="text-sm text-red-500">{t('demoPasswordDisabledMessage')}</p>
|
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="disable-password"
|
id="disable-password"
|
||||||
@@ -329,7 +252,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
onCheckedChange={setDisablePassword}
|
onCheckedChange={setDisablePassword}
|
||||||
disabled={serverSettings.isDemo}
|
disabled={serverSettings.isDemo}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="disable-password">{t('disablePasswordLabel')}</Label>
|
<Label htmlFor="disable-password">Disable password</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -337,7 +260,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>
|
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{currentUser && currentUser.isAdmin && <PermissionSelector
|
{currentUser && currentUser.isAdmin && <PermissionSelector
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
@@ -348,47 +271,15 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
{t('cancelButton')}
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!username}>
|
<Button type="submit" disabled={!username}>
|
||||||
{isEditing ? t('saveChangesButton') : t('createUserButton')}
|
{isEditing ? 'Save Changes' : 'Create User'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,36 +2,32 @@
|
|||||||
|
|
||||||
import { signIn } from '@/app/actions/user';
|
import { signIn } from '@/app/actions/user';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { currentUserAtom, usersAtom } from '@/lib/atoms';
|
import { usersAtom } from '@/lib/atoms';
|
||||||
|
import { useHelpers } from '@/lib/client-helpers';
|
||||||
import { SafeUser, User } from '@/lib/types';
|
import { SafeUser, User } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Description } from '@radix-ui/react-dialog';
|
import { Description } from '@radix-ui/react-dialog';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import PasswordEntryForm from './PasswordEntryForm';
|
import PasswordEntryForm from './PasswordEntryForm';
|
||||||
import UserForm from './UserForm';
|
import UserForm from './UserForm';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
|
function UserCard({
|
||||||
|
user,
|
||||||
function UserCard({
|
|
||||||
user,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onEdit,
|
onEdit,
|
||||||
showEdit,
|
showEdit,
|
||||||
isCurrentUser,
|
isCurrentUser
|
||||||
}: {
|
}: {
|
||||||
user: User,
|
user: User,
|
||||||
onSelect: () => void,
|
onSelect: () => void,
|
||||||
onEdit: () => void,
|
onEdit: () => void,
|
||||||
showEdit: boolean,
|
showEdit: boolean,
|
||||||
isCurrentUser: boolean,
|
isCurrentUser: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('UserSelectModal');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={user.id} className="relative group">
|
<div key={user.id} className="relative group">
|
||||||
<button
|
<button
|
||||||
@@ -42,9 +38,9 @@ function UserCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar className="h-16 w-16">
|
<Avatar className="h-16 w-16">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
|
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<UserIcon className="h-8 w-8" />
|
<UserIcon className="h-8 w-8" />
|
||||||
@@ -56,27 +52,21 @@ function UserCard({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{showEdit && (
|
{showEdit && (
|
||||||
<div className="absolute top-0 right-0 flex space-x-1">
|
<button
|
||||||
{showEdit && (
|
onClick={(e) => {
|
||||||
<button
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
onEdit();
|
||||||
e.stopPropagation(); // Prevent card selection
|
}}
|
||||||
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"
|
||||||
}}
|
>
|
||||||
className="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" />
|
||||||
title={t('editUserTooltip')}
|
</button>
|
||||||
>
|
|
||||||
<UserRoundPen className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddUserButton({ onClick }: { onClick: () => void }) {
|
function AddUserButton({ onClick }: { onClick: () => void }) {
|
||||||
const t = useTranslations('UserSelectModal');
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -87,53 +77,51 @@ function AddUserButton({ onClick }: { onClick: () => void }) {
|
|||||||
<Plus className="h-8 w-8" />
|
<Plus className="h-8 w-8" />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-sm font-medium">{t('addUserButton')}</span>
|
<span className="text-sm font-medium">Add User</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserSelectionView({
|
function UserSelectionView({
|
||||||
users,
|
users,
|
||||||
currentUserFromHook, // Renamed to avoid confusion with map variable
|
currentUser,
|
||||||
onUserSelect,
|
onUserSelect,
|
||||||
onEditUser,
|
onEditUser,
|
||||||
onCreateUser,
|
onCreateUser
|
||||||
}: {
|
}: {
|
||||||
users: User[],
|
users: User[],
|
||||||
currentUserFromHook?: SafeUser,
|
currentUser?: SafeUser,
|
||||||
onUserSelect: (userId: string) => void,
|
onUserSelect: (userId: string) => void,
|
||||||
onEditUser: (userId: string) => void,
|
onEditUser: (userId: string) => void,
|
||||||
onCreateUser: () => void,
|
onCreateUser: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||||
{users
|
{users
|
||||||
.filter(user => user.id !== currentUserFromHook?.id) // Show other users
|
.filter(user => user.id !== currentUser?.id)
|
||||||
.map((user) => (
|
.map((user) => (
|
||||||
<UserCard
|
<UserCard
|
||||||
key={user.id}
|
key={user.id}
|
||||||
user={user}
|
user={user}
|
||||||
onSelect={() => onUserSelect(user.id)}
|
onSelect={() => onUserSelect(user.id)}
|
||||||
onEdit={() => onEditUser(user.id)}
|
onEdit={() => onEditUser(user.id)}
|
||||||
showEdit={!!currentUserFromHook?.isAdmin}
|
showEdit={!!currentUser?.isAdmin}
|
||||||
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
|
isCurrentUser={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
|
{currentUser?.isAdmin && <AddUserButton onClick={onCreateUser} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
||||||
const t = useTranslations('UserSelectModal');
|
|
||||||
const [selectedUser, setSelectedUser] = useState<string>();
|
const [selectedUser, setSelectedUser] = useState<string>();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [usersData, setUsersData] = useAtom(usersAtom);
|
const [usersData] = useAtom(usersAtom);
|
||||||
const users = usersData.users;
|
const users = usersData.users;
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const {currentUser} = useHelpers();
|
||||||
|
|
||||||
|
|
||||||
const handleUserSelect = (userId: string) => {
|
const handleUserSelect = (userId: string) => {
|
||||||
setSelectedUser(userId);
|
setSelectedUser(userId);
|
||||||
@@ -168,14 +156,14 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<Description></Description>
|
<Description></Description>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isCreating ? t('createNewUserTitle') : t('selectUserTitle')}</DialogTitle>
|
<DialogTitle>{isCreating ? 'Create New User' : 'Select User'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{!selectedUser && !isCreating && !isEditing ? (
|
{!selectedUser && !isCreating && !isEditing ? (
|
||||||
<UserSelectionView
|
<UserSelectionView
|
||||||
users={users}
|
users={users}
|
||||||
currentUserFromHook={currentUser}
|
currentUser={currentUser}
|
||||||
onUserSelect={handleUserSelect}
|
onUserSelect={handleUserSelect}
|
||||||
onEditUser={handleEditUser}
|
onEditUser={handleEditUser}
|
||||||
onCreateUser={handleCreateUser}
|
onCreateUser={handleCreateUser}
|
||||||
@@ -196,19 +184,19 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
|||||||
const user = users.find(u => u.id === selectedUser);
|
const user = users.find(u => u.id === selectedUser);
|
||||||
if (!user) throw new Error("User not found");
|
if (!user) throw new Error("User not found");
|
||||||
await signIn(user.username, password);
|
await signIn(user.username, password);
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('signInSuccessTitle'),
|
title: "Signed in successfully",
|
||||||
description: t('signInSuccessDescription', { username: user.username }),
|
description: `Welcome back, ${user.username}!`,
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => window.location.reload(), 300);
|
setTimeout(() => window.location.reload(), 300);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('errorInvalidPassword'));
|
setError('invalid password');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,28 +2,30 @@
|
|||||||
|
|
||||||
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
|
import type { ViewType } from '@/lib/types'
|
||||||
import { cn, isHabitDueToday } from '@/lib/utils'
|
import { cn, isHabitDueToday } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
|
||||||
import { NotificationBadge } from './ui/notification-badge'
|
import { NotificationBadge } from './ui/notification-badge'
|
||||||
|
|
||||||
interface ViewToggleProps {
|
interface ViewToggleProps {
|
||||||
|
defaultView?: ViewType
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ViewToggle({
|
export function ViewToggle({
|
||||||
|
defaultView = 'habits',
|
||||||
className
|
className
|
||||||
}: ViewToggleProps) {
|
}: ViewToggleProps) {
|
||||||
const t = useTranslations('ViewToggle')
|
|
||||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||||
const [habits] = useAtom(habitsAtom)
|
const [habits] = useAtom(habitsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleViewChange = () => {
|
const handleViewChange = (checked: boolean) => {
|
||||||
router.push(pathname.includes("habits") ? "/tasks" : "/habits");
|
const newView = checked ? 'tasks' : 'habits'
|
||||||
|
setBrowserSettings({
|
||||||
|
...browserSettings,
|
||||||
|
viewType: newView,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate due tasks count
|
// Calculate due tasks count
|
||||||
@@ -35,36 +37,36 @@ export function ViewToggle({
|
|||||||
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
|
<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">
|
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
|
||||||
<button
|
<button
|
||||||
onClick={handleViewChange}
|
onClick={() => handleViewChange(false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||||
pathname.includes('habits') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
browserSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HabitIcon className="h-4 w-4" />
|
<HabitIcon className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">{t('habitsLabel')}</span>
|
<span className="hidden sm:inline">Habits</span>
|
||||||
</button>
|
</button>
|
||||||
<NotificationBadge
|
<NotificationBadge
|
||||||
label={dueTasksCount}
|
label={dueTasksCount}
|
||||||
show={dueTasksCount > 0}
|
show={dueTasksCount > 0}
|
||||||
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
|
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
|
||||||
className="shadow-md"
|
className="shadow-md"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleViewChange}
|
onClick={() => handleViewChange(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||||
pathname.includes('tasks') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TaskIcon className="h-4 w-4" />
|
<TaskIcon className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">{t('tasksLabel')}</span>
|
<span className="hidden sm:inline">Tasks</span>
|
||||||
</button>
|
</button>
|
||||||
</NotificationBadge>
|
</NotificationBadge>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
|
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
|
||||||
pathname.includes('habits') ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
browserSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
import { usersAtom } from '@/lib/atoms'
|
||||||
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { User, WishlistItemType } from '@/lib/types'
|
import { User, WishlistItemType } from '@/lib/types'
|
||||||
import { hasPermission } from '@/lib/utils'
|
|
||||||
import { useAtom } from 'jotai'
|
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'
|
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
|
||||||
interface WishlistItemProps {
|
interface WishlistItemProps {
|
||||||
item: WishlistItemType
|
item: WishlistItemType
|
||||||
@@ -30,13 +28,13 @@ interface WishlistItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
|
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
|
||||||
if (!item.userIds || item.userIds.length <= 1) return <></>;
|
if (!item.userIds || item.userIds.length <= 1) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||||
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||||
const user = usersData.users.find(u => u.id === userId)
|
const user = usersData.users.find(u => u.id === userId)
|
||||||
if (!user) return <></>;
|
if (!user) return null
|
||||||
return (
|
return (
|
||||||
<Avatar key={user.id} className="h-6 w-6">
|
<Avatar key={user.id} className="h-6 w-6">
|
||||||
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
@@ -59,13 +57,11 @@ export default function WishlistItem({
|
|||||||
isHighlighted,
|
isHighlighted,
|
||||||
isRecentlyRedeemed
|
isRecentlyRedeemed
|
||||||
}: WishlistItemProps) {
|
}: WishlistItemProps) {
|
||||||
const t = useTranslations('WishlistItem')
|
const { currentUser, hasPermission } = useHelpers()
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const canWrite = hasPermission('wishlist', 'write')
|
||||||
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
|
const canInteract = hasPermission('wishlist', 'interact')
|
||||||
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
|
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
id={`wishlist-${item.id}`}
|
id={`wishlist-${item.id}`}
|
||||||
@@ -73,51 +69,37 @@ export default function WishlistItem({
|
|||||||
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
|
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
|
||||||
} ${item.archived ? 'opacity-75' : ''}`}
|
} ${item.archived ? 'opacity-75' : ''}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-shrink-0">
|
<CardHeader className="flex-none">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex items-center gap-2">
|
||||||
<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' : ''}`}>
|
||||||
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
{item.name}
|
||||||
{item.name}
|
</CardTitle>
|
||||||
</CardTitle>
|
{item.targetCompletions && (
|
||||||
{item.targetCompletions && (
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
|
({item.targetCompletions} {item.targetCompletions === 1 ? 'use' : 'uses'} left)
|
||||||
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
</span>
|
||||||
</span>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{item.description && (
|
||||||
|
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||||
|
{item.description}
|
||||||
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{renderUserAvatars(item, currentUser as User, usersData)}
|
{renderUserAvatars(item, currentUser as User, usersData)}
|
||||||
</div>
|
</div>
|
||||||
{(item.description || item.drawing) && (
|
|
||||||
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
|
|
||||||
{item.description && (
|
|
||||||
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
|
||||||
{item.description}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
{item.drawing && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<DrawingDisplay
|
|
||||||
drawingData={item.drawing}
|
|
||||||
width={120}
|
|
||||||
height={80}
|
|
||||||
className=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-grow flex flex-col justify-end">
|
<CardContent className="flex-1">
|
||||||
<div className="mt-auto">
|
<div className="flex items-center gap-2">
|
||||||
<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'}`} />
|
||||||
<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' : ''}`}>
|
||||||
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
{item.coinCost} coins
|
||||||
{item.coinCost} {t('coinsSuffix')}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
<CardFooter className="flex justify-between gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={canRedeem ? "default" : "secondary"}
|
variant={canRedeem ? "default" : "secondary"}
|
||||||
@@ -130,13 +112,13 @@ export default function WishlistItem({
|
|||||||
<span>
|
<span>
|
||||||
{isRecentlyRedeemed ? (
|
{isRecentlyRedeemed ? (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">{t('redeemedDone')}</span>
|
<span className="sm:hidden">Done</span>
|
||||||
<span className="hidden sm:inline">{t('redeemedExclamation')}</span>
|
<span className="hidden sm:inline">Redeemed!</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">{t('redeem')}</span>
|
<span className="sm:hidden">Redeem</span>
|
||||||
<span className="hidden sm:inline">{t('redeem')}</span>
|
<span className="hidden sm:inline">Redeem</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -152,10 +134,10 @@ export default function WishlistItem({
|
|||||||
className="hidden sm:flex"
|
className="hidden sm:flex"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
<span className="ml-2">{t('editButton')}</span>
|
<span className="ml-2">Edit</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
@@ -165,27 +147,27 @@ export default function WishlistItem({
|
|||||||
{!item.archived && (
|
{!item.archived && (
|
||||||
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
|
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
<span>{t('archiveButton')}</span>
|
<span>Archive</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{item.archived && (
|
{item.archived && (
|
||||||
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
|
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
|
||||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||||
<span>{t('unarchiveButton')}</span>
|
<span>Unarchive</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
|
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
{t('editButton')}
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="sm:hidden" />
|
<DropdownMenuSeparator className="sm:hidden" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{t('deleteButton')}
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -194,3 +176,4 @@ export default function WishlistItem({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useWishlist } from '@/hooks/useWishlist'
|
import { useWishlist } from '@/hooks/useWishlist'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { Plus, Gift } from 'lucide-react'
|
import { Plus, Gift } from 'lucide-react'
|
||||||
import EmptyState from './EmptyState'
|
import EmptyState from './EmptyState'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -14,7 +13,6 @@ import { openWindow } from '@/lib/utils'
|
|||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
export default function WishlistManager() {
|
export default function WishlistManager() {
|
||||||
const t = useTranslations('WishlistManager')
|
|
||||||
const {
|
const {
|
||||||
addWishlistItem,
|
addWishlistItem,
|
||||||
editWishlistItem,
|
editWishlistItem,
|
||||||
@@ -66,14 +64,14 @@ export default function WishlistManager() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setRecentlyRedeemedId(null)
|
setRecentlyRedeemedId(null)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
if (item.link) {
|
if (item.link) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const opened = openWindow(item.link!)
|
const opened = openWindow(item.link!)
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
toast({
|
toast({
|
||||||
title: t('popupBlockedTitle'),
|
title: "Popup Blocked",
|
||||||
description: t('popupBlockedDescription'),
|
description: "Please allow popups to open the link",
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -83,11 +81,11 @@ export default function WishlistManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
|
<h1 className="text-3xl font-bold">My Wishlist</h1>
|
||||||
<Button onClick={() => setIsModalOpen(true)}>
|
<Button onClick={() => setIsModalOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" /> {t('addRewardButton')}
|
<Plus className="mr-2 h-4 w-4" /> Add Reward
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
|
||||||
@@ -95,8 +93,8 @@ export default function WishlistManager() {
|
|||||||
<div className="col-span-1 lg:col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Gift}
|
icon={Gift}
|
||||||
title={t('emptyStateTitle')}
|
title="Your wishlist is empty"
|
||||||
description={t('emptyStateDescription')}
|
description="Add rewards that you'd like to earn with your coins"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -126,12 +124,12 @@ export default function WishlistManager() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{archivedItems.length > 0 && (
|
{archivedItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-1 lg:col-span-2 relative flex items-center my-6">
|
<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" />
|
<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">{t('archivedSectionTitle')}</span>
|
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
|
||||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
{archivedItems.map((item) => (
|
{archivedItems.map((item) => (
|
||||||
@@ -152,15 +150,14 @@ export default function WishlistManager() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isModalOpen &&
|
<AddEditWishlistItemModal
|
||||||
<AddEditWishlistItemModal
|
isOpen={isModalOpen}
|
||||||
setIsOpen={setIsModalOpen}
|
setIsOpen={setIsModalOpen}
|
||||||
editingItem={editingItem}
|
editingItem={editingItem}
|
||||||
setEditingItem={setEditingItem}
|
setEditingItem={setEditingItem}
|
||||||
addWishlistItem={addWishlistItem}
|
addWishlistItem={addWishlistItem}
|
||||||
editWishlistItem={editWishlistItem}
|
editWishlistItem={editWishlistItem}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirmation.isOpen}
|
isOpen={deleteConfirmation.isOpen}
|
||||||
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
||||||
@@ -170,9 +167,9 @@ export default function WishlistManager() {
|
|||||||
}
|
}
|
||||||
setDeleteConfirmation({ isOpen: false, itemId: null })
|
setDeleteConfirmation({ isOpen: false, itemId: null })
|
||||||
}}
|
}}
|
||||||
title={t('deleteDialogTitle')}
|
title="Delete Reward"
|
||||||
message={t('deleteDialogMessage')}
|
message="Are you sure you want to delete this reward? This action cannot be undone."
|
||||||
confirmText={t('deleteButton')}
|
confirmText="Delete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function JotaiHydrate({
|
|||||||
[coinsAtom, initialValues.coins],
|
[coinsAtom, initialValues.coins],
|
||||||
[wishlistAtom, initialValues.wishlist],
|
[wishlistAtom, initialValues.wishlist],
|
||||||
[usersAtom, initialValues.users],
|
[usersAtom, initialValues.users],
|
||||||
[serverSettingsAtom, initialValues.serverSettings],
|
[serverSettingsAtom, initialValues.serverSettings]
|
||||||
])
|
])
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
"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,
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -73,7 +73,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
return <></>;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,7 +135,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
const tooltipLabel = React.useMemo(() => {
|
||||||
if (hideLabel || !payload?.length) {
|
if (hideLabel || !payload?.length) {
|
||||||
return <></>;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const [item] = payload
|
const [item] = payload
|
||||||
@@ -155,7 +155,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return <></>;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
@@ -170,7 +170,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
if (!active || !payload?.length) {
|
||||||
return <></>;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
@@ -273,7 +273,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
const { config } = useChart()
|
const { config } = useChart()
|
||||||
|
|
||||||
if (!payload?.length) {
|
if (!payload?.length) {
|
||||||
return <></>;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
habittrove:
|
habittrove:
|
||||||
image: ghcr.io/manindark/habittrove
|
|
||||||
container_name: habittrove
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/app/data"
|
- "./data:/app/data"
|
||||||
- "./backups:/app/backups"
|
- "./backups:/app/backups"
|
||||||
|
image: dohsimpson/habittrove
|
||||||
environment:
|
environment:
|
||||||
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret
|
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,109 +1,77 @@
|
|||||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
|
import { useAtom } from 'jotai'
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
coinsBalanceAtom,
|
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
coinsSpentTodayAtom,
|
|
||||||
currentUserAtom,
|
|
||||||
settingsAtom,
|
|
||||||
totalEarnedAtom,
|
totalEarnedAtom,
|
||||||
totalSpentAtom,
|
totalSpentAtom,
|
||||||
|
coinsSpentTodayAtom,
|
||||||
transactionsTodayAtom,
|
transactionsTodayAtom,
|
||||||
|
coinsBalanceAtom,
|
||||||
|
settingsAtom,
|
||||||
usersAtom,
|
usersAtom,
|
||||||
} from '@/lib/atoms';
|
} from '@/lib/atoms'
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
||||||
import { CoinsData } from '@/lib/types';
|
import { CoinsData, User } from '@/lib/types'
|
||||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { useAtom } from 'jotai';
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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
|
||||||
|
}
|
||||||
|
|
||||||
export function useCoins(options?: { selectedUser?: string }) {
|
export function useCoins(options?: { selectedUser?: string }) {
|
||||||
const t = useTranslations('useCoins');
|
|
||||||
const tCommon = useTranslations('Common');
|
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [users] = useAtom(usersAtom)
|
const [users] = useAtom(usersAtom)
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const { currentUser } = useHelpers()
|
||||||
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
|
let user: User | undefined;
|
||||||
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
|
if (!options?.selectedUser) {
|
||||||
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
|
user = currentUser;
|
||||||
const [atomTotalEarned] = useAtom(totalEarnedAtom)
|
} else {
|
||||||
const [atomTotalSpent] = useAtom(totalSpentAtom)
|
user = users.users.find(u => u.id === options.selectedUser)
|
||||||
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]);
|
|
||||||
|
|
||||||
const timezone = settings.system.timezone;
|
// Filter transactions for the selectd user
|
||||||
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
|
const transactions = coins.transactions.filter(t => t.userId === user?.id)
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [balance] = useAtom(coinsBalanceAtom)
|
||||||
// Calculate other metrics
|
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
||||||
if (targetUser?.id && targetUser.id === currentUser?.id) {
|
const [totalEarned] = useAtom(totalEarnedAtom)
|
||||||
// If the target user is the currently logged-in user, use the derived atom's value
|
const [totalSpent] = useAtom(totalSpentAtom)
|
||||||
setCoinsEarnedToday(atomCoinsEarnedToday);
|
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
||||||
setTotalEarned(atomTotalEarned);
|
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
||||||
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) => {
|
const add = async (amount: number, description: string, note?: string) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
|
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||||
if (isNaN(amount) || amount <= 0) {
|
if (isNaN(amount) || amount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
title: t("invalidAmountTitle"),
|
title: "Invalid amount",
|
||||||
description: t("invalidAmountDescription")
|
description: "Please enter a valid positive number"
|
||||||
})
|
})
|
||||||
return <></>;
|
return null
|
||||||
}
|
|
||||||
if (amount > MAX_COIN_LIMIT) {
|
|
||||||
toast({
|
|
||||||
title: t("invalidAmountTitle"),
|
|
||||||
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
|
|
||||||
})
|
|
||||||
return <></>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await addCoins({
|
const data = await addCoins({
|
||||||
@@ -111,29 +79,22 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note,
|
note,
|
||||||
userId: targetUser?.id
|
userId: user?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
|
toast({ title: "Success", description: `Added ${amount} coins` })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (amount: number, description: string, note?: string) => {
|
const remove = async (amount: number, description: string, note?: string) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
|
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||||
const numAmount = Math.abs(amount)
|
const numAmount = Math.abs(amount)
|
||||||
if (isNaN(numAmount) || numAmount <= 0) {
|
if (isNaN(numAmount) || numAmount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
title: t("invalidAmountTitle"),
|
title: "Invalid amount",
|
||||||
description: t("invalidAmountDescription")
|
description: "Please enter a valid positive number"
|
||||||
})
|
})
|
||||||
return <></>;
|
return null
|
||||||
}
|
|
||||||
if (numAmount > MAX_COIN_LIMIT) {
|
|
||||||
toast({
|
|
||||||
title: t("invalidAmountTitle"),
|
|
||||||
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
|
|
||||||
})
|
|
||||||
return <></>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await removeCoins({
|
const data = await removeCoins({
|
||||||
@@ -141,22 +102,22 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note,
|
note,
|
||||||
userId: targetUser?.id
|
userId: user?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
|
toast({ title: "Success", description: `Removed ${numAmount} coins` })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNote = async (transactionId: string, note: string) => {
|
const updateNote = async (transactionId: string, note: string) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
|
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||||
const transaction = coins.transactions.find(t => t.id === transactionId)
|
const transaction = coins.transactions.find(t => t.id === transactionId)
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
toast({
|
toast({
|
||||||
title: tCommon("errorTitle"),
|
title: "Error",
|
||||||
description: t("transactionNotFoundDescription")
|
description: "Transaction not found"
|
||||||
})
|
})
|
||||||
return <></>;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTransaction = {
|
const updatedTransaction = {
|
||||||
|
|||||||
@@ -1,37 +1,63 @@
|
|||||||
|
import { useAtom, atom } from 'jotai'
|
||||||
|
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
|
||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { DateTime } from 'luxon'
|
||||||
import { Habit } from '@/lib/types'
|
|
||||||
import {
|
import {
|
||||||
d2s,
|
getNowInMilliseconds,
|
||||||
|
getTodayInTimezone,
|
||||||
|
isSameDate,
|
||||||
|
t2d,
|
||||||
d2t,
|
d2t,
|
||||||
|
getNow,
|
||||||
getCompletionsForDate,
|
getCompletionsForDate,
|
||||||
getISODate,
|
getISODate,
|
||||||
getNow,
|
d2s,
|
||||||
getTodayInTimezone,
|
|
||||||
handlePermissionCheck,
|
|
||||||
isSameDate,
|
|
||||||
playSound,
|
playSound,
|
||||||
t2d
|
checkPermission
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { Undo2 } from 'lucide-react'
|
import { Undo2 } from 'lucide-react'
|
||||||
import { DateTime } from 'luxon'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useHabits() {
|
export function useHabits() {
|
||||||
const t = useTranslations('useHabits');
|
|
||||||
const tCommon = useTranslations('Common');
|
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const { currentUser } = useHelpers()
|
||||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [habitFreqMap] = useAtom(habitFreqMapAtom)
|
const [habitFreqMap] = useAtom(habitFreqMapAtom)
|
||||||
|
|
||||||
const completeHabit = async (habit: Habit) => {
|
const completeHabit = async (habit: Habit) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
|
||||||
const timezone = settings.system.timezone
|
const timezone = settings.system.timezone
|
||||||
const today = getTodayInTimezone(timezone)
|
const today = getTodayInTimezone(timezone)
|
||||||
|
|
||||||
@@ -46,8 +72,8 @@ export function useHabits() {
|
|||||||
// Check if already completed
|
// Check if already completed
|
||||||
if (completionsToday >= target) {
|
if (completionsToday >= target) {
|
||||||
toast({
|
toast({
|
||||||
title: t("alreadyCompletedTitle"),
|
title: "Already completed",
|
||||||
description: t("alreadyCompletedDescription"),
|
description: `You've already completed this habit today.`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -76,21 +102,21 @@ export function useHabits() {
|
|||||||
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
||||||
relatedItemId: habit.id,
|
relatedItemId: habit.id,
|
||||||
})
|
})
|
||||||
playSound()
|
isTargetReached && playSound()
|
||||||
toast({
|
toast({
|
||||||
title: t("completedTitle"),
|
title: "Completed!",
|
||||||
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
|
description: `You earned ${habit.coinReward} coins.`,
|
||||||
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||||
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
|
<Undo2 className="h-4 w-4" />Undo
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
setCoins(updatedCoins)
|
setCoins(updatedCoins)
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: t("progressTitle"),
|
title: "Progress!",
|
||||||
description: t("progressDescription", { count: completionsToday + 1, target }),
|
description: `You've completed ${completionsToday + 1}/${target} times today.`,
|
||||||
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||||
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
|
<Undo2 className="h-4 w-4" />Undo
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -105,7 +131,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const undoComplete = async (habit: Habit) => {
|
const undoComplete = async (habit: Habit) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
|
||||||
const timezone = settings.system.timezone
|
const timezone = settings.system.timezone
|
||||||
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
|
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
|
||||||
|
|
||||||
@@ -144,17 +170,14 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("completionUndoneTitle"),
|
title: "Completion undone",
|
||||||
description: t("completionUndoneDescription", {
|
description: `You have ${getCompletionsForDate({
|
||||||
count: getCompletionsForDate({
|
habit: updatedHabit,
|
||||||
habit: updatedHabit,
|
date: today,
|
||||||
date: today,
|
timezone
|
||||||
timezone
|
})}/${target} completions today.`,
|
||||||
}),
|
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
|
||||||
target
|
<Undo2 className="h-4 w-4" />Redo
|
||||||
}),
|
|
||||||
action: <ToastAction altText={tCommon('redoButton')} onClick={() => completeHabit(updatedHabit)}>
|
|
||||||
<Undo2 className="h-4 w-4" />{tCommon('redoButton')}
|
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,8 +188,8 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: t("noCompletionsToUndoTitle"),
|
title: "No completions to undo",
|
||||||
description: t("noCompletionsToUndoDescription"),
|
description: "This habit hasn't been completed today.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -174,10 +197,10 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
|
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
|
||||||
const newHabit = {
|
const newHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
id: habit.id || crypto.randomUUID()
|
id: habit.id || getNowInMilliseconds().toString()
|
||||||
}
|
}
|
||||||
const updatedHabits = habit.id
|
const updatedHabits = habit.id
|
||||||
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
|
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
|
||||||
@@ -189,7 +212,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteHabit = async (id: string) => {
|
const deleteHabit = async (id: string) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
|
||||||
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
|
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
|
||||||
await saveHabitsData({ habits: updatedHabits })
|
await saveHabitsData({ habits: updatedHabits })
|
||||||
setHabitsData({ habits: updatedHabits })
|
setHabitsData({ habits: updatedHabits })
|
||||||
@@ -197,7 +220,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const completePastHabit = async (habit: Habit, date: DateTime) => {
|
const completePastHabit = async (habit: Habit, date: DateTime) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
|
||||||
const timezone = settings.system.timezone
|
const timezone = settings.system.timezone
|
||||||
const dateKey = getISODate({ dateTime: date, timezone })
|
const dateKey = getISODate({ dateTime: date, timezone })
|
||||||
|
|
||||||
@@ -209,8 +232,8 @@ export function useHabits() {
|
|||||||
|
|
||||||
if (completionsOnDate >= target) {
|
if (completionsOnDate >= target) {
|
||||||
toast({
|
toast({
|
||||||
title: t("alreadyCompletedPastDateTitle"),
|
title: "Already completed",
|
||||||
description: t("alreadyCompletedPastDateDescription", { dateKey: d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' }) }),
|
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -250,12 +273,12 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: isTargetReached ? t("completedTitle") : t("progressTitle"),
|
title: isTargetReached ? "Completed!" : "Progress!",
|
||||||
description: isTargetReached
|
description: isTargetReached
|
||||||
? t("earnedCoinsPastDateDescription", { coinReward: habit.coinReward, dateKey })
|
? `You earned ${habit.coinReward} coins for ${dateKey}.`
|
||||||
: t("progressPastDateDescription", { count: completionsOnDate + 1, target, dateKey }),
|
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`,
|
||||||
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||||
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
|
<Undo2 className="h-4 w-4" />Undo
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -267,7 +290,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const archiveHabit = async (id: string) => {
|
const archiveHabit = async (id: string) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
|
||||||
const updatedHabits = habitsData.habits.map(h =>
|
const updatedHabits = habitsData.habits.map(h =>
|
||||||
h.id === id ? { ...h, archived: true } : h
|
h.id === id ? { ...h, archived: true } : h
|
||||||
)
|
)
|
||||||
@@ -276,7 +299,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unarchiveHabit = async (id: string) => {
|
const unarchiveHabit = async (id: string) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
|
||||||
const updatedHabits = habitsData.habits.map(h =>
|
const updatedHabits = habitsData.habits.map(h =>
|
||||||
h.id === id ? { ...h, archived: false } : h
|
h.id === id ? { ...h, archived: false } : h
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,23 +1,47 @@
|
|||||||
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
|
|
||||||
import { toast } from '@/hooks/use-toast'
|
|
||||||
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
|
|
||||||
import { WishlistItemType } from '@/lib/types'
|
|
||||||
import { handlePermissionCheck } from '@/lib/utils'
|
|
||||||
import { celebrations } from '@/utils/celebrations'
|
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
|
||||||
|
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { celebrations } from '@/utils/celebrations'
|
||||||
|
import { checkPermission } from '@/lib/utils'
|
||||||
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
import { useCoins } from './useCoins'
|
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() {
|
export function useWishlist() {
|
||||||
const t = useTranslations('useWishlist');
|
const { currentUser: user } = useHelpers()
|
||||||
const tCommon = useTranslations('Common');
|
|
||||||
const [user] = useAtom(currentUserAtom)
|
|
||||||
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const { balance } = useCoins()
|
const { balance } = useCoins()
|
||||||
|
|
||||||
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
||||||
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
|
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
||||||
const newItem = { ...item, id: Date.now().toString() }
|
const newItem = { ...item, id: Date.now().toString() }
|
||||||
const newItems = [...wishlist.items, newItem]
|
const newItems = [...wishlist.items, newItem]
|
||||||
const newWishListData = { items: newItems }
|
const newWishListData = { items: newItems }
|
||||||
@@ -26,7 +50,7 @@ export function useWishlist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const editWishlistItem = async (updatedItem: WishlistItemType) => {
|
const editWishlistItem = async (updatedItem: WishlistItemType) => {
|
||||||
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
|
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
||||||
const newItems = wishlist.items.map(item =>
|
const newItems = wishlist.items.map(item =>
|
||||||
item.id === updatedItem.id ? updatedItem : item
|
item.id === updatedItem.id ? updatedItem : item
|
||||||
)
|
)
|
||||||
@@ -36,7 +60,7 @@ export function useWishlist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteWishlistItem = async (id: string) => {
|
const deleteWishlistItem = async (id: string) => {
|
||||||
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
|
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
||||||
const newItems = wishlist.items.filter(item => item.id !== id)
|
const newItems = wishlist.items.filter(item => item.id !== id)
|
||||||
const newWishListData = { items: newItems }
|
const newWishListData = { items: newItems }
|
||||||
setWishlist(newWishListData)
|
setWishlist(newWishListData)
|
||||||
@@ -44,13 +68,13 @@ export function useWishlist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redeemWishlistItem = async (item: WishlistItemType) => {
|
const redeemWishlistItem = async (item: WishlistItemType) => {
|
||||||
if (!handlePermissionCheck(user, 'wishlist', 'interact', tCommon)) return false
|
if (!handlePermissionCheck(user, 'wishlist', 'interact')) return false
|
||||||
if (balance >= item.coinCost) {
|
if (balance >= item.coinCost) {
|
||||||
// Check if item has target completions and if we've reached the limit
|
// Check if item has target completions and if we've reached the limit
|
||||||
if (item.targetCompletions && item.targetCompletions <= 0) {
|
if (item.targetCompletions && item.targetCompletions <= 0) {
|
||||||
toast({
|
toast({
|
||||||
title: t("redemptionLimitReachedTitle"),
|
title: "Redemption limit reached",
|
||||||
description: t("redemptionLimitReachedDescription", { itemName: item.name }),
|
description: `You've reached the maximum redemptions for "${item.name}".`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -97,15 +121,15 @@ export function useWishlist() {
|
|||||||
randomEffect()
|
randomEffect()
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("rewardRedeemedTitle"),
|
title: "🎉 Reward Redeemed!",
|
||||||
description: t("rewardRedeemedDescription", { itemName: item.name, itemCoinCost: item.coinCost }),
|
description: `You've redeemed "${item.name}" for ${item.coinCost} coins.`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: t("notEnoughCoinsTitle"),
|
title: "Not enough coins",
|
||||||
description: t("notEnoughCoinsDescription", { coinsNeeded: item.coinCost - balance }),
|
description: `You need ${item.coinCost - balance} more coins to redeem this reward.`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -115,7 +139,7 @@ export function useWishlist() {
|
|||||||
const canRedeem = (cost: number) => balance >= cost
|
const canRedeem = (cost: number) => balance >= cost
|
||||||
|
|
||||||
const archiveWishlistItem = async (id: string) => {
|
const archiveWishlistItem = async (id: string) => {
|
||||||
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
|
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
||||||
const newItems = wishlist.items.map(item =>
|
const newItems = wishlist.items.map(item =>
|
||||||
item.id === id ? { ...item, archived: true } : item
|
item.id === id ? { ...item, archived: true } : item
|
||||||
)
|
)
|
||||||
@@ -125,7 +149,7 @@ export function useWishlist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unarchiveWishlistItem = async (id: string) => {
|
const unarchiveWishlistItem = async (id: string) => {
|
||||||
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
|
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
||||||
const newItems = wishlist.items.map(item =>
|
const newItems = wishlist.items.map(item =>
|
||||||
item.id === id ? { ...item, archived: false } : item
|
item.id === id ? { ...item, archived: false } : item
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
83
lib/atoms.ts
83
lib/atoms.ts
@@ -4,19 +4,16 @@ import {
|
|||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
calculateTotalSpent,
|
calculateTotalSpent,
|
||||||
calculateTransactionsToday,
|
calculateTransactionsToday,
|
||||||
generateCryptoHash,
|
|
||||||
getCompletionsForToday,
|
getCompletionsForToday,
|
||||||
getHabitFreq,
|
getHabitFreq,
|
||||||
|
getTodayInTimezone,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
prepareDataForHashing,
|
|
||||||
roundToInteger,
|
|
||||||
t2d
|
t2d
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import {
|
import {
|
||||||
CoinsData,
|
|
||||||
CompletionCache,
|
CompletionCache,
|
||||||
Freq,
|
Freq,
|
||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
@@ -26,61 +23,54 @@ import {
|
|||||||
getDefaultUsersData,
|
getDefaultUsersData,
|
||||||
getDefaultWishlistData,
|
getDefaultWishlistData,
|
||||||
Habit,
|
Habit,
|
||||||
HabitsData,
|
ViewType
|
||||||
ServerSettings,
|
|
||||||
Settings,
|
|
||||||
UserData,
|
|
||||||
UserId,
|
|
||||||
WishlistData
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface BrowserSettings {
|
export interface BrowserSettings {
|
||||||
|
viewType: ViewType
|
||||||
expandedHabits: boolean
|
expandedHabits: boolean
|
||||||
expandedTasks: boolean
|
expandedTasks: boolean
|
||||||
expandedWishlist: boolean
|
expandedWishlist: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||||
|
viewType: 'habits',
|
||||||
expandedHabits: false,
|
expandedHabits: false,
|
||||||
expandedTasks: false,
|
expandedTasks: false,
|
||||||
expandedWishlist: false
|
expandedWishlist: false
|
||||||
} as BrowserSettings)
|
} as BrowserSettings)
|
||||||
|
|
||||||
export const usersAtom = atom(getDefaultUsersData<UserData>())
|
export const usersAtom = atom(getDefaultUsersData())
|
||||||
export const settingsAtom = atom(getDefaultSettings<Settings>());
|
export const settingsAtom = atom(getDefaultSettings());
|
||||||
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
|
export const habitsAtom = atom(getDefaultHabitsData());
|
||||||
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
|
export const coinsAtom = atom(getDefaultCoinsData());
|
||||||
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
|
export const wishlistAtom = atom(getDefaultWishlistData());
|
||||||
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
|
export const serverSettingsAtom = atom(getDefaultServerSettings());
|
||||||
|
|
||||||
// Derived atom for coins earned today
|
// Derived atom for coins earned today
|
||||||
export const coinsEarnedTodayAtom = atom((get) => {
|
export const coinsEarnedTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||||
return roundToInteger(value);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total earned
|
// Derived atom for total earned
|
||||||
export const totalEarnedAtom = atom((get) => {
|
export const totalEarnedAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const value = calculateTotalEarned(coins.transactions);
|
return calculateTotalEarned(coins.transactions);
|
||||||
return roundToInteger(value);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total spent
|
// Derived atom for total spent
|
||||||
export const totalSpentAtom = atom((get) => {
|
export const totalSpentAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const value = calculateTotalSpent(coins.transactions);
|
return calculateTotalSpent(coins.transactions);
|
||||||
return roundToInteger(value);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for coins spent today
|
// Derived atom for coins spent today
|
||||||
export const coinsSpentTodayAtom = atom((get) => {
|
export const coinsSpentTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
||||||
return roundToInteger(value);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for transactions today
|
// Derived atom for transactions today
|
||||||
@@ -90,27 +80,10 @@ export const transactionsTodayAtom = atom((get) => {
|
|||||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atom to store the current logged-in user's ID.
|
// Derived atom for current balance from all transactions
|
||||||
// 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) => {
|
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);
|
const coins = get(coinsAtom);
|
||||||
const balance = coins.transactions
|
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||||
.filter(transaction => transaction.userId === loggedInUserId)
|
|
||||||
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
|
||||||
return roundToInteger(balance);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* transient atoms */
|
/* transient atoms */
|
||||||
@@ -129,23 +102,6 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const userSelectAtom = atom<boolean>(false)
|
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
|
// Derived atom for completion cache
|
||||||
export const completionCacheAtom = atom((get) => {
|
export const completionCacheAtom = atom((get) => {
|
||||||
@@ -234,3 +190,10 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
|
|||||||
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// client helpers
|
// client helpers
|
||||||
'use-client'
|
'use-client'
|
||||||
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
|
import { User, UserId } from './types'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
import { usersAtom } from './atoms'
|
import { usersAtom } from './atoms'
|
||||||
import { hasPermission } from './utils'
|
import { checkPermission } from './utils'
|
||||||
|
|
||||||
export function useHelpers() {
|
export function useHelpers() {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
@@ -13,7 +14,7 @@ export function useHelpers() {
|
|||||||
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||||
// detect iOS: https://stackoverflow.com/a/9039885
|
// detect iOS: https://stackoverflow.com/a/9039885
|
||||||
function iOS() {
|
function iOS() {
|
||||||
return typeof navigator !== "undefined" && ([
|
return [
|
||||||
'iPad Simulator',
|
'iPad Simulator',
|
||||||
'iPhone Simulator',
|
'iPhone Simulator',
|
||||||
'iPod Simulator',
|
'iPod Simulator',
|
||||||
@@ -22,7 +23,7 @@ export function useHelpers() {
|
|||||||
'iPod',
|
'iPod',
|
||||||
].includes(navigator.platform)
|
].includes(navigator.platform)
|
||||||
// iPad on iOS 13 detection
|
// iPad on iOS 13 detection
|
||||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
|
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -30,7 +31,8 @@ export function useHelpers() {
|
|||||||
currentUser,
|
currentUser,
|
||||||
usersData,
|
usersData,
|
||||||
status,
|
status,
|
||||||
hasPermission,
|
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
|
||||||
|
checkPermission(currentUser?.permissions, resource, action),
|
||||||
isIOS: iOS(),
|
isIOS: iOS(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,4 @@ export const QUICK_DATES = [
|
|||||||
{ label: 'Friday', value: 'this friday' },
|
{ label: 'Friday', value: 'this friday' },
|
||||||
{ label: 'Saturday', value: 'this saturday' },
|
{ label: 'Saturday', value: 'this saturday' },
|
||||||
{ label: 'Sunday', value: 'this sunday' },
|
{ label: 'Sunday', value: 'this sunday' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const MAX_COIN_LIMIT = 9999
|
|
||||||
|
|
||||||
export const DESKTOP_DISPLAY_ITEM_COUNT = 4
|
|
||||||
@@ -37,4 +37,4 @@ export function verifyPassword(password?: string, storedHash?: string): boolean
|
|||||||
const newHash = saltAndHashPassword(password, salt).split(':')[1]
|
const newHash = saltAndHashPassword(password, salt).split(':')[1]
|
||||||
// Compare the new hash with the stored hash
|
// Compare the new hash with the stored hash
|
||||||
return newHash === hash
|
return newHash === hash
|
||||||
}
|
}
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
87
lib/types.ts
87
lib/types.ts
@@ -1,4 +1,5 @@
|
|||||||
import { RRule } from "rrule"
|
import { RRule } from "rrule"
|
||||||
|
import { uuid } from "./utils"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
export type UserId = string
|
export type UserId = string
|
||||||
@@ -46,7 +47,6 @@ export type Habit = {
|
|||||||
archived?: boolean // mark the habit as archived
|
archived?: boolean // mark the habit as archived
|
||||||
pinned?: boolean // mark the habit as pinned
|
pinned?: boolean // mark the habit as pinned
|
||||||
userIds?: UserId[]
|
userIds?: UserId[]
|
||||||
drawing?: string // Optional JSON string of drawing data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ export type WishlistItemType = {
|
|||||||
targetCompletions?: number // Optional field, infinity when unset
|
targetCompletions?: number // Optional field, infinity when unset
|
||||||
link?: string // Optional URL to external resource
|
link?: string // Optional URL to external resource
|
||||||
userIds?: UserId[]
|
userIds?: UserId[]
|
||||||
drawing?: string // Optional JSON string of drawing data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
||||||
@@ -98,58 +97,51 @@ export interface WishlistData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default value functions
|
// Default value functions
|
||||||
export function getDefaultUsersData<UserData>(): UserData {
|
export const getDefaultUsersData = (): UserData => ({
|
||||||
return {
|
users: [
|
||||||
users: [
|
{
|
||||||
{
|
id: uuid(),
|
||||||
id: crypto.randomUUID(),
|
username: 'admin',
|
||||||
username: 'admin',
|
// password: '', // No default password for admin initially? Or set a secure default?
|
||||||
// password: '', // No default password for admin initially? Or set a secure default?
|
isAdmin: true,
|
||||||
isAdmin: true,
|
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||||
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
}
|
||||||
}
|
]
|
||||||
]
|
});
|
||||||
} as UserData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getDefaultHabitsData<HabitsData>(): HabitsData {
|
export const getDefaultHabitsData = (): HabitsData => ({
|
||||||
return { habits: [] } as HabitsData;
|
habits: []
|
||||||
}
|
});
|
||||||
|
|
||||||
export function getDefaultTasksData<TasksData>(): TasksData {
|
|
||||||
return { tasks: [] } as TasksData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getDefaultCoinsData<CoinsData>(): CoinsData {
|
export const getDefaultCoinsData = (): CoinsData => ({
|
||||||
return { balance: 0, transactions: [] } as CoinsData;
|
balance: 0,
|
||||||
};
|
transactions: []
|
||||||
|
});
|
||||||
|
|
||||||
export function getDefaultWishlistData<WishlistData>(): WishlistData {
|
export const getDefaultWishlistData = (): WishlistData => ({
|
||||||
return { items: [] } as WishlistData;
|
items: []
|
||||||
}
|
});
|
||||||
|
|
||||||
export function getDefaultSettings<Settings>(): Settings {
|
export const getDefaultSettings = (): Settings => ({
|
||||||
return {
|
ui: {
|
||||||
ui: {
|
useNumberFormatting: true,
|
||||||
useNumberFormatting: true,
|
useGrouping: true,
|
||||||
useGrouping: true,
|
},
|
||||||
},
|
system: {
|
||||||
system: {
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
weekStartDay: 1, // Monday
|
||||||
weekStartDay: 1, // Monday
|
autoBackupEnabled: true, // Add this line (default to true)
|
||||||
autoBackupEnabled: true, // Add this line (default to true)
|
},
|
||||||
language: 'en', // Default language
|
profile: {}
|
||||||
},
|
});
|
||||||
profile: {}
|
|
||||||
} as Settings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
|
export const getDefaultServerSettings = (): ServerSettings => ({
|
||||||
return { isDemo: false } as ServerSettings;
|
isDemo: false
|
||||||
}
|
})
|
||||||
|
|
||||||
// Map of data types to their default values
|
// Map of data types to their default values
|
||||||
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
|
export const DATA_DEFAULTS = {
|
||||||
wishlist: getDefaultWishlistData,
|
wishlist: getDefaultWishlistData,
|
||||||
habits: getDefaultHabitsData,
|
habits: getDefaultHabitsData,
|
||||||
coins: getDefaultCoinsData,
|
coins: getDefaultCoinsData,
|
||||||
@@ -171,7 +163,6 @@ export interface SystemSettings {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
weekStartDay: WeekDay;
|
weekStartDay: WeekDay;
|
||||||
autoBackupEnabled: boolean; // Add this line
|
autoBackupEnabled: boolean; // Add this line
|
||||||
language: string; // Add this line for language preference
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileSettings {
|
export interface ProfileSettings {
|
||||||
@@ -190,6 +181,8 @@ export type CompletionCache = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ViewType = 'habits' | 'tasks'
|
||||||
|
|
||||||
export interface JotaiHydrateInitialValues {
|
export interface JotaiHydrateInitialValues {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
coins: CoinsData;
|
coins: CoinsData;
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import {
|
|||||||
cn,
|
cn,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
getNow,
|
getNow,
|
||||||
|
getNowInMilliseconds,
|
||||||
t2d,
|
t2d,
|
||||||
d2t,
|
d2t,
|
||||||
d2s,
|
d2s,
|
||||||
|
d2sDate,
|
||||||
|
d2n,
|
||||||
isSameDate,
|
isSameDate,
|
||||||
calculateCoinsEarnedToday,
|
calculateCoinsEarnedToday,
|
||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
@@ -13,19 +16,16 @@ import {
|
|||||||
calculateCoinsSpentToday,
|
calculateCoinsSpentToday,
|
||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
|
uuid,
|
||||||
isTaskOverdue,
|
isTaskOverdue,
|
||||||
deserializeRRule,
|
deserializeRRule,
|
||||||
serializeRRule,
|
serializeRRule,
|
||||||
convertHumanReadableFrequencyToMachineReadable,
|
convertHumanReadableFrequencyToMachineReadable,
|
||||||
convertMachineReadableFrequencyToHumanReadable,
|
convertMachineReadableFrequencyToHumanReadable,
|
||||||
prepareDataForHashing,
|
getUnsupportedRRuleReason
|
||||||
getUnsupportedRRuleReason,
|
|
||||||
roundToInteger,
|
|
||||||
generateCryptoHash
|
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
|
import { CoinTransaction, ParsedResultType } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
|
|
||||||
import { RRule, Weekday } from 'rrule';
|
import { RRule, Weekday } from 'rrule';
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
import { INITIAL_DUE } from './constants';
|
import { INITIAL_DUE } from './constants';
|
||||||
@@ -39,33 +39,6 @@ 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', () => {
|
describe('getUnsupportedRRuleReason', () => {
|
||||||
test('should return message for HOURLY frequency', () => {
|
test('should return message for HOURLY frequency', () => {
|
||||||
const rrule = new RRule({ freq: RRule.HOURLY });
|
const rrule = new RRule({ freq: RRule.HOURLY });
|
||||||
@@ -166,7 +139,7 @@ describe('isTaskOverdue', () => {
|
|||||||
// Create a task due "tomorrow" in UTC
|
// Create a task due "tomorrow" in UTC
|
||||||
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
|
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
|
||||||
const habit = createTestHabit(tomorrow)
|
const habit = createTestHabit(tomorrow)
|
||||||
|
|
||||||
// Test in various timezones
|
// Test in various timezones
|
||||||
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
|
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
|
||||||
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
|
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
|
||||||
@@ -174,6 +147,32 @@ 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', () => {
|
describe('datetime utilities', () => {
|
||||||
let fixedNow: DateTime;
|
let fixedNow: DateTime;
|
||||||
let currentDateIndex = 0;
|
let currentDateIndex = 0;
|
||||||
@@ -291,6 +290,13 @@ 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', () => {
|
describe('timestamp conversion utilities', () => {
|
||||||
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
||||||
const testDateTime = DateTime.fromISO(testTimestamp);
|
const testDateTime = DateTime.fromISO(testTimestamp);
|
||||||
@@ -314,6 +320,16 @@ describe('timestamp conversion utilities', () => {
|
|||||||
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
|
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
|
||||||
expect(customFormat).toBe('2024-01-01')
|
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', () => {
|
describe('isSameDate', () => {
|
||||||
@@ -578,7 +594,7 @@ describe('isHabitDueToday', () => {
|
|||||||
|
|
||||||
test('should return false for invalid recurrence rule', () => {
|
test('should return false for invalid recurrence rule', () => {
|
||||||
const habit = testHabit('INVALID_RRULE')
|
const habit = testHabit('INVALID_RRULE')
|
||||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -691,7 +707,7 @@ describe('isHabitDue', () => {
|
|||||||
test('should return false for invalid recurrence rule', () => {
|
test('should return false for invalid recurrence rule', () => {
|
||||||
const habit = testHabit('INVALID_RRULE')
|
const habit = testHabit('INVALID_RRULE')
|
||||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
|
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)
|
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -940,96 +956,3 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
|||||||
expect(humanReadable).toBe('invalid')
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|||||||
137
lib/utils.ts
137
lib/utils.ts
@@ -1,12 +1,12 @@
|
|||||||
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 { clsx, type ClassValue } from "clsx"
|
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 { 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 { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||||
|
import * as chrono from 'chrono-node'
|
||||||
|
import _ from "lodash"
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -18,11 +18,6 @@ export function getTodayInTimezone(timezone: string): string {
|
|||||||
return getISODate({ dateTime: now, timezone });
|
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 {
|
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
|
||||||
return dateTime.setZone(timezone).toISODate()!;
|
return dateTime.setZone(timezone).toISODate()!;
|
||||||
}
|
}
|
||||||
@@ -32,6 +27,12 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
|
|||||||
return DateTime.now().setZone(timezone, { keepLocalTime });
|
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
|
// iso timestamp to datetime object, most for storage read
|
||||||
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
|
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
|
||||||
return DateTime.fromISO(timestamp).setZone(timezone);
|
return DateTime.fromISO(timestamp).setZone(timezone);
|
||||||
@@ -54,11 +55,30 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
|
|||||||
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
|
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)
|
// compare the date portion of two datetime objects (i.e. same year, month, day)
|
||||||
export function isSameDate(a: DateTime, b: DateTime) {
|
export function isSameDate(a: DateTime, b: DateTime) {
|
||||||
return a.hasSame(b, 'day');
|
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({
|
export function getCompletionsForDate({
|
||||||
habit,
|
habit,
|
||||||
date,
|
date,
|
||||||
@@ -412,20 +432,22 @@ export const openWindow = (url: string): boolean => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPermission(
|
export function deepMerge<T>(a: T, b: T) {
|
||||||
user: User | undefined,
|
return _.merge(a, b, (x: unknown, y: unknown) => {
|
||||||
|
if (_.isArray(a)) {
|
||||||
|
return a.concat(b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkPermission(
|
||||||
|
permissions: Permission[] | undefined,
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
resource: 'habit' | 'wishlist' | 'coins',
|
||||||
action: 'write' | 'interact'
|
action: 'write' | 'interact'
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!user || !user.permissions) {
|
if (!permissions) return false
|
||||||
return false;
|
|
||||||
}
|
return permissions.some(permission => {
|
||||||
// If user is admin, they have all permissions.
|
|
||||||
if (user.isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Otherwise, check specific permissions.
|
|
||||||
return user.permissions.some(permission => {
|
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
case 'habit':
|
case 'habit':
|
||||||
return permission.habit[action]
|
return permission.habit[action]
|
||||||
@@ -439,73 +461,6 @@ export function hasPermission(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function uuid() {
|
||||||
* Prepares a consistent string representation of the data for hashing.
|
return uuidv4()
|
||||||
* 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
447
messages/ca.json
@@ -1,447 +0,0 @@
|
|||||||
{
|
|
||||||
"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
449
messages/de.json
@@ -1,449 +0,0 @@
|
|||||||
{
|
|
||||||
"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
447
messages/en.json
@@ -1,447 +0,0 @@
|
|||||||
{
|
|
||||||
"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
449
messages/es.json
@@ -1,449 +0,0 @@
|
|||||||
{
|
|
||||||
"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
449
messages/fr.json
@@ -1,449 +0,0 @@
|
|||||||
{
|
|
||||||
"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
449
messages/ja.json
@@ -1,449 +0,0 @@
|
|||||||
{
|
|
||||||
"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
449
messages/ko.json
@@ -1,449 +0,0 @@
|
|||||||
{
|
|
||||||
"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
449
messages/ru.json
@@ -1,449 +0,0 @@
|
|||||||
{
|
|
||||||
"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
449
messages/zh.json
@@ -1,449 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "取消"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
@@ -52,5 +51,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
export default nextConfig;
|
||||||
export default withNextIntl(nextConfig);
|
|
||||||
|
|||||||
1396
package-lock.json
generated
1396
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.30",
|
"version": "0.2.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@next/font": "^14.2.15",
|
"@next/font": "^14.2.15",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-context-menu": "^2.2.4",
|
"@radix-ui/react-context-menu": "^2.2.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
@@ -29,7 +28,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.3",
|
"@radix-ui/react-separator": "^1.1.3",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
@@ -45,12 +44,10 @@
|
|||||||
"js-confetti": "^0.12.0",
|
"js-confetti": "^0.12.0",
|
||||||
"linkify": "^0.2.1",
|
"linkify": "^0.2.1",
|
||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "^v15.5.7",
|
"next": "15.2.3",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-intl": "^4.1.0",
|
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -62,6 +59,7 @@
|
|||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.0.5",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 130 KiB |
@@ -8,17 +8,6 @@ export default {
|
|||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
screens: {
|
|
||||||
'3xs': '320px',
|
|
||||||
'2xs': '375px',
|
|
||||||
'xs': '480px',
|
|
||||||
'sm': '640px',
|
|
||||||
'md': '768px',
|
|
||||||
'lg': '1024px',
|
|
||||||
'xl': '1280px',
|
|
||||||
'2xl': '1536px',
|
|
||||||
'3xl': '1920px'
|
|
||||||
},
|
|
||||||
extend: {
|
extend: {
|
||||||
keyframes: {
|
keyframes: {
|
||||||
celebrate: {
|
celebrate: {
|
||||||
|
|||||||
Reference in New Issue
Block a user