mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 20:09:50 +01:00
Compare commits
95 Commits
v0.1.26
...
8c9698c048
| Author | SHA1 | Date | |
|---|---|---|---|
|
8c9698c048
|
|||
|
630363af1f
|
|||
|
f7034116a3
|
|||
|
c418bddd9e
|
|||
|
c397f40239
|
|||
|
244692d8f9
|
|||
|
bb2e4be41b
|
|||
|
|
b01c5dcd6a | ||
|
3f9cd87c4d
|
|||
|
083fae020a
|
|||
|
38da61c6c2
|
|||
|
0689a5827f
|
|||
|
ab0c5e3e99
|
|||
|
3cc8543067
|
|||
|
06aa27af63
|
|||
|
c5bacb719c
|
|||
|
3ae2a3cb79
|
|||
|
|
3e6b4b75ec | ||
|
|
31700c9a45 | ||
|
|
e05b982307 | ||
|
|
ee2821b2bf | ||
|
|
8fb7cd1810 | ||
|
1b17d6b50a
|
|||
|
|
a6f5bf1baa | ||
|
|
8dda60b9b1 | ||
|
8269f3adad
|
|||
|
|
ad2504dc7f | ||
|
4cadf4cea7
|
|||
|
06e802f2f5
|
|||
|
6c0b196de2
|
|||
|
0f073760ee
|
|||
|
55c2e3577d
|
|||
|
043201217f
|
|||
|
4e11f17729
|
|||
|
faa6f4cb76
|
|||
|
84d6321153
|
|||
|
1af98fb233
|
|||
|
|
8d2bfaf62c | ||
|
9046d40a7a
|
|||
|
be0a5c48b3
|
|||
|
|
98b5d5eebb | ||
|
|
276e8a8a7b | ||
|
|
1967d154ed | ||
|
|
9e0ae1e0da | ||
|
|
5ae659469b | ||
|
|
6ef4aacfb8 | ||
|
|
95203426a3 | ||
|
|
b673d54ede | ||
|
|
42c8d14d6d | ||
|
|
3ac311c3fd | ||
|
|
1a286a99f4 | ||
|
3d78a00c66
|
|||
|
9c2e3f7dec
|
|||
|
|
82f45343ae | ||
|
|
a3d2b1ef96 | ||
|
e93b1c1c57
|
|||
|
92d1462010
|
|||
|
|
ac71c94d53 | ||
|
eff14f3772
|
|||
|
|
91ffe46863 | ||
|
d9fa0426ce
|
|||
|
49a0ea8804
|
|||
|
9bf24db477
|
|||
|
8530f703d9
|
|||
|
1a447e00bf
|
|||
|
ac116e8322
|
|||
|
8c7a7a63d0
|
|||
|
7c7d0e2f32
|
|||
|
e908f1edec
|
|||
|
8e6ddf0b9f
|
|||
|
c5a8f403ef
|
|||
|
33d36d0600
|
|||
|
942356eaed
|
|||
|
e4a52657af
|
|||
|
dbd0d0c7b7
|
|||
|
|
95197e216c | ||
|
|
660005d857 | ||
|
|
2408ed84bd | ||
|
|
dda8b522e3 | ||
|
|
909bfa7c6f | ||
|
|
e53e2f649a | ||
|
|
a42c0324c5 | ||
|
|
685cb80321 | ||
|
|
f1e3ee5747 | ||
|
|
d31982bf29 | ||
|
|
9052c9f37a | ||
|
|
a615a45c39 | ||
|
|
dea2b30c3b | ||
|
|
ea0203dc86 | ||
|
|
b7933ea040 | ||
|
|
8ac2ec053d | ||
|
|
363b31e934 | ||
|
|
7065d5694b | ||
|
|
b62cf77ba8 | ||
|
|
c66e28162c |
@@ -7,3 +7,17 @@ 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
|
||||||
|
|||||||
95
.github/workflows/docker-publish.yml
vendored
95
.github/workflows/docker-publish.yml
vendored
@@ -1,95 +0,0 @@
|
|||||||
name: Docker Build and Publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- github-actions
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
EXISTS: ${{ steps.check-version.outputs.EXISTS }}
|
|
||||||
VERSION: ${{ steps.package-version.outputs.VERSION }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
|
|
||||||
- name: Get version from package.json
|
|
||||||
id: package-version
|
|
||||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Check if version exists
|
|
||||||
id: check-version
|
|
||||||
run: |
|
|
||||||
if docker pull dohsimpson/habittrove:v${{ steps.package-version.outputs.VERSION }} 2>/dev/null; then
|
|
||||||
echo "EXISTS=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "EXISTS=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and push Docker images
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
|
|
||||||
${{ steps.check-version.outputs.EXISTS == 'false' && 'dohsimpson/habittrove:latest' || '' }}
|
|
||||||
dohsimpson/habittrove:dev
|
|
||||||
|
|
||||||
deploy-demo:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build-and-push
|
|
||||||
# demo tracks the latest tag
|
|
||||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions-hub/kubectl@master
|
|
||||||
env:
|
|
||||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
|
||||||
with:
|
|
||||||
args: rollout restart -n ${{ secrets.KUBE_NAMESPACE }} deploy/${{ secrets.KUBE_DEPLOYMENT }}
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build-and-push
|
|
||||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
VERSION: ${{ needs.build-and-push.outputs.VERSION }}
|
|
||||||
run: |
|
|
||||||
# Extract release notes from CHANGELOG.md
|
|
||||||
notes=$(awk -v version="$VERSION" '
|
|
||||||
$0 ~ "## Version " version {flag=1;next}
|
|
||||||
$0 ~ "## Version " && flag {exit}
|
|
||||||
flag' CHANGELOG.md)
|
|
||||||
|
|
||||||
gh release create "v$VERSION" \
|
|
||||||
--repo="$GITHUB_REPOSITORY" \
|
|
||||||
--title="v$VERSION" \
|
|
||||||
--notes="$notes"
|
|
||||||
40
.github/workflows/release.yml
vendored
Normal file
40
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Create and publish a Docker image to Github Container Registry
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-and-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
25
.github/workflows/test.yml
vendored
25
.github/workflows/test.yml
vendored
@@ -1,25 +0,0 @@
|
|||||||
name: Unit Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: bun test
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -41,6 +41,11 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# customize
|
# customize
|
||||||
data/*
|
/data/*
|
||||||
|
/data.*/*
|
||||||
Budfile
|
Budfile
|
||||||
certificates
|
certificates
|
||||||
|
/backups/*
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
CHANGELOG.md.tmp
|
||||||
|
|||||||
12
.husky/pre-commit
Normal file → Executable file
12
.husky/pre-commit
Normal file → Executable file
@@ -1,7 +1,11 @@
|
|||||||
if git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚'; then
|
#!/bin/sh
|
||||||
echo "Error: Found debug marker 🪚 in these files:"
|
# Check that package.json version exists in CHANGELOG.md
|
||||||
git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚' | awk -F: '{print " " $1 ":" $2}'
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "✅ Version $VERSION found in CHANGELOG.md"
|
||||||
|
|
||||||
npm run typecheck && npm run test
|
npm run typecheck && npm run lint && npm run test
|
||||||
|
|||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"i18n",
|
||||||
|
"messages"
|
||||||
|
]
|
||||||
|
}
|
||||||
284
CHANGELOG.md
284
CHANGELOG.md
@@ -1,5 +1,289 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* support searching and sorting in habit list
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
* Show overdue tasks in daily overview
|
||||||
|
* Context menu option for tasks changed from "Move to Today" to "Move to Tomorrow"
|
||||||
|
* More context menu items in daily overview
|
||||||
|
* code refactor for context menu and daily overview item section
|
||||||
|
|
||||||
|
|
||||||
|
## Version 0.2.10
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
* performance optimization: faster load time for large data set
|
||||||
|
|
||||||
|
## Version 0.2.9
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Auto backup feature: Automatically backs up data
|
||||||
|
* Backup rotation: Keeps the last 7 daily backups
|
||||||
|
* Setting to enable/disable auto backup.
|
||||||
|
|
||||||
|
## Version 0.2.8
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* notification for admin users on shared habit / wishlist completion (#92)
|
||||||
|
|
||||||
|
## Version 0.2.7
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* visual pin indicators for pinned habits/tasks
|
||||||
|
* pin/unpin options in context menus
|
||||||
|
* support click and right-click context menu in dailyoverview
|
||||||
|
|
||||||
|
## Version 0.2.6
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* support weekly / monthly intervals for recurring frequency (#99)
|
||||||
|
* show error when frequency is unsupported (#56)
|
||||||
|
* add task / habit button in habit view
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* make user select modal scrollable
|
||||||
|
|
||||||
|
## Version 0.2.5
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* bumped Nextjs version (cve-2025-29927)
|
||||||
|
|
||||||
|
## Version 0.2.4
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* admin can select user to view coins for that user
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* fix disable password in demo instance (#74)
|
||||||
|
|
||||||
|
## Version 0.2.3
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* gracefully handle invalid rrule (#76)
|
||||||
|
* fix long habit name overflow in daily (#75)
|
||||||
|
* disable password in demo instance (#74)
|
||||||
|
|
||||||
|
## Version 0.2.2
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* persist "show all" settings in browser (#72)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* nav bar spacing
|
||||||
|
* completion count badge
|
||||||
|
|
||||||
|
## Version 0.2.1
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* Added bottom padding for nav bar on iOS devices (#63)
|
||||||
|
|
||||||
|
## Version 0.2.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Multi-user support with permissions system
|
||||||
|
* Sharing habits and wishlist items with other users
|
||||||
|
* show both tasks and habits in dashboard (#58)
|
||||||
|
* show tasks in completion streak (#57)
|
||||||
|
|
||||||
|
### BREAKING CHANGE
|
||||||
|
|
||||||
|
* PLEASE BACK UP `data/` DIRECTORY BEFORE UPGRADE.
|
||||||
|
* Requires AUTH_SECRET environment variable for user authentication. Generate a secure secret with: `openssl rand -base64 32`
|
||||||
|
* Previous coin balance will be hidden. If this is undesirable, consider using manual adjustment to adjust coin balance after upgrade.
|
||||||
|
|
||||||
|
## Version 0.1.30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- fix responsive layout on mobile for habits and wishlist when has archived items
|
||||||
|
|
||||||
|
## Version 0.1.29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- actually working redeem link for wishlist items (#52)
|
||||||
|
|
||||||
|
## Version 0.1.28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- redeem link for wishlist items (#52)
|
||||||
|
- sound effect for habit / task completion (#53)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- fail habit create or edit if frequency is not set (#54)
|
||||||
|
- archive task when completed (#50)
|
||||||
|
|
||||||
|
## Version 0.1.27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- dark mode toggle (#48)
|
||||||
|
- notification badge for tasks (#51)
|
||||||
|
|
||||||
## Version 0.1.26
|
## Version 0.1.26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
100
CLAUDE.md
Normal file
100
CLAUDE.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
HabitTrove is a gamified habit tracking PWA built with Next.js 15, TypeScript, and Jotai state management. Users earn coins for completing habits and can redeem them for rewards. Features multi-user support with admin capabilities and shared ownership of habits/wishlist items.
|
||||||
|
|
||||||
|
## Essential Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- `npm run dev` - Start development server with Turbopack
|
||||||
|
- `npm run setup:dev` - Full setup: installs bun, dependencies, runs typecheck and lint
|
||||||
|
- `npm install --force` - Install dependencies (force flag required)
|
||||||
|
|
||||||
|
### Quality Assurance (Run these before committing)
|
||||||
|
- `npm run typecheck` - TypeScript type checking (required)
|
||||||
|
- `npm run lint` - ESLint code linting (required)
|
||||||
|
- `npm test` - Run tests with Bun
|
||||||
|
- `npm run build` - Build production version
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
- `npm run docker-build` - Build Docker image locally
|
||||||
|
- `docker compose up -d` - Run with docker-compose (recommended)
|
||||||
|
- Requires `AUTH_SECRET` environment variable: `openssl rand -base64 32`
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
### Creating a New Version
|
||||||
|
1. Update version in `package.json`
|
||||||
|
2. Update `CHANGELOG.md` with new version and changes (follow existing patterns in the file, keep entries concise - ideally 1 line per change)
|
||||||
|
3. Run `npm run typecheck && npm run lint` to ensure code quality
|
||||||
|
4. Commit changes: `git add . && git commit -m "feat: description"`
|
||||||
|
* Follow Conventional Commits Standard: `<type>[scope]: <description>` (e.g., `feat(auth): add OAuth integration`, `fix: resolve memory leak in task loader`).
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### State Management (Jotai)
|
||||||
|
- **Central atoms**: `habitsAtom`, `coinsAtom`, `wishlistAtom`, `usersAtom` in `lib/atoms.ts`
|
||||||
|
- **Derived atoms**: Computed values like `dailyHabitsAtom`, `coinsBalanceAtom`
|
||||||
|
- **Business logic hooks**: `useHabits`, `useCoins`, `useWishlist` in `/hooks`
|
||||||
|
|
||||||
|
### Data Models & Ownership
|
||||||
|
- **Individual ownership**: `CoinTransaction` has single `userId`
|
||||||
|
- **Shared ownership**: `Habit` and `WishlistItemType` have `userIds: string[]` array
|
||||||
|
- **Admin features**: Admin users can view/manage any user's data via dropdown selectors
|
||||||
|
- **Data persistence**: JSON files in `/data` directory with automatic `/backups`
|
||||||
|
|
||||||
|
### Key Components Structure
|
||||||
|
- **Feature components**: `HabitList`, `CoinsManager`, `WishlistManager` - main page components
|
||||||
|
- **Modal components**: `AddEditHabitModal`, `AddEditWishlistItemModal`, `UserSelectModal`
|
||||||
|
- **UI components**: `/components/ui` - shadcn/ui based components
|
||||||
|
|
||||||
|
### Authentication & Users
|
||||||
|
- NextAuth.js v5 with multi-user support
|
||||||
|
- User permissions: regular users vs admin users
|
||||||
|
- Admin dropdown patterns: Similar implementation across Habits/Wishlist pages (reference CoinsManager for pattern)
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
- `next-intl` with messages in `/messages/*.json`
|
||||||
|
- Supported languages: English, Spanish, German, French, Russian, Chinese, Japanese
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
```typescript
|
||||||
|
// Standard component pattern:
|
||||||
|
export default function ComponentName() {
|
||||||
|
const [data, setData] = useAtom(dataAtom)
|
||||||
|
const { businessLogicFunction } = useCustomHook()
|
||||||
|
// Component logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook Patterns
|
||||||
|
- Custom hooks accept options: `useHabits({ selectedUser?: string })`
|
||||||
|
- Return destructured functions and computed values
|
||||||
|
- Handle both individual and shared ownership models
|
||||||
|
|
||||||
|
### Shared Ownership Pattern
|
||||||
|
```typescript
|
||||||
|
// Filtering for shared ownership:
|
||||||
|
const userItems = allItems.filter(item =>
|
||||||
|
item.userIds && item.userIds.includes(targetUserId)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Dropdown Pattern
|
||||||
|
Reference `CoinsManager.tsx:107-119` for admin user selection implementation. Similar pattern should be applied to Habits and Wishlist pages.
|
||||||
|
|
||||||
|
## Data Safety
|
||||||
|
- Always backup `/data` before major changes
|
||||||
|
- Test with existing data files to prevent data loss
|
||||||
|
- Validate user permissions for all data operations
|
||||||
|
- Handle migration scripts carefully (see PLAN.md for shared ownership migration)
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
- State updates use immutable patterns
|
||||||
|
- Large dataset filtering happens at hook level
|
||||||
|
- Derived atoms prevent unnecessary re-renders
|
||||||
34
Dockerfile
34
Dockerfile
@@ -1,56 +1,52 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS base
|
# Use build platform for base images to avoid emulation
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM --platform=$BUILDPLATFORM 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
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Use cache mounts for npm cache
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
RUN \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
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; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM --platform=$BUILDPLATFORM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN \
|
# Use cache mount for Next.js cache
|
||||||
|
RUN --mount=type=cache,target=/app/.next/cache \
|
||||||
if [ -f yarn.lock ]; then yarn run build; \
|
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, copy all the files and run next
|
# Production image - use target platform
|
||||||
FROM base AS runner
|
FROM node:22-alpine 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 && \
|
||||||
RUN adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs && \
|
||||||
|
mkdir -p /app/data /app/backups && \
|
||||||
# Create data directory and set permissions
|
chown nextjs:nodejs /app/data /app/backups
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -61,6 +57,6 @@ EXPOSE 3000
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data", "/app/backups"]
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -1,12 +1,22 @@
|
|||||||
# HabitTrove
|
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
|
||||||
|
|
||||||
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
||||||
|
|
||||||
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
## Differences to Upstream
|
||||||
|
|
||||||
## Try the Demo
|
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
|
||||||
|
|
||||||
Want to try HabitTrove before installing? Visit the public [demo instance](https://habittrove.app.enting.org) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
|
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
|
||||||
|
|
||||||
|
Differences (as of writing) are:
|
||||||
|
- resolved linting problems so you can actually commit things
|
||||||
|
- added missing dependency
|
||||||
|
- refactored adding habit modal to cause less errors
|
||||||
|
- resolved undefined error
|
||||||
|
- replaced dockerhub release flow with github
|
||||||
|
- miscellaneous refactorings
|
||||||
|
- split habits & tasks page into two different pages
|
||||||
|
- only display "show all" if there are more than 4 entries
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -14,18 +24,18 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
|
|||||||
- 🏆 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)
|
||||||
- 🌙 Dark mode support (WIP)
|
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
|
||||||
- 📲 Progressive Web App (PWA) support (Planned)
|
- 🌙 Dark mode support
|
||||||
|
- 📲 Progressive Web App (PWA) support
|
||||||
|
- 💾 Automatic daily backups with rotation
|
||||||
|
|
||||||
## 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
|
||||||
@@ -39,18 +49,30 @@ The easiest way to run HabitTrove is using our pre-built Docker images from Dock
|
|||||||
1. First, prepare the data directory with correct permissions:
|
1. First, prepare the data directory with correct permissions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data
|
mkdir -p data backups
|
||||||
chown -R 1001:1001 data # Required for the nextjs user in container
|
chown -R 1001:1001 data backups # Required for the nextjs user in container
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Then run using either method:
|
2. Then run using either method:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Generate a secure authentication secret
|
||||||
|
export AUTH_SECRET=$(openssl rand -base64 32)
|
||||||
|
echo $AUTH_SECRET
|
||||||
|
|
||||||
# Using docker-compose (recommended)
|
# Using docker-compose (recommended)
|
||||||
|
## Update the AUTH_SECRET environment variable in docker-compose.yaml
|
||||||
|
nano docker-compose.yaml
|
||||||
|
## Start the container
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Or using docker run directly
|
# Or using docker run directly
|
||||||
docker run -d -p 3000:3000 -v ./data:/app/data dohsimpson/habittrove
|
docker run -d \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v ./data:/app/data \
|
||||||
|
-v ./backups:/app/backups \ # Add this line to map the backups directory
|
||||||
|
-e AUTH_SECRET=$AUTH_SECRET \
|
||||||
|
ghcr.io/manindark/habittrove
|
||||||
```
|
```
|
||||||
|
|
||||||
Available image tags:
|
Available image tags:
|
||||||
@@ -62,9 +84,11 @@ Available image tags:
|
|||||||
Choose your tag based on needs:
|
Choose your tag based on needs:
|
||||||
|
|
||||||
- Use `latest` for general production use
|
- Use `latest` for general production use
|
||||||
- Use version tags (e.g., `v0.1.4`) for reproducible deployments
|
- Use version tags (e.g., `v0.2.9`) for reproducible deployments
|
||||||
- Use `dev` for testing new features
|
- Use `dev` for testing new features
|
||||||
|
|
||||||
|
**Note on Volumes:** The application stores user data in `/app/data` and backups in `/app/backups` inside the container. The examples above map `./data` and `./backups` from your host machine to these container directories. Ensure these host directories exist and have the correct permissions (`chown -R 1001:1001 data backups`).
|
||||||
|
|
||||||
### Building Locally
|
### Building Locally
|
||||||
|
|
||||||
If you want to build the image locally (useful for development):
|
If you want to build the image locally (useful for development):
|
||||||
@@ -95,7 +119,7 @@ To contribute to HabitTrove, you'll need to set up a development environment. He
|
|||||||
1. Clone the repository and navigate to the project directory:
|
1. Clone the repository and navigate to the project directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dohsimpson/habittrove.git
|
git clone https://github.com/ManInDark/HabitTrove.git
|
||||||
cd habittrove
|
cd habittrove
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -148,7 +172,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/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment.
|
We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import fs from 'fs/promises'
|
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
|
||||||
import path from 'path'
|
|
||||||
import {
|
import {
|
||||||
HabitsData,
|
|
||||||
CoinsData,
|
CoinsData,
|
||||||
CoinTransaction,
|
CoinTransaction,
|
||||||
TransactionType,
|
|
||||||
WishlistItemType,
|
|
||||||
WishlistData,
|
|
||||||
Settings,
|
|
||||||
DataType,
|
|
||||||
DATA_DEFAULTS,
|
DATA_DEFAULTS,
|
||||||
getDefaultSettings
|
DataType,
|
||||||
} from '@/lib/types'
|
getDefaultCoinsData,
|
||||||
import { d2t, getNow } from '@/lib/utils';
|
getDefaultHabitsData,
|
||||||
|
getDefaultSettings,
|
||||||
|
getDefaultUsersData,
|
||||||
|
getDefaultWishlistData,
|
||||||
|
HabitsData,
|
||||||
|
Permission,
|
||||||
|
ServerSettings,
|
||||||
|
Settings,
|
||||||
|
TransactionType,
|
||||||
|
User,
|
||||||
|
UserData,
|
||||||
|
WishlistData,
|
||||||
|
WishlistItemType
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
||||||
|
import { signInSchema } from '@/lib/zod';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||||
|
type ActionType = 'write' | 'interact'
|
||||||
|
|
||||||
|
|
||||||
function getDefaultData<T>(type: DataType): T {
|
function getDefaultData<T>(type: DataType): T {
|
||||||
return DATA_DEFAULTS[type]() as T;
|
return DATA_DEFAULTS[type]() as T;
|
||||||
@@ -29,6 +46,27 @@ async function ensureDataDir() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Backup Debug Action ---
|
||||||
|
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
|
||||||
|
// Optional: Add extra permission check if needed for debug actions
|
||||||
|
// const user = await getCurrentUser();
|
||||||
|
// if (!user?.isAdmin) {
|
||||||
|
// return { success: false, message: "Permission denied." };
|
||||||
|
// }
|
||||||
|
|
||||||
|
console.log("Manual backup trigger requested...");
|
||||||
|
try {
|
||||||
|
// Import runBackup locally to avoid potential circular dependencies if moved
|
||||||
|
const { runBackup } = await import('@/lib/backup');
|
||||||
|
await runBackup();
|
||||||
|
console.log("Manual backup trigger completed successfully.");
|
||||||
|
return { success: true, message: "Backup process completed successfully." };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Manual backup trigger failed:", error);
|
||||||
|
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData<T>(type: DataType): Promise<T> {
|
async function loadData<T>(type: DataType): Promise<T> {
|
||||||
try {
|
try {
|
||||||
await ensureDataDir()
|
await ensureDataDir()
|
||||||
@@ -38,14 +76,14 @@ 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(type)
|
const initialData = getDefaultData<T>(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
|
||||||
}
|
}
|
||||||
|
|
||||||
// File exists, read and return its contents
|
// File exists, read and return its contents
|
||||||
const data = await fs.readFile(filePath, 'utf8')
|
const data = await fs.readFile(filePath, 'utf8')
|
||||||
const jsonData = JSON.parse(data)
|
const jsonData = JSON.parse(data) as T
|
||||||
return jsonData
|
return jsonData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading ${type} data:`, error)
|
console.error(`Error loading ${type} data:`, error)
|
||||||
@@ -55,6 +93,9 @@ async function loadData<T>(type: DataType): Promise<T> {
|
|||||||
|
|
||||||
async function saveData<T>(type: DataType, data: T): Promise<void> {
|
async function saveData<T>(type: DataType, data: T): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) throw new Error('User not authenticated')
|
||||||
|
|
||||||
await ensureDataDir()
|
await ensureDataDir()
|
||||||
const filePath = path.join(process.cwd(), 'data', `${type}.json`)
|
const filePath = path.join(process.cwd(), 'data', `${type}.json`)
|
||||||
const saveData = data
|
const saveData = data
|
||||||
@@ -64,9 +105,44 @@ 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> {
|
||||||
return loadData<WishlistData>('wishlist')
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return getDefaultWishlistData<WishlistData>()
|
||||||
|
|
||||||
|
const data = await loadData<WishlistData>('wishlist')
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
items: data.items.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
||||||
@@ -74,31 +150,94 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
|||||||
return data.items
|
return data.items
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWishlistItems(items: WishlistItemType[]): Promise<void> {
|
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
||||||
return saveData('wishlist', { items })
|
const user = await getCurrentUser()
|
||||||
|
|
||||||
|
data.items = data.items.map(wishlist => ({
|
||||||
|
...wishlist,
|
||||||
|
userIds: wishlist.userIds || (user ? [user.id] : undefined)
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!user?.isAdmin) {
|
||||||
|
const existingData = await loadData<WishlistData>('wishlist')
|
||||||
|
existingData.items = existingData.items.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
||||||
|
data.items = [
|
||||||
|
...existingData.items,
|
||||||
|
...data.items
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveData('wishlist', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Habits specific functions
|
// Habits specific functions
|
||||||
export async function loadHabitsData(): Promise<HabitsData> {
|
export async function loadHabitsData(): Promise<HabitsData> {
|
||||||
return loadData<HabitsData>('habits')
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return getDefaultHabitsData<HabitsData>()
|
||||||
|
const data = await loadData<HabitsData>('habits')
|
||||||
|
return {
|
||||||
|
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> {
|
||||||
return saveData('habits', data)
|
const user = await getCurrentUser()
|
||||||
|
// Create clone of input data
|
||||||
|
const newData = _.cloneDeep(data)
|
||||||
|
|
||||||
|
// Map habits with user IDs
|
||||||
|
newData.habits = newData.habits.map(habit => ({
|
||||||
|
...habit,
|
||||||
|
userIds: habit.userIds || (user ? [user.id] : undefined)
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!user?.isAdmin) {
|
||||||
|
const existingData = await loadHabitsData();
|
||||||
|
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
||||||
|
newData.habits = [
|
||||||
|
...existingHabits,
|
||||||
|
...newData.habits
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveData('habits', newData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Coins specific functions
|
// Coins specific functions
|
||||||
export async function loadCoinsData(): Promise<CoinsData> {
|
export async function loadCoinsData(): Promise<CoinsData> {
|
||||||
try {
|
try {
|
||||||
return await loadData<CoinsData>('coins')
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return getDefaultCoinsData<CoinsData>()
|
||||||
|
const data = await loadData<CoinsData>('coins')
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { balance: 0, transactions: [] }
|
return getDefaultCoinsData<CoinsData>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCoinsData(data: CoinsData): Promise<void> {
|
export async function saveCoinsData(data: CoinsData): Promise<void> {
|
||||||
return saveData('coins', data)
|
const user = await getCurrentUser()
|
||||||
|
|
||||||
|
// Create clones of the data
|
||||||
|
const newData = _.cloneDeep(data)
|
||||||
|
newData.transactions = newData.transactions.map(transaction => ({
|
||||||
|
...transaction,
|
||||||
|
userId: transaction.userId || user?.id
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!user?.isAdmin) {
|
||||||
|
const existingData = await loadData<CoinsData>('coins')
|
||||||
|
const existingTransactions = existingData.transactions.filter(x => user?.id && x.userId !== user.id)
|
||||||
|
newData.transactions = [
|
||||||
|
...newData.transactions,
|
||||||
|
...existingTransactions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return saveData('coins', newData)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addCoins({
|
export async function addCoins({
|
||||||
@@ -107,13 +246,16 @@ export async function addCoins({
|
|||||||
type = 'MANUAL_ADJUSTMENT',
|
type = 'MANUAL_ADJUSTMENT',
|
||||||
relatedItemId,
|
relatedItemId,
|
||||||
note,
|
note,
|
||||||
|
userId,
|
||||||
}: {
|
}: {
|
||||||
amount: number
|
amount: number
|
||||||
description: string
|
description: string
|
||||||
type?: TransactionType
|
type?: TransactionType
|
||||||
relatedItemId?: string
|
relatedItemId?: string
|
||||||
note?: string
|
note?: string
|
||||||
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -122,7 +264,8 @@ export async function addCoins({
|
|||||||
description,
|
description,
|
||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId }),
|
...(relatedItemId && { relatedItemId }),
|
||||||
...(note && note.trim() !== '' && { note })
|
...(note && note.trim() !== '' && { note }),
|
||||||
|
userId: userId || currentUser?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
@@ -135,9 +278,11 @@ export async function addCoins({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
const defaultSettings = getDefaultSettings()
|
const defaultSettings = getDefaultSettings<Settings>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return defaultSettings
|
||||||
const data = await loadData<Settings>('settings')
|
const data = await loadData<Settings>('settings')
|
||||||
return { ...defaultSettings, ...data }
|
return { ...defaultSettings, ...data }
|
||||||
} catch {
|
} catch {
|
||||||
@@ -155,13 +300,16 @@ export async function removeCoins({
|
|||||||
type = 'MANUAL_ADJUSTMENT',
|
type = 'MANUAL_ADJUSTMENT',
|
||||||
relatedItemId,
|
relatedItemId,
|
||||||
note,
|
note,
|
||||||
|
userId,
|
||||||
}: {
|
}: {
|
||||||
amount: number
|
amount: number
|
||||||
description: string
|
description: string
|
||||||
type?: TransactionType
|
type?: TransactionType
|
||||||
relatedItemId?: string
|
relatedItemId?: string
|
||||||
note?: string
|
note?: string
|
||||||
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -170,7 +318,8 @@ export async function removeCoins({
|
|||||||
description,
|
description,
|
||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId }),
|
...(relatedItemId && { relatedItemId }),
|
||||||
...(note && note.trim() !== '' && { note })
|
...(note && note.trim() !== '' && { note }),
|
||||||
|
userId: userId || currentUser?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
@@ -182,7 +331,7 @@ export async function removeCoins({
|
|||||||
return newData
|
return newData
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadAvatar(formData: FormData) {
|
export async function uploadAvatar(formData: FormData): Promise<string> {
|
||||||
const file = formData.get('avatar') as File
|
const file = formData.get('avatar') as File
|
||||||
if (!file) throw new Error('No file provided')
|
if (!file) throw new Error('No file provided')
|
||||||
|
|
||||||
@@ -203,18 +352,7 @@ export async function uploadAvatar(formData: FormData) {
|
|||||||
const buffer = await file.arrayBuffer()
|
const buffer = await file.arrayBuffer()
|
||||||
await fs.writeFile(filePath, Buffer.from(buffer))
|
await fs.writeFile(filePath, Buffer.from(buffer))
|
||||||
|
|
||||||
// Update settings with new avatar path
|
return `/data/avatars/${filename}`
|
||||||
const settings = await loadSettings()
|
|
||||||
const newSettings = {
|
|
||||||
...settings,
|
|
||||||
profile: {
|
|
||||||
...settings.profile,
|
|
||||||
avatarPath: `/data/avatars/${filename}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveSettings(newSettings)
|
|
||||||
return newSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChangelog(): Promise<string> {
|
export async function getChangelog(): Promise<string> {
|
||||||
@@ -226,3 +364,259 @@ export async function getChangelog(): Promise<string> {
|
|||||||
return '# Changelog\n\nNo changelog available.'
|
return '# Changelog\n\nNo changelog available.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// user logic
|
||||||
|
export async function loadUsersData(): Promise<UserData> {
|
||||||
|
try {
|
||||||
|
return await loadData<UserData>('auth')
|
||||||
|
} catch {
|
||||||
|
return getDefaultUsersData<UserData>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUsersData(data: UserData): Promise<void> {
|
||||||
|
return saveData('auth', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(username: string, plainTextPassword?: string): Promise<User | null> {
|
||||||
|
const data = await loadUsersData()
|
||||||
|
|
||||||
|
const user = data.users.find(user => user.username === username)
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
// Verify the plaintext password against the stored salt:hash
|
||||||
|
const isValidPassword = verifyPassword(plainTextPassword, user.password)
|
||||||
|
if (!isValidPassword) return null
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(formData: FormData): Promise<User> {
|
||||||
|
const username = formData.get('username') as string;
|
||||||
|
let password = formData.get('password') as string | undefined;
|
||||||
|
const avatarPath = formData.get('avatarPath') as string;
|
||||||
|
const permissions = formData.get('permissions') ?
|
||||||
|
JSON.parse(formData.get('permissions') as string) as Permission[] :
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
if (password === null) password = undefined
|
||||||
|
// Validate username and password against schema
|
||||||
|
await signInSchema.parseAsync({ username, password });
|
||||||
|
|
||||||
|
const data = await loadUsersData();
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
if (data.users.some(user => user.username === username)) {
|
||||||
|
throw new Error('Username already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = password ? saltAndHashPassword(password) : undefined;
|
||||||
|
|
||||||
|
|
||||||
|
const newUser: User = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
permissions,
|
||||||
|
isAdmin: false,
|
||||||
|
lastNotificationReadTimestamp: undefined,
|
||||||
|
...(avatarPath && { avatarPath })
|
||||||
|
};
|
||||||
|
|
||||||
|
const newData: UserData = {
|
||||||
|
users: [...data.users, newUser]
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveUsersData(newData);
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> {
|
||||||
|
const data = await loadUsersData()
|
||||||
|
const userIndex = data.users.findIndex(user => user.id === userId)
|
||||||
|
|
||||||
|
if (userIndex === -1) {
|
||||||
|
throw new Error('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If username is being updated, check for duplicates
|
||||||
|
if (updates.username) {
|
||||||
|
const isDuplicate = data.users.some(
|
||||||
|
user => user.username === updates.username && user.id !== userId
|
||||||
|
)
|
||||||
|
if (isDuplicate) {
|
||||||
|
throw new Error('Username already exists')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = {
|
||||||
|
...data.users[userIndex],
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData: UserData = {
|
||||||
|
users: [
|
||||||
|
...data.users.slice(0, userIndex),
|
||||||
|
updatedUser,
|
||||||
|
...data.users.slice(userIndex + 1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUsersData(newData)
|
||||||
|
return updatedUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {
|
||||||
|
const data = await loadUsersData()
|
||||||
|
const userIndex = data.users.findIndex(user => user.id === userId)
|
||||||
|
|
||||||
|
if (userIndex === -1) {
|
||||||
|
throw new Error('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = newPassword ? saltAndHashPassword(newPassword) : ''
|
||||||
|
|
||||||
|
const updatedUser = {
|
||||||
|
...data.users[userIndex],
|
||||||
|
password: hashedPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData: UserData = {
|
||||||
|
users: [
|
||||||
|
...data.users.slice(0, userIndex),
|
||||||
|
updatedUser,
|
||||||
|
...data.users.slice(userIndex + 1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUsersData(newData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string): Promise<void> {
|
||||||
|
// Load all necessary data
|
||||||
|
const wishlistData = await loadData<WishlistData>('wishlist')
|
||||||
|
const habitsData = await loadData<HabitsData>('habits')
|
||||||
|
const coinsData = await loadData<CoinsData>('coins')
|
||||||
|
const authData = await loadUsersData()
|
||||||
|
|
||||||
|
// Process Wishlist Data
|
||||||
|
const updatedWishlistItems = wishlistData.items.reduce((acc, item) => {
|
||||||
|
if (item.userIds?.includes(userId)) {
|
||||||
|
if (item.userIds.length === 1) {
|
||||||
|
// Remove item if this is the only user
|
||||||
|
return acc
|
||||||
|
} else {
|
||||||
|
// Remove userId from item's userIds
|
||||||
|
acc.push({
|
||||||
|
...item,
|
||||||
|
userIds: item.userIds.filter(id => id !== userId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keep item as is
|
||||||
|
acc.push(item)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [] as WishlistItemType[])
|
||||||
|
wishlistData.items = updatedWishlistItems
|
||||||
|
await saveData('wishlist', wishlistData)
|
||||||
|
|
||||||
|
// Process Habits Data
|
||||||
|
const updatedHabits = habitsData.habits.reduce((acc, habit) => {
|
||||||
|
if (habit.userIds?.includes(userId)) {
|
||||||
|
if (habit.userIds.length === 1) {
|
||||||
|
// Remove habit if this is the only user
|
||||||
|
return acc
|
||||||
|
} else {
|
||||||
|
// Remove userId from habit's userIds
|
||||||
|
acc.push({
|
||||||
|
...habit,
|
||||||
|
userIds: habit.userIds.filter(id => id !== userId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keep habit as is
|
||||||
|
acc.push(habit)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [] as HabitsData['habits'])
|
||||||
|
habitsData.habits = updatedHabits
|
||||||
|
await saveData('habits', habitsData)
|
||||||
|
|
||||||
|
// Process Coins Data
|
||||||
|
coinsData.transactions = coinsData.transactions.filter(
|
||||||
|
transaction => transaction.userId !== userId
|
||||||
|
)
|
||||||
|
// Recalculate balance
|
||||||
|
coinsData.balance = coinsData.transactions.reduce(
|
||||||
|
(sum, transaction) => sum + transaction.amount,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
await saveData('coins', coinsData)
|
||||||
|
|
||||||
|
// Delete User from Auth Data
|
||||||
|
const userIndex = authData.users.findIndex(user => user.id === userId)
|
||||||
|
|
||||||
|
if (userIndex === -1) {
|
||||||
|
throw new Error('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
authData.users = [
|
||||||
|
...authData.users.slice(0, userIndex),
|
||||||
|
...authData.users.slice(userIndex + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
await saveUsersData(authData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
|
||||||
|
const data = await loadUsersData()
|
||||||
|
const userIndex = data.users.findIndex(user => user.id === userId)
|
||||||
|
|
||||||
|
if (userIndex === -1) {
|
||||||
|
throw new Error('User not found for updating notification timestamp')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = {
|
||||||
|
...data.users[userIndex],
|
||||||
|
lastNotificationReadTimestamp: timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData: UserData = {
|
||||||
|
users: [
|
||||||
|
...data.users.slice(0, userIndex),
|
||||||
|
updatedUser,
|
||||||
|
...data.users.slice(userIndex + 1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUsersData(newData)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function loadServerSettings(): Promise<ServerSettings> {
|
||||||
|
return {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
27
app/actions/user.ts
Normal file
27
app/actions/user.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { signIn as signInNextAuth, signOut as signOutNextAuth } from '@/auth';
|
||||||
|
|
||||||
|
export async function signIn(username: string, password: string) {
|
||||||
|
try {
|
||||||
|
const result = await signInNextAuth("credentials", {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
redirect: false, // This needs to be passed as an option, not as form data
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut() {
|
||||||
|
try {
|
||||||
|
const result = await signOutNextAuth({
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Failed to sign out");
|
||||||
|
}
|
||||||
|
}
|
||||||
2
app/api/auth/[...nextauth]/route.ts
Normal file
2
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/auth"
|
||||||
|
export const { GET, POST } = handlers
|
||||||
50
app/api/user/delete/route.ts
Normal file
50
app/api/user/delete/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { deleteUser } from '@/app/actions/data'
|
||||||
|
import { getCurrentUser } from '@/lib/server-helpers'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = session.user.id
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
// This case should ideally not happen if session.user.id exists,
|
||||||
|
// but as a safeguard:
|
||||||
|
return NextResponse.json({ error: 'Unauthorized: User not found in system' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let userIdToDelete: string
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
userIdToDelete = body.userId
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body: Could not parse JSON.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!userIdToDelete) {
|
||||||
|
return NextResponse.json({ error: 'Bad Request: userId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security Check: Users can only delete their own account unless they are an admin.
|
||||||
|
if (!currentUser.isAdmin && userIdToDelete !== currentUserId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden: You do not have permission to delete this user.' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUser(userIdToDelete)
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'User deleted successfully' }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error)
|
||||||
|
if (error instanceof Error && error.message === 'User not found') {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import HabitCalendar from '@/components/HabitCalendar'
|
import HabitCalendar from '@/components/HabitCalendar'
|
||||||
import { ViewToggle } from '@/components/ViewToggle'
|
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col">
|
||||||
<div className="flex justify-end">
|
|
||||||
{/* <ViewToggle /> */}
|
|
||||||
</div>
|
|
||||||
<HabitCalendar />
|
<HabitCalendar />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import CoinsManager from '@/components/CoinsManager'
|
import CoinsManager from '@/components/CoinsManager'
|
||||||
|
|
||||||
export default function CoinsPage() {
|
export default function CoinsPage() {
|
||||||
|
|||||||
60
app/debug/backup/page.tsx
Normal file
60
app/debug/backup/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { triggerManualBackup } from '@/app/actions/data'; // Import the server action
|
||||||
|
import { Loader2 } from 'lucide-react'; // For loading indicator
|
||||||
|
|
||||||
|
export default function DebugBackupPage() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const handleBackupClick = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setStatusMessage('Starting backup...');
|
||||||
|
setIsError(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await triggerManualBackup();
|
||||||
|
setStatusMessage(result.message);
|
||||||
|
setIsError(!result.success);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error calling triggerManualBackup action:", error);
|
||||||
|
setStatusMessage(`Client-side error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
setIsError(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="text-xl font-bold mb-4">Debug Backup</h1>
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-800 p-4 rounded space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Click the button below to manually trigger the data backup process.
|
||||||
|
Check the server console logs for detailed output. Backups are stored in the `/backups` directory.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleBackupClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Running Backup...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Run Manual Backup Now'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{statusMessage && (
|
||||||
|
<div className={`mt-4 p-3 rounded ${isError ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200'}`}>
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
app/debug/habits/page.tsx
Normal file
69
app/debug/habits/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { habitsAtom, settingsAtom } from "@/lib/atoms";
|
||||||
|
import { Habit } from "@/lib/types";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type CompletionCache = {
|
||||||
|
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
|
||||||
|
[habitId: string]: number // number of completions on that date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function DebugPage() {
|
||||||
|
const [habits] = useAtom(habitsAtom);
|
||||||
|
const [settings] = useAtom(settingsAtom);
|
||||||
|
|
||||||
|
function buildCompletionCache(habits: Habit[], timezone: string): CompletionCache {
|
||||||
|
const cache: CompletionCache = {};
|
||||||
|
|
||||||
|
habits.forEach(habit => {
|
||||||
|
habit.completions.forEach(utcTimestamp => {
|
||||||
|
// Convert UTC timestamp to local date string in specified timezone
|
||||||
|
const localDate = DateTime
|
||||||
|
.fromISO(utcTimestamp)
|
||||||
|
.setZone(timezone)
|
||||||
|
.toFormat('yyyy-MM-dd');
|
||||||
|
|
||||||
|
if (!cache[localDate]) {
|
||||||
|
cache[localDate] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment completion count for this habit on this date
|
||||||
|
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletedHabitsForDate(
|
||||||
|
habits: Habit[],
|
||||||
|
date: DateTime,
|
||||||
|
timezone: string,
|
||||||
|
completionCache: CompletionCache
|
||||||
|
): Habit[] {
|
||||||
|
const dateKey = date.setZone(timezone).toFormat('yyyy-MM-dd');
|
||||||
|
const dateCompletions = completionCache[dateKey] || {};
|
||||||
|
|
||||||
|
return habits.filter(habit => {
|
||||||
|
const completionsNeeded = habit.targetCompletions || 1;
|
||||||
|
const completionsAchieved = dateCompletions[habit.id] || 0;
|
||||||
|
return completionsAchieved >= completionsNeeded;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const habitCache = buildCompletionCache(habits.habits, settings.system.timezone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
|
||||||
|
<div className="bg-gray-100 p-4 rounded break-all">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/debug/layout.tsx
Normal file
10
app/debug/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function Debug({children}: {children: ReactNode}) {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return <></>
|
||||||
|
return (
|
||||||
|
<div className="debug">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
app/debug/user/page.tsx
Normal file
16
app/debug/user/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { saltAndHashPassword } from "@/lib/server-helpers";
|
||||||
|
|
||||||
|
export default function DebugPage() {
|
||||||
|
const password = 'admin';
|
||||||
|
const hashedPassword = saltAndHashPassword(password);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
|
||||||
|
<div className="bg-gray-100 p-4 rounded break-all">
|
||||||
|
<p><strong>Password:</strong> {password}</p>
|
||||||
|
<p><strong>Hashed Password:</strong> {hashedPassword}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import HabitList from '@/components/HabitList'
|
import HabitList from '@/components/HabitList'
|
||||||
import { ViewToggle } from '@/components/ViewToggle'
|
|
||||||
|
|
||||||
export default function HabitsPage() {
|
export default function HabitsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col">
|
||||||
<div className="flex justify-end">
|
<HabitList isTasksView={false} />
|
||||||
{/* <ViewToggle /> */}
|
|
||||||
</div>
|
|
||||||
<HabitList />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
import './globals.css'
|
|
||||||
import { Inter } from 'next/font/google'
|
|
||||||
import { DM_Sans } from 'next/font/google'
|
|
||||||
import { JotaiProvider } from '@/components/jotai-providers'
|
|
||||||
import { Suspense } from 'react'
|
|
||||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
|
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 { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
|
import { SessionProvider } from 'next-auth/react'
|
||||||
// Inter (clean, modern, excellent readability)
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
// const inter = Inter({
|
import { getLocale, getMessages } from 'next-intl/server'
|
||||||
// subsets: ['latin'],
|
import { DM_Sans } from 'next/font/google'
|
||||||
// weight: ['400', '500', '600', '700']
|
import { Suspense } from 'react'
|
||||||
// })
|
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
// Clean and contemporary
|
// Clean and contemporary
|
||||||
const dmSans = DM_Sans({
|
const activeFont = 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',
|
||||||
@@ -34,46 +30,50 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [initialSettings, initialHabits, initialCoins, initialWishlist] = await Promise.all([
|
const locale = await getLocale();
|
||||||
|
// Providing all messages to the client
|
||||||
|
// side is the easiest way to get started
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
|
||||||
loadSettings(),
|
loadSettings(),
|
||||||
loadHabitsData(),
|
loadHabitsData(),
|
||||||
loadCoinsData(),
|
loadCoinsData(),
|
||||||
loadWishlistData()
|
loadWishlistData(),
|
||||||
|
loadUsersData(),
|
||||||
|
loadServerSettings(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
|
||||||
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<body className={activeFont.className}>
|
<body className={activeFont.className}>
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/sw.js')
|
|
||||||
.then(registration => {
|
|
||||||
console.log('ServiceWorker registration successful');
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.log('ServiceWorker registration failed: ', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<Suspense fallback="loading">
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<JotaiHydrate
|
<JotaiHydrate
|
||||||
initialValues={{
|
initialValues={{
|
||||||
settings: initialSettings,
|
settings: initialSettings,
|
||||||
habits: initialHabits,
|
habits: initialHabits,
|
||||||
coins: initialCoins,
|
coins: initialCoins,
|
||||||
wishlist: initialWishlist
|
wishlist: initialWishlist,
|
||||||
|
users: initialUsers,
|
||||||
|
serverSettings: initialServerSettings,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Layout>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
{children}
|
<ThemeProvider
|
||||||
</Layout>
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<SessionProvider>
|
||||||
|
<Layout>
|
||||||
|
{children}
|
||||||
|
</Layout>
|
||||||
|
</SessionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
</JotaiHydrate>
|
</JotaiHydrate>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
|
|||||||
@@ -1,42 +1,62 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label';
|
||||||
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR'
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useAtom } from 'jotai'
|
import {
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
Tooltip,
|
||||||
import { Settings, WeekDay } from '@/lib/types'
|
TooltipContent,
|
||||||
import { saveSettings, uploadAvatar } from '../actions/data'
|
TooltipProvider,
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
TooltipTrigger,
|
||||||
import { Button } from '@/components/ui/button'
|
} from "@/components/ui/tooltip";
|
||||||
import { User } from 'lucide-react'
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
|
||||||
|
import { Settings, WeekDay } from '@/lib/types';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
|
||||||
|
import { Info } from 'lucide-react'; // Import Info icon
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { saveSettings } from '../actions/data';
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react'; // signOut removed
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
// AlertDialog components and useState removed
|
||||||
|
// Trash2 icon removed
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useAtom(settingsAtom)
|
const t = useTranslations('SettingsPage');
|
||||||
|
// tWarning removed
|
||||||
|
const [settings, setSettings] = useAtom(settingsAtom);
|
||||||
|
const [serverSettings] = useAtom(serverSettingsAtom);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
// showConfirmDialog and isDeleting states removed
|
||||||
|
|
||||||
const updateSettings = async (newSettings: Settings) => {
|
const updateSettings = async (newSettings: Settings) => {
|
||||||
await saveSettings(newSettings)
|
await saveSettings(newSettings)
|
||||||
setSettings(newSettings)
|
setSettings(newSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteAccount function removed
|
||||||
|
|
||||||
if (!settings) return null
|
if (!settings) return <></>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-6">Settings</h1>
|
<h1 className="text-3xl font-bold mb-6">{t('title')}</h1>
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>UI Settings</CardTitle>
|
<CardTitle>{t('uiSettingsTitle')}</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">Number Formatting</Label>
|
<Label htmlFor="number-formatting">{t('numberFormattingLabel')}</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Format large numbers (e.g., 1K, 1M, 1B)
|
{t('numberFormattingDescription')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -53,9 +73,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">Number Grouping</Label>
|
<Label htmlFor="number-grouping">{t('numberGroupingLabel')}</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Use thousand separators (e.g., 1,000 vs 1000)
|
{t('numberGroupingDescription')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -74,14 +94,14 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>System Settings</CardTitle>
|
<CardTitle>{t('systemSettingsTitle')}</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">Timezone</Label>
|
<Label htmlFor="timezone">{t('timezoneLabel')}</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Select your timezone for accurate date tracking
|
{t('timezoneDescription')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
@@ -94,7 +114,7 @@ export default function SettingsPage() {
|
|||||||
system: { ...settings.system, timezone: e.target.value }
|
system: { ...settings.system, timezone: e.target.value }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
|
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
|
||||||
>
|
>
|
||||||
{Intl.supportedValuesOf('timeZone').map((tz) => (
|
{Intl.supportedValuesOf('timeZone').map((tz) => (
|
||||||
<option key={tz} value={tz}>
|
<option key={tz} value={tz}>
|
||||||
@@ -107,9 +127,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="timezone">Week Start Day</Label>
|
<Label htmlFor="weekStartDay">{t('weekStartDayLabel')}</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Select your preferred first day of the week
|
{t('weekStartDayDescription')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
@@ -122,7 +142,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-[200px] rounded-md border border-input bg-background px-3 py-2"
|
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2"
|
||||||
>
|
>
|
||||||
{([
|
{([
|
||||||
['sunday', 0],
|
['sunday', 0],
|
||||||
@@ -132,70 +152,98 @@ export default function SettingsPage() {
|
|||||||
['thursday', 4],
|
['thursday', 4],
|
||||||
['friday', 5],
|
['friday', 5],
|
||||||
['saturday', 6]
|
['saturday', 6]
|
||||||
] as Array<[string, WeekDay]>).map(([dayName, dayNumber]) => (
|
] as Array<["sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday", WeekDay]>).map(([dayName, dayNumber]) => (
|
||||||
<option key={dayNumber} value={dayNumber}>
|
<option key={dayNumber} value={dayNumber}>
|
||||||
{dayName.charAt(0).toUpperCase() + dayName.slice(1)}
|
{t(`weekdays.${dayName}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* Add this section for Auto Backup */}
|
||||||
<Card className="mb-6">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Profile Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<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="avatar">Avatar</Label>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="auto-backup">{t('autoBackupLabel')}</Label>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="start">
|
||||||
|
<p className="max-w-xs text-sm">
|
||||||
|
{t('autoBackupTooltip')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Customize your profile picture
|
{t('autoBackupDescription')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<Switch
|
||||||
<Avatar className="h-16 w-16">
|
id="auto-backup"
|
||||||
<AvatarImage src={settings.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
|
checked={settings.system.autoBackupEnabled}
|
||||||
<AvatarFallback>
|
onCheckedChange={(checked) =>
|
||||||
<User className="h-8 w-8" />
|
updateSettings({
|
||||||
</AvatarFallback>
|
...settings,
|
||||||
</Avatar>
|
system: { ...settings.system, autoBackupEnabled: checked }
|
||||||
<form action={async (formData: FormData) => {
|
})
|
||||||
const newSettings = await uploadAvatar(formData)
|
}
|
||||||
setSettings(newSettings)
|
/>
|
||||||
}}>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="avatar"
|
|
||||||
name="avatar"
|
|
||||||
accept="image/png, image/jpeg"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
if (file.size > 5 * 1024 * 1024) { // 5MB
|
|
||||||
alert('File size must be less than 5MB')
|
|
||||||
e.target.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const form = e.target.form
|
|
||||||
if (form) form.requestSubmit()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => document.getElementById('avatar')?.click()}
|
|
||||||
>
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* End of Auto Backup section */}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="language-select">{t('languageLabel')}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t('languageDescription')}
|
||||||
|
</div>
|
||||||
|
{serverSettings.isDemo && (
|
||||||
|
<div className="text-sm text-red-500">
|
||||||
|
{t('languageDisabledInDemoTooltip')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="language-select"
|
||||||
|
value={settings.system.language}
|
||||||
|
disabled={serverSettings.isDemo}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
system: { ...settings.system, language: e.target.value }
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: t('languageChangedTitle'),
|
||||||
|
description: t('languageChangedDescription'),
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 ${serverSettings.isDemo ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Add more languages as needed */}
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
<option value="ca">Català</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="zh">简体中文</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Danger Zone Card Removed */}
|
||||||
</div >
|
</div >
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
10
app/tasks/page.tsx
Normal file
10
app/tasks/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import HabitList from '@/components/HabitList'
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<HabitList isTasksView={true} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import WishlistManager from '@/components/WishlistManager'
|
import WishlistManager from '@/components/WishlistManager'
|
||||||
|
|
||||||
export default function WishlistPage() {
|
export default function WishlistPage() {
|
||||||
|
|||||||
44
auth.ts
Normal file
44
auth.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import NextAuth from "next-auth"
|
||||||
|
import Credentials from "next-auth/providers/credentials"
|
||||||
|
import { getUser } from "./app/actions/data"
|
||||||
|
import { signInSchema } from "./lib/zod"
|
||||||
|
import { SafeUser, SessionUser } from "./lib/types"
|
||||||
|
|
||||||
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
trustHost: true,
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
username: {},
|
||||||
|
password: {},
|
||||||
|
},
|
||||||
|
authorize: async (credentials) => {
|
||||||
|
const { username, password } = await signInSchema.parseAsync(credentials)
|
||||||
|
|
||||||
|
// Pass the plaintext password to getUser for verification
|
||||||
|
const user = await getUser(username, password)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Invalid credentials.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeUser: SessionUser = { id: user.id }
|
||||||
|
return safeUser
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
jwt: async ({ token, user }) => {
|
||||||
|
if (user) {
|
||||||
|
token.id = (user as SessionUser).id
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
session: async ({ session, token }) => {
|
||||||
|
if (session?.user) {
|
||||||
|
session.user.id = token.id as string
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -5,24 +5,25 @@ 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({ isOpen, onClose }: AboutModalProps) {
|
export default function AboutModal({ 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={isOpen} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle aria-label="about"></DialogTitle>
|
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-6 text-center py-4">
|
<div className="space-y-6 text-center py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -40,14 +41,14 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
|||||||
onClick={() => setChangelogOpen(true)}
|
onClick={() => setChangelogOpen(true)}
|
||||||
>
|
>
|
||||||
<History className="w-3 h-3 mr-1" />
|
<History className="w-3 h-3 mr-1" />
|
||||||
Changelog
|
{t('changelogButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
Created with ❤️ by{' '}
|
{t('createdByPrefix')}{' '}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/dohsimpson"
|
href="https://github.com/dohsimpson"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -56,17 +57,19 @@ export default function AboutModal({ isOpen, 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/dohsimpson/habittrove"
|
href="https://github.com/ManInDark/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" />
|
||||||
Star on GitHub
|
{t('starOnGitHubButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,81 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { RRule, RRuleSet, rrulestr } from 'rrule'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
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 { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Info, SmilePlus } from 'lucide-react'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import data from '@emoji-mart/data'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import Picker from '@emoji-mart/react'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
||||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
import { useAtom } from 'jotai'
|
||||||
import * as chrono from 'chrono-node';
|
import { Brush, Zap } from 'lucide-react'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { RRule } from 'rrule'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
import DrawingModal from './DrawingModal'
|
||||||
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
import ModalOverlay from './ModalOverlay'; // Import the new component
|
||||||
|
|
||||||
|
|
||||||
interface AddEditHabitModalProps {
|
interface AddEditHabitModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
|
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
|
||||||
habit?: Habit | null
|
habit?: Habit | null
|
||||||
|
isTask: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) {
|
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
|
||||||
|
const t = useTranslations('AddEditHabitModal');
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
|
||||||
const [name, setName] = useState(habit?.name || '')
|
const [name, setName] = useState(habit?.name || '')
|
||||||
const [description, setDescription] = useState(habit?.description || '')
|
const [description, setDescription] = useState(habit?.description || '')
|
||||||
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||||
const isRecurRule = !isTasksView
|
const isRecurRule = !isTask
|
||||||
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE
|
// Initialize ruleText with the actual frequency string or default, not the display text
|
||||||
const [ruleText, setRuleText] = useState<string>(origRuleText)
|
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||||
const now = getNow({ timezone: settings.system.timezone })
|
frequency: habit.frequency,
|
||||||
|
isRecurRule,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||||
|
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
|
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
const users = usersData.users
|
||||||
|
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
|
||||||
|
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
||||||
|
|
||||||
|
function getFrequencyUpdate() {
|
||||||
|
if (ruleText === initialRuleText && habit?.frequency) {
|
||||||
|
// If text hasn't changed and original frequency exists, return it
|
||||||
|
return habit.frequency;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedResult = convertHumanReadableFrequencyToMachineReadable({
|
||||||
|
text: ruleText,
|
||||||
|
timezone: settings.system.timezone,
|
||||||
|
isRecurring: isRecurRule
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsedResult.result) {
|
||||||
|
return isRecurRule
|
||||||
|
? serializeRRule(parsedResult.result as RRule)
|
||||||
|
: d2t({
|
||||||
|
dateTime: parsedResult.result as DateTime,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -46,182 +85,287 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
|||||||
coinReward,
|
coinReward,
|
||||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||||
completions: habit?.completions || [],
|
completions: habit?.completions || [],
|
||||||
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }),
|
frequency: getFrequencyUpdate(),
|
||||||
isTask: isTasksView ? true : undefined
|
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}>
|
<>
|
||||||
<DialogContent>
|
<ModalOverlay />
|
||||||
<DialogHeader>
|
<Dialog open={true} onOpenChange={(open) => {
|
||||||
<DialogTitle>{habit ? `Edit ${isTasksView ? 'Task' : 'Habit'}` : `Add New ${isTasksView ? 'Task' : 'Habit'}`}</DialogTitle>
|
if (!open && !isDrawingModalOpen) {
|
||||||
</DialogHeader>
|
onClose()
|
||||||
<form onSubmit={handleSubmit}>
|
}
|
||||||
<div className="grid gap-4 py-4">
|
}} modal={false}>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
<Label htmlFor="name" className="text-right">
|
<DialogHeader>
|
||||||
Name
|
<DialogTitle>
|
||||||
</Label>
|
{habit
|
||||||
<div className='flex col-span-3 gap-2'>
|
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
|
||||||
<Input
|
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
|
||||||
id="name"
|
</DialogTitle>
|
||||||
value={name}
|
</DialogHeader>
|
||||||
onChange={(e) => setName(e.target.value)}
|
<form onSubmit={handleSubmit}>
|
||||||
required
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
{t('nameLabel')}
|
||||||
|
</Label>
|
||||||
|
<div className='flex col-span-3 gap-2'>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
/>
|
/>
|
||||||
<Popover>
|
</div>
|
||||||
<PopoverTrigger asChild>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="recurrence" className="text-right">
|
||||||
|
{t('whenLabel')}
|
||||||
|
</Label>
|
||||||
|
{/* date input (task) */}
|
||||||
|
<div className="col-span-3 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="recurrence"
|
||||||
|
value={ruleText}
|
||||||
|
onChange={(e) => setRuleText(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{isTask && (
|
||||||
|
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{QUICK_DATES.map((date) => (
|
||||||
|
<Button
|
||||||
|
key={date.value}
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setRuleText(date.value);
|
||||||
|
setIsQuickDatesOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{date.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* rrule input (habit) */}
|
||||||
|
<div className="col-start-2 col-span-3 text-sm">
|
||||||
|
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||||
|
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<Label htmlFor="targetCompletions">
|
||||||
|
{t('completeLabel')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<div className="flex 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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
onClick={(e) => {
|
||||||
className="h-8 w-8"
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDrawingModalOpen(true)
|
||||||
|
}}
|
||||||
|
className="flex-1 justify-start"
|
||||||
>
|
>
|
||||||
<SmilePlus className="h-8 w-8" />
|
<Brush className="h-4 w-4 mr-2" />
|
||||||
|
{drawing ? t('editDrawing') : t('addDrawing')}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
{drawing && (
|
||||||
<PopoverContent className="w-[300px] p-0">
|
<div className="flex-shrink-0">
|
||||||
<Picker
|
<DrawingDisplay
|
||||||
data={data}
|
drawingData={drawing}
|
||||||
onEmojiSelect={(emoji: { native: string }) => {
|
width={80}
|
||||||
setName(prev => {
|
height={53}
|
||||||
// Add space before emoji if there isn't one already
|
className=""
|
||||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
/>
|
||||||
return `${prev}${space}${emoji.native}`;
|
</div>
|
||||||
})
|
)}
|
||||||
// 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>
|
|
||||||
<div className="col-span-3 space-y-2">
|
|
||||||
<Input
|
|
||||||
id="recurrence"
|
|
||||||
value={ruleText}
|
|
||||||
onChange={(e) => setRuleText(e.target.value)}
|
|
||||||
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{(() => {
|
|
||||||
try {
|
|
||||||
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<Label htmlFor="targetCompletions">
|
|
||||||
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>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
times
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{users && users.length > 1 && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Label htmlFor="coinReward">
|
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
|
||||||
Reward
|
</div>
|
||||||
</Label>
|
<div className="col-span-3">
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="col-span-3">
|
{users.filter((u) => u.id !== currentUser?.id).map(user => (
|
||||||
<div className="flex items-center gap-4">
|
<Avatar
|
||||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
key={user.id}
|
||||||
<button
|
className={`h-8 w-8 border-2 cursor-pointer
|
||||||
type="button"
|
${selectedUserIds.includes(user.id)
|
||||||
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
|
? 'border-primary'
|
||||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
: 'border-muted'
|
||||||
>
|
}`}
|
||||||
-
|
title={user.username}
|
||||||
</button>
|
onClick={() => {
|
||||||
<Input
|
setSelectedUserIds(prev =>
|
||||||
id="coinReward"
|
prev.includes(user.id)
|
||||||
type="number"
|
? prev.filter(id => id !== user.id)
|
||||||
value={coinReward}
|
: [...prev, user.id]
|
||||||
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"
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
/>
|
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||||
<button
|
</Avatar>
|
||||||
type="button"
|
))}
|
||||||
onClick={() => setCoinReward(prev => prev + 1)}
|
</div>
|
||||||
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>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
coins
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DialogFooter>
|
||||||
<DialogFooter>
|
<Button type="submit" disabled={!!errorMessage}>
|
||||||
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTasksView ? 'Task' : 'Habit'}`}</Button>
|
{habit
|
||||||
</DialogFooter>
|
? t('saveChangesButton')
|
||||||
</form>
|
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
||||||
</DialogContent>
|
</Button>
|
||||||
</Dialog>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<DrawingModal
|
||||||
|
isOpen={isDrawingModalOpen}
|
||||||
|
onClose={() => setIsDrawingModalOpen(false)}
|
||||||
|
onSave={(drawingData) => setDrawing(drawingData)}
|
||||||
|
initialDrawing={drawing}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,220 +1,363 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
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 { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
import { SmilePlus, Info } from 'lucide-react'
|
|
||||||
import data from '@emoji-mart/data'
|
|
||||||
import Picker from '@emoji-mart/react'
|
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Brush } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
import DrawingModal from './DrawingModal'
|
||||||
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
import ModalOverlay from './ModalOverlay'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
|
||||||
interface AddEditWishlistItemModalProps {
|
interface AddEditWishlistItemModalProps {
|
||||||
isOpen: boolean
|
setIsOpen: (isOpen: boolean) => void
|
||||||
onClose: () => void
|
editingItem: WishlistItemType | null
|
||||||
onSave: (item: Omit<WishlistItemType, 'id'>) => void
|
setEditingItem: (item: WishlistItemType | null) => void
|
||||||
item?: WishlistItemType | null
|
addWishlistItem: (item: Omit<WishlistItemType, 'id'>) => void
|
||||||
|
editWishlistItem: (item: WishlistItemType) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
|
export default function AddEditWishlistItemModal({
|
||||||
const [name, setName] = useState(item?.name || '')
|
setIsOpen,
|
||||||
const [description, setDescription] = useState(item?.description || '')
|
editingItem,
|
||||||
const [coinCost, setCoinCost] = useState(item?.coinCost || 1)
|
setEditingItem,
|
||||||
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(item?.targetCompletions)
|
addWishlistItem,
|
||||||
|
editWishlistItem
|
||||||
|
}: AddEditWishlistItemModalProps) {
|
||||||
|
const t = useTranslations('AddEditWishlistItemModal')
|
||||||
|
const [name, setName] = useState(editingItem?.name || '')
|
||||||
|
const [description, setDescription] = useState(editingItem?.description || '')
|
||||||
|
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
|
||||||
|
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
|
||||||
|
const [link, setLink] = useState(editingItem?.link || '')
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
|
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 [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
|
||||||
|
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (editingItem) {
|
||||||
setName(item.name)
|
setName(editingItem.name)
|
||||||
setDescription(item.description)
|
setDescription(editingItem.description)
|
||||||
setCoinCost(item.coinCost)
|
setCoinCost(editingItem.coinCost)
|
||||||
setTargetCompletions(item.targetCompletions)
|
setTargetCompletions(editingItem.targetCompletions)
|
||||||
|
setLink(editingItem.link || '')
|
||||||
|
setDrawing(editingItem.drawing || '')
|
||||||
} else {
|
} else {
|
||||||
setName('')
|
setName('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setCoinCost(1)
|
setCoinCost(1)
|
||||||
setTargetCompletions(undefined)
|
setTargetCompletions(undefined)
|
||||||
|
setLink('')
|
||||||
|
setDrawing('')
|
||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
}, [item])
|
}, [editingItem])
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const newErrors: { [key: string]: string } = {}
|
const newErrors: { [key: string]: string } = {}
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
newErrors.name = 'Name is required'
|
newErrors.name = t('errorNameRequired')
|
||||||
}
|
}
|
||||||
if (coinCost < 1) {
|
if (coinCost < 1) {
|
||||||
newErrors.coinCost = 'Coin cost must be at least 1'
|
newErrors.coinCost = t('errorCoinCostMin')
|
||||||
|
} else if (coinCost > MAX_COIN_LIMIT) {
|
||||||
|
newErrors.coinCost = t('errorCoinCostMax', { max: MAX_COIN_LIMIT })
|
||||||
}
|
}
|
||||||
if (targetCompletions !== undefined && targetCompletions < 1) {
|
if (targetCompletions !== undefined && targetCompletions < 1) {
|
||||||
newErrors.targetCompletions = 'Target completions must be at least 1'
|
newErrors.targetCompletions = t('errorTargetCompletionsMin')
|
||||||
|
}
|
||||||
|
if (link && !isValidUrl(link)) {
|
||||||
|
newErrors.link = t('errorInvalidUrl')
|
||||||
}
|
}
|
||||||
setErrors(newErrors)
|
setErrors(newErrors)
|
||||||
return Object.keys(newErrors).length === 0
|
return Object.keys(newErrors).length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const isValidUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false)
|
||||||
|
setEditingItem(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
onSave({
|
|
||||||
|
const itemData = {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
coinCost,
|
coinCost,
|
||||||
targetCompletions: targetCompletions || undefined
|
targetCompletions: targetCompletions || undefined,
|
||||||
})
|
link: link.trim() || undefined,
|
||||||
|
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
|
||||||
|
drawing: drawing && drawing !== '[]' ? drawing : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingItem) {
|
||||||
|
editWishlistItem({ ...itemData, id: editingItem.id })
|
||||||
|
} else {
|
||||||
|
addWishlistItem(itemData)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(false)
|
||||||
|
setEditingItem(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<>
|
||||||
<DialogContent>
|
<ModalOverlay />
|
||||||
<DialogHeader>
|
<Dialog open={true} onOpenChange={(open) => {
|
||||||
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
|
if (!open && !isDrawingModalOpen) {
|
||||||
</DialogHeader>
|
handleClose()
|
||||||
<form onSubmit={handleSubmit}>
|
}
|
||||||
<div className="grid gap-4 py-4">
|
}} modal={false}>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
<Label htmlFor="name" className="text-right">
|
<DialogHeader>
|
||||||
Name
|
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||||
</Label>
|
</DialogHeader>
|
||||||
<div className="col-span-3 flex gap-2">
|
<form onSubmit={handleSave}>
|
||||||
<Input
|
<div className="grid gap-4 py-4">
|
||||||
id="name"
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
value={name}
|
<Label htmlFor="name" className="text-right">
|
||||||
onChange={(e) => setName(e.target.value)}
|
{t('nameLabel')}
|
||||||
className="flex-1"
|
</Label>
|
||||||
required
|
<div className="col-span-3 flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<EmojiPickerButton
|
||||||
|
inputIdToFocus="name"
|
||||||
|
onEmojiSelect={(emoji) => {
|
||||||
|
setName(prev => {
|
||||||
|
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||||
|
return `${prev}${space}${emoji}`;
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div 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"
|
||||||
/>
|
/>
|
||||||
<Popover>
|
</div>
|
||||||
<PopoverTrigger asChild>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<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="ghost"
|
variant="outline"
|
||||||
size="icon"
|
onClick={(e) => {
|
||||||
className="h-8 w-8"
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDrawingModalOpen(true)
|
||||||
|
}}
|
||||||
|
className="flex-1 justify-start"
|
||||||
>
|
>
|
||||||
<SmilePlus className="h-4 w-4" />
|
<Brush className="h-4 w-4 mr-2" />
|
||||||
|
{drawing ? t('editDrawing') : t('addDrawing')}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
{drawing && (
|
||||||
<PopoverContent className="w-[300px] p-0">
|
<div className="flex-shrink-0">
|
||||||
<Picker
|
<DrawingDisplay
|
||||||
data={data}
|
drawingData={drawing}
|
||||||
onEmojiSelect={(emoji: { native: string }) => {
|
width={80}
|
||||||
setName(prev => `${prev}${emoji.native}`)
|
height={53}
|
||||||
// Focus back on input after selection
|
className=""
|
||||||
const input = document.getElementById('name') as HTMLInputElement
|
/>
|
||||||
input?.focus()
|
</div>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
</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>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
coins
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{usersData.users && usersData.users.length > 1 && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Label htmlFor="targetCompletions">
|
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
|
||||||
Redeemable
|
</div>
|
||||||
</Label>
|
<div className="col-span-3">
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="col-span-3">
|
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
|
||||||
<div className="flex items-center gap-4">
|
<Avatar
|
||||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
key={user.id}
|
||||||
<button
|
className={`h-8 w-8 border-2 cursor-pointer
|
||||||
type="button"
|
${selectedUserIds.includes(user.id)
|
||||||
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
|
? 'border-primary'
|
||||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
: 'border-muted'
|
||||||
>
|
}`}
|
||||||
-
|
title={user.username}
|
||||||
</button>
|
onClick={() => {
|
||||||
<Input
|
setSelectedUserIds(prev =>
|
||||||
id="targetCompletions"
|
prev.includes(user.id)
|
||||||
type="number"
|
? prev.filter(id => id !== user.id)
|
||||||
value={targetCompletions || ''}
|
: [...prev, user.id]
|
||||||
onChange={(e) => {
|
)
|
||||||
const value = e.target.value
|
}}
|
||||||
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
|
>
|
||||||
}}
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
min={0}
|
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||||
placeholder="∞"
|
</Avatar>
|
||||||
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
))}
|
||||||
/>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
times
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{errors.targetCompletions && (
|
)}
|
||||||
<div className="text-sm text-red-500">
|
|
||||||
{errors.targetCompletions}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DialogFooter>
|
||||||
<DialogFooter>
|
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
|
||||||
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
|
</DialogFooter>
|
||||||
</DialogFooter>
|
</form>
|
||||||
</form>
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
<DrawingModal
|
||||||
|
isOpen={isDrawingModalOpen}
|
||||||
|
onClose={() => setIsDrawingModalOpen(false)}
|
||||||
|
onSave={(drawingData) => setDrawing(drawingData)}
|
||||||
|
initialDrawing={drawing}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,97 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ReactNode } from 'react'
|
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
|
||||||
import { useAtom } from 'jotai'
|
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
|
||||||
import { pomodoroAtom } from '@/lib/atoms'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import PomodoroTimer from './PomodoroTimer'
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
|
import AboutModal from './AboutModal';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
import PomodoroTimer from './PomodoroTimer';
|
||||||
|
import RefreshBanner from './RefreshBanner';
|
||||||
|
import UserSelectModal from './UserSelectModal';
|
||||||
|
|
||||||
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
function ClientWrapperContent({ children }: { children: ReactNode }) {
|
||||||
const [pomo] = useAtom(pomodoroAtom)
|
const [pomo] = useAtom(pomodoroAtom)
|
||||||
|
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||||
|
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||||
|
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const currentUserId = session?.user.id
|
||||||
|
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
|
||||||
|
|
||||||
|
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
|
||||||
|
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
|
||||||
|
const clientToken = useAtomValue(clientFreshnessTokenAtom);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'loading') return
|
||||||
|
if (!currentUserId && !userSelect) {
|
||||||
|
setUserSelect(true)
|
||||||
|
}
|
||||||
|
}, [currentUserId, status, userSelect, 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 && (
|
{pomo.show && <PomodoroTimer />}
|
||||||
<PomodoroTimer />
|
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
|
||||||
)}
|
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
||||||
|
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load),
|
||||||
|
// to prevent SSR hydration errors in the children components
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<ClientWrapperContent>{children}</ClientWrapperContent>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Coins } from 'lucide-react'
|
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Coins } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
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>Coin Balance</CardTitle>
|
<CardTitle>{t('coinBalanceTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
|
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-4xl font-bold">
|
<span className="text-4xl font-bold">{coinBalance}</span>
|
||||||
<FormattedNumber amount={coinBalance} settings={settings} />
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TodayEarnedCoins longFormat={true} />
|
<TodayEarnedCoins longFormat={true} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
|
||||||
import { History, Pencil } from 'lucide-react'
|
|
||||||
import EmptyState from './EmptyState'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { Input } from '@/components/ui/input'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
|
import { TransactionType } from '@/lib/types'
|
||||||
|
import { calculateTransactionsToday, d2s, t2d } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { History } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
|
||||||
|
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
|
||||||
|
import EmptyState from './EmptyState'
|
||||||
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||||
|
|
||||||
export default function CoinsManager() {
|
export default function CoinsManager() {
|
||||||
|
const t = useTranslations('CoinsManager')
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
|
const [selectedUser, setSelectedUser] = useState<string>()
|
||||||
const {
|
const {
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
@@ -24,16 +32,41 @@ export default function CoinsManager() {
|
|||||||
coinsEarnedToday,
|
coinsEarnedToday,
|
||||||
totalEarned,
|
totalEarned,
|
||||||
totalSpent,
|
totalSpent,
|
||||||
coinsSpentToday,
|
coinsSpentToday
|
||||||
transactionsToday
|
} = useCoins({ selectedUser })
|
||||||
} = useCoins()
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const [usersData] = useAtom(usersAtom)
|
||||||
const DEFAULT_AMOUNT = '0'
|
const DEFAULT_AMOUNT = '0'
|
||||||
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
|
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
|
||||||
const [pageSize, setPageSize] = useState(50)
|
const [pageSize, setPageSize] = useState(50)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const highlightId = searchParams.get('highlight')
|
||||||
|
const userIdFromQuery = searchParams.get('user') // Get user ID from query
|
||||||
|
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
const PAGE_ENTRY_COUNTS = [10, 50, 100, 500];
|
||||||
|
|
||||||
|
// Effect to set selected user from query param if admin
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUser?.isAdmin && userIdFromQuery && userIdFromQuery !== selectedUser) {
|
||||||
|
// Check if the user ID from query exists in usersData
|
||||||
|
if (usersData.users.some(u => u.id === userIdFromQuery)) {
|
||||||
|
setSelectedUser(userIdFromQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
|
||||||
|
}, [userIdFromQuery, currentUser, usersData.users, selectedUser]);
|
||||||
|
|
||||||
|
// Effect to scroll to highlighted transaction
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightId && transactionRefs.current[highlightId]) {
|
||||||
|
transactionRefs.current[highlightId]?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [highlightId, transactions]); // Re-run if highlightId or transactions change
|
||||||
|
|
||||||
const handleSaveNote = async (transactionId: string, note: string) => {
|
const handleSaveNote = async (transactionId: string, note: string) => {
|
||||||
await updateNote(transactionId, note)
|
await updateNote(transactionId, note)
|
||||||
@@ -56,9 +89,35 @@ 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 className="container mx-auto px-4 py-8">
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-6">Coins Management</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
|
||||||
|
{currentUser?.isAdmin && (
|
||||||
|
<select
|
||||||
|
className="border rounded p-2"
|
||||||
|
value={selectedUser}
|
||||||
|
onChange={(e) => setSelectedUser(e.target.value)}
|
||||||
|
>
|
||||||
|
{usersData.users.map(user => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -66,8 +125,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">Current Balance</div>
|
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
|
||||||
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> coins</div>
|
<div className="text-3xl font-bold">{balance} {t('coinsSuffix')}</div>
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -79,7 +138,11 @@ 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 => (Number(prev) - 1).toString())}
|
onClick={() => setAmount(prev => {
|
||||||
|
const current = Number(prev);
|
||||||
|
const next = current - 1;
|
||||||
|
return (Math.abs(next) > MAX_COIN_LIMIT ? (next < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT) : next).toString();
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</Button>
|
</Button>
|
||||||
@@ -87,7 +150,22 @@ export default function CoinsManager() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const rawValue = e.target.value;
|
||||||
|
if (rawValue === '' || rawValue === '-') {
|
||||||
|
setAmount(rawValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let numericValue = Number(rawValue); // Changed const to let
|
||||||
|
if (isNaN(numericValue)) return; // Or handle error
|
||||||
|
|
||||||
|
if (Math.abs(numericValue) > MAX_COIN_LIMIT) {
|
||||||
|
numericValue = numericValue < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT;
|
||||||
|
}
|
||||||
|
setAmount(numericValue.toString());
|
||||||
|
}}
|
||||||
|
min={-MAX_COIN_LIMIT}
|
||||||
|
max={MAX_COIN_LIMIT}
|
||||||
className="text-center text-xl font-medium h-12"
|
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">
|
||||||
@@ -98,7 +176,11 @@ 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 => (Number(prev) + 1).toString())}
|
onClick={() => setAmount(prev => {
|
||||||
|
const current = Number(prev);
|
||||||
|
const next = current + 1;
|
||||||
|
return (Math.abs(next) > MAX_COIN_LIMIT ? (next < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT) : next).toString();
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</Button>
|
</Button>
|
||||||
@@ -112,7 +194,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 ? 'Add Coins' : 'Remove Coins'}
|
{Number(amount) >= 0 ? t('addCoinsButton') : t('removeCoinsButton')}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,27 +206,23 @@ export default function CoinsManager() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Statistics</CardTitle>
|
<CardTitle>{t('statisticsTitle')}</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">Total Earned</div>
|
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
|
<div className="text-2xl font-bold text-green-900 dark:text-green-50">{totalEarned} 🪙</div>
|
||||||
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
|
|
||||||
</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">Total Spent</div>
|
<div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
|
<div className="text-2xl font-bold text-red-900 dark:text-red-50">{totalSpent} 💸</div>
|
||||||
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
|
|
||||||
</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">Total Transactions</div>
|
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">{t('totalTransactionsLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
|
<div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
|
||||||
{transactions.length} 📈
|
{transactions.length} 📈
|
||||||
</div>
|
</div>
|
||||||
@@ -152,23 +230,19 @@ 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">Today's Earned</div>
|
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
|
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">{coinsEarnedToday} 🪙</div>
|
||||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
|
|
||||||
</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">Today's Spent</div>
|
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
|
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">{coinsSpentToday} 💸</div>
|
||||||
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
|
|
||||||
</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">Today's Transactions</div>
|
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
|
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
|
||||||
{transactionsToday} 📊
|
{calculateTransactionsToday(transactions, settings.system.timezone)} 📊
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,13 +251,13 @@ export default function CoinsManager() {
|
|||||||
|
|
||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Transaction History</CardTitle>
|
<CardTitle>{t('transactionHistoryTitle')}</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">Show:</span>
|
<span className="text-sm text-muted-foreground">{t('showLabel')}</span>
|
||||||
<select
|
<select
|
||||||
className="border rounded p-1"
|
className="border rounded p-1"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
@@ -192,22 +266,20 @@ export default function CoinsManager() {
|
|||||||
setCurrentPage(1) // Reset to first page when changing page size
|
setCurrentPage(1) // Reset to first page when changing page size
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value={50}>50</option>
|
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={500}>500</option>
|
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted-foreground">entries</span>
|
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing {Math.min((currentPage - 1) * pageSize + 1, transactions.length)} to {Math.min(currentPage * pageSize, transactions.length)} of {transactions.length} entries
|
{t('showingEntries', { from: Math.min((currentPage - 1) * pageSize + 1, transactions.length), to: Math.min(currentPage * pageSize, transactions.length), total: transactions.length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={History}
|
icon={History}
|
||||||
title="No transactions yet"
|
title={t('noTransactionsTitle')}
|
||||||
description="Your transaction history will appear here once you start earning or spending coins"
|
description={t('noTransactionsDescription')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -229,13 +301,17 @@ export default function CoinsManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
className="flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
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
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
|
||||||
{transaction.relatedItemId ? (
|
{transaction.relatedItemId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
|
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
|
||||||
@@ -250,8 +326,20 @@ 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()}`}
|
||||||
>
|
>
|
||||||
{transaction.type.split('_').join(' ')}
|
{getTransactionTypeLabel(transaction.type as TransactionType)}
|
||||||
</span>
|
</span>
|
||||||
|
{transaction.userId && currentUser?.isAdmin && (
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage
|
||||||
|
src={transactionUser?.avatarPath ?
|
||||||
|
`/api/avatars/${transactionUser?.avatarPath?.split('/').pop()}` : undefined}
|
||||||
|
alt={transactionUser?.username}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{transactionUser?.username?.[0] || '?'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}
|
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}
|
||||||
@@ -263,14 +351,16 @@ export default function CoinsManager() {
|
|||||||
onDelete={handleDeleteNote}
|
onDelete={handleDeleteNote}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div className="flex-shrink-0 text-right"> {/* Ensure amount stays on the right */}
|
||||||
className={`font-mono ${transaction.amount >= 0
|
<span
|
||||||
? 'text-green-600 dark:text-green-400'
|
className={`font-mono ${transaction.amount >= 0
|
||||||
: 'text-red-600 dark:text-red-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
}`}
|
: 'text-red-600 dark:text-red-400'
|
||||||
>
|
}`}
|
||||||
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
|
>
|
||||||
</span>
|
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -294,9 +384,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">Page</span>
|
<span className="text-sm font-medium">{t('pageLabel')}</span>
|
||||||
<span className="text-sm font-bold">{currentPage}</span>
|
<span className="text-sm font-bold">{currentPage}</span>
|
||||||
<span className="text-sm font-medium">of</span>
|
<span className="text-sm font-medium">{t('ofLabel')}</span>
|
||||||
<span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
|
<span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
35
components/CompletionCountBadge.tsx
Normal file
35
components/CompletionCountBadge.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
|
||||||
|
import { getTodayInTimezone } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
interface CompletionCountBadgeProps {
|
||||||
|
type: 'habits' | 'tasks'
|
||||||
|
date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompletionCountBadge({
|
||||||
|
type,
|
||||||
|
date
|
||||||
|
}: CompletionCountBadgeProps) {
|
||||||
|
const t = useTranslations('CompletionCountBadge');
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||||
|
const targetDate = date || getTodayInTimezone(settings.system.timezone)
|
||||||
|
const [dueHabits] = useAtom(habitsByDateFamily(targetDate))
|
||||||
|
|
||||||
|
const completedCount = completedHabitsMap.get(targetDate)?.filter(h =>
|
||||||
|
type === 'tasks' ? h.isTask : !h.isTask
|
||||||
|
).length || 0
|
||||||
|
|
||||||
|
const totalCount = dueHabits.filter(h =>
|
||||||
|
type === 'tasks' ? h.isTask : !h.isTask
|
||||||
|
).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{t('countCompleted', { completedCount, totalCount })}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -23,9 +24,13 @@ export default function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
confirmText = "Confirm",
|
confirmText,
|
||||||
cancelText = "Cancel"
|
cancelText,
|
||||||
}: 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>
|
||||||
@@ -37,10 +42,10 @@ export default function ConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{cancelText}
|
{finalCancelText}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={onConfirm}>
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
{confirmText}
|
{finalConfirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer } from 'lucide-react'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuTrigger
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import {
|
||||||
import { Habit } from '@/lib/types'
|
Tooltip,
|
||||||
import Linkify from './linkify'
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { browserSettingsAtom, completedHabitsMapAtom, settingsAtom } from '@/lib/atoms'
|
||||||
|
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
||||||
|
import { Habit, WishlistItemType } from '@/lib/types'
|
||||||
|
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
|
import CompletionCountBadge from './CompletionCountBadge'
|
||||||
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
|
import Linkify from './linkify'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -25,31 +36,353 @@ interface UpcomingItemsProps {
|
|||||||
coinBalance: number
|
coinBalance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ItemSectionProps {
|
||||||
|
title: string;
|
||||||
|
items: Habit[];
|
||||||
|
emptyMessage: string;
|
||||||
|
isTask: boolean;
|
||||||
|
viewLink: string;
|
||||||
|
addNewItem: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemSection = ({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
emptyMessage,
|
||||||
|
isTask,
|
||||||
|
viewLink,
|
||||||
|
addNewItem,
|
||||||
|
}: ItemSectionProps) => {
|
||||||
|
const t = useTranslations('DailyOverview');
|
||||||
|
const { completeHabit, undoComplete, saveHabit, deleteHabit, habitFreqMap } = useHabits();
|
||||||
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
|
||||||
|
const [settings] = useAtom(settingsAtom);
|
||||||
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom);
|
||||||
|
|
||||||
|
const today = getTodayInTimezone(settings.system.timezone);
|
||||||
|
const currentTodayCompletions = completedHabitsMap.get(today) || [];
|
||||||
|
const currentBadgeType = isTask ? 'tasks' : 'habits';
|
||||||
|
|
||||||
|
const currentExpanded = isTask ? browserSettings.expandedTasks : browserSettings.expandedHabits;
|
||||||
|
const setCurrentExpanded = (value: boolean) => {
|
||||||
|
setBrowserSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[isTask ? 'expandedTasks' : 'expandedHabits']: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(false);
|
||||||
|
const [habitToDelete, setHabitToDelete] = useState<Habit | null>(null);
|
||||||
|
const [habitToEdit, setHabitToEdit] = useState<Habit | null>(null);
|
||||||
|
|
||||||
|
const handleDeleteClick = (habit: Habit) => {
|
||||||
|
setHabitToDelete(habit);
|
||||||
|
setIsConfirmDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (habitToDelete) {
|
||||||
|
await deleteHabit(habitToDelete.id);
|
||||||
|
setHabitToDelete(null);
|
||||||
|
setIsConfirmDeleteDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (habit: Habit) => {
|
||||||
|
setHabitToEdit(habit);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold">{title}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
||||||
|
onClick={addNewItem}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-muted-foreground text-sm py-4">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CompletionCountBadge type={currentBadgeType} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
||||||
|
onClick={addNewItem}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${currentExpanded ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||||
|
{items
|
||||||
|
.sort((a, b) => {
|
||||||
|
// First by pinned status
|
||||||
|
if (a.pinned !== b.pinned) {
|
||||||
|
return a.pinned ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then by completion status
|
||||||
|
const aCompleted = currentTodayCompletions.includes(a);
|
||||||
|
const bCompleted = currentTodayCompletions.includes(b);
|
||||||
|
if (aCompleted !== bCompleted) {
|
||||||
|
return aCompleted ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then by frequency (daily first)
|
||||||
|
const aFreq = habitFreqMap.get(a.id) || 'daily';
|
||||||
|
const bFreq = habitFreqMap.get(b.id) || 'daily';
|
||||||
|
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
|
||||||
|
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
|
||||||
|
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then by coin reward (higher first)
|
||||||
|
if (a.coinReward !== b.coinReward) {
|
||||||
|
return b.coinReward - a.coinReward;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally by target completions (higher first)
|
||||||
|
const aTarget = a.targetCompletions || 1;
|
||||||
|
const bTarget = b.targetCompletions || 1;
|
||||||
|
return bTarget - aTarget;
|
||||||
|
})
|
||||||
|
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||||
|
.map((habit) => {
|
||||||
|
const completionsToday = habit.completions.filter(completion =>
|
||||||
|
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||||
|
).length
|
||||||
|
const target = habit.targetCompletions || 1
|
||||||
|
const isCompleted = completionsToday >= target || (isTask && habit.archived)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={`flex items-center justify-between text-sm p-2 rounded-md
|
||||||
|
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
|
||||||
|
key={habit.id}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer flex-1 min-w-0">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isCompleted) {
|
||||||
|
undoComplete(habit);
|
||||||
|
} else {
|
||||||
|
completeHabit(habit);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative hover:opacity-70 transition-opacity w-4 h-4"
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<div className="relative h-4 w-4">
|
||||||
|
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
||||||
|
<div
|
||||||
|
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(
|
||||||
|
currentColor ${(completionsToday / target) * 360}deg,
|
||||||
|
transparent ${(completionsToday / target) * 360}deg 360deg
|
||||||
|
)`,
|
||||||
|
mask: 'radial-gradient(transparent 50%, black 51%)',
|
||||||
|
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{habit.pinned && (
|
||||||
|
<Pin className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/habits?highlight=${habit.id}`}
|
||||||
|
className="flex items-center gap-1 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{/* The AlertTriangle itself doesn't need hover styles if the parent Link handles it */}
|
||||||
|
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t('overdueTooltip')}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
isCompleted ? 'line-through' : '',
|
||||||
|
'break-all' // Text specific styles
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{habit.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{habit.drawing && (
|
||||||
|
<div className="ml-2 pr-2">
|
||||||
|
<DrawingDisplay
|
||||||
|
drawingData={habit.drawing}
|
||||||
|
width={40}
|
||||||
|
height={26}
|
||||||
|
className="border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-64">
|
||||||
|
<HabitContextMenuItems
|
||||||
|
habit={habit}
|
||||||
|
onEditRequest={() => handleEditClick(habit)}
|
||||||
|
onDeleteRequest={() => handleDeleteClick(habit)}
|
||||||
|
context="daily-overview"
|
||||||
|
/>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
|
||||||
|
{habit.targetCompletions && (
|
||||||
|
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
|
||||||
|
{completionsToday}/{target}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{habitFreqMap.get(habit.id) !== 'daily' && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{habitFreqMap.get(habit.id)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Coins className={cn(
|
||||||
|
"h-3 w-3 mr-1 transition-all",
|
||||||
|
isCompleted
|
||||||
|
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
|
||||||
|
: "text-gray-400"
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
"transition-all",
|
||||||
|
isCompleted
|
||||||
|
? "text-yellow-500 font-medium"
|
||||||
|
: "text-gray-400"
|
||||||
|
)}>
|
||||||
|
{habit.coinReward}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentExpanded(!currentExpanded)}
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
|
||||||
|
<>
|
||||||
|
{t('showLessButton')}
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('showAllButton')}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={viewLink}
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{habitToDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={isConfirmDeleteDialogOpen}
|
||||||
|
onClose={() => setIsConfirmDeleteDialogOpen(false)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title={`Delete ${isTask ? 'Task' : 'Habit'}`}
|
||||||
|
message={`Are you sure you want to delete "${habitToDelete.name}"? This action cannot be undone.`}
|
||||||
|
confirmText="Delete"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{habitToEdit && (
|
||||||
|
<AddEditHabitModal
|
||||||
|
onClose={() => setHabitToEdit(null)}
|
||||||
|
onSave={async (updatedHabit) => {
|
||||||
|
await saveHabit({ ...habitToEdit, ...updatedHabit });
|
||||||
|
setHabitToEdit(null);
|
||||||
|
}}
|
||||||
|
habit={habitToEdit}
|
||||||
|
isTask={habitToEdit.isTask || false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function DailyOverview({
|
export default function DailyOverview({
|
||||||
habits,
|
habits,
|
||||||
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 [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
|
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||||
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||||
const today = getTodayInTimezone(settings.system.timezone)
|
const today = getTodayInTimezone(settings.system.timezone)
|
||||||
const todayCompletions = completedHabitsMap.get(today) || []
|
const todayCompletions = completedHabitsMap.get(today) || []
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
const { saveHabit } = useHabits()
|
||||||
|
|
||||||
useEffect(() => {
|
const timezone = settings.system.timezone
|
||||||
// Filter habits that are due today based on their recurrence rule
|
const todayDateObj = getNow({ timezone })
|
||||||
const filteredHabits = habits.filter(habit =>
|
|
||||||
(isTasksView ? habit.isTask : !habit.isTask) &&
|
const dailyTasks = habits.filter(habit =>
|
||||||
isHabitDueToday({ habit, timezone: settings.system.timezone })
|
habit.isTask &&
|
||||||
)
|
!habit.archived &&
|
||||||
setDailyHabits(filteredHabits)
|
(isHabitDue({ habit, timezone, date: todayDateObj }) || isTaskOverdue(habit, timezone))
|
||||||
}, [habits, isTasksView])
|
)
|
||||||
|
const dailyHabits = habits.filter(habit =>
|
||||||
|
!habit.isTask &&
|
||||||
|
!habit.archived &&
|
||||||
|
isHabitDue({ habit, timezone, date: todayDateObj })
|
||||||
|
)
|
||||||
|
|
||||||
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
||||||
|
// Filter out archived wishlist items
|
||||||
const sortedWishlistItems = wishlistItems
|
const sortedWishlistItems = wishlistItems
|
||||||
|
.filter(item => !item.archived)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aRedeemable = a.coinCost <= coinBalance
|
const aRedeemable = a.coinCost <= coinBalance
|
||||||
const bRedeemable = b.coinCost <= coinBalance
|
const bRedeemable = b.coinCost <= coinBalance
|
||||||
@@ -63,201 +396,64 @@ export default function DailyOverview({
|
|||||||
return a.coinCost - b.coinCost
|
return a.coinCost - b.coinCost
|
||||||
})
|
})
|
||||||
|
|
||||||
const [expandedHabits, setExpandedHabits] = useState(false)
|
const [modalConfig, setModalConfig] = useState<{
|
||||||
const [expandedWishlist, setExpandedWishlist] = useState(false)
|
isOpen: boolean,
|
||||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
isTask: boolean
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
isTask: false
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Today's Overview</CardTitle>
|
<CardTitle>{t('todaysOverviewTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div>
|
{/* Tasks Section */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
{habits.some(habit => habit.isTask === true) && (
|
||||||
<h3 className="font-semibold">{isTasksView ? 'Daily Tasks' : 'Daily Habits'}</h3>
|
<ItemSection
|
||||||
<Badge variant="secondary">
|
title={t('dailyTasksTitle')}
|
||||||
{`${dailyHabits.filter(habit => {
|
items={dailyTasks}
|
||||||
const completions = (completedHabitsMap.get(today) || [])
|
emptyMessage={t('noTasksDueTodayMessage')}
|
||||||
.filter(h => h.id === habit.id).length;
|
isTask={true}
|
||||||
return completions >= (habit.targetCompletions || 1);
|
viewLink="/habits?view=tasks"
|
||||||
}).length}/${dailyHabits.length} Completed`}
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
|
||||||
</Badge>
|
/>
|
||||||
</div>
|
)}
|
||||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
|
||||||
{dailyHabits
|
|
||||||
.sort((a, b) => {
|
|
||||||
// First by completion status
|
|
||||||
const aCompleted = todayCompletions.includes(a);
|
|
||||||
const bCompleted = todayCompletions.includes(b);
|
|
||||||
if (aCompleted !== bCompleted) {
|
|
||||||
return aCompleted ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then by frequency (daily first)
|
{/* Habits Section */}
|
||||||
const aFreq = getHabitFreq(a);
|
<ItemSection
|
||||||
const bFreq = getHabitFreq(b);
|
title={t('dailyHabitsTitle')}
|
||||||
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
|
items={dailyHabits}
|
||||||
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
|
emptyMessage={t('noHabitsDueTodayMessage')}
|
||||||
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
|
isTask={false}
|
||||||
}
|
viewLink="/habits"
|
||||||
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
|
||||||
// Then by coin reward (higher first)
|
/>
|
||||||
if (a.coinReward !== b.coinReward) {
|
|
||||||
return b.coinReward - a.coinReward;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally by target completions (higher first)
|
|
||||||
const aTarget = a.targetCompletions || 1;
|
|
||||||
const bTarget = b.targetCompletions || 1;
|
|
||||||
return bTarget - aTarget;
|
|
||||||
})
|
|
||||||
.slice(0, expandedHabits ? undefined : 5)
|
|
||||||
.map((habit) => {
|
|
||||||
const completionsToday = habit.completions.filter(completion =>
|
|
||||||
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
|
||||||
).length
|
|
||||||
const target = habit.targetCompletions || 1
|
|
||||||
const isCompleted = completionsToday >= target
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={`flex items-center justify-between text-sm p-2 rounded-md
|
|
||||||
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
|
|
||||||
key={habit.id}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<div className="flex-none">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (isCompleted) {
|
|
||||||
undoComplete(habit);
|
|
||||||
} else {
|
|
||||||
completeHabit(habit);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="relative hover:opacity-70 transition-opacity w-4 h-4"
|
|
||||||
>
|
|
||||||
{isCompleted ? (
|
|
||||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<div className="relative h-4 w-4">
|
|
||||||
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
|
||||||
<div
|
|
||||||
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
|
||||||
style={{
|
|
||||||
background: `conic-gradient(
|
|
||||||
currentColor ${(completionsToday / target) * 360}deg,
|
|
||||||
transparent ${(completionsToday / target) * 360}deg 360deg
|
|
||||||
)`,
|
|
||||||
mask: 'radial-gradient(transparent 50%, black 51%)',
|
|
||||||
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<span className={isCompleted ? 'line-through' : ''}>
|
|
||||||
<Linkify>
|
|
||||||
{habit.name}
|
|
||||||
</Linkify>
|
|
||||||
</span>
|
|
||||||
<ContextMenuContent className="w-64">
|
|
||||||
<ContextMenuItem onClick={() => {
|
|
||||||
setPomo((prev) => ({
|
|
||||||
...prev,
|
|
||||||
show: true,
|
|
||||||
selectedHabitId: habit.id
|
|
||||||
}))
|
|
||||||
}}>
|
|
||||||
<Timer className="mr-2 h-4 w-4" />
|
|
||||||
<span>Start Pomodoro</span>
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
{habit.targetCompletions && (
|
|
||||||
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
|
|
||||||
{completionsToday}/{target}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{getHabitFreq(habit) !== 'daily' && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{getHabitFreq(habit)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Coins className={cn(
|
|
||||||
"h-3 w-3 mr-1 transition-all",
|
|
||||||
isCompleted
|
|
||||||
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
|
|
||||||
: "text-gray-400"
|
|
||||||
)} />
|
|
||||||
<span className={cn(
|
|
||||||
"transition-all",
|
|
||||||
isCompleted
|
|
||||||
? "text-yellow-500 font-medium"
|
|
||||||
: "text-gray-400"
|
|
||||||
)}>
|
|
||||||
{habit.coinReward}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedHabits(!expandedHabits)}
|
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{expandedHabits ? (
|
|
||||||
<>
|
|
||||||
Show less
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Show all
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/habits"
|
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
<ArrowRight className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">Wishlist Goals</h3>
|
<h3 className="font-semibold">{t('wishlistGoalsTitle')}</h3>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
|
{t('redeemableBadgeLabel', {
|
||||||
|
count: wishlistItems.filter(item => item.coinCost <= coinBalance).length,
|
||||||
|
total: wishlistItems.length
|
||||||
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${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">
|
||||||
No wishlist items yet. Add some goals to work towards!
|
{t('noWishlistItemsMessage')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sortedWishlistItems
|
{sortedWishlistItems
|
||||||
.slice(0, expandedWishlist ? undefined : 5)
|
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const isRedeemable = item.coinCost <= coinBalance
|
const isRedeemable = item.coinCost <= coinBalance
|
||||||
return (
|
return (
|
||||||
@@ -270,9 +466,19 @@ export default function DailyOverview({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<Linkify>{item.name}</Linkify>
|
<span className="text-sm">
|
||||||
</span>
|
<Linkify>{item.name}</Linkify>
|
||||||
|
</span>
|
||||||
|
{item.drawing && (
|
||||||
|
<DrawingDisplay
|
||||||
|
drawingData={item.drawing}
|
||||||
|
width={40}
|
||||||
|
height={26}
|
||||||
|
className="border-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="text-xs flex items-center">
|
<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",
|
||||||
@@ -299,8 +505,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
|
||||||
? "Ready to redeem!"
|
? t('readyToRedeemMessage')
|
||||||
: `${item.coinCost - coinBalance} coins to go`
|
: t('coinsToGoMessage', { amount: item.coinCost - coinBalance })
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -311,26 +517,26 @@ export default function DailyOverview({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedWishlist(!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"
|
||||||
>
|
>
|
||||||
{expandedWishlist ? (
|
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
|
||||||
<>
|
<>
|
||||||
Show less
|
{t('showLessButton')}
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Show all
|
{t('showAllButton')}
|
||||||
<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"
|
||||||
>
|
>
|
||||||
View
|
{t('viewButton')}
|
||||||
<ArrowRight className="h-3 w-3" />
|
<ArrowRight className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,6 +545,17 @@ export default function DailyOverview({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{modalConfig.isOpen && (
|
||||||
|
<AddEditHabitModal
|
||||||
|
onClose={() => setModalConfig({ isOpen: false, isTask: false })}
|
||||||
|
onSave={async (habit) => {
|
||||||
|
await saveHabit({ ...habit, isTask: modalConfig.isTask })
|
||||||
|
setModalConfig({ isOpen: false, isTask: false });
|
||||||
|
}}
|
||||||
|
habit={null}
|
||||||
|
isTask={modalConfig.isTask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,33 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import CoinBalance from './CoinBalance'
|
||||||
import DailyOverview from './DailyOverview'
|
import DailyOverview from './DailyOverview'
|
||||||
import HabitStreak from './HabitStreak'
|
import HabitStreak from './HabitStreak'
|
||||||
import CoinBalance from './CoinBalance'
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
|
||||||
import { ViewToggle } from './ViewToggle'
|
|
||||||
|
|
||||||
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 [settings] = useAtom(settingsAtom)
|
const { balance } = useCoins()
|
||||||
const [coins] = useAtom(coinsAtom)
|
|
||||||
const coinBalance = coins.balance
|
|
||||||
const [wishlist] = useAtom(wishlistAtom)
|
const [wishlist] = useAtom(wishlistAtom)
|
||||||
const wishlistItems = wishlist.items
|
const wishlistItems = wishlist.items
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
|
||||||
<ViewToggle />
|
|
||||||
</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={coinBalance} />
|
<CoinBalance coinBalance={balance} />
|
||||||
<HabitStreak habits={habits} />
|
<HabitStreak habits={habits} />
|
||||||
<DailyOverview
|
<DailyOverview
|
||||||
wishlistItems={wishlistItems}
|
wishlistItems={wishlistItems}
|
||||||
habits={habits}
|
habits={habits}
|
||||||
coinBalance={coinBalance}
|
coinBalance={balance}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* <HabitHeatmap habits={habits} /> */}
|
{/* <HabitHeatmap habits={habits} /> */}
|
||||||
|
|||||||
237
components/DrawingCanvas.tsx
Normal file
237
components/DrawingCanvas.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'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(() => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}, [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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
components/DrawingDisplay.tsx
Normal file
113
components/DrawingDisplay.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface DrawingDisplayProps {
|
||||||
|
drawingData?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawingStroke {
|
||||||
|
color: string
|
||||||
|
thickness: number
|
||||||
|
points: Array<{ x: number; y: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DrawingDisplay({
|
||||||
|
drawingData,
|
||||||
|
width = 120,
|
||||||
|
height = 80,
|
||||||
|
className = ''
|
||||||
|
}: DrawingDisplayProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas || !drawingData) return
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const strokes: DrawingStroke[] = JSON.parse(drawingData)
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Set up context for drawing
|
||||||
|
context.lineCap = 'round'
|
||||||
|
context.lineJoin = 'round'
|
||||||
|
|
||||||
|
// Calculate scaling to fit the drawing in the small canvas
|
||||||
|
if (strokes.length === 0) return
|
||||||
|
|
||||||
|
// Find bounds of the drawing
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||||
|
|
||||||
|
strokes.forEach(stroke => {
|
||||||
|
stroke.points.forEach(point => {
|
||||||
|
minX = Math.min(minX, point.x)
|
||||||
|
minY = Math.min(minY, point.y)
|
||||||
|
maxX = Math.max(maxX, point.x)
|
||||||
|
maxY = Math.max(maxY, point.y)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add padding
|
||||||
|
const padding = 10
|
||||||
|
const drawingWidth = maxX - minX + padding * 2
|
||||||
|
const drawingHeight = maxY - minY + padding * 2
|
||||||
|
|
||||||
|
// Calculate scale to fit in canvas
|
||||||
|
const scaleX = canvas.width / drawingWidth
|
||||||
|
const scaleY = canvas.height / drawingHeight
|
||||||
|
const scale = Math.min(scaleX, scaleY, 1) // Don't scale up
|
||||||
|
|
||||||
|
// Center the drawing
|
||||||
|
const offsetX = (canvas.width - drawingWidth * scale) / 2 - (minX - padding) * scale
|
||||||
|
const offsetY = (canvas.height - drawingHeight * scale) / 2 - (minY - padding) * scale
|
||||||
|
|
||||||
|
// Draw each stroke
|
||||||
|
strokes.forEach(stroke => {
|
||||||
|
if (stroke.points.length === 0) return
|
||||||
|
|
||||||
|
context.beginPath()
|
||||||
|
context.strokeStyle = stroke.color
|
||||||
|
context.lineWidth = Math.max(1, stroke.thickness * scale) // Ensure minimum line width
|
||||||
|
|
||||||
|
const firstPoint = stroke.points[0]
|
||||||
|
context.moveTo(
|
||||||
|
firstPoint.x * scale + offsetX,
|
||||||
|
firstPoint.y * scale + offsetY
|
||||||
|
)
|
||||||
|
|
||||||
|
stroke.points.forEach(point => {
|
||||||
|
context.lineTo(
|
||||||
|
point.x * scale + offsetX,
|
||||||
|
point.y * scale + offsetY
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
context.stroke()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to render drawing:', error)
|
||||||
|
}
|
||||||
|
}, [drawingData, width, height])
|
||||||
|
|
||||||
|
if (!drawingData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={`border-2 border-muted-foreground rounded bg-white ${className}`}
|
||||||
|
style={{ width: `${width}px`, height: `${height}px` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
components/DrawingModal.tsx
Normal file
83
components/DrawingModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import DrawingCanvas from './DrawingCanvas'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
interface DrawingModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (drawingData: string) => void
|
||||||
|
initialDrawing?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DrawingModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
initialDrawing,
|
||||||
|
title = 'Drawing'
|
||||||
|
}: DrawingModalProps) {
|
||||||
|
const t = useTranslations('DrawingModal')
|
||||||
|
const [currentDrawing, setCurrentDrawing] = useState<string>(initialDrawing || '')
|
||||||
|
|
||||||
|
const handleSave = (drawingData: string) => {
|
||||||
|
setCurrentDrawing(drawingData)
|
||||||
|
onSave(drawingData)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setCurrentDrawing('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DrawingCanvas
|
||||||
|
initialDrawing={currentDrawing}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClear={handleClear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-2 p-6 border-t">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('cancelButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
components/EmojiPickerButton.tsx
Normal file
51
components/EmojiPickerButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { SmilePlus } from 'lucide-react'
|
||||||
|
import data from '@emoji-mart/data'
|
||||||
|
import Picker from '@emoji-mart/react'
|
||||||
|
|
||||||
|
interface EmojiPickerButtonProps {
|
||||||
|
onEmojiSelect: (emoji: string) => void
|
||||||
|
inputIdToFocus?: string // Optional: ID of the input to focus after selection
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: EmojiPickerButtonProps) {
|
||||||
|
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover modal={false} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8" // Consistent sizing
|
||||||
|
>
|
||||||
|
<SmilePlus className="h-4 w-4" /> {/* Consistent icon size */}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[300px] p-0"
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (inputIdToFocus) {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = document.getElementById(inputIdToFocus) as HTMLInputElement;
|
||||||
|
input?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={(emoji: { native: string }) => {
|
||||||
|
onEmojiSelect(emoji.native);
|
||||||
|
setIsEmojiPickerOpen(false);
|
||||||
|
// Focus is handled by onCloseAutoFocus
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { formatNumber } from '@/lib/utils/formatNumber'
|
|
||||||
import { Settings } from '@/lib/types'
|
|
||||||
|
|
||||||
interface FormattedNumberProps {
|
|
||||||
amount: number
|
|
||||||
settings: Settings
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormattedNumber({ amount, settings, className }: FormattedNumberProps) {
|
|
||||||
return (
|
|
||||||
<span className={`break-all ${className || ''}`}>
|
|
||||||
{formatNumber({ amount, settings })}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,33 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from 'react'
|
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
||||||
import { Calendar } from '@/components/ui/calendar'
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Check, Circle, CircleCheck } from 'lucide-react'
|
|
||||||
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
|
import { completedHabitsMapAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import Linkify from './linkify'
|
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
|
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Circle, CircleCheck } from 'lucide-react'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import Linkify from './linkify'
|
||||||
|
|
||||||
export default function HabitCalendar() {
|
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('Error completing past habit:', error)
|
console.error(t('errorCompletingPastHabit'), error)
|
||||||
}
|
}
|
||||||
}, [completePastHabit])
|
}, [completePastHabit, t])
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [selectedDate, setSelectedDate] = 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 [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
const habits = habitsData.habits
|
const habits = habitsData.habits
|
||||||
|
|
||||||
@@ -39,18 +41,18 @@ export default function HabitCalendar() {
|
|||||||
}, [completedHabitsMap, settings.system.timezone])
|
}, [completedHabitsMap, settings.system.timezone])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
|
<h1 className="text-xl xs:text-3xl font-bold mb-6">{t('title')}</h1>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Calendar</CardTitle>
|
<CardTitle>{t('calendarCardTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={selectedDate.toJSDate()}
|
selected={selectedDateTime.toJSDate()}
|
||||||
onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))}
|
onSelect={(e) => e && setSelectedDateTime(DateTime.fromJSDate(e))}
|
||||||
weekStartsOn={settings.system.weekStartDay}
|
weekStartsOn={settings.system.weekStartDay}
|
||||||
className="rounded-md border"
|
className="rounded-md border"
|
||||||
modifiers={{
|
modifiers={{
|
||||||
@@ -62,7 +64,7 @@ export default function HabitCalendar() {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
modifiersClassNames={{
|
modifiersClassNames={{
|
||||||
completed: 'bg-green-100 text-green-800 font-bold',
|
completed: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 font-medium rounded-md',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -70,67 +72,135 @@ export default function HabitCalendar() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{selectedDate ? (
|
{selectedDateTime ? (
|
||||||
<>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: "yyyy-MM-dd" })}</>
|
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
|
||||||
) : (
|
) : (
|
||||||
'Select a date'
|
t('selectDatePrompt')
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{selectedDate && (
|
{selectedDateTime && (
|
||||||
<ul className="space-y-2">
|
<div className="space-y-8">
|
||||||
{habits
|
{habits.some(habit => habit.isTask === true) && (
|
||||||
.filter(habit => isHabitDue({
|
<div className="pt-2 border-t">
|
||||||
habit,
|
<div className="flex items-center justify-between mb-3">
|
||||||
timezone: settings.system.timezone,
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>
|
||||||
date: selectedDate
|
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
|
||||||
}))
|
</div>
|
||||||
.map((habit) => {
|
<ul className="space-y-3">
|
||||||
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
|
{habits
|
||||||
const isCompleted = completions >= (habit.targetCompletions || 1)
|
.filter(habit => habit.isTask && isHabitDue({
|
||||||
return (
|
habit,
|
||||||
<li key={habit.id} className="flex items-center justify-between gap-2">
|
timezone: settings.system.timezone,
|
||||||
<span>
|
date: selectedDateTime
|
||||||
<Linkify>{habit.name}</Linkify>
|
}))
|
||||||
</span>
|
.map((habit) => {
|
||||||
<div className="flex items-center gap-2">
|
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
|
||||||
<div className="flex items-center gap-2">
|
const isCompleted = completions >= (habit.targetCompletions || 1)
|
||||||
{habit.targetCompletions && (
|
return (
|
||||||
<span className="text-sm text-muted-foreground">
|
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
|
||||||
{completions}/{habit.targetCompletions}
|
<span className="flex items-center gap-2">
|
||||||
|
<Linkify>{habit.name}</Linkify>
|
||||||
</span>
|
</span>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => handleCompletePastHabit(habit, selectedDate)}
|
{habit.targetCompletions && (
|
||||||
disabled={isCompleted}
|
<span className="text-sm text-muted-foreground">
|
||||||
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
{completions}/{habit.targetCompletions}
|
||||||
>
|
</span>
|
||||||
{isCompleted ? (
|
)}
|
||||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
<button
|
||||||
) : (
|
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
|
||||||
<div className="relative h-4 w-4">
|
disabled={isCompleted}
|
||||||
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
||||||
<div
|
>
|
||||||
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
{isCompleted ? (
|
||||||
style={{
|
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||||
background: `conic-gradient(
|
) : (
|
||||||
|
<div className="relative h-4 w-4">
|
||||||
|
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
||||||
|
<div
|
||||||
|
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(
|
||||||
|
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
|
||||||
|
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
|
||||||
|
)`,
|
||||||
|
mask: 'radial-gradient(transparent 50%, black 51%)',
|
||||||
|
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('habitsSectionTitle')}</h3>
|
||||||
|
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{habits
|
||||||
|
.filter(habit => !habit.isTask && isHabitDue({
|
||||||
|
habit,
|
||||||
|
timezone: settings.system.timezone,
|
||||||
|
date: selectedDateTime
|
||||||
|
}))
|
||||||
|
.map((habit) => {
|
||||||
|
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
|
||||||
|
const isCompleted = completions >= (habit.targetCompletions || 1)
|
||||||
|
return (
|
||||||
|
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Linkify>{habit.name}</Linkify>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{habit.targetCompletions && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{completions}/{habit.targetCompletions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
|
||||||
|
disabled={isCompleted}
|
||||||
|
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<div className="relative h-4 w-4">
|
||||||
|
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
||||||
|
<div
|
||||||
|
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
158
components/HabitContextMenuItems.tsx
Normal file
158
components/HabitContextMenuItems.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Habit } from '@/lib/types';
|
||||||
|
import { useHabits } from '@/hooks/useHabits';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
|
||||||
|
import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
|
||||||
|
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||||
|
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||||
|
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface HabitContextMenuItemsProps {
|
||||||
|
habit: Habit;
|
||||||
|
onEditRequest: () => void;
|
||||||
|
onDeleteRequest: () => void;
|
||||||
|
context?: 'daily-overview' | 'habit-item';
|
||||||
|
onClose?: () => void; // Optional: To close the dropdown if an action is taken
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HabitContextMenuItems({
|
||||||
|
habit,
|
||||||
|
onEditRequest,
|
||||||
|
onDeleteRequest,
|
||||||
|
context = 'habit-item',
|
||||||
|
onClose,
|
||||||
|
}: HabitContextMenuItemsProps) {
|
||||||
|
const t = useTranslations('HabitContextMenuItems');
|
||||||
|
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
||||||
|
const [settings] = useAtom(settingsAtom);
|
||||||
|
const [, setPomo] = useAtom(pomodoroAtom);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
|
const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
||||||
|
const canInteract = hasPermission(currentUser, 'habit', 'interact');
|
||||||
|
|
||||||
|
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
|
||||||
|
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
|
||||||
|
|
||||||
|
const taskIsDueToday = habit.isTask ? isHabitDueToday({ habit, timezone: settings.system.timezone }) : false;
|
||||||
|
|
||||||
|
const handleAction = (action: () => void) => {
|
||||||
|
action();
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canInteract}
|
||||||
|
onClick={() => handleAction(() => {
|
||||||
|
setPomo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
show: true,
|
||||||
|
selectedHabitId: habit.id,
|
||||||
|
}));
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Timer className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('startPomodoro')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* "Move to Today" option: Show if task is not due today */}
|
||||||
|
{habit.isTask && !habit.archived && !taskIsDueToday && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => {
|
||||||
|
const today = getNow({ timezone: settings.system.timezone });
|
||||||
|
saveHabit({ ...habit, frequency: d2t({ dateTime: today }) });
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('moveToToday')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* "Move to Tomorrow" option: Show if task is due today OR not due today */}
|
||||||
|
{habit.isTask && !habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => {
|
||||||
|
const tomorrow = getNow({ timezone: settings.system.timezone }).plus({ days: 1 });
|
||||||
|
saveHabit({ ...habit, frequency: d2t({ dateTime: tomorrow }) });
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('moveToTomorrow')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
|
||||||
|
>
|
||||||
|
<Pin className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t(habit.pinned ? 'unpin' : 'pin')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{context === 'habit-item' && !habit.archived && ( // Edit button visible in dropdown only for habit-item context on small screens
|
||||||
|
<MenuItemComponent
|
||||||
|
onClick={() => handleAction(onEditRequest)}
|
||||||
|
className="sm:hidden" // Kept the sm:hidden for HabitItem specific responsive behavior
|
||||||
|
disabled={!canWrite}
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('edit')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{context === 'daily-overview' && !habit.archived && ( // Edit button always visible in dropdown for daily-overview context
|
||||||
|
<MenuItemComponent
|
||||||
|
onClick={() => handleAction(onEditRequest)}
|
||||||
|
disabled={!canWrite}
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('edit')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{!habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => archiveHabit(habit.id))}
|
||||||
|
>
|
||||||
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('archive')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
|
||||||
|
>
|
||||||
|
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('unarchive')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{context === 'habit-item' && !habit.archived && <MenuSeparatorComponent className="sm:hidden" />}
|
||||||
|
|
||||||
|
{(context === 'daily-overview' || habit.archived) && <MenuSeparatorComponent />}
|
||||||
|
|
||||||
|
|
||||||
|
<MenuItemComponent
|
||||||
|
onClick={() => handleAction(onDeleteRequest)}
|
||||||
|
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||||
|
disabled={!canWrite} // Assuming delete is a write operation
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('delete')}</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import { Habit } from '@/lib/types'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils'
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore } from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { DateTime } from 'luxon'
|
import { Habit, User } from '@/lib/types'
|
||||||
|
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
|
||||||
interface HabitItemProps {
|
interface HabitItemProps {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
@@ -23,19 +24,39 @@ interface HabitItemProps {
|
|||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
|
||||||
|
if (!habit.userIds || habit.userIds.length <= 1) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||||
|
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||||
|
const user = usersData.users.find(u => u.id === userId)
|
||||||
|
if (!user) return <></>;
|
||||||
|
return (
|
||||||
|
<Avatar key={user.id} className="h-6 w-6">
|
||||||
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
|
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||||
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits()
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
||||||
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 }))
|
|
||||||
).length || 0
|
|
||||||
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 [browserSettings] = useAtom(browserSettingsAtom)
|
const t = useTranslations('HabitItem');
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
const [usersData] = useAtom(usersAtom)
|
||||||
const isRecurRule = !isTasksView
|
const pathname = usePathname();
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
|
const canWrite = hasPermission(currentUser, 'habit', 'write')
|
||||||
|
const canInteract = hasPermission(currentUser, 'habit', 'interact')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
@@ -61,29 +82,68 @@ 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-none">
|
<CardHeader className="flex-shrink-0">
|
||||||
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.name}</CardTitle>
|
<div className="flex justify-between items-start">
|
||||||
{habit.description && (
|
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
|
||||||
<CardDescription className={`whitespace-pre-line ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
<div className="flex items-center gap-1">
|
||||||
{habit.description}
|
{habit.pinned && (
|
||||||
</CardDescription>
|
<Pin className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<span>{habit.name}</span>
|
||||||
|
</div>
|
||||||
|
{isTaskOverdue(habit, settings.system.timezone) && (
|
||||||
|
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
|
||||||
|
{t('overdue')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
{renderUserAvatars(habit, currentUser as User, usersData)}
|
||||||
|
</div>
|
||||||
|
{(habit.description || habit.drawing) && (
|
||||||
|
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
|
||||||
|
{habit.description && (
|
||||||
|
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||||
|
{habit.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
{habit.drawing && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<DrawingDisplay
|
||||||
|
drawingData={habit.drawing}
|
||||||
|
width={120}
|
||||||
|
height={80}
|
||||||
|
className=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-grow flex flex-col justify-end">
|
||||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</p>
|
<div className="mt-auto">
|
||||||
<div className="flex items-center mt-2">
|
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
{t('whenLabel', {
|
||||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
|
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||||
|
frequency: habit.frequency,
|
||||||
|
isRecurRule: pathname.includes("habits"),
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||||
|
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-between gap-2">
|
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant={isCompletedToday ? "secondary" : "default"}
|
variant={isCompletedToday ? "secondary" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => await completeHabit(habit)}
|
onClick={async () => await completeHabit(habit)}
|
||||||
disabled={habit.archived || (isCompletedToday && completionsToday >= target)}
|
disabled={!canInteract || habit.archived || (isCompletedToday && completionsToday >= target)}
|
||||||
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
|
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 sm:mr-2" />
|
<Check className="h-4 w-4 sm:mr-2" />
|
||||||
@@ -91,19 +151,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
{isCompletedToday ? (
|
{isCompletedToday ? (
|
||||||
target > 1 ? (
|
target > 1 ? (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">{completionsToday}/{target}</span>
|
<span className="sm:hidden">{t('completedStatusCountMobile', { completed: completionsToday, target })}</span>
|
||||||
<span className="hidden sm:inline">Completed ({completionsToday}/{target})</span>
|
<span className="hidden sm:inline">{t('completedStatusCount', { completed: completionsToday, target })}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Completed'
|
t('completedStatus')
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
target > 1 ? (
|
target > 1 ? (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">{completionsToday}/{target}</span>
|
<span className="sm:hidden">{t('completeButtonCountMobile', { completed: completionsToday, target })}</span>
|
||||||
<span className="hidden sm:inline">Complete ({completionsToday}/{target})</span>
|
<span className="hidden sm:inline">{t('completeButtonCount', { completed: completionsToday, target })}</span>
|
||||||
</>
|
</>
|
||||||
) : 'Complete'
|
) : t('completeButton')
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
||||||
@@ -121,10 +181,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => await undoComplete(habit)}
|
onClick={async () => await undoComplete(habit)}
|
||||||
|
disabled={!canWrite}
|
||||||
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">Undo</span>
|
<span className="hidden sm:inline ml-2">{t('undoButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,59 +195,26 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
variant="edit"
|
variant="edit"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
|
disabled={!canWrite}
|
||||||
className="hidden sm:flex"
|
className="hidden sm:flex"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
<span className="ml-2">Edit</span>
|
<span className="ml-2">{t('editButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<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" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{!habit.archived && (
|
<HabitContextMenuItems
|
||||||
<DropdownMenuItem onClick={() => {
|
habit={habit}
|
||||||
setPomo((prev) => ({
|
onEditRequest={onEdit}
|
||||||
...prev,
|
onDeleteRequest={onDelete}
|
||||||
show: true,
|
context="habit-item"
|
||||||
selectedHabitId: habit.id
|
/>
|
||||||
}))
|
|
||||||
}}>
|
|
||||||
<Timer className="mr-2 h-4 w-4" />
|
|
||||||
<span>Start Pomodoro</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{!habit.archived && (
|
|
||||||
<DropdownMenuItem onClick={() => archiveHabit(habit.id)}>
|
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
|
||||||
<span>Archive</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{habit.archived && (
|
|
||||||
<DropdownMenuItem onClick={() => unarchiveHabit(habit.id)}>
|
|
||||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
|
||||||
<span>Unarchive</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={onEdit}
|
|
||||||
className="sm:hidden"
|
|
||||||
disabled={habit.archived}
|
|
||||||
>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className="sm:hidden" />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,117 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Plus, ListTodo } from 'lucide-react'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import EmptyState from './EmptyState'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import HabitItem from './HabitItem'
|
import { Input } from '@/components/ui/input'; // Added
|
||||||
|
import { Label } from '@/components/ui/label'; // Added
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
|
||||||
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { habitsAtom } from '@/lib/atoms'
|
||||||
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
|
import { Habit } from '@/lib/types'
|
||||||
|
import { getHabitFreq } from '@/lib/utils'; // Added
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
|
||||||
|
import { DateTime } from 'luxon'; // Added
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
|
||||||
import AddEditHabitModal from './AddEditHabitModal'
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
import { Habit } from '@/lib/types'
|
import EmptyState from './EmptyState'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import HabitItem from './HabitItem'
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
|
||||||
|
|
||||||
export default function HabitList() {
|
export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||||
|
const t = useTranslations('HabitList');
|
||||||
const { saveHabit, deleteHabit } = useHabits()
|
const { saveHabit, deleteHabit } = useHabits()
|
||||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
|
||||||
const habits = habitsData.habits.filter(habit =>
|
type SortOrder = 'asc' | 'desc';
|
||||||
isTasksView ? habit.isTask : !habit.isTask
|
|
||||||
)
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const activeHabits = habits.filter(h => !h.archived)
|
const [sortBy, setSortBy] = useState<SortableField>('name');
|
||||||
const archivedHabits = habits.filter(h => h.archived)
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
useEffect(() => {
|
||||||
|
if (isTasksView && sortBy === 'frequency') {
|
||||||
|
setSortBy('name');
|
||||||
|
} else if (!isTasksView && sortBy === 'dueDate') {
|
||||||
|
setSortBy('name');
|
||||||
|
}
|
||||||
|
}, [isTasksView, sortBy]);
|
||||||
|
|
||||||
|
const compareHabits = useMemo(() => {
|
||||||
|
return (a: Habit, b: Habit, currentSortBy: SortableField, currentSortOrder: SortOrder, tasksView: boolean): number => {
|
||||||
|
let comparison = 0;
|
||||||
|
switch (currentSortBy) {
|
||||||
|
case 'name':
|
||||||
|
comparison = a.name.localeCompare(b.name);
|
||||||
|
break;
|
||||||
|
case 'coinReward':
|
||||||
|
comparison = a.coinReward - b.coinReward;
|
||||||
|
break;
|
||||||
|
case 'dueDate':
|
||||||
|
if (tasksView && a.isTask && b.isTask) {
|
||||||
|
const dateA = DateTime.fromISO(a.frequency);
|
||||||
|
const dateB = DateTime.fromISO(b.frequency);
|
||||||
|
if (dateA.isValid && dateB.isValid) comparison = dateA.toMillis() - dateB.toMillis();
|
||||||
|
else if (dateA.isValid) comparison = -1; // Valid dates first
|
||||||
|
else if (dateB.isValid) comparison = 1;
|
||||||
|
// If both invalid, comparison remains 0
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'frequency':
|
||||||
|
if (!tasksView && !a.isTask && !b.isTask) {
|
||||||
|
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
|
||||||
|
const freqAVal = getHabitFreq(a);
|
||||||
|
const freqBVal = getHabitFreq(b);
|
||||||
|
comparison = freqOrder.indexOf(freqAVal) - freqOrder.indexOf(freqBVal);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return currentSortOrder === 'asc' ? comparison : -comparison;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allHabitsInView = useMemo(() => {
|
||||||
|
return habitsData.habits.filter(habit =>
|
||||||
|
isTasksView ? habit.isTask : !habit.isTask
|
||||||
|
);
|
||||||
|
}, [habitsData.habits, isTasksView]);
|
||||||
|
|
||||||
|
const searchedHabits = useMemo(() => {
|
||||||
|
if (!searchTerm.trim()) {
|
||||||
|
return allHabitsInView;
|
||||||
|
}
|
||||||
|
const lowercasedSearchTerm = searchTerm.toLowerCase();
|
||||||
|
return allHabitsInView.filter(habit =>
|
||||||
|
habit.name.toLowerCase().includes(lowercasedSearchTerm) ||
|
||||||
|
(habit.description && habit.description.toLowerCase().includes(lowercasedSearchTerm))
|
||||||
|
);
|
||||||
|
}, [allHabitsInView, searchTerm]);
|
||||||
|
|
||||||
|
const activeHabits = useMemo(() => {
|
||||||
|
return searchedHabits
|
||||||
|
.filter(h => !h.archived)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.pinned && !b.pinned) return -1;
|
||||||
|
if (!a.pinned && b.pinned) return 1;
|
||||||
|
// For items in the same pinned group (both pinned or both not pinned), apply general sort
|
||||||
|
return compareHabits(a, b, sortBy, sortOrder, isTasksView);
|
||||||
|
});
|
||||||
|
}, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]);
|
||||||
|
|
||||||
|
const archivedHabits = useMemo(() => {
|
||||||
|
return searchedHabits
|
||||||
|
.filter(h => h.archived)
|
||||||
|
.sort((a, b) => compareHabits(a, b, sortBy, sortOrder, isTasksView));
|
||||||
|
}, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]);
|
||||||
|
const [modalConfig, setModalConfig] = useState<{
|
||||||
|
isOpen: boolean,
|
||||||
|
isTask: boolean
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
isTask: false
|
||||||
|
})
|
||||||
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
|
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
|
||||||
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
|
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
@@ -33,22 +120,63 @@ export default function HabitList() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-xl xs:text-3xl font-bold">
|
||||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
{t(isTasksView ? 'myTasks' : 'myHabits')}
|
||||||
</h1>
|
</h1>
|
||||||
<Button onClick={() => setIsModalOpen(true)}>
|
<span>
|
||||||
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
|
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
||||||
</Button>
|
<Plus className='mr-2 h-4 w-4' />{isTasksView ? t("addTaskButton") : t("addHabitButton")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Sort Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
|
||||||
|
<div className="relative flex-grow w-full sm:w-auto">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Search className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t(isTasksView ? 'searchTasksPlaceholder' : 'searchHabitsPlaceholder')}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 self-start sm:self-center w-full sm:w-auto">
|
||||||
|
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">{t('sortByLabel')}</Label>
|
||||||
|
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
|
||||||
|
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
|
||||||
|
<SelectValue placeholder={t('sortByLabel')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="name">{t('sortByName')}</SelectItem>
|
||||||
|
<SelectItem value="coinReward">{t('sortByCoinReward')}</SelectItem>
|
||||||
|
{isTasksView && <SelectItem value="dueDate">{t('sortByDueDate')}</SelectItem>}
|
||||||
|
{!isTasksView && <SelectItem value="frequency">{t('sortByFrequency')}</SelectItem>}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
|
||||||
|
{sortOrder === 'asc' ? <ArrowUpNarrowWide className="h-4 w-4" /> : <ArrowDownWideNarrow className="h-4 w-4" />}
|
||||||
|
<span className="sr-only">{t('toggleSortOrderAriaLabel')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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 ? (
|
{activeHabits.length === 0 && searchTerm.trim() ? (
|
||||||
|
<div className="col-span-2 text-center text-muted-foreground py-8">
|
||||||
|
{t(isTasksView ? 'noTasksFoundMessage' : 'noHabitsFoundMessage')}
|
||||||
|
</div>
|
||||||
|
) : activeHabits.length === 0 ? (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={isTasksView ? TaskIcon : HabitIcon}
|
icon={isTasksView ? TaskIcon : HabitIcon}
|
||||||
title={isTasksView ? "No tasks yet" : "No habits yet"}
|
title={t(isTasksView ? 'emptyStateTasksTitle' : 'emptyStateHabitsTitle')}
|
||||||
description={isTasksView ? "Create your first task to start tracking your progress" : "Create your first habit to start tracking your progress"}
|
description={t(isTasksView ? 'emptyStateTasksDescription' : 'emptyStateHabitsDescription')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -58,18 +186,18 @@ export default function HabitList() {
|
|||||||
habit={habit}
|
habit={habit}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingHabit(habit)
|
setEditingHabit(habit)
|
||||||
setIsModalOpen(true)
|
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-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">Archived</span>
|
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
|
||||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
{archivedHabits.map((habit: Habit) => (
|
{archivedHabits.map((habit: Habit) => (
|
||||||
@@ -78,7 +206,7 @@ export default function HabitList() {
|
|||||||
habit={habit}
|
habit={habit}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingHabit(habit)
|
setEditingHabit(habit)
|
||||||
setIsModalOpen(true)
|
setModalConfig({ isOpen: true, isTask: isTasksView })
|
||||||
}}
|
}}
|
||||||
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
||||||
/>
|
/>
|
||||||
@@ -86,18 +214,19 @@ export default function HabitList() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isModalOpen &&
|
{modalConfig.isOpen &&
|
||||||
<AddEditHabitModal
|
<AddEditHabitModal
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsModalOpen(false)
|
setModalConfig({ isOpen: false, isTask: false })
|
||||||
setEditingHabit(null)
|
setEditingHabit(null)
|
||||||
}}
|
}}
|
||||||
onSave={async (habit) => {
|
onSave={async (habit) => {
|
||||||
await saveHabit({ ...habit, id: editingHabit?.id })
|
await saveHabit({ ...habit, id: editingHabit?.id, isTask: modalConfig.isTask })
|
||||||
setIsModalOpen(false)
|
setModalConfig({ isOpen: false, isTask: false })
|
||||||
setEditingHabit(null)
|
setEditingHabit(null)
|
||||||
}}
|
}}
|
||||||
habit={editingHabit}
|
habit={editingHabit}
|
||||||
|
isTask={modalConfig.isTask}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -109,9 +238,9 @@ export default function HabitList() {
|
|||||||
}
|
}
|
||||||
setDeleteConfirmation({ isOpen: false, habitId: null })
|
setDeleteConfirmation({ isOpen: false, habitId: null })
|
||||||
}}
|
}}
|
||||||
title={isTasksView ? "Delete Task" : "Delete Habit"}
|
title={t(isTasksView ? 'deleteTaskDialogTitle' : 'deleteHabitDialogTitle')}
|
||||||
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."}
|
message={t(isTasksView ? 'deleteTaskDialogMessage' : 'deleteHabitDialogMessage')}
|
||||||
confirmText="Delete"
|
confirmText={t('deleteButton')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Habit } from '@/lib/types'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { completedHabitsMapAtom, settingsAtom } from '@/lib/atoms';
|
||||||
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
|
import { Habit } from '@/lib/types';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { d2s, getNow } from '@/lib/utils';
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai';
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
interface HabitStreakProps {
|
export default function HabitStreak({ habits }: { habits: Habit[] }) {
|
||||||
habits: Habit[]
|
const t = useTranslations('HabitStreak');
|
||||||
}
|
|
||||||
|
|
||||||
export default function HabitStreak({ habits }: HabitStreakProps) {
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
||||||
|
|
||||||
// Get the last 7 days of data
|
// Get the last 7 days of data
|
||||||
const dates = Array.from({ length: 7 }, (_, i) => {
|
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||||
const d = getNow({ timezone: settings.system.timezone });
|
const d = getNow({ timezone: settings.system.timezone });
|
||||||
@@ -20,21 +20,24 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
}).reverse()
|
}).reverse()
|
||||||
|
|
||||||
const completions = dates.map(date => {
|
const completions = dates.map(date => {
|
||||||
const completedCount = getCompletedHabitsForDate({
|
// Get completed habits for the date from the map
|
||||||
habits,
|
const completedOnDate = completedHabitsMap.get(date) || [];
|
||||||
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
|
|
||||||
timezone: settings.system.timezone
|
// Filter the completed list to count habits and tasks
|
||||||
}).length;
|
const completedHabitsCount = completedOnDate.filter(h => !h.isTask).length;
|
||||||
|
const completedTasksCount = completedOnDate.filter(h => h.isTask).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
completed: completedCount
|
habits: completedHabitsCount,
|
||||||
|
tasks: completedTasksCount
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Daily Habit Completion Streak</CardTitle>
|
<CardTitle>{t('dailyCompletionStreakTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="w-full aspect-[2/1]">
|
<div className="w-full aspect-[2/1]">
|
||||||
@@ -50,15 +53,29 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
<YAxis />
|
<YAxis allowDecimals={false} />
|
||||||
<Tooltip formatter={(value) => [`${value} habits`, 'Completed']} />
|
<Tooltip formatter={(value, name) => {
|
||||||
|
const translatedName = name === 'habits' ? t('tooltipHabitsLabel') : t('tooltipTasksLabel');
|
||||||
|
return [`${value} ${translatedName}`, t('tooltipCompletedLabel')];
|
||||||
|
}} />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="completed"
|
name={t('tooltipHabitsLabel')}
|
||||||
|
dataKey="habits"
|
||||||
stroke="#14b8a6"
|
stroke="#14b8a6"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
|
{habits.some(habit => habit.isTask === true) && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
name={t('tooltipTasksLabel')}
|
||||||
|
dataKey="tasks"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,13 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
|
||||||
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
||||||
import AboutModal from './AboutModal'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import HeaderActions from './HeaderActions'
|
||||||
|
|
||||||
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 [showAbout, setShowAbout] = useState(false)
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
const [coins] = useAtom(coinsAtom)
|
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
|
||||||
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 || ''}`}>
|
||||||
@@ -39,62 +16,10 @@ 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>
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<HeaderActions />
|
||||||
<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={coins.balance}
|
|
||||||
settings={settings}
|
|
||||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
|
||||||
/>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<TodayEarnedCoins />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="flex items-center gap-2">
|
|
||||||
{/* <Menu className="h-5 w-5" /> */}
|
|
||||||
<Avatar className="h-8 w-8">
|
|
||||||
<AvatarImage src={settings?.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
|
|
||||||
<AvatarFallback>
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56 p-2">
|
|
||||||
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
aria-label='settings'
|
|
||||||
className="flex items-center w-full gap-2"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
<span>Settings</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAbout(true)}
|
|
||||||
className="flex items-center w-full gap-2"
|
|
||||||
>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<span>About</span>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
33
components/HeaderActions.tsx
Normal file
33
components/HeaderActions.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Coins } from 'lucide-react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import NotificationBell from './NotificationBell'
|
||||||
|
import { Profile } from './Profile'
|
||||||
|
|
||||||
|
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">
|
||||||
|
<span className="text-gray-800 dark:text-gray-100 font-medium text-lg">{balance}</span>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<TodayEarnedCoins />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<NotificationBell />
|
||||||
|
<Profile />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
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" />
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<Header className="sticky top-0 z-50" />
|
||||||
<Navigation viewPort='main' />
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col">
|
<Navigation position='main' />
|
||||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
<div className="flex-1 flex flex-col">
|
||||||
<ClientWrapper>
|
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||||
{children}
|
{/* responsive container (optimized for mobile) */}
|
||||||
</ClientWrapper>
|
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||||
|
<ClientWrapper>
|
||||||
|
<PermissionError />
|
||||||
|
{children}
|
||||||
|
</ClientWrapper>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Navigation viewPort='mobile' />
|
<Navigation position='mobile' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
61
components/LoadingSpinner.tsx
Normal file
61
components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Coins } from 'lucide-react';
|
||||||
|
import { Logo } from '@/components/Logo';
|
||||||
|
|
||||||
|
const subtexts = [
|
||||||
|
"Unearthing your treasures",
|
||||||
|
"Polishing your gems",
|
||||||
|
"Mining for good habits",
|
||||||
|
"Stumbling upon brilliance",
|
||||||
|
"Discovering your potential",
|
||||||
|
"Crafting your success story",
|
||||||
|
"Forging new paths",
|
||||||
|
"Summoning success",
|
||||||
|
"Brewing brilliance",
|
||||||
|
"Charging up your awesome",
|
||||||
|
"Assembling achievements",
|
||||||
|
"Leveling up your day",
|
||||||
|
"Questing for quality",
|
||||||
|
"Unlocking awesomeness",
|
||||||
|
"Plotting your progress",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LoadingSpinner: React.FC = () => {
|
||||||
|
const [currentSubtext, setCurrentSubtext] = useState<string>('Loading your data');
|
||||||
|
const [animatedDots, setAnimatedDots] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const randomIndex = Math.floor(Math.random() * subtexts.length);
|
||||||
|
setCurrentSubtext(subtexts[randomIndex]);
|
||||||
|
|
||||||
|
const dotAnimationInterval = setInterval(() => {
|
||||||
|
setAnimatedDots(prevDots => {
|
||||||
|
if (prevDots.length >= 3) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return prevDots + '.';
|
||||||
|
});
|
||||||
|
}, 200); // Adjust timing as needed
|
||||||
|
|
||||||
|
return () => clearInterval(dotAnimationInterval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<Coins className="h-12 w-12 animate-bounce text-yellow-500" />
|
||||||
|
<Logo />
|
||||||
|
{currentSubtext && (
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
{currentSubtext}{animatedDots}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Sparkles } from "lucide-react"
|
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">
|
||||||
<Sparkles className="h-6 w-6 text-primary" />
|
<Image src="/icons/icon.png" alt="HabitTrove Logo" width={96} height={96} className="h-12 w-12 hidden xs:inline" />
|
||||||
<span className="font-bold text-xl">HabitTrove</span>
|
<span className="font-bold text-xl">HabitTrove</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
9
components/ModalOverlay.tsx
Normal file
9
components/ModalOverlay.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* ModalOverlay provides a dimming effect for non-modal dialogs or popovers
|
||||||
|
* that need to appear modal (e.g., to prevent interaction with background elements).
|
||||||
|
* It should be rendered alongside the dialog/popover it's intended to overlay for.
|
||||||
|
* Ensure the dialog/popover has a z-index higher than this overlay (default z-40).
|
||||||
|
*/
|
||||||
|
export default function ModalOverlay() {
|
||||||
|
return <div className="fixed inset-0 z-50 bg-black/80" />
|
||||||
|
}
|
||||||
61
components/NavDisplay.tsx
Normal file
61
components/NavDisplay.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useHelpers } from '@/lib/client-helpers';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { NavItemType } from './Navigation';
|
||||||
|
|
||||||
|
export default function NavDisplay({ navItems, displayType }: { navItems: NavItemType[], displayType: 'main' | 'mobile' }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isIOS } = useHelpers()
|
||||||
|
|
||||||
|
if (displayType === 'mobile') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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,100 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import AboutModal from './AboutModal'
|
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
|
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { ElementType } from 'react'
|
||||||
|
import NavDisplay from './NavDisplay'
|
||||||
|
|
||||||
type ViewPort = 'main' | 'mobile'
|
export interface NavItemType {
|
||||||
|
icon: ElementType;
|
||||||
const navItems = (isTasksView: boolean) => [
|
label: string;
|
||||||
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
|
href: string;
|
||||||
{
|
|
||||||
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({ className, viewPort }: NavigationProps) {
|
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
|
||||||
const [showAbout, setShowAbout] = useState(false)
|
const t = useTranslations('Navigation');
|
||||||
const [isMobileView, setIsMobileView] = useState(false)
|
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
|
||||||
|
|
||||||
useEffect(() => {
|
const currentNavItems: NavItemType[] = [
|
||||||
const handleResize = () => {
|
{ icon: Home, label: t('dashboard'), href: '/' },
|
||||||
setIsMobileView(window.innerWidth < 1024)
|
{ 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' },
|
||||||
|
]
|
||||||
|
|
||||||
// Set initial value
|
return <NavDisplay navItems={currentNavItems} displayType={position} />
|
||||||
handleResize()
|
|
||||||
|
|
||||||
// Add event listener
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (viewPort === 'mobile' && isMobileView) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="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">
|
|
||||||
<div className="flex justify-around">
|
|
||||||
{[...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 py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
|
||||||
>
|
|
||||||
<item.icon className="h-6 w-6" />
|
|
||||||
<span className="text-xs mt-1">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
134
components/NotificationBell.tsx
Normal file
134
components/NotificationBell.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import NotificationDropdown from './NotificationDropdown';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
||||||
|
import { d2t, getNow, t2d } from '@/lib/utils';
|
||||||
|
import { User, CoinTransaction } from '@/lib/types';
|
||||||
|
|
||||||
|
export default function NotificationBell() {
|
||||||
|
const t = useTranslations('NotificationBell');
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const [coinsData] = useAtom(coinsAtom)
|
||||||
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
|
const [wishlistData] = useAtom(wishlistAtom)
|
||||||
|
const [usersData] = useAtom(usersAtom);
|
||||||
|
|
||||||
|
// --- Calculate Unread and Read Notifications ---
|
||||||
|
const { unreadNotifications, displayedReadNotifications } = useMemo(() => {
|
||||||
|
const unread: CoinTransaction[] = [];
|
||||||
|
const read: CoinTransaction[] = [];
|
||||||
|
const MAX_READ_NOTIFICATIONS = 10; // Limit the number of past notifications shown
|
||||||
|
|
||||||
|
if (!currentUser || !currentUser.id) {
|
||||||
|
return { unreadNotifications: [], displayedReadNotifications: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastReadTimestamp = currentUser.lastNotificationReadTimestamp
|
||||||
|
? t2d({ timestamp: currentUser.lastNotificationReadTimestamp, timezone: 'UTC' })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Iterate through transactions (assuming they are sorted newest first)
|
||||||
|
for (const tx of coinsData.transactions) {
|
||||||
|
// Stop processing if we have enough read notifications
|
||||||
|
if (read.length >= MAX_READ_NOTIFICATIONS && (!lastReadTimestamp || t2d({ timestamp: tx.timestamp, timezone: 'UTC' }) <= lastReadTimestamp)) {
|
||||||
|
break; // Optimization: stop early if we have enough read and are past the unread ones
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic checks: must have a related item and be triggered by someone else
|
||||||
|
if (!tx.relatedItemId || tx.userId === currentUser.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the transaction type indicates a notification-worthy event
|
||||||
|
const isRelevantType = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION' || tx.type === 'WISH_REDEMPTION';
|
||||||
|
if (!isRelevantType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the related item is shared with the current user
|
||||||
|
let isShared = false;
|
||||||
|
const isHabitCompletion = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION';
|
||||||
|
const isWishRedemption = tx.type === 'WISH_REDEMPTION';
|
||||||
|
|
||||||
|
if (isHabitCompletion) {
|
||||||
|
const habit = habitsData.habits.find(h => h.id === tx.relatedItemId);
|
||||||
|
if (habit?.userIds?.includes(currentUser.id) && tx.userId && habit.userIds.includes(tx.userId)) {
|
||||||
|
isShared = true;
|
||||||
|
}
|
||||||
|
} else if (isWishRedemption) {
|
||||||
|
const wish = wishlistData.items.find(w => w.id === tx.relatedItemId);
|
||||||
|
if (wish?.userIds?.includes(currentUser.id) && tx.userId && wish.userIds.includes(tx.userId)) {
|
||||||
|
isShared = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isShared) {
|
||||||
|
continue; // Skip if not shared
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction is relevant, determine if read or unread
|
||||||
|
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
|
||||||
|
if (!lastReadTimestamp || txTimestamp > lastReadTimestamp) {
|
||||||
|
unread.push(tx);
|
||||||
|
} else if (read.length < MAX_READ_NOTIFICATIONS) {
|
||||||
|
// Only add to read if we haven't hit the limit
|
||||||
|
read.push(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transactions are assumed to be sorted newest first from the source
|
||||||
|
return { unreadNotifications: unread, displayedReadNotifications: read };
|
||||||
|
}, [coinsData.transactions, habitsData.habits, wishlistData.items, currentUser]);
|
||||||
|
// --- End Calculate Notifications ---
|
||||||
|
|
||||||
|
const unreadCount = unreadNotifications.length;
|
||||||
|
|
||||||
|
const handleNotificationClick = async () => {
|
||||||
|
if (!currentUser || !currentUser.id || unreadCount === 0) return; // Only update if there are unread notifications
|
||||||
|
try {
|
||||||
|
const nowTimestamp = d2t({ dateTime: getNow({}) });
|
||||||
|
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(t('errorUpdateTimestamp'), error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu onOpenChange={(open) => {
|
||||||
|
// Update timestamp only when opening the dropdown and there are unread notifications
|
||||||
|
if (open && unreadCount > 0) {
|
||||||
|
handleNotificationClick();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 block h-2 w-2 rounded-full bg-red-500 ring-1 ring-white dark:ring-gray-800" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
|
||||||
|
<NotificationDropdown
|
||||||
|
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
|
||||||
|
unreadNotifications={unreadNotifications}
|
||||||
|
displayedReadNotifications={displayedReadNotifications}
|
||||||
|
habitsData={habitsData} // Pass necessary data down
|
||||||
|
wishlistData={wishlistData}
|
||||||
|
usersData={usersData}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
components/NotificationDropdown.tsx
Normal file
133
components/NotificationDropdown.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
|
||||||
|
import { t2d } from '@/lib/utils';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface NotificationDropdownProps {
|
||||||
|
currentUser: User | null;
|
||||||
|
unreadNotifications: CoinTransaction[];
|
||||||
|
displayedReadNotifications: CoinTransaction[];
|
||||||
|
habitsData: HabitsData;
|
||||||
|
wishlistData: WishlistData;
|
||||||
|
usersData: UserData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get the name of the related item
|
||||||
|
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
|
||||||
|
if (!tx.relatedItemId) return undefined;
|
||||||
|
if (tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION') {
|
||||||
|
return habitsData.habits.find(h => h.id === tx.relatedItemId)?.name;
|
||||||
|
}
|
||||||
|
if (tx.type === 'WISH_REDEMPTION') {
|
||||||
|
return wishlistData.items.find(w => w.id === tx.relatedItemId)?.name;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default function NotificationDropdown({
|
||||||
|
currentUser,
|
||||||
|
unreadNotifications, // Use props directly
|
||||||
|
displayedReadNotifications, // Use props directly
|
||||||
|
habitsData,
|
||||||
|
wishlistData,
|
||||||
|
usersData,
|
||||||
|
}: 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) {
|
||||||
|
return <div className="p-4 text-sm text-gray-500">{t('notLoggedIn')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
|
||||||
|
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
|
||||||
|
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
|
||||||
|
const message = getNotificationMessage(tx, triggeringUser, relatedItemName); // Uses the new t-aware helper
|
||||||
|
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
|
||||||
|
const timeAgo = txTimestamp.toRelative();
|
||||||
|
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Wrap the Link with DropdownMenuItem and use asChild to pass props
|
||||||
|
<DropdownMenuItem key={tx.id} asChild className={`p-0 focus:bg-inherit dark:focus:bg-inherit cursor-pointer`}>
|
||||||
|
<Link href={linkHref} className={`block hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${isUnread ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} scroll={true}>
|
||||||
|
<div className="p-3 flex items-start gap-3">
|
||||||
|
<Avatar className="h-8 w-8 mt-1">
|
||||||
|
<AvatarImage src={triggeringUser?.avatarPath ? `/api/avatars/${triggeringUser.avatarPath.split('/').pop()}` : undefined} alt={triggeringUser?.username} />
|
||||||
|
<AvatarFallback>{triggeringUser?.username?.charAt(0).toUpperCase() || '?'}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={`text-sm ${isUnread ? 'font-semibold' : ''}`}>{message}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* Removed the outer div as width is now set on DropdownMenuContent in NotificationBell */}
|
||||||
|
<>
|
||||||
|
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-medium">{t('notificationsTitle')}</h4>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="max-w-xs">
|
||||||
|
<p className="text-xs">
|
||||||
|
{t('notificationsTooltip')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">{t('noNotificationsYet')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unreadNotifications.length > 0 && (
|
||||||
|
<>
|
||||||
|
{unreadNotifications.map(tx => renderNotification(tx, true))}
|
||||||
|
{displayedReadNotifications.length > 0 && <Separator className="my-2" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayedReadNotifications.length > 0 && (
|
||||||
|
<>
|
||||||
|
{displayedReadNotifications.map(tx => renderNotification(tx, false))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</> {/* Close the fragment */}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
components/PasswordEntryForm.tsx
Normal file
95
components/PasswordEntryForm.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { User } from '@/lib/types';
|
||||||
|
import { User as UserIcon } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
interface PasswordEntryFormProps {
|
||||||
|
user: User;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (password: string) => Promise<void>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordEntryForm({
|
||||||
|
user,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
error
|
||||||
|
}: PasswordEntryFormProps) {
|
||||||
|
const t = useTranslations('PasswordEntryForm');
|
||||||
|
const hasPassword = !!user.password;
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await onSubmit(password);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t('loginErrorToastTitle'),
|
||||||
|
description: err instanceof Error ? err.message : t('loginFailedErrorToastDescription'),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||||
|
<Avatar className="h-24 w-24">
|
||||||
|
<AvatarImage
|
||||||
|
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
<UserIcon className="h-12 w-12" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-medium text-lg">
|
||||||
|
{user.username}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
|
||||||
|
>
|
||||||
|
{t('notYouButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPassword && <div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder={t('passwordPlaceholder')}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={error ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
{t('cancelButton')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={hasPassword && !password}>
|
||||||
|
{t('loginButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/PermissionError.tsx
Normal file
43
components/PermissionError.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { checkStartupPermissions } from '@/lib/startup-checks'
|
||||||
|
import RecheckButton from './RecheckButton'
|
||||||
|
|
||||||
|
export default async function PermissionError() {
|
||||||
|
const permissionResult = await checkStartupPermissions()
|
||||||
|
|
||||||
|
// If everything is fine, render nothing
|
||||||
|
if (permissionResult.success) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get error message
|
||||||
|
const getErrorMessage = () => {
|
||||||
|
return permissionResult.error?.message || 'Unknown permission error occurred.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-bold">Permission Error</AlertTitle>
|
||||||
|
<AlertDescription className="mt-2 flex flex-col">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{getErrorMessage()}{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.habittrove.com/troubleshooting"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-red-300"
|
||||||
|
>
|
||||||
|
Troubleshooting Guide
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<RecheckButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
components/PermissionSelector.tsx
Normal file
110
components/PermissionSelector.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Switch } from './ui/switch';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Permission } from '@/lib/types';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface PermissionSelectorProps {
|
||||||
|
permissions: Permission[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
onPermissionsChange: (permissions: Permission[]) => void;
|
||||||
|
onAdminChange: (isAdmin: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionSelector({
|
||||||
|
permissions,
|
||||||
|
isAdmin,
|
||||||
|
onPermissionsChange,
|
||||||
|
onAdminChange,
|
||||||
|
}: PermissionSelectorProps) {
|
||||||
|
const t = useTranslations('PermissionSelector');
|
||||||
|
|
||||||
|
const permissionLabels: { [key: string]: string } = {
|
||||||
|
habit: t('resourceHabitTask'),
|
||||||
|
wishlist: t('resourceWishlist'),
|
||||||
|
coins: t('resourceCoins')
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPermissions = isAdmin ?
|
||||||
|
{
|
||||||
|
habit: { write: true, interact: true },
|
||||||
|
wishlist: { write: true, interact: true },
|
||||||
|
coins: { write: true, interact: true }
|
||||||
|
} :
|
||||||
|
permissions[0] || {
|
||||||
|
habit: { write: false, interact: true },
|
||||||
|
wishlist: { write: false, interact: true },
|
||||||
|
coins: { write: false, interact: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePermissionChange = (resource: keyof Permission, type: 'write' | 'interact', checked: boolean) => {
|
||||||
|
const newPermissions = [{
|
||||||
|
...currentPermissions,
|
||||||
|
[resource]: {
|
||||||
|
...currentPermissions[resource],
|
||||||
|
[type]: checked
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
onPermissionsChange(newPermissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('permissionsTitle')}</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium text-sm">{t('adminAccessLabel')}</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="isAdmin"
|
||||||
|
className="h-4 w-7"
|
||||||
|
checked={isAdmin}
|
||||||
|
onCheckedChange={onAdminChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<p className="text-xs text-muted-foreground px-3">
|
||||||
|
{t('adminAccessDescription')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{['habit', 'wishlist', 'coins'].map((resource) => (
|
||||||
|
<div key={resource} className="p-3 space-y-3 rounded-lg border bg-muted/50">
|
||||||
|
<div className="font-medium capitalize text-sm border-b pb-2">{permissionLabels[resource]}</div>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||||
|
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">{t('permissionWrite')}</Label>
|
||||||
|
<Switch
|
||||||
|
id={`${resource}-write`}
|
||||||
|
className="h-4 w-7"
|
||||||
|
checked={currentPermissions[resource as keyof Permission].write}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handlePermissionChange(resource as keyof Permission, 'write', checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||||
|
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">{t('permissionInteract')}</Label>
|
||||||
|
<Switch
|
||||||
|
id={`${resource}-interact`}
|
||||||
|
className="h-4 w-7"
|
||||||
|
checked={currentPermissions[resource as keyof Permission].interact}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handlePermissionChange(resource as keyof Permission, 'interact', checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,82 +1,70 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
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 { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
|
|
||||||
import { cn, getCompletionsForToday } from '@/lib/utils'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
|
|
||||||
import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils'
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { habitsAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||||
|
import { cn, getTodayCompletions } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface PomoConfig {
|
interface PomoConfig {
|
||||||
labels: string[]
|
getLabels: () => 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 [settings] = useAtom(settingsAtom)
|
const t = useTranslations('PomodoroTimer')
|
||||||
|
|
||||||
|
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
|
||||||
|
focus: {
|
||||||
|
getLabels: () => [
|
||||||
|
t('focusLabel1'), t('focusLabel2'), t('focusLabel3'), t('focusLabel4'), t('focusLabel5'),
|
||||||
|
t('focusLabel6'), t('focusLabel7'), t('focusLabel8'), t('focusLabel9'), t('focusLabel10')
|
||||||
|
],
|
||||||
|
duration: 25 * 60,
|
||||||
|
type: 'focus',
|
||||||
|
},
|
||||||
|
break: {
|
||||||
|
getLabels: () => [
|
||||||
|
t('breakLabel1'), t('breakLabel2'), t('breakLabel3'), t('breakLabel4'), t('breakLabel5'),
|
||||||
|
t('breakLabel6'), t('breakLabel7'), t('breakLabel8'), t('breakLabel9'), t('breakLabel10')
|
||||||
|
],
|
||||||
|
duration: 5 * 60,
|
||||||
|
type: 'break',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const [pomo, setPomo] = useAtom(pomodoroAtom)
|
const [pomo, setPomo] = useAtom(pomodoroAtom)
|
||||||
const { show, selectedHabitId, autoStart, minimized } = pomo
|
const { show, selectedHabitId, autoStart, minimized } = pomo
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
|
const [settingsData] = useAtom(settingsAtom)
|
||||||
const { completeHabit } = useHabits()
|
const { completeHabit } = useHabits()
|
||||||
const selectedHabit = selectedHabitId ? habitsData.habits.find(habit => habit.id === selectedHabitId) : null
|
const selectedHabit = selectedHabitId ? habitsData.habits.find(habit => habit.id === selectedHabitId) : null
|
||||||
const [timeLeft, setTimeLeft] = useState(PomoConfigs.focus.duration)
|
const [timeLeft, setTimeLeft] = useState(PomoConfigs.focus.duration)
|
||||||
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 = getTodayCompletions(pomo, habitsData, settingsData);
|
||||||
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus)
|
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
|
||||||
const [currentLabel, setCurrentLabel] = useState(
|
const [currentLabel, setCurrentLabel] = useState(() => {
|
||||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
const labels = currentTimerRef.current.getLabels();
|
||||||
)
|
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('Browser does not support wakelock')
|
console.debug(t('wakeLockNotSupported'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (wakeLock.current && !wakeLock.current.released) {
|
if (wakeLock.current && !wakeLock.current.released) {
|
||||||
console.debug('Wake lock already in use')
|
console.debug(t('wakeLockInUse'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state === 'started') {
|
if (state === 'started') {
|
||||||
@@ -85,7 +73,7 @@ export default function PomodoroTimer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error requesting wake lock:', err)
|
console.error(t('wakeLockRequestError'), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +84,7 @@ export default function PomodoroTimer() {
|
|||||||
wakeLock.current = null
|
wakeLock.current = null
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error releasing wake lock:', err)
|
console.error(t('wakeLockReleaseError'), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,59 +109,48 @@ export default function PomodoroTimer() {
|
|||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
}
|
}
|
||||||
}, [state])
|
}, [state, t])
|
||||||
|
|
||||||
// Timer logic
|
// Timer logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: ReturnType<typeof setInterval> | null = null
|
const handleTimerEnd = async () => {
|
||||||
|
setState("stopped");
|
||||||
|
const currentTimerType = currentTimerRef.current.type;
|
||||||
|
currentTimerRef.current =
|
||||||
|
currentTimerType === "focus" ? PomoConfigs.break : PomoConfigs.focus;
|
||||||
|
setTimeLeft(currentTimerRef.current.duration);
|
||||||
|
const newLabels = currentTimerRef.current.getLabels();
|
||||||
|
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)]);
|
||||||
|
|
||||||
if (state === 'started') {
|
// update habits only after focus sessions
|
||||||
|
if (selectedHabit && currentTimerType === "focus") {
|
||||||
|
await completeHabit(selectedHabit);
|
||||||
|
// The atom will automatically update with the new completions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
if (state === "started") {
|
||||||
// Calculate the target end time based on current timeLeft
|
// 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()
|
handleTimerEnd();
|
||||||
} 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])
|
}, [state, timeLeft, PomoConfigs.break, PomoConfigs.focus, completeHabit, selectedHabit]);
|
||||||
|
|
||||||
|
|
||||||
const playSound = useCallback(() => {
|
|
||||||
const audio = new Audio('/sounds/timer-end.wav')
|
|
||||||
audio.play().catch(error => {
|
|
||||||
console.error('Error playing sound:', error)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleTimerEnd = async () => {
|
|
||||||
setState("stopped")
|
|
||||||
const currentTimerType = currentTimer.current.type
|
|
||||||
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
|
|
||||||
setTimeLeft(currentTimer.current.duration)
|
|
||||||
setCurrentLabel(
|
|
||||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Play sound
|
|
||||||
playSound()
|
|
||||||
|
|
||||||
// update habits only after focus sessions
|
|
||||||
if (selectedHabit && currentTimerType === 'focus') {
|
|
||||||
await completeHabit(selectedHabit)
|
|
||||||
// The atom will automatically update with the new completions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleTimer = () => {
|
const toggleTimer = () => {
|
||||||
setState(prev => prev === 'started' ? 'paused' : 'started')
|
setState(prev => prev === 'started' ? 'paused' : 'started')
|
||||||
@@ -181,17 +158,16 @@ export default function PomodoroTimer() {
|
|||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
setState("stopped")
|
setState("stopped")
|
||||||
setTimeLeft(currentTimer.current.duration)
|
setTimeLeft(currentTimerRef.current.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipTimer = () => {
|
const skipTimer = () => {
|
||||||
currentTimer.current = currentTimer.current.type === 'focus'
|
currentTimerRef.current = currentTimerRef.current.type === 'focus'
|
||||||
? PomoConfigs.break
|
? PomoConfigs.break
|
||||||
: PomoConfigs.focus
|
: PomoConfigs.focus
|
||||||
resetTimer()
|
resetTimer() // This will also reset timeLeft to the new timer's duration
|
||||||
setCurrentLabel(
|
const newLabels = currentTimerRef.current.getLabels();
|
||||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
@@ -200,9 +176,9 @@ export default function PomodoroTimer() {
|
|||||||
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
|
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = (timeLeft / currentTimer.current.duration) * 100
|
const progress = (timeLeft / currentTimerRef.current.duration) * 100
|
||||||
|
|
||||||
if (!show) return null
|
if (!show) return <></>
|
||||||
|
|
||||||
return (
|
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">
|
||||||
@@ -253,11 +229,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
|
||||||
currentTimer.current.type === 'focus' && 'bg-green-500',
|
currentTimerRef.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',
|
||||||
currentTimer.current.type === 'break' && 'bg-blue-500',
|
currentTimerRef.current.type === 'break' && 'bg-blue-500',
|
||||||
)} />
|
)} />
|
||||||
<div className="font-bold text-foreground">
|
<div className="font-bold text-foreground">
|
||||||
{selectedHabit.name}
|
{selectedHabit.name}
|
||||||
@@ -265,7 +241,9 @@ export default function PomodoroTimer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span>{currentTimer.current.type.charAt(0).toUpperCase() + currentTimer.current.type.slice(1)}: {currentLabel}</span>
|
<span>
|
||||||
|
{currentTimerRef.current.type === 'focus' ? t('focusType') : t('breakType')}: {currentLabel}
|
||||||
|
</span>
|
||||||
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
|
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
|
||||||
<div className="flex justify-center gap-1 mt-2">
|
<div className="flex justify-center gap-1 mt-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -304,12 +282,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">Pause</span>
|
<span className="hidden sm:inline">{t('pauseButton')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="h-4 w-4 sm:mr-2" />
|
<Play className="h-4 w-4 sm:mr-2" />
|
||||||
<span className="hidden sm:inline">Start</span>
|
<span className="hidden sm:inline">{t('startButton')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -320,7 +298,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">Reset</span>
|
<span className="hidden sm:inline">{t('resetButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -329,7 +307,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">Skip</span>
|
<span className="hidden sm:inline">{t('skipButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
196
components/Profile.tsx
Normal file
196
components/Profile.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { signOut } from "@/app/actions/user"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
import { toast } from "@/hooks/use-toast"
|
||||||
|
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||||
|
import { useAtom } from "jotai"
|
||||||
|
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||||
|
import UserForm from './UserForm'
|
||||||
|
|
||||||
|
export function Profile() {
|
||||||
|
const t = useTranslations('Profile');
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [user] = useAtom(currentUserAtom)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
try {
|
||||||
|
await signOut()
|
||||||
|
toast({
|
||||||
|
title: t('signOutSuccessTitle'),
|
||||||
|
description: t('signOutSuccessDescription'),
|
||||||
|
})
|
||||||
|
setTimeout(() => window.location.reload(), 300);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('signOutErrorTitle'),
|
||||||
|
description: t('signOutErrorDescription'),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[200px] p-2">
|
||||||
|
<div className="px-2 py-1.5 mb-2 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col mr-4">
|
||||||
|
<span className="text-sm font-semibold flex items-center gap-1 break-all">
|
||||||
|
{user?.username || t('guestUsername')}
|
||||||
|
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
||||||
|
</span>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
|
||||||
|
>
|
||||||
|
{t('editProfileButton')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
handleSignOut();
|
||||||
|
}}
|
||||||
|
className="border border-primary/50 text-primary rounded-md p-1.5 transition-colors hover:bg-primary/10 hover:border-primary active:scale-95"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
|
||||||
|
setOpen(false); // Close the dropdown
|
||||||
|
setUserSelect(true); // Open the user select modal
|
||||||
|
}}>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
<span>{t('switchUserButton')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||||
|
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
aria-label={t('settingsLink')}
|
||||||
|
className="flex items-center justify-between w-full"
|
||||||
|
onClick={() => setOpen(false)} // Ensure dropdown closes on click
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span>{t('settingsLink')}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
|
||||||
|
setOpen(false); // Close the dropdown
|
||||||
|
setAboutOpen(true); // Open the about modal
|
||||||
|
}}>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<span>{t('aboutButton')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
<span>{t('themeLabel')}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
w-12 h-6 rounded-full relative transition-all duration-300 ease-in-out
|
||||||
|
hover:scale-105 shadow-inner
|
||||||
|
${theme === 'dark'
|
||||||
|
? 'bg-blue-600/90 hover:bg-blue-600'
|
||||||
|
: 'bg-gray-200 hover:bg-gray-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`
|
||||||
|
w-5 h-5 rounded-full absolute top-0.5 left-0.5
|
||||||
|
transition-all duration-300 ease-in-out
|
||||||
|
shadow-md bg-white
|
||||||
|
${theme === 'dark' ? 'translate-x-6' : 'translate-x-0'}
|
||||||
|
`}>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<Moon className="h-3 w-3 text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<Sun className="h-3 w-3 text-gray-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Add the UserForm dialog */}
|
||||||
|
{isEditing && user && (
|
||||||
|
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('editProfileModalTitle')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<UserForm
|
||||||
|
userId={user.id}
|
||||||
|
onCancel={() => setIsEditing(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
components/RecheckButton.tsx
Normal file
22
components/RecheckButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function RecheckButton() {
|
||||||
|
const handleRecheck = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleRecheck}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-red-50 border-red-300 text-red-700 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Recheck
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
components/RefreshBanner.tsx
Normal file
27
components/RefreshBanner.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { AlertTriangle } from "lucide-react"
|
||||||
|
|
||||||
|
interface RefreshBannerProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RefreshBanner({ onRefresh }: RefreshBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[100] bg-yellow-400 dark:bg-yellow-500 text-black dark:text-gray-900 p-4 rounded-lg shadow-lg flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Data out of sync</p>
|
||||||
|
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRefresh}
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700 border-yellow-600 dark:border-yellow-700 text-white dark:text-gray-900"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useAtom } from 'jotai'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
|
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
|
||||||
const [settings] = useAtom(settingsAtom)
|
const t = useTranslations('TodayEarnedCoins')
|
||||||
const { coinsEarnedToday } = useCoins()
|
const { coinsEarnedToday } = useCoins()
|
||||||
|
|
||||||
if (coinsEarnedToday <= 0) return null
|
if (coinsEarnedToday <= 0) return <></>;
|
||||||
|
|
||||||
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">
|
||||||
{"+"}
|
{"+"}{coinsEarnedToday}
|
||||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
|
|
||||||
{longFormat ?
|
{longFormat ?
|
||||||
<span className="text-sm text-muted-foreground"> today</span>
|
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
|
||||||
: null}
|
: null}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -19,6 +20,7 @@ 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)
|
||||||
@@ -27,8 +29,8 @@ export function TransactionNoteEditor({
|
|||||||
const trimmedNote = noteText.trim()
|
const trimmedNote = noteText.trim()
|
||||||
if (trimmedNote.length > 200) {
|
if (trimmedNote.length > 200) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Note too long',
|
title: t('noteTooLongTitle'),
|
||||||
description: 'Notes must be less than 200 characters',
|
description: t('noteTooLongDescription'),
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -40,8 +42,8 @@ export function TransactionNoteEditor({
|
|||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error saving note',
|
title: t('errorSavingNoteTitle'),
|
||||||
description: 'Please try again',
|
description: t('pleaseTryAgainDescription'),
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
// Revert to initial value on error
|
// Revert to initial value on error
|
||||||
@@ -59,8 +61,8 @@ export function TransactionNoteEditor({
|
|||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error deleting note',
|
title: t('errorDeletingNoteTitle'),
|
||||||
description: 'Please try again',
|
description: t('pleaseTryAgainDescription'),
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,7 +76,7 @@ export function TransactionNoteEditor({
|
|||||||
<Input
|
<Input
|
||||||
value={noteText}
|
value={noteText}
|
||||||
onChange={(e) => setNoteText(e.target.value)}
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
placeholder="Add a note..."
|
placeholder={t('addNotePlaceholder')}
|
||||||
className="w-64"
|
className="w-64"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
/>
|
/>
|
||||||
@@ -85,7 +87,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="Save note"
|
title={t('saveNoteTitle')}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
@@ -98,7 +100,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="Cancel"
|
title={t('cancelButtonTitle')}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -109,7 +111,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="Delete note"
|
title={t('deleteNoteTitle')}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
@@ -129,7 +131,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="Edit note"
|
aria-label={t('editNoteAriaLabel')}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
396
components/UserForm.tsx
Normal file
396
components/UserForm.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||||
|
import { Permission } from '@/lib/types';
|
||||||
|
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { User as UserIcon } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PermissionSelector } from './PermissionSelector';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Switch } from './ui/switch';
|
||||||
|
|
||||||
|
|
||||||
|
interface UserFormProps {
|
||||||
|
userId?: string; // if provided, we're editing; if not, we're creating
|
||||||
|
onCancel: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
|
||||||
|
const t = useTranslations('UserForm');
|
||||||
|
const [users, setUsersData] = useAtom(usersAtom);
|
||||||
|
const serverSettings = useAtomValue(serverSettingsAtom)
|
||||||
|
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
|
const getDefaultPermissions = (): Permission[] => [{
|
||||||
|
habit: {
|
||||||
|
write: true,
|
||||||
|
interact: true
|
||||||
|
},
|
||||||
|
wishlist: {
|
||||||
|
write: true,
|
||||||
|
interact: true
|
||||||
|
},
|
||||||
|
coins: {
|
||||||
|
write: true,
|
||||||
|
interact: true
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
|
||||||
|
const [username, setUsername] = useState(user?.username || '');
|
||||||
|
const [password, setPassword] = useState<string | undefined>('');
|
||||||
|
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
|
||||||
|
const [permissions, setPermissions] = useState<Permission[]>(
|
||||||
|
user?.permissions || getDefaultPermissions()
|
||||||
|
);
|
||||||
|
const isEditing = !!user;
|
||||||
|
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (serverSettings.isDemo) {
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('toastDemoDeleteDisabled'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser && currentUser.id === user.id) {
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('toastCannotDeleteSelf'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: user.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setUsersData(prev => ({
|
||||||
|
...prev,
|
||||||
|
users: prev.users.filter(u => u.id !== user.id),
|
||||||
|
}));
|
||||||
|
toast({
|
||||||
|
title: t('toastUserDeletedTitle'),
|
||||||
|
description: t('toastUserDeletedDescription', { username: user.username }),
|
||||||
|
variant: 'default'
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: errorData.error || t('genericError'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('networkError'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate username
|
||||||
|
const usernameResult = usernameSchema.safeParse(username);
|
||||||
|
if (!usernameResult.success) {
|
||||||
|
setError(usernameResult.error.errors[0].message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password unless disabled
|
||||||
|
if (!disablePassword && password) {
|
||||||
|
const passwordResult = passwordSchema.safeParse(password);
|
||||||
|
if (!passwordResult.success) {
|
||||||
|
setError(passwordResult.error.errors[0].message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
// Update existing user
|
||||||
|
if (username !== user.username || avatarPath !== user.avatarPath || !_.isEqual(permissions, user.permissions) || isAdmin !== user.isAdmin) {
|
||||||
|
await updateUser(user.id, { username, avatarPath, permissions, isAdmin });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle password update
|
||||||
|
if (disablePassword) {
|
||||||
|
await updateUserPassword(user.id, undefined);
|
||||||
|
} else if (password) {
|
||||||
|
await updateUserPassword(user.id, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsersData(prev => ({
|
||||||
|
...prev,
|
||||||
|
users: prev.users.map(u =>
|
||||||
|
u.id === user.id ? {
|
||||||
|
...u,
|
||||||
|
username,
|
||||||
|
avatarPath,
|
||||||
|
permissions,
|
||||||
|
isAdmin,
|
||||||
|
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
|
||||||
|
} : u
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('toastUserUpdatedTitle'),
|
||||||
|
description: t('toastUserUpdatedDescription', { username }),
|
||||||
|
variant: 'default'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('username', username);
|
||||||
|
if (disablePassword) {
|
||||||
|
formData.append('password', '');
|
||||||
|
} else if (password) {
|
||||||
|
formData.append('password', password);
|
||||||
|
}
|
||||||
|
formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions));
|
||||||
|
formData.append('isAdmin', JSON.stringify(isAdmin));
|
||||||
|
formData.append('avatarPath', avatarPath || '');
|
||||||
|
|
||||||
|
const newUser = await createUser(formData);
|
||||||
|
setUsersData(prev => ({
|
||||||
|
...prev,
|
||||||
|
users: [...prev.users, newUser]
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('toastUserCreatedTitle'),
|
||||||
|
description: t('toastUserCreatedDescription', { username }),
|
||||||
|
variant: 'default'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword('');
|
||||||
|
setError('');
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
const action = isEditing ? t('actionUpdate') : t('actionCreate');
|
||||||
|
setError(err instanceof Error ? err.message : t('errorFailedUserAction', { action }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarChange = async (file: File) => {
|
||||||
|
if (file.size > 5 * 1024 * 1024) { // 5MB
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('errorFileSizeLimit'),
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = await uploadAvatar(formData);
|
||||||
|
setAvatarPath(path);
|
||||||
|
setAvatarFile(null); // Clear the file since we've uploaded it
|
||||||
|
toast({
|
||||||
|
title: t('toastAvatarUploadedTitle'),
|
||||||
|
description: t('toastAvatarUploadedDescription'),
|
||||||
|
variant: 'default'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('errorFailedAvatarUpload'),
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6 max-h-[80vh] overflow-y-auto p-4">
|
||||||
|
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||||
|
<Avatar className="h-24 w-24">
|
||||||
|
<AvatarImage
|
||||||
|
src={avatarPath && `/api/avatars/${avatarPath.split('/').pop()}`}
|
||||||
|
alt={username}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
<UserIcon className="h-12 w-12" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="avatar"
|
||||||
|
name="avatar"
|
||||||
|
accept="image/png, image/jpeg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleAvatarChange(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.getElementById('avatar') as HTMLInputElement;
|
||||||
|
input.value = ''; // Reset input to allow selecting same file again
|
||||||
|
input.click();
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isEditing ? t('changeAvatarButton') : t('uploadAvatarButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">{t('usernameLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder={t('usernamePlaceholder')}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className={error ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
{isEditing ? t('newPasswordLabel') : t('passwordLabel')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder={isEditing ? t('passwordPlaceholderEdit') : t('passwordPlaceholderCreate')}
|
||||||
|
value={password || ''}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={error ? 'border-red-500' : ''}
|
||||||
|
disabled={disablePassword}
|
||||||
|
/>
|
||||||
|
{serverSettings.isDemo && (
|
||||||
|
<p className="text-sm text-red-500">{t('demoPasswordDisabledMessage')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="disable-password"
|
||||||
|
checked={disablePassword}
|
||||||
|
onCheckedChange={setDisablePassword}
|
||||||
|
disabled={serverSettings.isDemo}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="disable-password">{t('disablePasswordLabel')}</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{currentUser && currentUser.isAdmin && <PermissionSelector
|
||||||
|
permissions={permissions}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onPermissionsChange={setPermissions}
|
||||||
|
onAdminChange={setIsAdmin}
|
||||||
|
/>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
{isEditing && (
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
className="mr-auto"
|
||||||
|
disabled={serverSettings.isDemo || isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? t('deletingButtonText') : t('deleteAccountButton')}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('deleteUserConfirmation', { username: user.username })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteUser}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{t('cancelButton')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!username}>
|
||||||
|
{isEditing ? t('saveChangesButton') : t('createUserButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
components/UserSelectModal.tsx
Normal file
222
components/UserSelectModal.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { signIn } from '@/app/actions/user';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { currentUserAtom, usersAtom } from '@/lib/atoms';
|
||||||
|
import { SafeUser, User } from '@/lib/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Description } from '@radix-ui/react-dialog';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import PasswordEntryForm from './PasswordEntryForm';
|
||||||
|
import UserForm from './UserForm';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function UserCard({
|
||||||
|
user,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
|
showEdit,
|
||||||
|
isCurrentUser,
|
||||||
|
}: {
|
||||||
|
user: User,
|
||||||
|
onSelect: () => void,
|
||||||
|
onEdit: () => void,
|
||||||
|
showEdit: boolean,
|
||||||
|
isCurrentUser: boolean,
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('UserSelectModal');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={user.id} className="relative group">
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
|
||||||
|
isCurrentUser && "ring-2 ring-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="h-16 w-16">
|
||||||
|
<AvatarImage
|
||||||
|
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
|
||||||
|
alt={user.username}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
<UserIcon className="h-8 w-8" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
{user.username}
|
||||||
|
{user.isAdmin && <Crown className="h-4 w-4 text-yellow-500" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showEdit && (
|
||||||
|
<div className="absolute top-0 right-0 flex space-x-1">
|
||||||
|
{showEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent card selection
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title={t('editUserTooltip')}
|
||||||
|
>
|
||||||
|
<UserRoundPen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUserButton({ onClick }: { onClick: () => void }) {
|
||||||
|
const t = useTranslations('UserSelectModal');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Avatar className="h-16 w-16">
|
||||||
|
<AvatarFallback>
|
||||||
|
<Plus className="h-8 w-8" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm font-medium">{t('addUserButton')}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserSelectionView({
|
||||||
|
users,
|
||||||
|
currentUserFromHook, // Renamed to avoid confusion with map variable
|
||||||
|
onUserSelect,
|
||||||
|
onEditUser,
|
||||||
|
onCreateUser,
|
||||||
|
}: {
|
||||||
|
users: User[],
|
||||||
|
currentUserFromHook?: SafeUser,
|
||||||
|
onUserSelect: (userId: string) => void,
|
||||||
|
onEditUser: (userId: string) => void,
|
||||||
|
onCreateUser: () => void,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||||
|
{users
|
||||||
|
.filter(user => user.id !== currentUserFromHook?.id) // Show other users
|
||||||
|
.map((user) => (
|
||||||
|
<UserCard
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
onSelect={() => onUserSelect(user.id)}
|
||||||
|
onEdit={() => onEditUser(user.id)}
|
||||||
|
showEdit={!!currentUserFromHook?.isAdmin}
|
||||||
|
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const t = useTranslations('UserSelectModal');
|
||||||
|
const [selectedUser, setSelectedUser] = useState<string>();
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [usersData, setUsersData] = useAtom(usersAtom);
|
||||||
|
const users = usersData.users;
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
|
|
||||||
|
const handleUserSelect = (userId: string) => {
|
||||||
|
setSelectedUser(userId);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = (userId: string) => {
|
||||||
|
setSelectedUser(userId);
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
setIsCreating(false);
|
||||||
|
setIsEditing(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormCancel = () => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
setIsCreating(false);
|
||||||
|
setIsEditing(false);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<Description></Description>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isCreating ? t('createNewUserTitle') : t('selectUserTitle')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{!selectedUser && !isCreating && !isEditing ? (
|
||||||
|
<UserSelectionView
|
||||||
|
users={users}
|
||||||
|
currentUserFromHook={currentUser}
|
||||||
|
onUserSelect={handleUserSelect}
|
||||||
|
onEditUser={handleEditUser}
|
||||||
|
onCreateUser={handleCreateUser}
|
||||||
|
/>
|
||||||
|
) : isCreating || isEditing ? (
|
||||||
|
<UserForm
|
||||||
|
userId={isEditing ? selectedUser : undefined}
|
||||||
|
onCancel={handleFormCancel}
|
||||||
|
onSuccess={handleFormSuccess}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PasswordEntryForm
|
||||||
|
user={users.find(u => u.id === selectedUser)!}
|
||||||
|
onCancel={() => setSelectedUser(undefined)}
|
||||||
|
onSubmit={async (password) => {
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
const user = users.find(u => u.id === selectedUser);
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
await signIn(user.username, password);
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('signInSuccessTitle'),
|
||||||
|
description: t('signInSuccessDescription', { username: user.username }),
|
||||||
|
variant: "default"
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => window.location.reload(), 300);
|
||||||
|
} catch (err) {
|
||||||
|
setError(t('errorInvalidPassword'));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +1,70 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { CheckSquare, ListChecks } from 'lucide-react'
|
|
||||||
import { browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import type { ViewType } from '@/lib/types'
|
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
|
import { cn, isHabitDueToday } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { NotificationBadge } from './ui/notification-badge'
|
||||||
|
|
||||||
interface ViewToggleProps {
|
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 [settings] = useAtom(settingsAtom)
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const handleViewChange = (checked: boolean) => {
|
const handleViewChange = () => {
|
||||||
const newView = checked ? 'tasks' : 'habits'
|
router.push(pathname.includes("habits") ? "/tasks" : "/habits");
|
||||||
setBrowserSettings({
|
|
||||||
...browserSettings,
|
|
||||||
viewType: newView,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate due tasks count
|
||||||
|
const dueTasksCount = habits.habits.filter(habit =>
|
||||||
|
habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone })
|
||||||
|
).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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(false)}
|
onClick={handleViewChange}
|
||||||
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',
|
||||||
browserSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
pathname.includes('habits') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HabitIcon className="h-4 w-4" />
|
<HabitIcon className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Habits</span>
|
<span className="hidden sm:inline">{t('habitsLabel')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<NotificationBadge
|
||||||
onClick={() => handleViewChange(true)}
|
label={dueTasksCount}
|
||||||
className={cn(
|
show={dueTasksCount > 0}
|
||||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
|
||||||
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
className="shadow-md"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<TaskIcon className="h-4 w-4" />
|
<button
|
||||||
<span className="hidden sm:inline">Tasks</span>
|
onClick={handleViewChange}
|
||||||
</button>
|
className={cn(
|
||||||
|
'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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TaskIcon className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{t('tasksLabel')}</span>
|
||||||
|
</button>
|
||||||
|
</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',
|
||||||
browserSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
pathname.includes('habits') ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { WishlistItemType } from '@/lib/types'
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -10,6 +7,14 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { User, WishlistItemType } from '@/lib/types'
|
||||||
|
import { hasPermission } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
interface WishlistItemProps {
|
interface WishlistItemProps {
|
||||||
item: WishlistItemType
|
item: WishlistItemType
|
||||||
@@ -24,6 +29,25 @@ interface WishlistItemProps {
|
|||||||
isArchived?: boolean
|
isArchived?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
|
||||||
|
if (!item.userIds || item.userIds.length <= 1) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||||
|
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||||
|
const user = usersData.users.find(u => u.id === userId)
|
||||||
|
if (!user) return <></>;
|
||||||
|
return (
|
||||||
|
<Avatar key={user.id} className="h-6 w-6">
|
||||||
|
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||||
|
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function WishlistItem({
|
export default function WishlistItem({
|
||||||
item,
|
item,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -35,6 +59,13 @@ export default function WishlistItem({
|
|||||||
isHighlighted,
|
isHighlighted,
|
||||||
isRecentlyRedeemed
|
isRecentlyRedeemed
|
||||||
}: WishlistItemProps) {
|
}: WishlistItemProps) {
|
||||||
|
const t = useTranslations('WishlistItem')
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
|
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
|
||||||
|
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
|
||||||
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
id={`wishlist-${item.id}`}
|
id={`wishlist-${item.id}`}
|
||||||
@@ -42,51 +73,70 @@ 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-none">
|
<CardHeader className="flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex justify-between items-start">
|
||||||
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{item.name}
|
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||||
</CardTitle>
|
{item.name}
|
||||||
{item.targetCompletions && (
|
</CardTitle>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
{item.targetCompletions && (
|
||||||
({item.targetCompletions} {item.targetCompletions === 1 ? 'use' : 'uses'} left)
|
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||||
</span>
|
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderUserAvatars(item, currentUser as User, usersData)}
|
||||||
</div>
|
</div>
|
||||||
{item.description && (
|
{(item.description || item.drawing) && (
|
||||||
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
|
||||||
{item.description}
|
{item.description && (
|
||||||
</CardDescription>
|
<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-1">
|
<CardContent className="flex-grow flex flex-col justify-end">
|
||||||
<div className="flex items-center gap-2">
|
<div className="mt-auto">
|
||||||
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||||
{item.coinCost} coins
|
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||||
</span>
|
{item.coinCost} {t('coinsSuffix')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-between gap-2">
|
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={canRedeem ? "default" : "secondary"}
|
variant={canRedeem ? "default" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRedeem}
|
onClick={onRedeem}
|
||||||
disabled={!canRedeem || item.archived}
|
disabled={!canRedeem || !canInteract || item.archived}
|
||||||
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`}
|
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
|
<Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
|
||||||
<span>
|
<span>
|
||||||
{isRecentlyRedeemed ? (
|
{isRecentlyRedeemed ? (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">Done</span>
|
<span className="sm:hidden">{t('redeemedDone')}</span>
|
||||||
<span className="hidden sm:inline">Redeemed!</span>
|
<span className="hidden sm:inline">{t('redeemedExclamation')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="sm:hidden">Redeem</span>
|
<span className="sm:hidden">{t('redeem')}</span>
|
||||||
<span className="hidden sm:inline">Redeem</span>
|
<span className="hidden sm:inline">{t('redeem')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -98,13 +148,14 @@ export default function WishlistItem({
|
|||||||
variant="edit"
|
variant="edit"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
|
disabled={!canWrite}
|
||||||
className="hidden sm:flex"
|
className="hidden sm:flex"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
<span className="ml-2">Edit</span>
|
<span className="ml-2">{t('editButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<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" />
|
||||||
@@ -112,28 +163,29 @@ export default function WishlistItem({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{!item.archived && (
|
{!item.archived && (
|
||||||
<DropdownMenuItem onClick={onArchive}>
|
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
<span>Archive</span>
|
<span>{t('archiveButton')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{item.archived && (
|
{item.archived && (
|
||||||
<DropdownMenuItem onClick={onUnarchive}>
|
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
|
||||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||||
<span>Unarchive</span>
|
<span>{t('unarchiveButton')}</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" />
|
||||||
Edit
|
{t('editButton')}
|
||||||
</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 cursor-pointer"
|
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
|
disabled={!canWrite}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{t('deleteButton')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -142,4 +194,3 @@ export default function WishlistItem({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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'
|
||||||
@@ -9,8 +10,11 @@ import WishlistItem from './WishlistItem'
|
|||||||
import AddEditWishlistItemModal from './AddEditWishlistItemModal'
|
import AddEditWishlistItemModal from './AddEditWishlistItemModal'
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { openWindow } from '@/lib/utils'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
export default function WishlistManager() {
|
export default function WishlistManager() {
|
||||||
|
const t = useTranslations('WishlistManager')
|
||||||
const {
|
const {
|
||||||
addWishlistItem,
|
addWishlistItem,
|
||||||
editWishlistItem,
|
editWishlistItem,
|
||||||
@@ -62,24 +66,37 @@ export default function WishlistManager() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setRecentlyRedeemedId(null)
|
setRecentlyRedeemedId(null)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
|
if (item.link) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const opened = openWindow(item.link!)
|
||||||
|
if (!opened) {
|
||||||
|
toast({
|
||||||
|
title: t('popupBlockedTitle'),
|
||||||
|
description: t('popupBlockedDescription'),
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold">My Wishlist</h1>
|
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
|
||||||
<Button onClick={() => setIsModalOpen(true)}>
|
<Button onClick={() => setIsModalOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Add Reward
|
<Plus className="mr-2 h-4 w-4" /> {t('addRewardButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
|
||||||
{activeItems.length === 0 ? (
|
{activeItems.length === 0 ? (
|
||||||
<div className="col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Gift}
|
icon={Gift}
|
||||||
title="Your wishlist is empty"
|
title={t('emptyStateTitle')}
|
||||||
description="Add rewards that you'd like to earn with your coins"
|
description={t('emptyStateDescription')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -109,12 +126,12 @@ export default function WishlistManager() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{archivedItems.length > 0 && (
|
{archivedItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="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">Archived</span>
|
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
|
||||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
{archivedItems.map((item) => (
|
{archivedItems.map((item) => (
|
||||||
@@ -135,23 +152,15 @@ export default function WishlistManager() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AddEditWishlistItemModal
|
{isModalOpen &&
|
||||||
isOpen={isModalOpen}
|
<AddEditWishlistItemModal
|
||||||
onClose={() => {
|
setIsOpen={setIsModalOpen}
|
||||||
setIsModalOpen(false)
|
editingItem={editingItem}
|
||||||
setEditingItem(null)
|
setEditingItem={setEditingItem}
|
||||||
}}
|
addWishlistItem={addWishlistItem}
|
||||||
onSave={(item) => {
|
editWishlistItem={editWishlistItem}
|
||||||
if (editingItem) {
|
/>
|
||||||
editWishlistItem({ ...item, id: editingItem.id })
|
}
|
||||||
} else {
|
|
||||||
addWishlistItem(item)
|
|
||||||
}
|
|
||||||
setIsModalOpen(false)
|
|
||||||
setEditingItem(null)
|
|
||||||
}}
|
|
||||||
item={editingItem}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirmation.isOpen}
|
isOpen={deleteConfirmation.isOpen}
|
||||||
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
||||||
@@ -161,9 +170,9 @@ export default function WishlistManager() {
|
|||||||
}
|
}
|
||||||
setDeleteConfirmation({ isOpen: false, itemId: null })
|
setDeleteConfirmation({ isOpen: false, itemId: null })
|
||||||
}}
|
}}
|
||||||
title="Delete Reward"
|
title={t('deleteDialogTitle')}
|
||||||
message="Are you sure you want to delete this reward? This action cannot be undone."
|
message={t('deleteDialogMessage')}
|
||||||
confirmText="Delete"
|
confirmText={t('deleteButton')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom } from "@/lib/atoms"
|
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom, serverSettingsAtom } from "@/lib/atoms"
|
||||||
import { useHydrateAtoms } from "jotai/utils"
|
import { useHydrateAtoms } from "jotai/utils"
|
||||||
import { JotaiHydrateInitialValues } from "@/lib/types"
|
import { JotaiHydrateInitialValues } from "@/lib/types"
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ export function JotaiHydrate({
|
|||||||
[settingsAtom, initialValues.settings],
|
[settingsAtom, initialValues.settings],
|
||||||
[habitsAtom, initialValues.habits],
|
[habitsAtom, initialValues.habits],
|
||||||
[coinsAtom, initialValues.coins],
|
[coinsAtom, initialValues.coins],
|
||||||
[wishlistAtom, initialValues.wishlist]
|
[wishlistAtom, initialValues.wishlist],
|
||||||
|
[usersAtom, initialValues.users],
|
||||||
|
[serverSettingsAtom, initialValues.serverSettings],
|
||||||
])
|
])
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
39
components/theme-toggle.tsx
Normal file
39
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
components/ui/alert-dialog.tsx
Normal file
141
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
61
components/ui/alert.tsx
Normal file
61
components/ui/alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
warning:
|
||||||
|
"border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -73,7 +73,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
return null
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 null
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [item] = payload
|
const [item] = payload
|
||||||
@@ -155,7 +155,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 null
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 null
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
41
components/ui/notification-badge.tsx
Normal file
41
components/ui/notification-badge.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Badge, BadgeProps} from './badge';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface NotificationBadgeProps extends BadgeProps {
|
||||||
|
label?: string | number;
|
||||||
|
show?: boolean;
|
||||||
|
variant?: 'destructive' | 'default' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationBadge = ({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
show,
|
||||||
|
variant = 'destructive',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: NotificationBadgeProps) => {
|
||||||
|
const showBadge =
|
||||||
|
typeof label !== 'undefined' && (typeof show === 'undefined' || show);
|
||||||
|
return (
|
||||||
|
<div className='inline-flex relative'>
|
||||||
|
{children}
|
||||||
|
{showBadge && (
|
||||||
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
'absolute rounded-full -top-1.5 -right-1.5 z-20 border h-4 w-4 p-0 flex items-center justify-center text-xs',
|
||||||
|
typeof label !== 'undefined' && ('' + label).length === 0
|
||||||
|
? ''
|
||||||
|
: 'min-w-[1rem]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{'' + label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
habittrove:
|
habittrove:
|
||||||
|
image: ghcr.io/manindark/habittrove
|
||||||
|
container_name: habittrove
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/app/data" # Use a relative path instead of $(pwd)
|
- "./data:/app/data"
|
||||||
image: dohsimpson/habittrove
|
- "./backups:/app/backups"
|
||||||
|
environment:
|
||||||
|
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret
|
||||||
|
|||||||
77
docs/translation-guide.md
Normal file
77
docs/translation-guide.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Language Guide
|
||||||
|
|
||||||
|
## Adding/Updating Translations
|
||||||
|
|
||||||
|
### Adding a New Language
|
||||||
|
|
||||||
|
To add a new language translation to HabitTrove:
|
||||||
|
|
||||||
|
1. **Create translation file**:
|
||||||
|
- Copy `messages/en.json` as a template
|
||||||
|
- Save as `messages/{language-code}.json` (e.g., `ko.json` for Korean)
|
||||||
|
- Translate all values while preserving keys and placeholder variables like `{username}`, `{count}`, etc.
|
||||||
|
|
||||||
|
2. **Validate translation structure**:
|
||||||
|
```bash
|
||||||
|
# Ensure JSON is valid
|
||||||
|
jq empty messages/{language-code}.json
|
||||||
|
|
||||||
|
# Compare structure with English (should show no differences)
|
||||||
|
diff <(jq -S . messages/en.json | jq -r 'keys | sort | .[]') <(jq -S . messages/{language-code}.json | jq -r 'keys | sort | .[]')
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add language option to UI**:
|
||||||
|
- Edit `app/settings/page.tsx`
|
||||||
|
- Add new `<option value="{language-code}">{Language Name}</option>` in alphabetical order
|
||||||
|
|
||||||
|
4. **Update documentation**:
|
||||||
|
- Add language to README.md supported languages list
|
||||||
|
- Create new changelog entry with version bump
|
||||||
|
- Update package.json version
|
||||||
|
|
||||||
|
### Example: Adding Korean (한국어)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Copy translation file
|
||||||
|
cp /path/to/ko.json messages/ko.json
|
||||||
|
|
||||||
|
# 2. Add to settings page
|
||||||
|
# Add: <option value="ko">한국어</option>
|
||||||
|
|
||||||
|
# 3. Update README.md
|
||||||
|
# Change: 简体中文, 日본語
|
||||||
|
# To: 简체中文, 한국어, 日본語
|
||||||
|
|
||||||
|
# 4. Add changelog entry
|
||||||
|
# Create new version section with language addition
|
||||||
|
|
||||||
|
# 5. Bump package version
|
||||||
|
# Update version in package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation Quality Guidelines
|
||||||
|
|
||||||
|
- Use natural, contextually appropriate expressions
|
||||||
|
- Maintain consistent terminology throughout
|
||||||
|
- Preserve all placeholder variables exactly: `{username}`, `{count}`, `{target}`, etc.
|
||||||
|
- Use appropriate formality level for the target language
|
||||||
|
- Ensure JSON structure matches English file exactly (385 total keys)
|
||||||
|
|
||||||
|
### Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check JSON validity
|
||||||
|
jq empty messages/{lang}.json
|
||||||
|
|
||||||
|
# Compare key structure
|
||||||
|
node -e "
|
||||||
|
const en = require('./messages/en.json');
|
||||||
|
const target = require('./messages/{lang}.json');
|
||||||
|
// ... deep key comparison script
|
||||||
|
"
|
||||||
|
|
||||||
|
# Verify placeholder consistency
|
||||||
|
grep -o '{[^}]*}' messages/en.json | sort | uniq > en_vars.txt
|
||||||
|
grep -o '{[^}]*}' messages/{lang}.json | sort | uniq > {lang}_vars.txt
|
||||||
|
diff en_vars.txt {lang}_vars.txt
|
||||||
|
```
|
||||||
@@ -1,73 +1,148 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
totalEarnedAtom,
|
|
||||||
totalSpentAtom,
|
|
||||||
coinsSpentTodayAtom,
|
coinsSpentTodayAtom,
|
||||||
transactionsTodayAtom
|
currentUserAtom,
|
||||||
} from '@/lib/atoms'
|
currentUserIdAtom,
|
||||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
settingsAtom,
|
||||||
import { CoinsData } from '@/lib/types'
|
totalEarnedAtom,
|
||||||
import { toast } from '@/hooks/use-toast'
|
usersAtom
|
||||||
|
} from '@/lib/atoms';
|
||||||
|
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
||||||
|
import { CoinsData } from '@/lib/types';
|
||||||
|
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export function useCoins() {
|
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 [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [totalEarned] = useAtom(totalEarnedAtom)
|
const [{users}] = useAtom(usersAtom)
|
||||||
const [totalSpent] = useAtom(totalSpentAtom)
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
const [coinsData] = useAtom(coinsAtom) // All coin transactions
|
||||||
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
const [loggedInUserId] = useAtom(currentUserIdAtom);
|
||||||
|
const loggedInUserBalance = loggedInUserId ? coins.transactions.filter(transaction => transaction.userId === loggedInUserId).reduce((sum, transaction) => sum + transaction.amount, 0) : 0;
|
||||||
|
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
|
||||||
|
const [atomTotalEarned] = useAtom(totalEarnedAtom)
|
||||||
|
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
|
||||||
|
const targetUser = options?.selectedUser ? users.find(u => u.id === options.selectedUser) : currentUser
|
||||||
|
|
||||||
|
const transactions = useMemo(() => {
|
||||||
|
return coinsData.transactions.filter(t => t.userId === targetUser?.id);
|
||||||
|
}, [coinsData, targetUser?.id]);
|
||||||
|
|
||||||
|
const timezone = settings.system.timezone;
|
||||||
|
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
|
||||||
|
const [totalEarned, setTotalEarned] = useState(0);
|
||||||
|
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
|
||||||
|
const [balance, setBalance] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Calculate other metrics
|
||||||
|
if (targetUser?.id && targetUser.id === currentUser?.id) {
|
||||||
|
// If the target user is the currently logged-in user, use the derived atom's value
|
||||||
|
setCoinsEarnedToday(atomCoinsEarnedToday);
|
||||||
|
setTotalEarned(atomTotalEarned);
|
||||||
|
setCoinsSpentToday(atomCoinsSpentToday);
|
||||||
|
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 spentToday = calculateCoinsSpentToday(transactions, timezone);
|
||||||
|
setCoinsSpentToday(roundToInteger(spentToday));
|
||||||
|
|
||||||
|
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,
|
||||||
|
atomCoinsSpentToday
|
||||||
|
]);
|
||||||
|
|
||||||
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 (isNaN(amount) || amount <= 0) {
|
if (isNaN(amount) || amount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
title: "Invalid amount",
|
title: t("invalidAmountTitle"),
|
||||||
description: "Please enter a valid positive number"
|
description: t("invalidAmountDescription")
|
||||||
})
|
})
|
||||||
return null
|
return <></>;
|
||||||
|
}
|
||||||
|
if (amount > MAX_COIN_LIMIT) {
|
||||||
|
toast({
|
||||||
|
title: t("invalidAmountTitle"),
|
||||||
|
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
|
||||||
|
})
|
||||||
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await addCoins({
|
const data = await addCoins({
|
||||||
amount,
|
amount,
|
||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note
|
note,
|
||||||
|
userId: targetUser?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: "Success", description: `Added ${amount} coins` })
|
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
|
||||||
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 <></>;
|
||||||
const numAmount = Math.abs(amount)
|
const numAmount = Math.abs(amount)
|
||||||
if (isNaN(numAmount) || numAmount <= 0) {
|
if (isNaN(numAmount) || numAmount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
title: "Invalid amount",
|
title: t("invalidAmountTitle"),
|
||||||
description: "Please enter a valid positive number"
|
description: t("invalidAmountDescription")
|
||||||
})
|
})
|
||||||
return null
|
return <></>;
|
||||||
|
}
|
||||||
|
if (numAmount > MAX_COIN_LIMIT) {
|
||||||
|
toast({
|
||||||
|
title: t("invalidAmountTitle"),
|
||||||
|
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
|
||||||
|
})
|
||||||
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await removeCoins({
|
const data = await removeCoins({
|
||||||
amount: numAmount,
|
amount: numAmount,
|
||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note
|
note,
|
||||||
|
userId: targetUser?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: "Success", description: `Removed ${numAmount} coins` })
|
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
|
||||||
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 <></>;
|
||||||
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: "Error",
|
title: tCommon("errorTitle"),
|
||||||
description: "Transaction not found"
|
description: t("transactionNotFoundDescription")
|
||||||
})
|
})
|
||||||
return null
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTransaction = {
|
const updatedTransaction = {
|
||||||
@@ -93,12 +168,11 @@ export function useCoins() {
|
|||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
updateNote,
|
updateNote,
|
||||||
balance: coins.balance,
|
balance,
|
||||||
transactions: coins.transactions,
|
transactions: transactions,
|
||||||
coinsEarnedToday,
|
coinsEarnedToday,
|
||||||
totalEarned,
|
totalEarned,
|
||||||
totalSpent,
|
totalSpent: calculateTotalSpent(coins.transactions),
|
||||||
coinsSpentToday,
|
coinsSpentToday
|
||||||
transactionsToday
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,41 @@
|
|||||||
import { useAtom } from 'jotai'
|
|
||||||
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
|
|
||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||||
import { Habit } from '@/lib/types'
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate, getISODate, d2s } from '@/lib/utils'
|
|
||||||
import { toast } from '@/hooks/use-toast'
|
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
import { coinsAtom, currentUserAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
|
import { Freq, Habit } from '@/lib/types'
|
||||||
|
import {
|
||||||
|
d2s,
|
||||||
|
d2t,
|
||||||
|
getCompletionsForDate,
|
||||||
|
getHabitFreq,
|
||||||
|
getISODate,
|
||||||
|
getNow,
|
||||||
|
getTodayInTimezone,
|
||||||
|
handlePermissionCheck,
|
||||||
|
isSameDate,
|
||||||
|
playSound,
|
||||||
|
t2d
|
||||||
|
} from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
import { Undo2 } from 'lucide-react'
|
import { Undo2 } from 'lucide-react'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export function useHabits() {
|
export function useHabits() {
|
||||||
|
const t = useTranslations('useHabits');
|
||||||
|
const tCommon = useTranslations('Common');
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
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 = new Map<string, Freq>();
|
||||||
|
habitsData.habits.forEach(habit => {
|
||||||
|
habitFreqMap.set(habit.id, getHabitFreq(habit));
|
||||||
|
})
|
||||||
|
|
||||||
const completeHabit = async (habit: Habit) => {
|
const completeHabit = async (habit: Habit) => {
|
||||||
|
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
|
||||||
const timezone = settings.system.timezone
|
const timezone = settings.system.timezone
|
||||||
const today = getTodayInTimezone(timezone)
|
const today = getTodayInTimezone(timezone)
|
||||||
|
|
||||||
@@ -28,17 +50,19 @@ export function useHabits() {
|
|||||||
// Check if already completed
|
// Check if already completed
|
||||||
if (completionsToday >= target) {
|
if (completionsToday >= target) {
|
||||||
toast({
|
toast({
|
||||||
title: "Already completed",
|
title: t("alreadyCompletedTitle"),
|
||||||
description: `You've already completed this habit today.`,
|
description: t("alreadyCompletedDescription"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new completion
|
// Add new completion
|
||||||
const updatedHabit = {
|
const updatedHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })]
|
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })],
|
||||||
|
// Archive the habit if it's a task and we're about to reach the target
|
||||||
|
archived: habit.isTask && completionsToday + 1 === target ? true : habit.archived
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedHabits = habitsData.habits.map(h =>
|
const updatedHabits = habitsData.habits.map(h =>
|
||||||
@@ -46,29 +70,36 @@ export function useHabits() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await saveHabitsData({ habits: updatedHabits })
|
await saveHabitsData({ habits: updatedHabits })
|
||||||
setHabitsData({ habits: updatedHabits })
|
|
||||||
|
|
||||||
// Check if we've now reached the target
|
// Check if we've now reached the target
|
||||||
const isTargetReached = completionsToday + 1 === target
|
const isTargetReached = completionsToday + 1 === target
|
||||||
if (isTargetReached) {
|
if (isTargetReached) {
|
||||||
const updatedCoins = await addCoins({
|
const updatedCoins = await addCoins({
|
||||||
amount: habit.coinReward,
|
amount: habit.coinReward,
|
||||||
description: `Completed habit: ${habit.name}`,
|
description: `Completed: ${habit.name}`,
|
||||||
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
||||||
relatedItemId: habit.id,
|
relatedItemId: habit.id,
|
||||||
})
|
})
|
||||||
|
playSound()
|
||||||
|
toast({
|
||||||
|
title: t("completedTitle"),
|
||||||
|
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
|
||||||
|
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||||
|
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
|
||||||
|
</ToastAction>
|
||||||
|
})
|
||||||
setCoins(updatedCoins)
|
setCoins(updatedCoins)
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t("progressTitle"),
|
||||||
|
description: t("progressDescription", { count: completionsToday + 1, target }),
|
||||||
|
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||||
|
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
|
||||||
|
</ToastAction>
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
// move atom update at the end of function to improve UI responsiveness
|
||||||
toast({
|
setHabitsData({ habits: updatedHabits })
|
||||||
title: isTargetReached ? "Habit completed!" : "Progress!",
|
|
||||||
description: isTargetReached
|
|
||||||
? `You earned ${habit.coinReward} coins.`
|
|
||||||
: `You've completed ${completionsToday + 1}/${target} times today.`,
|
|
||||||
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
|
||||||
<Undo2 className="h-4 w-4" />Undo
|
|
||||||
</ToastAction>
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updatedHabits,
|
updatedHabits,
|
||||||
@@ -78,6 +109,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const undoComplete = async (habit: Habit) => {
|
const undoComplete = async (habit: Habit) => {
|
||||||
|
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) 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 })
|
||||||
|
|
||||||
@@ -87,12 +119,13 @@ export function useHabits() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (todayCompletions.length > 0) {
|
if (todayCompletions.length > 0) {
|
||||||
// Remove the most recent completion
|
// Remove the most recent completion and unarchive if needed
|
||||||
const updatedHabit = {
|
const updatedHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
completions: habit.completions.filter(
|
completions: habit.completions.filter(
|
||||||
(_, index) => index !== habit.completions.length - 1
|
(_, index) => index !== habit.completions.length - 1
|
||||||
)
|
),
|
||||||
|
archived: habit.isTask ? false : habit.archived // Unarchive if it's a task
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedHabits = habitsData.habits.map(h =>
|
const updatedHabits = habitsData.habits.map(h =>
|
||||||
@@ -107,7 +140,7 @@ export function useHabits() {
|
|||||||
if (todayCompletions.length === target) {
|
if (todayCompletions.length === target) {
|
||||||
const updatedCoins = await removeCoins({
|
const updatedCoins = await removeCoins({
|
||||||
amount: habit.coinReward,
|
amount: habit.coinReward,
|
||||||
description: `Undid habit completion: ${habit.name}`,
|
description: `Undid completion: ${habit.name}`,
|
||||||
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
|
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
|
||||||
relatedItemId: habit.id,
|
relatedItemId: habit.id,
|
||||||
})
|
})
|
||||||
@@ -115,14 +148,17 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Completion undone",
|
title: t("completionUndoneTitle"),
|
||||||
description: `You have ${getCompletionsForDate({
|
description: t("completionUndoneDescription", {
|
||||||
habit: updatedHabit,
|
count: getCompletionsForDate({
|
||||||
date: today,
|
habit: updatedHabit,
|
||||||
timezone
|
date: today,
|
||||||
})}/${target} completions today.`,
|
timezone
|
||||||
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
|
}),
|
||||||
<Undo2 className="h-4 w-4" />Redo
|
target
|
||||||
|
}),
|
||||||
|
action: <ToastAction altText={tCommon('redoButton')} onClick={() => completeHabit(updatedHabit)}>
|
||||||
|
<Undo2 className="h-4 w-4" />{tCommon('redoButton')}
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,18 +169,19 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "No completions to undo",
|
title: t("noCompletionsToUndoTitle"),
|
||||||
description: "This habit hasn't been completed today.",
|
description: t("noCompletionsToUndoDescription"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
const newHabit = {
|
const newHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
id: habit.id || getNowInMilliseconds().toString()
|
id: habit.id || crypto.randomUUID()
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -156,6 +193,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteHabit = async (id: string) => {
|
const deleteHabit = async (id: string) => {
|
||||||
|
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) 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 })
|
||||||
@@ -163,6 +201,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
|
||||||
const timezone = settings.system.timezone
|
const timezone = settings.system.timezone
|
||||||
const dateKey = getISODate({ dateTime: date, timezone })
|
const dateKey = getISODate({ dateTime: date, timezone })
|
||||||
|
|
||||||
@@ -174,11 +213,11 @@ export function useHabits() {
|
|||||||
|
|
||||||
if (completionsOnDate >= target) {
|
if (completionsOnDate >= target) {
|
||||||
toast({
|
toast({
|
||||||
title: "Already completed",
|
title: t("alreadyCompletedPastDateTitle"),
|
||||||
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`,
|
description: t("alreadyCompletedPastDateDescription", { dateKey: d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' }) }),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use current time but with the past date
|
// Use current time but with the past date
|
||||||
@@ -207,7 +246,7 @@ export function useHabits() {
|
|||||||
if (isTargetReached) {
|
if (isTargetReached) {
|
||||||
const updatedCoins = await addCoins({
|
const updatedCoins = await addCoins({
|
||||||
amount: habit.coinReward,
|
amount: habit.coinReward,
|
||||||
description: `Completed habit: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
|
description: `Completed: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
|
||||||
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
||||||
relatedItemId: habit.id,
|
relatedItemId: habit.id,
|
||||||
})
|
})
|
||||||
@@ -215,12 +254,12 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: isTargetReached ? "Habit completed!" : "Progress!",
|
title: isTargetReached ? t("completedTitle") : t("progressTitle"),
|
||||||
description: isTargetReached
|
description: isTargetReached
|
||||||
? `You earned ${habit.coinReward} coins for ${dateKey}.`
|
? t("earnedCoinsPastDateDescription", { coinReward: habit.coinReward, dateKey })
|
||||||
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`,
|
: t("progressPastDateDescription", { count: completionsOnDate + 1, target, dateKey }),
|
||||||
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||||
<Undo2 className="h-4 w-4" />Undo
|
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,6 +271,7 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const archiveHabit = async (id: string) => {
|
const archiveHabit = async (id: string) => {
|
||||||
|
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) 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
|
||||||
)
|
)
|
||||||
@@ -240,8 +280,9 @@ export function useHabits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unarchiveHabit = async (id: string) => {
|
const unarchiveHabit = async (id: string) => {
|
||||||
|
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
||||||
const updatedHabits = habitsData.habits.map(h =>
|
const updatedHabits = habitsData.habits.map(h =>
|
||||||
h.id === id ? { ...h, archived: undefined } : h
|
h.id === id ? { ...h, archived: false } : h
|
||||||
)
|
)
|
||||||
await saveHabitsData({ habits: updatedHabits })
|
await saveHabitsData({ habits: updatedHabits })
|
||||||
setHabitsData({ habits: updatedHabits })
|
setHabitsData({ habits: updatedHabits })
|
||||||
@@ -254,6 +295,7 @@ export function useHabits() {
|
|||||||
deleteHabit,
|
deleteHabit,
|
||||||
completePastHabit,
|
completePastHabit,
|
||||||
archiveHabit,
|
archiveHabit,
|
||||||
unarchiveHabit
|
unarchiveHabit,
|
||||||
|
habitFreqMap,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,56 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
|
||||||
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
|
|
||||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { handlePermissionCheck } from '@/lib/utils'
|
||||||
import { celebrations } from '@/utils/celebrations'
|
import { celebrations } from '@/utils/celebrations'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useCoins } from './useCoins'
|
||||||
|
|
||||||
export function useWishlist() {
|
export function useWishlist() {
|
||||||
|
const t = useTranslations('useWishlist');
|
||||||
|
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 = coins.balance
|
const { balance } = useCoins()
|
||||||
|
|
||||||
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
||||||
|
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) 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]
|
||||||
setWishlist({ items: newItems })
|
const newWishListData = { items: newItems }
|
||||||
await saveWishlistItems(newItems)
|
setWishlist(newWishListData)
|
||||||
|
await saveWishlistItems(newWishListData)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editWishlistItem = async (updatedItem: WishlistItemType) => {
|
const editWishlistItem = async (updatedItem: WishlistItemType) => {
|
||||||
|
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) 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
|
||||||
)
|
)
|
||||||
setWishlist({ items: newItems })
|
const newWishListData = { items: newItems }
|
||||||
await saveWishlistItems(newItems)
|
setWishlist(newWishListData)
|
||||||
|
await saveWishlistItems(newWishListData)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteWishlistItem = async (id: string) => {
|
const deleteWishlistItem = async (id: string) => {
|
||||||
|
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
|
||||||
const newItems = wishlist.items.filter(item => item.id !== id)
|
const newItems = wishlist.items.filter(item => item.id !== id)
|
||||||
setWishlist({ items: newItems })
|
const newWishListData = { items: newItems }
|
||||||
await saveWishlistItems(newItems)
|
setWishlist(newWishListData)
|
||||||
|
await saveWishlistItems(newWishListData)
|
||||||
}
|
}
|
||||||
|
|
||||||
const redeemWishlistItem = async (item: WishlistItemType) => {
|
const redeemWishlistItem = async (item: WishlistItemType) => {
|
||||||
|
if (!handlePermissionCheck(user, 'wishlist', 'interact', tCommon)) 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: "Redemption limit reached",
|
title: t("redemptionLimitReachedTitle"),
|
||||||
description: `You've reached the maximum redemptions for "${item.name}".`,
|
description: t("redemptionLimitReachedDescription", { itemName: item.name }),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -71,8 +84,9 @@ export function useWishlist() {
|
|||||||
}
|
}
|
||||||
return wishlistItem
|
return wishlistItem
|
||||||
})
|
})
|
||||||
setWishlist({ items: newItems })
|
const newWishListData = { items: newItems }
|
||||||
await saveWishlistItems(newItems)
|
setWishlist(newWishListData)
|
||||||
|
await saveWishlistItems(newWishListData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Randomly choose a celebration effect
|
// Randomly choose a celebration effect
|
||||||
@@ -83,15 +97,15 @@ export function useWishlist() {
|
|||||||
randomEffect()
|
randomEffect()
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "🎉 Reward Redeemed!",
|
title: t("rewardRedeemedTitle"),
|
||||||
description: `You've redeemed "${item.name}" for ${item.coinCost} coins.`,
|
description: t("rewardRedeemedDescription", { itemName: item.name, itemCoinCost: item.coinCost }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "Not enough coins",
|
title: t("notEnoughCoinsTitle"),
|
||||||
description: `You need ${item.coinCost - balance} more coins to redeem this reward.`,
|
description: t("notEnoughCoinsDescription", { coinsNeeded: item.coinCost - balance }),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -101,19 +115,23 @@ 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
|
||||||
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
|
||||||
)
|
)
|
||||||
setWishlist({ items: newItems })
|
const newWishListData = { items: newItems }
|
||||||
await saveWishlistItems(newItems)
|
setWishlist(newWishListData)
|
||||||
|
await saveWishlistItems(newWishListData)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unarchiveWishlistItem = async (id: string) => {
|
const unarchiveWishlistItem = async (id: string) => {
|
||||||
|
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
|
||||||
const newItems = wishlist.items.map(item =>
|
const newItems = wishlist.items.map(item =>
|
||||||
item.id === id ? { ...item, archived: undefined } : item
|
item.id === id ? { ...item, archived: false } : item
|
||||||
)
|
)
|
||||||
setWishlist({ items: newItems })
|
const newWishListData = { items: newItems }
|
||||||
await saveWishlistItems(newItems)
|
setWishlist(newWishListData)
|
||||||
|
await saveWishlistItems(newWishListData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
13
i18n/request.ts
Normal file
13
i18n/request.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
import { loadSettings } from '@/app/actions/data'; // Adjust path as necessary
|
||||||
|
|
||||||
|
export default getRequestConfig(async () => {
|
||||||
|
// Load settings to get the user's preferred language
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const locale = settings.system.language || 'en'; // Fallback to 'en' if not set
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default
|
||||||
|
};
|
||||||
|
});
|
||||||
28
instrumentation.ts
Normal file
28
instrumentation.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { init } from '@/lib/env.server'; // startup env var check
|
||||||
|
|
||||||
|
// Ensure this function is exported
|
||||||
|
export async function register() {
|
||||||
|
// We only want to run this code on the server side
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
console.log('Node.js runtime detected, running server-side instrumentation...');
|
||||||
|
// Initialize environment variables first
|
||||||
|
console.log('Initializing environment variables...');
|
||||||
|
init();
|
||||||
|
console.log('Environment variables initialized.');
|
||||||
|
|
||||||
|
// Dynamically import the scheduler initializer
|
||||||
|
// Use await import() for ESM compatibility
|
||||||
|
try {
|
||||||
|
console.log('Attempting to import scheduler...');
|
||||||
|
// Ensure the path is correct relative to the project root
|
||||||
|
const { initializeScheduler } = await import('./lib/scheduler');
|
||||||
|
console.log('Scheduler imported successfully. Initializing...');
|
||||||
|
initializeScheduler();
|
||||||
|
console.log('Scheduler initialization called.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import or initialize scheduler:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Instrumentation hook running in environment: ${process.env.NEXT_RUNTIME}. Skipping server-side initialization.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
lib/atoms.ts
233
lib/atoms.ts
@@ -1,79 +1,51 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
getDefaultSettings,
|
calculateCoinsEarnedToday,
|
||||||
getDefaultHabitsData,
|
calculateCoinsSpentToday,
|
||||||
|
calculateTotalEarned,
|
||||||
|
generateCryptoHash,
|
||||||
|
isHabitDue,
|
||||||
|
prepareDataForHashing,
|
||||||
|
roundToInteger,
|
||||||
|
t2d
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import {
|
||||||
|
BrowserSettings,
|
||||||
|
CoinsData,
|
||||||
|
CompletionCache,
|
||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
|
getDefaultHabitsData,
|
||||||
|
getDefaultServerSettings,
|
||||||
|
getDefaultSettings,
|
||||||
|
getDefaultUsersData,
|
||||||
getDefaultWishlistData,
|
getDefaultWishlistData,
|
||||||
Habit,
|
Habit,
|
||||||
ViewType,
|
HabitsData,
|
||||||
|
PomodoroAtom,
|
||||||
|
ServerSettings,
|
||||||
|
Settings,
|
||||||
|
UserData,
|
||||||
|
UserId,
|
||||||
|
WishlistData
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
|
||||||
getTodayInTimezone,
|
|
||||||
isSameDate,
|
|
||||||
t2d,
|
|
||||||
calculateCoinsEarnedToday,
|
|
||||||
calculateTotalEarned,
|
|
||||||
calculateTotalSpent,
|
|
||||||
calculateCoinsSpentToday,
|
|
||||||
calculateTransactionsToday,
|
|
||||||
getCompletionsForToday,
|
|
||||||
getISODate
|
|
||||||
} from "@/lib/utils";
|
|
||||||
import { atomWithStorage } from "jotai/utils";
|
|
||||||
|
|
||||||
export interface BrowserSettings {
|
|
||||||
viewType: ViewType
|
|
||||||
}
|
|
||||||
|
|
||||||
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||||
viewType: 'habits'
|
expandedHabits: false,
|
||||||
|
expandedTasks: false,
|
||||||
|
expandedWishlist: false
|
||||||
} as BrowserSettings)
|
} as BrowserSettings)
|
||||||
|
|
||||||
export const settingsAtom = atom(getDefaultSettings());
|
export const usersAtom = atom(getDefaultUsersData<UserData>())
|
||||||
export const habitsAtom = atom(getDefaultHabitsData());
|
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
|
||||||
export const coinsAtom = atom(getDefaultCoinsData());
|
export const settingsAtom = atom(getDefaultSettings<Settings>());
|
||||||
export const wishlistAtom = atom(getDefaultWishlistData());
|
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
|
||||||
|
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
|
||||||
// Derived atom for coins earned today
|
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
|
||||||
export const coinsEarnedTodayAtom = atom((get) => {
|
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
|
||||||
const coins = get(coinsAtom);
|
export const userSelectAtom = atom<boolean>(false)
|
||||||
const settings = get(settingsAtom);
|
export const aboutOpenAtom = atom<boolean>(false)
|
||||||
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived atom for total earned
|
|
||||||
export const totalEarnedAtom = atom((get) => {
|
|
||||||
const coins = get(coinsAtom);
|
|
||||||
return calculateTotalEarned(coins.transactions);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived atom for total spent
|
|
||||||
export const totalSpentAtom = atom((get) => {
|
|
||||||
const coins = get(coinsAtom);
|
|
||||||
return calculateTotalSpent(coins.transactions);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived atom for coins spent today
|
|
||||||
export const coinsSpentTodayAtom = atom((get) => {
|
|
||||||
const coins = get(coinsAtom);
|
|
||||||
const settings = get(settingsAtom);
|
|
||||||
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived atom for transactions today
|
|
||||||
export const transactionsTodayAtom = atom((get) => {
|
|
||||||
const coins = get(coinsAtom);
|
|
||||||
const settings = get(settingsAtom);
|
|
||||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* transient atoms */
|
|
||||||
interface PomodoroAtom {
|
|
||||||
show: boolean
|
|
||||||
selectedHabitId: string | null
|
|
||||||
autoStart: boolean
|
|
||||||
minimized: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pomodoroAtom = atom<PomodoroAtom>({
|
export const pomodoroAtom = atom<PomodoroAtom>({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -82,50 +54,95 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
|||||||
minimized: false,
|
minimized: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Derived atom for *fully* completed habits by date, respecting target completions
|
// Derived atom for coins earned today
|
||||||
export const completedHabitsMapAtom = atom((get) => {
|
export const coinsEarnedTodayAtom = atom((get) => {
|
||||||
const habits = get(habitsAtom).habits
|
const coins = get(coinsAtom);
|
||||||
const timezone = get(settingsAtom).system.timezone
|
const settings = get(settingsAtom);
|
||||||
|
const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||||
|
return roundToInteger(value);
|
||||||
|
});
|
||||||
|
|
||||||
const map = new Map<string, Habit[]>()
|
// Derived atom for total earned
|
||||||
|
export const totalEarnedAtom = atom((get) => {
|
||||||
|
const coins = get(coinsAtom);
|
||||||
|
const value = calculateTotalEarned(coins.transactions);
|
||||||
|
return roundToInteger(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived atom for coins spent today
|
||||||
|
export const coinsSpentTodayAtom = atom((get) => {
|
||||||
|
const coins = get(coinsAtom);
|
||||||
|
const settings = get(settingsAtom);
|
||||||
|
const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
||||||
|
return roundToInteger(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const currentUserAtom = atom((get) => {
|
||||||
|
const currentUserId = get(currentUserIdAtom);
|
||||||
|
const users = get(usersAtom);
|
||||||
|
return users.users.find(user => user.id === currentUserId);
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 completed habits by date, using the cache
|
||||||
|
export const completedHabitsMapAtom = atom((get) => {
|
||||||
|
const habits = get(habitsAtom).habits;
|
||||||
|
const completionCache: CompletionCache = {};
|
||||||
|
const map = new Map<string, Habit[]>();
|
||||||
|
const timezone = get(settingsAtom).system.timezone;
|
||||||
|
|
||||||
habits.forEach(habit => {
|
habits.forEach(habit => {
|
||||||
// Group completions by date
|
habit.completions.forEach(utcTimestamp => {
|
||||||
const completionsByDate = new Map<string, number>()
|
const localDate = t2d({ timestamp: utcTimestamp, timezone })
|
||||||
|
.toFormat('yyyy-MM-dd');
|
||||||
|
|
||||||
habit.completions.forEach(completion => {
|
if (!completionCache[localDate]) {
|
||||||
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone })
|
completionCache[localDate] = {};
|
||||||
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if habit meets target completions for each date
|
|
||||||
completionsByDate.forEach((count, dateKey) => {
|
|
||||||
const target = habit.targetCompletions || 1
|
|
||||||
if (count >= target) {
|
|
||||||
if (!map.has(dateKey)) {
|
|
||||||
map.set(dateKey, [])
|
|
||||||
}
|
|
||||||
map.get(dateKey)!.push(habit)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
completionCache[localDate][habit.id] = (completionCache[localDate][habit.id] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// For each date in the cache
|
||||||
|
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
|
||||||
|
const completedHabits = habits.filter(habit => {
|
||||||
|
const completionsNeeded = habit.targetCompletions || 1;
|
||||||
|
const completionsAchieved = habitCompletions[habit.id] || 0;
|
||||||
|
return completionsAchieved >= completionsNeeded;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (completedHabits.length > 0) {
|
||||||
|
map.set(dateKey, completedHabits);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atom family for habits by specific date
|
||||||
|
export const habitsByDateFamily = atomFamily((dateString: string) =>
|
||||||
|
atom((get) => {
|
||||||
|
const habits = get(habitsAtom).habits;
|
||||||
|
const settings = get(settingsAtom);
|
||||||
|
const timezone = settings.system.timezone;
|
||||||
|
|
||||||
|
const date = DateTime.fromISO(dateString).setZone(timezone);
|
||||||
|
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
||||||
})
|
})
|
||||||
|
);
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
|
||||||
const pomo = get(pomodoroAtom)
|
|
||||||
const habits = get(habitsAtom)
|
|
||||||
const settings = get(settingsAtom)
|
|
||||||
|
|
||||||
if (!pomo.selectedHabitId) return 0
|
|
||||||
|
|
||||||
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId!)
|
|
||||||
if (!selectedHabit) return 0
|
|
||||||
|
|
||||||
return getCompletionsForToday({
|
|
||||||
habit: selectedHabit,
|
|
||||||
timezone: settings.system.timezone
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
143
lib/backup.ts
Normal file
143
lib/backup.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import { createWriteStream } from 'fs'; // Use specific import for createWriteStream
|
||||||
|
import path from 'path';
|
||||||
|
import archiver from 'archiver';
|
||||||
|
import { loadSettings } from '@/app/actions/data'; // Adjust path if needed
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
const BACKUP_DIR = path.join(process.cwd(), 'backups');
|
||||||
|
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||||
|
const MAX_BACKUPS = 7; // Number of backups to keep
|
||||||
|
|
||||||
|
async function ensureBackupDir() {
|
||||||
|
try {
|
||||||
|
await fs.access(BACKUP_DIR);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(BACKUP_DIR, { recursive: true });
|
||||||
|
console.log('Created backup directory:', BACKUP_DIR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateBackups() {
|
||||||
|
try {
|
||||||
|
await ensureBackupDir();
|
||||||
|
const files = await fs.readdir(BACKUP_DIR);
|
||||||
|
const backupFiles = files
|
||||||
|
.filter(file => file.startsWith('backup-') && file.endsWith('.zip'))
|
||||||
|
.map(file => ({
|
||||||
|
name: file,
|
||||||
|
path: path.join(BACKUP_DIR, file),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (backupFiles.length <= MAX_BACKUPS) {
|
||||||
|
console.log(`Rotation check: ${backupFiles.length} backups found, less than or equal to max ${MAX_BACKUPS}. No rotation needed.`);
|
||||||
|
return; // No rotation needed
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Rotation check: ${backupFiles.length} backups found, exceeding max ${MAX_BACKUPS}. Starting rotation.`);
|
||||||
|
|
||||||
|
// Get stats to sort by creation time (mtime as proxy)
|
||||||
|
const fileStats = await Promise.all(
|
||||||
|
backupFiles.map(async (file) => ({
|
||||||
|
...file,
|
||||||
|
stat: await fs.stat(file.path),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort oldest first
|
||||||
|
fileStats.sort((a, b) => a.stat.mtime.getTime() - b.stat.mtime.getTime());
|
||||||
|
|
||||||
|
const filesToDelete = fileStats.slice(0, fileStats.length - MAX_BACKUPS);
|
||||||
|
console.log(`Identified ${filesToDelete.length} backups to delete.`);
|
||||||
|
|
||||||
|
for (const file of filesToDelete) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(file.path);
|
||||||
|
console.log(`Rotated (deleted) old backup: ${file.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error deleting old backup ${file.name}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during backup rotation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBackup() {
|
||||||
|
try {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
if (!settings.system.autoBackupEnabled) {
|
||||||
|
console.log('Auto backup is disabled in settings. Skipping backup.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Starting daily backup...');
|
||||||
|
await ensureBackupDir();
|
||||||
|
|
||||||
|
const timestamp = DateTime.now().toFormat('yyyy-MM-dd_HH-mm-ss');
|
||||||
|
const backupFileName = `backup-${timestamp}.zip`;
|
||||||
|
const backupFilePath = path.join(BACKUP_DIR, backupFileName);
|
||||||
|
|
||||||
|
// Use createWriteStream from fs directly
|
||||||
|
const output = createWriteStream(backupFilePath);
|
||||||
|
const archive = archiver('zip', {
|
||||||
|
zlib: { level: 9 } // Sets the compression level.
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
output.on('close', async () => {
|
||||||
|
console.log(`Backup created successfully: ${backupFileName} (${archive.pointer()} total bytes)`);
|
||||||
|
try {
|
||||||
|
await rotateBackups(); // Rotate after successful backup
|
||||||
|
resolve();
|
||||||
|
} catch (rotationError) {
|
||||||
|
console.error("Error during post-backup rotation:", rotationError);
|
||||||
|
// Decide if backup failure should depend on rotation failure
|
||||||
|
// For now, resolve even if rotation fails, as backup itself succeeded.
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stream finish event for better completion tracking
|
||||||
|
output.on('finish', () => {
|
||||||
|
console.log('Backup file stream finished writing.');
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on('warning', (err) => {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
// Log specific warnings but don't necessarily reject
|
||||||
|
console.warn('Archiver warning (ENOENT):', err);
|
||||||
|
} else {
|
||||||
|
// Treat other warnings as potential issues, but maybe not fatal
|
||||||
|
console.warn('Archiver warning:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on('error', (err) => {
|
||||||
|
console.error('Archiver error:', err);
|
||||||
|
reject(err); // Reject the promise on critical archiver errors
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipe archive data to the file
|
||||||
|
archive.pipe(output);
|
||||||
|
|
||||||
|
// Append the entire data directory to the archive
|
||||||
|
// The second argument specifies the path prefix inside the zip file (false means root)
|
||||||
|
console.log(`Archiving directory: ${DATA_DIR}`);
|
||||||
|
archive.directory(DATA_DIR, false);
|
||||||
|
|
||||||
|
// Finalize the archive (writes the central directory)
|
||||||
|
console.log('Finalizing archive...');
|
||||||
|
archive.finalize().catch(err => {
|
||||||
|
// Catch potential errors during finalization
|
||||||
|
console.error('Error during archive finalization:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to run backup:', error);
|
||||||
|
// Rethrow or handle as appropriate for the scheduler
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
lib/client-helpers.ts
Normal file
36
lib/client-helpers.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// client helpers
|
||||||
|
'use-client'
|
||||||
|
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { usersAtom } from './atoms'
|
||||||
|
import { hasPermission } from './utils'
|
||||||
|
|
||||||
|
export function useHelpers() {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const currentUserId = session?.user.id
|
||||||
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||||
|
// detect iOS: https://stackoverflow.com/a/9039885
|
||||||
|
function iOS() {
|
||||||
|
return typeof navigator !== "undefined" && ([
|
||||||
|
'iPad Simulator',
|
||||||
|
'iPhone Simulator',
|
||||||
|
'iPod Simulator',
|
||||||
|
'iPad',
|
||||||
|
'iPhone',
|
||||||
|
'iPod',
|
||||||
|
].includes(navigator.platform)
|
||||||
|
// iPad on iOS 13 detection
|
||||||
|
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUserId,
|
||||||
|
currentUser,
|
||||||
|
usersData,
|
||||||
|
status,
|
||||||
|
hasPermission,
|
||||||
|
isIOS: iOS(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CheckSquare, Target } from "lucide-react"
|
import { CheckSquare, Target } from "lucide-react"
|
||||||
|
|
||||||
export const INITIAL_RECURRENCE_RULE = 'daily'
|
export const INITIAL_RECURRENCE_RULE = 'every day'
|
||||||
export const INITIAL_DUE = 'today'
|
export const INITIAL_DUE = 'today'
|
||||||
|
|
||||||
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
||||||
@@ -19,3 +19,18 @@ export const DUE_MAP: { [key: string]: string } = {
|
|||||||
|
|
||||||
export const HabitIcon = Target
|
export const HabitIcon = Target
|
||||||
export const TaskIcon = CheckSquare;
|
export const TaskIcon = CheckSquare;
|
||||||
|
export const QUICK_DATES = [
|
||||||
|
{ label: 'Today', value: 'today' },
|
||||||
|
{ label: 'Tomorrow', value: 'tomorrow' },
|
||||||
|
{ label: 'Monday', value: 'this monday' },
|
||||||
|
{ label: 'Tuesday', value: 'this tuesday' },
|
||||||
|
{ label: 'Wednesday', value: 'this wednesday' },
|
||||||
|
{ label: 'Thursday', value: 'this thursday' },
|
||||||
|
{ label: 'Friday', value: 'this friday' },
|
||||||
|
{ label: 'Saturday', value: 'this saturday' },
|
||||||
|
{ label: 'Sunday', value: 'this sunday' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const MAX_COIN_LIMIT = 9999
|
||||||
|
|
||||||
|
export const DESKTOP_DISPLAY_ITEM_COUNT = 4
|
||||||
32
lib/env.server.ts
Normal file
32
lib/env.server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const zodEnv = z.object({
|
||||||
|
AUTH_SECRET: z.string(),
|
||||||
|
DEMO: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
|
||||||
|
AUTH_SECRET: string;
|
||||||
|
DEMO?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
try {
|
||||||
|
zodEnv.parse(process.env)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
const { fieldErrors } = err.flatten()
|
||||||
|
const errorMessage = Object.entries(fieldErrors)
|
||||||
|
.map(([field, errors]) =>
|
||||||
|
errors ? `${field}: ${errors.join(", ")}` : field,
|
||||||
|
)
|
||||||
|
.join("\n ")
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Missing environment variables:\n ${errorMessage}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/exceptions.ts
Normal file
6
lib/exceptions.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export class PermissionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'PermissionError'
|
||||||
|
}
|
||||||
|
}
|
||||||
54
lib/scheduler.ts
Normal file
54
lib/scheduler.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import { runBackup } from './backup';
|
||||||
|
|
||||||
|
let isSchedulerInitialized = false;
|
||||||
|
|
||||||
|
export function initializeScheduler() {
|
||||||
|
if (isSchedulerInitialized) {
|
||||||
|
console.log('Scheduler already initialized.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Initializing scheduler...');
|
||||||
|
|
||||||
|
// Schedule backup to run daily at 2:00 AM server time
|
||||||
|
// Format: second minute hour day-of-month month day-of-week
|
||||||
|
// '0 2 * * *' means at minute 0 of hour 2 (2:00 AM) every day
|
||||||
|
const backupJob = cron.schedule('0 2 * * *', async () => {
|
||||||
|
console.log(`[${new Date().toISOString()}] Running scheduled daily backup task...`);
|
||||||
|
try {
|
||||||
|
await runBackup();
|
||||||
|
console.log(`[${new Date().toISOString()}] Scheduled backup task completed successfully.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${new Date().toISOString()}] Scheduled backup task failed:`, err);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
scheduled: true,
|
||||||
|
// Consider adding timezone support later if needed, based on user settings
|
||||||
|
// timezone: "Your/Timezone"
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Scheduler initialized. Daily backup scheduled for 2:00 AM server time.');
|
||||||
|
isSchedulerInitialized = true;
|
||||||
|
|
||||||
|
// Graceful shutdown handling (optional but recommended)
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SIGTERM signal received. Stopping scheduler...');
|
||||||
|
backupJob.stop();
|
||||||
|
// Add cleanup for other jobs if needed
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('SIGINT signal received. Stopping scheduler...');
|
||||||
|
backupJob.stop();
|
||||||
|
// Add cleanup for other jobs if needed
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Add other scheduled tasks here in the future ---
|
||||||
|
// Example:
|
||||||
|
// cron.schedule('* * * * *', () => {
|
||||||
|
// console.log('Running every minute');
|
||||||
|
// });
|
||||||
|
}
|
||||||
40
lib/server-helpers.ts
Normal file
40
lib/server-helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { auth } from '@/auth'
|
||||||
|
import 'server-only'
|
||||||
|
import { User, UserId } from './types'
|
||||||
|
import { loadUsersData } from '@/app/actions/data'
|
||||||
|
import { randomBytes, scryptSync } from 'crypto'
|
||||||
|
|
||||||
|
export async function getCurrentUserId(): Promise<UserId | undefined> {
|
||||||
|
const session = await auth()
|
||||||
|
const user = session?.user
|
||||||
|
return user?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser(): Promise<User | undefined> {
|
||||||
|
const currentUserId = await getCurrentUserId()
|
||||||
|
if (!currentUserId) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const usersData = await loadUsersData()
|
||||||
|
return usersData.users.find((u) => u.id === currentUserId)
|
||||||
|
}
|
||||||
|
export function saltAndHashPassword(password: string, salt?: string): string {
|
||||||
|
if (password.length === 0) throw new Error('Password must not be empty')
|
||||||
|
salt = salt || randomBytes(16).toString('hex')
|
||||||
|
const hash = scryptSync(password, salt, 64).toString('hex')
|
||||||
|
return `${salt}:${hash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password?: string, storedHash?: string): boolean {
|
||||||
|
// if both password and storedHash is undefined, return true
|
||||||
|
if (!password && !storedHash) return true
|
||||||
|
// else if either password or storedHash is undefined, return false
|
||||||
|
if (!password || !storedHash) return false
|
||||||
|
|
||||||
|
// Split the stored hash into its salt and hash components
|
||||||
|
const [salt, hash] = storedHash.split(':')
|
||||||
|
// Hash the input password with the same salt
|
||||||
|
const newHash = saltAndHashPassword(password, salt).split(':')[1]
|
||||||
|
// Compare the new hash with the stored hash
|
||||||
|
return newHash === hash
|
||||||
|
}
|
||||||
205
lib/startup-checks.test.ts
Normal file
205
lib/startup-checks.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, expect, test, beforeEach, mock } from 'bun:test'
|
||||||
|
import { checkStartupPermissions } from './startup-checks'
|
||||||
|
|
||||||
|
// Mock the fs promises module
|
||||||
|
const mockStat = mock()
|
||||||
|
const mockWriteFile = mock()
|
||||||
|
const mockReadFile = mock()
|
||||||
|
const mockUnlink = mock()
|
||||||
|
|
||||||
|
mock.module('fs', () => ({
|
||||||
|
promises: {
|
||||||
|
stat: mockStat,
|
||||||
|
writeFile: mockWriteFile,
|
||||||
|
readFile: mockReadFile,
|
||||||
|
unlink: mockUnlink,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('checkStartupPermissions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
mockStat.mockReset()
|
||||||
|
mockWriteFile.mockReset()
|
||||||
|
mockReadFile.mockReset()
|
||||||
|
mockUnlink.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return success when directory exists and has proper permissions', async () => {
|
||||||
|
// Mock successful directory stat
|
||||||
|
mockStat.mockResolvedValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock successful file operations
|
||||||
|
mockWriteFile.mockResolvedValue(undefined)
|
||||||
|
mockReadFile.mockResolvedValue('permission-test')
|
||||||
|
mockUnlink.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await checkStartupPermissions()
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||||
|
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return error when directory does not exist', async () => {
|
||||||
|
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'))
|
||||||
|
|
||||||
|
const result = await checkStartupPermissions()
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: 'data',
|
||||||
|
message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.',
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return error when path exists but is not a directory', async () => {
|
||||||
|
// Mock path exists but is a file, not directory
|
||||||
|
mockStat.mockResolvedValue({
|
||||||
|
isDirectory: () => false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await checkStartupPermissions()
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: 'data',
|
||||||
|
message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.',
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return error when write permission fails', async () => {
|
||||||
|
// Mock successful directory stat
|
||||||
|
mockStat.mockResolvedValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock write failure
|
||||||
|
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||||
|
|
||||||
|
const result = await checkStartupPermissions()
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: 'data',
|
||||||
|
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return error when read permission fails', async () => {
|
||||||
|
// Mock successful directory stat and write
|
||||||
|
mockStat.mockResolvedValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
})
|
||||||
|
mockWriteFile.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// Mock read failure
|
||||||
|
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||||
|
|
||||||
|
const result = await checkStartupPermissions()
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: 'data',
|
||||||
|
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return error when read content does not match written content', async () => {
|
||||||
|
// Mock successful directory stat and write
|
||||||
|
mockStat.mockResolvedValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
})
|
||||||
|
mockWriteFile.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// Mock read with different content
|
||||||
|
mockReadFile.mockResolvedValue('different-content')
|
||||||
|
|
||||||
|
const result = await checkStartupPermissions()
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: 'data',
|
||||||
|
message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.',
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||||
|
expect(mockUnlink).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return error when cleanup (unlink) fails', async () => {
|
||||||
|
// Mock successful directory stat, write, and read
|
||||||
|
mockStat.mockResolvedValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
})
|
||||||
|
mockWriteFile.mockResolvedValue(undefined)
|
||||||
|
mockReadFile.mockResolvedValue('permission-test')
|
||||||
|
|
||||||
|
// Mock cleanup failure
|
||||||
|
mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||||
|
|
||||||
|
const result = await checkStartupPermissions()
|
||||||
|
|
||||||
|
// Should return error since cleanup failed and is part of the try-catch block
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: 'data',
|
||||||
|
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||||
|
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should use correct file paths', async () => {
|
||||||
|
// Mock successful operations
|
||||||
|
mockStat.mockResolvedValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
})
|
||||||
|
mockWriteFile.mockResolvedValue(undefined)
|
||||||
|
mockReadFile.mockResolvedValue('permission-test')
|
||||||
|
mockUnlink.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await checkStartupPermissions()
|
||||||
|
|
||||||
|
// Verify the correct paths are used
|
||||||
|
expect(mockStat).toHaveBeenCalledWith('data')
|
||||||
|
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||||
|
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||||
|
})
|
||||||
|
})
|
||||||
73
lib/startup-checks.ts
Normal file
73
lib/startup-checks.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const DEFAULT_DATA_DIR = 'data'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks startup permissions for the data directory
|
||||||
|
*/
|
||||||
|
interface StartupPermissionResult {
|
||||||
|
success: boolean
|
||||||
|
error?: { path: string; message: string; type?: 'writable_data_dir' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkStartupPermissions(): Promise<StartupPermissionResult> {
|
||||||
|
const dirPath = DEFAULT_DATA_DIR
|
||||||
|
|
||||||
|
// Check if directory exists and is accessible
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(dirPath)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: dirPath,
|
||||||
|
message: `Path '${dirPath}' exists but is not a directory. Please ensure the data directory is properly configured.`,
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (statError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: dirPath,
|
||||||
|
message: `Data directory '${dirPath}' does not exist or is not accessible. Check volume mounts and permissions.`,
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test read/write permissions with a temporary file
|
||||||
|
const testFilePath = join(dirPath, '.habittrove-permission-test')
|
||||||
|
const testContent = 'permission-test'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(testFilePath, testContent)
|
||||||
|
const readContent = await fs.readFile(testFilePath, 'utf8')
|
||||||
|
|
||||||
|
if (readContent !== testContent) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: dirPath,
|
||||||
|
message: `Data integrity check failed in '${dirPath}'. File system may be corrupted or have inconsistent behavior.`,
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.unlink(testFilePath)
|
||||||
|
return { success: true }
|
||||||
|
|
||||||
|
} catch (rwError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
path: dirPath,
|
||||||
|
message: `Insufficient read/write permissions for data directory '${dirPath}'. Check file permissions and ownership.`,
|
||||||
|
type: 'writable_data_dir'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
lib/types.ts
153
lib/types.ts
@@ -1,3 +1,39 @@
|
|||||||
|
import { RRule } from "rrule"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
|
export type UserId = string
|
||||||
|
|
||||||
|
export type Permission = {
|
||||||
|
habit: {
|
||||||
|
write: boolean
|
||||||
|
interact: boolean
|
||||||
|
}
|
||||||
|
wishlist: {
|
||||||
|
write: boolean
|
||||||
|
interact: boolean
|
||||||
|
}
|
||||||
|
coins: {
|
||||||
|
write: boolean
|
||||||
|
interact: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionUser = {
|
||||||
|
id: UserId
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SafeUser = SessionUser & {
|
||||||
|
username: string
|
||||||
|
avatarPath?: string
|
||||||
|
permissions?: Permission[]
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type User = SafeUser & {
|
||||||
|
password?: string // Optional: Allow users without passwords (e.g., initial setup)
|
||||||
|
lastNotificationReadTimestamp?: string // UTC ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
export type Habit = {
|
export type Habit = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -8,6 +44,9 @@ export type Habit = {
|
|||||||
completions: string[] // Array of UTC ISO date strings
|
completions: string[] // Array of UTC ISO date strings
|
||||||
isTask?: boolean // mark the habit as a task
|
isTask?: boolean // mark the habit as a task
|
||||||
archived?: boolean // mark the habit as archived
|
archived?: boolean // mark the habit as archived
|
||||||
|
pinned?: boolean // mark the habit as pinned
|
||||||
|
userIds?: UserId[]
|
||||||
|
drawing?: string // Optional JSON string of drawing data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +59,9 @@ export type WishlistItemType = {
|
|||||||
coinCost: number
|
coinCost: number
|
||||||
archived?: boolean // mark the wishlist item as archived
|
archived?: boolean // mark the wishlist item as archived
|
||||||
targetCompletions?: number // Optional field, infinity when unset
|
targetCompletions?: number // Optional field, infinity when unset
|
||||||
|
link?: string // Optional URL to external resource
|
||||||
|
userIds?: UserId[]
|
||||||
|
drawing?: string // Optional JSON string of drawing data
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
||||||
@@ -32,6 +74,11 @@ export interface CoinTransaction {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
relatedItemId?: string;
|
relatedItemId?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
userId?: UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
users: User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HabitsData {
|
export interface HabitsData {
|
||||||
@@ -51,38 +98,63 @@ export interface WishlistData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default value functions
|
// Default value functions
|
||||||
export const getDefaultHabitsData = (): HabitsData => ({
|
export function getDefaultUsersData<UserData>(): UserData {
|
||||||
habits: []
|
return {
|
||||||
});
|
users: [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
username: 'admin',
|
||||||
|
// password: '', // No default password for admin initially? Or set a secure default?
|
||||||
|
isAdmin: true,
|
||||||
|
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as UserData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultHabitsData<HabitsData>(): HabitsData {
|
||||||
|
return { habits: [] } as HabitsData;
|
||||||
|
}
|
||||||
|
|
||||||
export const getDefaultCoinsData = (): CoinsData => ({
|
export function getDefaultTasksData<TasksData>(): TasksData {
|
||||||
balance: 0,
|
return { tasks: [] } as TasksData;
|
||||||
transactions: []
|
};
|
||||||
});
|
|
||||||
|
|
||||||
export const getDefaultWishlistData = (): WishlistData => ({
|
export function getDefaultCoinsData<CoinsData>(): CoinsData {
|
||||||
items: []
|
return { balance: 0, transactions: [] } as CoinsData;
|
||||||
});
|
};
|
||||||
|
|
||||||
export const getDefaultSettings = (): Settings => ({
|
export function getDefaultWishlistData<WishlistData>(): WishlistData {
|
||||||
ui: {
|
return { items: [] } as WishlistData;
|
||||||
useNumberFormatting: true,
|
}
|
||||||
useGrouping: true,
|
|
||||||
},
|
export function getDefaultSettings<Settings>(): Settings {
|
||||||
system: {
|
return {
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
ui: {
|
||||||
weekStartDay: 1 // Monday
|
useNumberFormatting: true,
|
||||||
},
|
useGrouping: true,
|
||||||
profile: {}
|
},
|
||||||
});
|
system: {
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
weekStartDay: 1, // Monday
|
||||||
|
autoBackupEnabled: true, // Add this line (default to true)
|
||||||
|
language: 'en', // Default language
|
||||||
|
},
|
||||||
|
profile: {}
|
||||||
|
} as Settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
|
||||||
|
return { isDemo: false } as ServerSettings;
|
||||||
|
}
|
||||||
|
|
||||||
// Map of data types to their default values
|
// Map of data types to their default values
|
||||||
export const DATA_DEFAULTS = {
|
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
|
||||||
wishlist: getDefaultWishlistData,
|
wishlist: getDefaultWishlistData,
|
||||||
habits: getDefaultHabitsData,
|
habits: getDefaultHabitsData,
|
||||||
coins: getDefaultCoinsData,
|
coins: getDefaultCoinsData,
|
||||||
settings: getDefaultSettings,
|
settings: getDefaultSettings,
|
||||||
|
auth: getDefaultUsersData,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Type for all possible data types
|
// Type for all possible data types
|
||||||
@@ -98,10 +170,12 @@ export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday
|
|||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
weekStartDay: WeekDay;
|
weekStartDay: WeekDay;
|
||||||
|
autoBackupEnabled: boolean; // Add this line
|
||||||
|
language: string; // Add this line for language preference
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileSettings {
|
export interface ProfileSettings {
|
||||||
avatarPath?: string;
|
avatarPath?: string; // deprecated
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -110,11 +184,42 @@ export interface Settings {
|
|||||||
profile: ProfileSettings;
|
profile: ProfileSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewType = 'habits' | 'tasks'
|
export type CompletionCache = {
|
||||||
|
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
|
||||||
|
[habitId: string]: number // number of completions on that date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface JotaiHydrateInitialValues {
|
export interface JotaiHydrateInitialValues {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
coins: CoinsData;
|
coins: CoinsData;
|
||||||
habits: HabitsData;
|
habits: HabitsData;
|
||||||
wishlist: WishlistData;
|
wishlist: WishlistData;
|
||||||
|
users: UserData;
|
||||||
|
serverSettings: ServerSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerSettings {
|
||||||
|
isDemo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedResultType = DateTime<true> | RRule | string | null // null if invalid
|
||||||
|
|
||||||
|
// return rrule / datetime (machine-readable frequency), string (human-readable frequency), or null (invalid)
|
||||||
|
export interface ParsedFrequencyResult {
|
||||||
|
message: string | null
|
||||||
|
result: ParsedResultType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PomodoroAtom {
|
||||||
|
show: boolean
|
||||||
|
selectedHabitId: string | null
|
||||||
|
autoStart: boolean
|
||||||
|
minimized: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserSettings {
|
||||||
|
expandedHabits: boolean
|
||||||
|
expandedTasks: boolean
|
||||||
|
expandedWishlist: boolean
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user