Compare commits

..

29 Commits

Author SHA1 Message Date
f177d6448d fix: add note to readme on how to fix permission errors 2026-03-09 13:12:09 +01:00
08dbca81b2 Merge Tag 'v0.2.31' 2026-03-09 12:33:06 +01:00
8c9698c048 fix: remove FormattedNumber 2026-03-09 11:53:09 +01:00
dohsimpson
5144d6106d fix: update package-lock.json for npm ci compatibility 2026-03-07 09:55:21 -05:00
Doh
62b5ea41b3 Release/v0.2.31 (#188) 2026-03-07 09:53:36 -05:00
630363af1f fix: migrate atoms to normal functions 2026-03-06 23:16:19 +01:00
f7034116a3 fix: migrate atoms to normal functions 2026-03-06 22:47:32 +01:00
c418bddd9e fix: remove service worker 2026-02-24 23:19:40 +01:00
c397f40239 fix: linting problem 2026-02-24 19:02:37 +01:00
244692d8f9 fix: faulty isMobile check 2026-02-24 18:59:22 +01:00
bb2e4be41b Merge Tag 'v0.2.30' 2025-12-04 23:21:16 +01:00
dohsimpson
b01c5dcd6a feat: update Next.js to v15.5.7 to address CVE-2025-55182 2025-12-03 12:48:53 -05:00
3f9cd87c4d fix: added missing i18n en entries 2025-09-02 22:52:25 +02:00
083fae020a fix: imports 2025-09-02 22:47:24 +02:00
38da61c6c2 Merge Tag 'v0.2.29' 2025-09-02 22:46:45 +02:00
0689a5827f fix: replace return null with empty tags 2025-09-02 22:35:36 +02:00
ab0c5e3e99 Merge Tag 'v0.2.28' 2025-09-02 22:31:39 +02:00
3cc8543067 Merge Tag 'v0.2.27' 2025-09-02 22:29:16 +02:00
06aa27af63 Merge Tag 'v0.2.26' 2025-09-02 22:28:48 +02:00
c5bacb719c Merge Tag 'v0.2.25' 2025-09-02 22:28:15 +02:00
3ae2a3cb79 Merge Tag 'v0.2.24' 2025-09-02 22:27:46 +02:00
Doh
3e6b4b75ec feat: freehand drawing capability and card layout improvements and v0.2.29 release (#180) 2025-08-21 23:04:50 -04:00
Doh
31700c9a45 feat: server permission checking and v0.2.28 release (#178) 2025-08-20 17:27:50 -04:00
Doh
e05b982307 feat: mobile navigation text centering and v0.2.27 release (#177) 2025-08-20 17:14:32 -04:00
Doh
ee2821b2bf feat: optimize Docker build performance and add version validation (#176) 2025-08-20 10:04:23 -04:00
Doh
8fb7cd1810 Add project documentation and translation guide (#174) 2025-08-20 09:26:29 -04:00
Doh
a6f5bf1baa Update README.md
updated tasktrove banner
2025-08-17 09:40:10 -04:00
Doh
8dda60b9b1 Add Korean translation (#169) 2025-08-14 14:56:09 -04:00
Doh
ad2504dc7f Update README.md 2025-07-11 22:12:00 -04:00
68 changed files with 3383 additions and 775 deletions

View File

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

1
.gitignore vendored
View File

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

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

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

View File

@@ -1,5 +1,71 @@
# Changelog # Changelog
## Version 0.2.31
**This Release contains important security patches, please update as soon as possible**
### Fixed
* Security: Updated Next.js from 15.5.7 to 15.5.10
* Security: Hardened avatar route against path traversal attacks
* Security: Sanitized user data in client-facing payloads
* Security: Hardened debug actions and avatar validation
* Fixed missing English translations for DrawingModal
Thank you @1ARdotNO for the security audit!
## 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 ## Version 0.2.23
### Fixed ### Fixed

100
CLAUDE.md Normal file
View File

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

View File

@@ -1,18 +1,17 @@
# syntax=docker.io/docker/dockerfile:1 # syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base # Use build platform for base images to avoid emulation
FROM --platform=$BUILDPLATFORM node:22-alpine AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
# 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; \
@@ -26,32 +25,28 @@ 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 and backups directories and set permissions chown nextjs:nodejs /app/data /app/backups
RUN mkdir -p /app/data /app/backups \
&& chown nextjs:nodejs /app/data /app/backups
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/CHANGELOG.md ./ COPY --from=builder /app/CHANGELOG.md ./
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

View File

@@ -2,8 +2,6 @@
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 ## Differences to Upstream
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`. I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
@@ -26,8 +24,9 @@ Differences (as of writing) are:
- 🏆 Earn coins for completing habits - 🏆 Earn coins for completing habits
- 💰 Create a wishlist of rewards to redeem with earned coins - 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics - 📊 View your habit completion streaks and statistics
- ✏️ Add freehand drawings to habits and wishlist items for visual reminders
- 📅 Calendar heatmap to visualize your progress (WIP) - 📅 Calendar heatmap to visualize your progress (WIP)
- 🌍 Multi-language support (English, Español, Deutsch, Français, Русский, 简体中文, 日本語) - 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
- 🌙 Dark mode support - 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support - 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation - 💾 Automatic daily backups with rotation
@@ -182,3 +181,26 @@ This project is licensed under the GNU Affero General Public License v3.0 - see
## Support ## Support
If you encounter any issues or have questions, please file an issue on the GitHub repository. If you encounter any issues or have questions, please file an issue on the GitHub repository.
## Issues
### Missing Permissions
Especially when updating from older versions, it may be that the permissions used in the newer versions have never been set. This causes numerous `missing permissions` errors to appear. The solution is to update the `auth.json` in the `data` directory for each user to include the following json:
```json
"permissions": [{
"habit": {
"write": true,
"interact": true
},
"wishlist": {
"write": true,
"interact": true
},
"coins": {
"write": true,
"interact": true
}
}
```

88
app/actions/data.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from 'bun:test'
import { sanitizeUserData } from '@/lib/user-sanitizer'
import { UserData } from '@/lib/types'
describe('sanitizeUserData', () => {
test('removes password field from every user', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'admin',
password: 'abcd1234:ef567890',
isAdmin: true,
},
{
id: 'u2',
username: 'no-pass',
isAdmin: false,
},
],
}
const output = sanitizeUserData(input)
expect(output.users).toHaveLength(2)
expect(output.users[0]).not.toHaveProperty('password')
expect(output.users[1]).not.toHaveProperty('password')
})
test('adds hasPassword metadata based on stored password', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'with-hash',
password: 'abcd1234:ef567890',
isAdmin: false,
},
{
id: 'u2',
username: 'empty-pass',
password: '',
isAdmin: false,
},
{
id: 'u3',
username: 'no-pass',
isAdmin: false,
},
],
}
const output = sanitizeUserData(input)
expect(output.users[0].hasPassword).toBe(true)
expect(output.users[1].hasPassword).toBe(false)
expect(output.users[2].hasPassword).toBe(false)
})
test('preserves other user properties', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'user',
password: 'hash',
avatarPath: '/data/avatars/u1.png',
isAdmin: false,
permissions: [
{
habit: { write: true, interact: true },
wishlist: { write: true, interact: true },
coins: { write: true, interact: true },
},
],
},
],
}
const output = sanitizeUserData(input)
expect(output.users[0].id).toBe('u1')
expect(output.users[0].username).toBe('user')
expect(output.users[0].avatarPath).toBe('/data/avatars/u1.png')
expect(output.users[0].isAdmin).toBe(false)
expect(output.users[0].permissions?.[0].habit.write).toBe(true)
})
})

View File

@@ -1,5 +1,6 @@
'use server' 'use server'
import { ALLOWED_AVATAR_EXTENSIONS, ALLOWED_AVATAR_MIME_TYPES } from '@/lib/avatar';
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers"; import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import { import {
CoinsData, CoinsData,
@@ -13,6 +14,8 @@ import {
getDefaultWishlistData, getDefaultWishlistData,
HabitsData, HabitsData,
Permission, Permission,
PublicUser,
PublicUserData,
ServerSettings, ServerSettings,
Settings, Settings,
TransactionType, TransactionType,
@@ -21,8 +24,10 @@ import {
WishlistData, WishlistData,
WishlistItemType WishlistItemType
} from '@/lib/types'; } from '@/lib/types';
import { sanitizeUserData } from '@/lib/user-sanitizer';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils'; import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod'; import { signInSchema } from '@/lib/zod';
import { randomUUID } from "crypto";
import fs from 'fs/promises'; import fs from 'fs/promises';
import _ from 'lodash'; import _ from 'lodash';
import path from 'path'; import path from 'path';
@@ -32,6 +37,20 @@ import path from 'path';
type ResourceType = 'habit' | 'wishlist' | 'coins' type ResourceType = 'habit' | 'wishlist' | 'coins'
type ActionType = 'write' | 'interact' type ActionType = 'write' | 'interact'
async function verifyPermission(
resource: ResourceType,
action: ActionType
): Promise<void> {
// const user = await getCurrentUser()
// if (!user) throw new PermissionError('User not authenticated')
// if (user.isAdmin) return // Admins bypass permission checks
// if (!checkPermission(user.permissions, resource, action)) {
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
// }
return
}
function getDefaultData<T>(type: DataType): T { function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T; return DATA_DEFAULTS[type]() as T;
@@ -48,22 +67,28 @@ async function ensureDataDir() {
// --- Backup Debug Action --- // --- Backup Debug Action ---
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> { export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
// Optional: Add extra permission check if needed for debug actions if (process.env.NODE_ENV !== 'development') {
// const user = await getCurrentUser(); return { success: false, message: 'Permission denied.' }
// if (!user?.isAdmin) { }
// return { success: false, message: "Permission denied." };
// }
console.log("Manual backup trigger requested..."); const user = await getCurrentUser()
if (!user?.isAdmin) {
return { success: false, message: 'Permission denied.' }
}
console.log('Manual backup trigger requested...')
try { try {
// Import runBackup locally to avoid potential circular dependencies if moved // Import runBackup locally to avoid potential circular dependencies if moved
const { runBackup } = await import('@/lib/backup'); const { runBackup } = await import('@/lib/backup')
await runBackup(); await runBackup()
console.log("Manual backup trigger completed successfully."); console.log('Manual backup trigger completed successfully.')
return { success: true, message: "Backup process completed successfully." }; return { success: true, message: 'Backup process completed successfully.' }
} catch (error) { } catch (error) {
console.error("Manual backup trigger failed:", error); console.error('Manual backup trigger failed:', error)
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` }; return {
success: false,
message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
}
} }
} }
@@ -136,7 +161,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
// Wishlist specific functions // Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> { export async function loadWishlistData(): Promise<WishlistData> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultWishlistData<WishlistData>() if (!user) return getDefaultWishlistData()
const data = await loadData<WishlistData>('wishlist') const data = await loadData<WishlistData>('wishlist')
return { return {
@@ -173,7 +198,7 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
// Habits specific functions // Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> { export async function loadHabitsData(): Promise<HabitsData> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultHabitsData<HabitsData>() if (!user) return getDefaultHabitsData()
const data = await loadData<HabitsData>('habits') const data = await loadData<HabitsData>('habits')
return { return {
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id)) habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
@@ -208,14 +233,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
export async function loadCoinsData(): Promise<CoinsData> { export async function loadCoinsData(): Promise<CoinsData> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultCoinsData<CoinsData>() if (!user) return getDefaultCoinsData()
const data = await loadData<CoinsData>('coins') const data = await loadData<CoinsData>('coins')
return { return {
...data, ...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id) transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
} }
} catch { } catch {
return getDefaultCoinsData<CoinsData>() return getDefaultCoinsData()
} }
} }
@@ -278,7 +303,7 @@ export async function addCoins({
} }
export async function loadSettings(): Promise<Settings> { export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings<Settings>() const defaultSettings = getDefaultSettings()
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
@@ -339,13 +364,22 @@ export async function uploadAvatar(formData: FormData): Promise<string> {
throw new Error('File size must be less than 5MB') throw new Error('File size must be less than 5MB')
} }
const mimeType = file.type.toLowerCase()
if (!ALLOWED_AVATAR_MIME_TYPES.has(mimeType)) {
throw new Error('Unsupported avatar MIME type')
}
const ext = path.extname(file.name).toLowerCase()
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
throw new Error('Unsupported avatar file extension')
}
// Create avatars directory if it doesn't exist // Create avatars directory if it doesn't exist
const avatarsDir = path.join(process.cwd(), 'data', 'avatars') const avatarsDir = path.join(process.cwd(), 'data', 'avatars')
await fs.mkdir(avatarsDir, { recursive: true }) await fs.mkdir(avatarsDir, { recursive: true })
// Generate unique filename // Generate unique filename
const ext = file.name.split('.').pop() const filename = `${Date.now()}-${randomUUID()}${ext}`
const filename = `${Date.now()}.${ext}`
const filePath = path.join(avatarsDir, filename) const filePath = path.join(avatarsDir, filename)
// Save file // Save file
@@ -366,14 +400,19 @@ export async function getChangelog(): Promise<string> {
} }
// user logic // user logic
export async function loadUsersData(): Promise<UserData> { async function loadUsersData(): Promise<UserData> {
try { try {
return await loadData<UserData>('auth') return await loadData<UserData>('auth')
} catch { } catch {
return getDefaultUsersData<UserData>() return getDefaultUsersData()
} }
} }
export async function loadUsersPublicData(): Promise<PublicUserData> {
const data = await loadUsersData()
return sanitizeUserData(data)
}
export async function saveUsersData(data: UserData): Promise<void> { export async function saveUsersData(data: UserData): Promise<void> {
return saveData('auth', data) return saveData('auth', data)
} }
@@ -391,7 +430,7 @@ export async function getUser(username: string, plainTextPassword?: string): Pro
return user return user
} }
export async function createUser(formData: FormData): Promise<User> { export async function createUser(formData: FormData): Promise<PublicUser> {
const username = formData.get('username') as string; const username = formData.get('username') as string;
let password = formData.get('password') as string | undefined; let password = formData.get('password') as string | undefined;
const avatarPath = formData.get('avatarPath') as string; const avatarPath = formData.get('avatarPath') as string;
@@ -428,10 +467,10 @@ export async function createUser(formData: FormData): Promise<User> {
}; };
await saveUsersData(newData); await saveUsersData(newData);
return newUser; return sanitizeUserData({ users: [newUser] }).users[0]
} }
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> { export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<PublicUser> {
const data = await loadUsersData() const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId) const userIndex = data.users.findIndex(user => user.id === userId)
@@ -463,7 +502,7 @@ export async function updateUser(userId: string, updates: Partial<Omit<User, 'id
} }
await saveUsersData(newData) await saveUsersData(newData)
return updatedUser return sanitizeUserData({ users: [updatedUser] }).users[0]
} }
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> { export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {

View File

@@ -0,0 +1,197 @@
import { afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import path from 'path'
const mockReadFile = mock()
const mockRealpath = mock()
const mockLstat = mock()
mock.module('fs/promises', () => ({
default: {
readFile: mockReadFile,
realpath: mockRealpath,
lstat: mockLstat,
},
readFile: mockReadFile,
realpath: mockRealpath,
lstat: mockLstat,
}))
let GET: typeof import('./route').GET
beforeAll(async () => {
;({ GET } = await import('./route'))
})
afterEach(() => {
mock.restore()
})
describe('GET /api/avatars/[...path]', () => {
beforeEach(() => {
mockReadFile.mockReset()
mockRealpath.mockReset()
mockLstat.mockReset()
spyOn(process, 'cwd').mockReturnValue('/app')
mockRealpath.mockImplementation(async (value: string) => value)
mockLstat.mockResolvedValue({ isSymbolicLink: () => false })
})
test('returns avatar image for valid file path', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'avatar.png'))
})
test('allows nested valid avatar paths', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/user-1/avatar.png'), {
params: Promise.resolve({ path: ['user-1', 'avatar.png'] }),
})
expect(response.status).toBe(200)
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'user-1', 'avatar.png'))
})
test('supports uppercase extensions', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.PNG'), {
params: Promise.resolve({ path: ['avatar.PNG'] }),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
})
test('rejects traversal segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/../auth.json'), {
params: Promise.resolve({ path: ['..', 'auth.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects encoded traversal payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/%2e%2e%2fauth.json'), {
params: Promise.resolve({ path: ['%2e%2e%2fauth.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects encoded backslash traversal payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/..%5cauth.png'), {
params: Promise.resolve({ path: ['..%5cauth.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects null byte payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png%00'), {
params: Promise.resolve({ path: ['avatar.png%00'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects dot-only segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/./avatar.png'), {
params: Promise.resolve({ path: ['.', 'avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects malformed encoded segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/%E0%A4%A'), {
params: Promise.resolve({ path: ['%E0%A4%A'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects unsupported file extensions', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/config.json'), {
params: Promise.resolve({ path: ['config.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Unsupported file type' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects symlinked avatar files', async () => {
mockLstat.mockResolvedValue({ isSymbolicLink: () => true })
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects files whose real path escapes avatars directory', async () => {
mockRealpath.mockImplementation(async (value: string) => {
if (value === path.resolve('/app', 'data', 'avatars')) {
return value
}
return path.resolve('/app', 'data', 'auth.png')
})
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('returns 404 when file is missing', async () => {
mockLstat.mockRejectedValue({ code: 'ENOENT' })
const response = await GET(new Request('http://localhost:3000/api/avatars/missing.png'), {
params: Promise.resolve({ path: ['missing.png'] }),
})
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'File not found' })
})
test('returns 500 for non-ENOENT read errors', async () => {
mockReadFile.mockRejectedValue({ code: 'EACCES' })
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'Internal server error' })
})
})

View File

@@ -1,26 +1,109 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { ALLOWED_AVATAR_EXTENSIONS, AVATAR_CONTENT_TYPE } from '@/lib/avatar'
function sanitizePathSegments(pathSegments?: string[]): string[] | null {
if (!pathSegments || pathSegments.length === 0) {
return null
}
const safeSegments: string[] = []
for (const rawSegment of pathSegments) {
let segment = rawSegment
try {
segment = decodeURIComponent(rawSegment)
} catch {
return null
}
if (!segment || segment === '.' || segment === '..') {
return null
}
if (segment.includes('/') || segment.includes('\\') || segment.includes('\0')) {
return null
}
safeSegments.push(segment)
}
return safeSegments
}
function isPathInsideBase(basePath: string, targetPath: string): boolean {
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
}
function getErrorCode(error: unknown): string | null {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return null
}
const { code } = error as { code?: unknown }
return typeof code === 'string' ? code : null
}
export async function GET( export async function GET(
request: Request, _request: Request,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
) { ) {
const { path: pathSegments } = await Promise.resolve(params)
const safeSegments = sanitizePathSegments(pathSegments)
if (!safeSegments) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const avatarsDir = path.resolve(process.cwd(), 'data', 'avatars')
const filePath = path.resolve(avatarsDir, ...safeSegments)
if (!isPathInsideBase(avatarsDir, filePath)) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const ext = path.extname(filePath).toLowerCase()
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
}
try { try {
const { path: pathSegments } = await Promise.resolve(params) const realAvatarsDir = await fs.realpath(avatarsDir)
const filePath = path.join(process.cwd(), 'data', 'avatars', ...(pathSegments || [])) const fileStats = await fs.lstat(filePath)
const file = await fs.readFile(filePath)
const ext = path.extname(filePath).slice(1) if (fileStats.isSymbolicLink()) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const realFilePath = await fs.realpath(filePath)
if (!isPathInsideBase(realAvatarsDir, realFilePath)) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const file = await fs.readFile(realFilePath)
return new NextResponse(file, { return new NextResponse(file, {
headers: { headers: {
'Content-Type': `image/${ext}`, 'Content-Type': AVATAR_CONTENT_TYPE[ext] ?? 'application/octet-stream',
'X-Content-Type-Options': 'nosniff',
}, },
}) })
} catch (error) { } catch (error) {
if (getErrorCode(error) === 'ENOENT') {
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
)
}
console.error('Error reading avatar file:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'File not found' }, { error: 'Internal server error' },
{ status: 404 } { status: 500 }
) )
} }
} }

View File

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

View File

@@ -9,7 +9,7 @@ import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server' import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google' import { DM_Sans } from 'next/font/google'
import { Suspense } from 'react' import { Suspense } from 'react'
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data' import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersPublicData, loadWishlistData } from './actions/data'
import './globals.css' import './globals.css'
// Clean and contemporary // Clean and contemporary
@@ -40,7 +40,7 @@ export default async function RootLayout({
loadHabitsData(), loadHabitsData(),
loadCoinsData(), loadCoinsData(),
loadWishlistData(), loadWishlistData(),
loadUsersData(), loadUsersPublicData(),
loadServerSettings(), loadServerSettings(),
]) ])
@@ -48,23 +48,6 @@ export default async function RootLayout({
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next) // set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
<html lang={locale} suppressHydrationWarning> <html lang={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={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate <JotaiHydrate

View File

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

View File

@@ -12,11 +12,13 @@ import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } fro
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils' import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { Zap } from 'lucide-react' import { Brush, Zap } from 'lucide-react'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState } from 'react' import { useState } from 'react'
import { RRule } from 'rrule' import { RRule } from 'rrule'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton' import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'; // Import the new component import ModalOverlay from './ModalOverlay'; // Import the new component
@@ -48,6 +50,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id)) const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const users = usersData.users const users = usersData.users
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
function getFrequencyUpdate() { function getFrequencyUpdate() {
if (ruleText === initialRuleText && habit?.frequency) { if (ruleText === initialRuleText && habit?.frequency) {
@@ -82,7 +86,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [], completions: habit?.completions || [],
frequency: getFrequencyUpdate(), frequency: getFrequencyUpdate(),
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
}) })
} }
@@ -91,7 +96,11 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
return ( return (
<> <>
<ModalOverlay /> <ModalOverlay />
<Dialog open={true} onOpenChange={onClose} modal={false}> <Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
onClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */} <DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@@ -275,6 +284,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
{t('drawingLabel')}
</Label>
<div className="col-span-3">
<div className="flex gap-4 items-center">
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
className="flex-1 justify-start"
>
<Brush className="h-4 w-4 mr-2" />
{drawing ? t('editDrawing') : t('addDrawing')}
</Button>
{drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={drawing}
width={80}
height={53}
className=""
/>
</div>
)}
</div>
</div>
</div>
{users && users.length > 1 && ( {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 justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -318,6 +359,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</> </>
) )
} }

View File

@@ -7,8 +7,11 @@ import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants' import { MAX_COIN_LIMIT } from '@/lib/constants'
import { WishlistItemType } from '@/lib/types' import { WishlistItemType } from '@/lib/types'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { Brush } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton' import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay' import ModalOverlay from './ModalOverlay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
@@ -38,6 +41,8 @@ export default function AddEditWishlistItemModal({
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id)) const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({}) const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (editingItem) { if (editingItem) {
@@ -46,12 +51,14 @@ export default function AddEditWishlistItemModal({
setCoinCost(editingItem.coinCost) setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions) setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '') setLink(editingItem.link || '')
setDrawing(editingItem.drawing || '')
} else { } else {
setName('') setName('')
setDescription('') setDescription('')
setCoinCost(1) setCoinCost(1)
setTargetCompletions(undefined) setTargetCompletions(undefined)
setLink('') setLink('')
setDrawing('')
} }
setErrors({}) setErrors({})
}, [editingItem]) }, [editingItem])
@@ -100,7 +107,8 @@ export default function AddEditWishlistItemModal({
coinCost, coinCost,
targetCompletions: targetCompletions || undefined, targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined, link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
} }
if (editingItem) { if (editingItem) {
@@ -116,7 +124,11 @@ export default function AddEditWishlistItemModal({
return ( return (
<> <>
<ModalOverlay /> <ModalOverlay />
<Dialog open={true} onOpenChange={handleClose} modal={false}> <Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
handleClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */} <DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader> <DialogHeader>
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle> <DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
@@ -267,6 +279,38 @@ export default function AddEditWishlistItemModal({
)} )}
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
{t('drawingLabel')}
</Label>
<div className="col-span-3">
<div className="flex gap-4 items-center">
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
className="flex-1 justify-start"
>
<Brush className="h-4 w-4 mr-2" />
{drawing ? t('editDrawing') : t('addDrawing')}
</Button>
{drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={drawing}
width={80}
height={53}
className=""
/>
</div>
)}
</div>
</div>
</div>
{usersData.users && usersData.users.length > 1 && ( {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 justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -306,6 +350,13 @@ export default function AddEditWishlistItemModal({
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</> </>
) )
} }

View File

@@ -1,9 +1,8 @@
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 { useTranslations } from 'next-intl'
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 })
@@ -21,9 +20,7 @@ export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
<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>

View File

@@ -1,15 +1,15 @@
'use client' 'use client'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCoins } from '@/hooks/useCoins' import { useCoins } from '@/hooks/useCoins'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants' import { MAX_COIN_LIMIT } from '@/lib/constants'
import { TransactionType } from '@/lib/types' import { TransactionType } from '@/lib/types'
import { d2s, t2d } from '@/lib/utils' import { calculateTransactionsToday, d2s, t2d } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { History } from 'lucide-react' import { History } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@@ -32,8 +32,7 @@ export default function CoinsManager() {
coinsEarnedToday, coinsEarnedToday,
totalEarned, totalEarned,
totalSpent, totalSpent,
coinsSpentToday, coinsSpentToday
transactionsToday
} = useCoins({ selectedUser }) } = useCoins({ selectedUser })
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
@@ -127,7 +126,7 @@ export default function CoinsManager() {
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span> <span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
<div> <div>
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div> <div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div> <div className="text-3xl font-bold">{balance} {t('coinsSuffix')}</div>
</div> </div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -214,16 +213,12 @@ export default function CoinsManager() {
{/* Top Row - Totals */} {/* Top Row - Totals */}
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900"> <div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div> <div className="text-sm text-green-800 dark:text-green-100 mb-1">{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">{t('totalSpentLabel')}</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">
@@ -236,22 +231,18 @@ export default function CoinsManager() {
{/* Bottom Row - Today */} {/* Bottom Row - Today */}
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900"> <div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div> <div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{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">{t('todaysSpentLabel')}</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">{t('todaysTransactionsLabel')}</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>

View File

@@ -13,21 +13,22 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms' import { browserSettingsAtom, completedHabitsMapAtom, settingsAtom } from '@/lib/atoms'
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
import { Habit, WishlistItemType } from '@/lib/types' import { Habit, WishlistItemType } from '@/lib/types'
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils' import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react';
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import AddEditHabitModal from './AddEditHabitModal' import AddEditHabitModal from './AddEditHabitModal'
import CompletionCountBadge from './CompletionCountBadge' import CompletionCountBadge from './CompletionCountBadge'
import ConfirmDialog from './ConfirmDialog' import ConfirmDialog from './ConfirmDialog'
import DrawingDisplay from './DrawingDisplay'
import { HabitContextMenuItems } from './HabitContextMenuItems' import { HabitContextMenuItems } from './HabitContextMenuItems'
import Linkify from './linkify' import Linkify from './linkify'
import { Button } from './ui/button' import { Button } from './ui/button'
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
interface UpcomingItemsProps { interface UpcomingItemsProps {
habits: Habit[] habits: Habit[]
@@ -53,8 +54,7 @@ const ItemSection = ({
addNewItem, addNewItem,
}: ItemSectionProps) => { }: ItemSectionProps) => {
const t = useTranslations('DailyOverview'); const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits(); const { completeHabit, undoComplete, saveHabit, deleteHabit, habitFreqMap } = useHabits();
const [_, setPomo] = useAtom(pomodoroAtom);
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom); const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
const [settings] = useAtom(settingsAtom); const [settings] = useAtom(settingsAtom);
const [completedHabitsMap] = useAtom(completedHabitsMapAtom); const [completedHabitsMap] = useAtom(completedHabitsMapAtom);
@@ -246,6 +246,16 @@ const ItemSection = ({
{habit.name} {habit.name}
</span> </span>
</Link> </Link>
{habit.drawing && (
<div className="ml-2 pr-2">
<DrawingDisplay
drawingData={habit.drawing}
width={40}
height={26}
className="border-0"
/>
</div>
)}
</span> </span>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
@@ -386,8 +396,6 @@ export default function DailyOverview({
return a.coinCost - b.coinCost return a.coinCost - b.coinCost
}) })
const [hasTasks] = useAtom(hasTasksAtom)
const [, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{ const [modalConfig, setModalConfig] = useState<{
isOpen: boolean, isOpen: boolean,
isTask: boolean isTask: boolean
@@ -405,7 +413,7 @@ export default function DailyOverview({
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-6">
{/* Tasks Section */} {/* Tasks Section */}
{hasTasks && ( {habits.some(habit => habit.isTask === true) && (
<ItemSection <ItemSection
title={t('dailyTasksTitle')} title={t('dailyTasksTitle')}
items={dailyTasks} items={dailyTasks}
@@ -458,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",

View 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>
)
}

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ 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 { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms' import { completedHabitsMapAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils' import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
@@ -29,7 +29,6 @@ export default function HabitCalendar() {
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone })) const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd") const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
const [habitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const habits = habitsData.habits const habits = habitsData.habits
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
@@ -83,7 +82,7 @@ export default function HabitCalendar() {
<CardContent> <CardContent>
{selectedDateTime && ( {selectedDateTime && (
<div className="space-y-8"> <div className="space-y-8">
{hasTasks && ( {habits.some(habit => habit.isTask === true) && (
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3> <h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>

View File

@@ -1,9 +1,8 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
@@ -14,8 +13,10 @@ import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import { HabitContextMenuItems } from './HabitContextMenuItems' import { HabitContextMenuItems } from './HabitContextMenuItems'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Button } from './ui/button'
interface HabitItemProps { interface HabitItemProps {
habit: Habit habit: Habit
@@ -24,13 +25,13 @@ interface HabitItemProps {
} }
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => { const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return null; if (!habit.userIds || habit.userIds.length <= 1) return <></>;
return ( return (
<div className="flex -space-x-2 ml-2 flex-shrink-0"> <div className="flex -space-x-2 ml-2 flex-shrink-0">
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => { {habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId) const user = usersData.users.find(u => u.id === userId)
if (!user) return null if (!user) return <></>;
return ( return (
<Avatar key={user.id} className="h-6 w-6"> <Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} /> <AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
@@ -81,7 +82,7 @@ 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">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}> <CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -98,28 +99,44 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</CardTitle> </CardTitle>
{renderUserAvatars(habit, currentUser as User, usersData)} {renderUserAvatars(habit, currentUser as User, usersData)}
</div> </div>
{habit.description && ( {(habit.description || habit.drawing) && (
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}> <div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
{habit.description} {habit.description && (
</CardDescription> <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'}`}> <div className="mt-auto">
{t('whenLabel', { <p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
frequency: convertMachineReadableFrequencyToHumanReadable({ {t('whenLabel', {
frequency: habit.frequency, frequency: convertMachineReadableFrequencyToHumanReadable({
isRecurRule: pathname.includes("habits"), frequency: habit.frequency,
timezone: settings.system.timezone isRecurRule: pathname.includes("habits"),
}) timezone: settings.system.timezone
})} })
</p> })}
<div className="flex items-center mt-2"> </p>
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} /> <div className="flex items-center mt-2">
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span> <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
@@ -205,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Card> </Card>
) )
} }

View File

@@ -1,21 +1,16 @@
'use client' 'use client'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom import { completedHabitsMapAtom, settingsAtom } from '@/lib/atoms';
import { Habit } from '@/lib/types'; import { Habit } from '@/lib/types';
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate import { d2s, getNow } from '@/lib/utils';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
interface HabitStreakProps { export default function HabitStreak({ habits }: { habits: Habit[] }) {
habits: Habit[]
}
export default function HabitStreak({ habits }: HabitStreakProps) {
const t = useTranslations('HabitStreak'); const t = useTranslations('HabitStreak');
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
// Get the last 7 days of data // Get the last 7 days of data
@@ -71,7 +66,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
/> />
{hasTasks && ( {habits.some(habit => habit.isTask === true) && (
<Line <Line
type="monotone" type="monotone"
name={t('tooltipTasksLabel')} name={t('tooltipTasksLabel')}

View File

@@ -1,13 +1,12 @@
'use client' 'use client'
import Link from 'next/link'
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 { settingsAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { Coins } from 'lucide-react' import { Coins } from 'lucide-react'
import NotificationBell from './NotificationBell'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import Link from 'next/link'
import NotificationBell from './NotificationBell'
import { Profile } from './Profile' import { Profile } from './Profile'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false }) const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
@@ -21,11 +20,7 @@ export default function 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"> <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" /> <Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2"> <div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber <span className="text-gray-800 dark:text-gray-100 font-medium text-lg">{balance}</span>
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
<div className="hidden sm:block"> <div className="hidden sm:block">
<TodayEarnedCoins /> <TodayEarnedCoins />
</div> </div>

View File

@@ -1,18 +1,21 @@
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 position='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">
{/* responsive container (optimized for mobile) */} <main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full"> {/* responsive container (optimized for mobile) */}
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
<ClientWrapper> <ClientWrapper>
<PermissionError />
{children} {children}
</ClientWrapper> </ClientWrapper>
</div> </div>

View File

@@ -3,14 +3,14 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { NavItemType } from './Navigation'; import { NavItemType } from './Navigation';
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) { export default function NavDisplay({ navItems, displayType }: { navItems: NavItemType[], displayType: 'main' | 'mobile' }) {
const pathname = usePathname(); const pathname = usePathname();
const { isIOS } = useHelpers() const { isIOS } = useHelpers()
if (isMobile) { if (displayType === 'mobile') {
return ( return (
<> <>
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)} <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" : ""}`}> <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"> <div className="grid grid-cols-6 w-full">
{navItems.map((item) => ( {navItems.map((item) => (

View File

@@ -3,7 +3,7 @@
import { HabitIcon, TaskIcon } from '@/lib/constants' import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Calendar, Coins, Gift, Home } from 'lucide-react' import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { ElementType, useEffect, useState } from 'react' import { ElementType } from 'react'
import NavDisplay from './NavDisplay' import NavDisplay from './NavDisplay'
export interface NavItemType { export interface NavItemType {
@@ -14,13 +14,6 @@ export interface NavItemType {
export default function Navigation({ position }: { position: 'main' | 'mobile' }) { export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
const t = useTranslations('Navigation'); const t = useTranslations('Navigation');
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
useEffect(() => {
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [setIsMobile]);
const currentNavItems: NavItemType[] = [ const currentNavItems: NavItemType[] = [
{ icon: Home, label: t('dashboard'), href: '/' }, { icon: Home, label: t('dashboard'), href: '/' },
@@ -31,10 +24,5 @@ export default function Navigation({ position }: { position: 'main' | 'mobile' }
{ icon: Coins, label: t('coins'), href: '/coins' }, { icon: Coins, label: t('coins'), href: '/coins' },
] ]
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) { return <NavDisplay navItems={currentNavItems} displayType={position} />
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
}
else {
return <></>
}
} }

View File

@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data'; import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils'; import { d2t, getNow, t2d } from '@/lib/utils';
import { User, CoinTransaction } from '@/lib/types'; import { CoinTransaction } from '@/lib/types';
export default function NotificationBell() { export default function NotificationBell() {
const t = useTranslations('NotificationBell'); const t = useTranslations('NotificationBell');
@@ -121,7 +121,7 @@ export default function NotificationBell() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96"> <DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown <NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined currentUser={currentUser ?? null}
unreadNotifications={unreadNotifications} unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications} displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down habitsData={habitsData} // Pass necessary data down

View File

@@ -8,19 +8,19 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types'; import { CoinTransaction, HabitsData, PublicUser, PublicUserData, WishlistData } from '@/lib/types';
import { t2d } from '@/lib/utils'; import { t2d } from '@/lib/utils';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
interface NotificationDropdownProps { interface NotificationDropdownProps {
currentUser: User | null; currentUser: PublicUser | null;
unreadNotifications: CoinTransaction[]; unreadNotifications: CoinTransaction[];
displayedReadNotifications: CoinTransaction[]; displayedReadNotifications: CoinTransaction[];
habitsData: HabitsData; habitsData: HabitsData;
wishlistData: WishlistData; wishlistData: WishlistData;
usersData: UserData; usersData: PublicUserData;
} }
// Helper function to get the name of the related item // Helper function to get the name of the related item
@@ -47,7 +47,7 @@ export default function NotificationDropdown({
const t = useTranslations('NotificationDropdown'); const t = useTranslations('NotificationDropdown');
// Helper function to generate notification message, now using t // Helper function to generate notification message, now using t
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => { const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: PublicUser, relatedItemName?: string): string => {
const username = triggeringUser?.username || t('defaultUsername'); const username = triggeringUser?.username || t('defaultUsername');
const itemName = relatedItemName || t('defaultItemName'); const itemName = relatedItemName || t('defaultItemName');
switch (tx.type) { switch (tx.type) {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { User } from '@/lib/types'; import { SafeUser } from '@/lib/types';
import { User as UserIcon } from 'lucide-react'; import { User as UserIcon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
@@ -11,7 +11,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
interface PasswordEntryFormProps { interface PasswordEntryFormProps {
user: User; user: SafeUser;
onCancel: () => void; onCancel: () => void;
onSubmit: (password: string) => Promise<void>; onSubmit: (password: string) => Promise<void>;
error?: string; error?: string;
@@ -24,7 +24,7 @@ export default function PasswordEntryForm({
error error
}: PasswordEntryFormProps) { }: PasswordEntryFormProps) {
const t = useTranslations('PasswordEntryForm'); const t = useTranslations('PasswordEntryForm');
const hasPassword = !!user.password; const hasPassword = user.hasPassword ?? false;
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {

View File

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

View File

@@ -3,8 +3,8 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms' import { habitsAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
import { cn } from '@/lib/utils' import { cn, getTodayCompletions } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react' import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@@ -41,12 +41,13 @@ export default function PomodoroTimer() {
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 currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus) const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(() => { const [currentLabel, setCurrentLabel] = useState(() => {
const labels = currentTimerRef.current.getLabels(); const labels = currentTimerRef.current.getLabels();
@@ -177,7 +178,7 @@ export default function PomodoroTimer() {
const progress = (timeLeft / currentTimerRef.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">

View File

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

View File

@@ -1,20 +1,15 @@
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
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 t = useTranslations('TodayEarnedCoins') const t = useTranslations('TodayEarnedCoins')
const [settings] = useAtom(settingsAtom)
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"> {t('todaySuffix')}</span> <span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
: null} : null}

View File

@@ -59,7 +59,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
const [avatarPath, setAvatarPath] = useState(user?.avatarPath) const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
const [username, setUsername] = useState(user?.username || ''); const [username, setUsername] = useState(user?.username || '');
const [password, setPassword] = useState<string | undefined>(''); const [password, setPassword] = useState<string | undefined>('');
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo); const [disablePassword, setDisablePassword] = useState(user ? !user.hasPassword : serverSettings.isDemo);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null); const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
@@ -173,7 +173,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
avatarPath, avatarPath,
permissions, permissions,
isAdmin, isAdmin,
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom hasPassword: disablePassword ? false : (password ? true : !!u.hasPassword)
} : u } : u
), ),
})); }));

View File

@@ -3,7 +3,7 @@
import { signIn } from '@/app/actions/user'; import { signIn } from '@/app/actions/user';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { currentUserAtom, usersAtom } from '@/lib/atoms'; import { currentUserAtom, usersAtom } from '@/lib/atoms';
import { SafeUser, User } from '@/lib/types'; import { SafeUser } from '@/lib/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Description } from '@radix-ui/react-dialog'; import { Description } from '@radix-ui/react-dialog';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
@@ -22,7 +22,7 @@ function UserCard({
showEdit, showEdit,
isCurrentUser, isCurrentUser,
}: { }: {
user: User, user: SafeUser,
onSelect: () => void, onSelect: () => void,
onEdit: () => void, onEdit: () => void,
showEdit: boolean, showEdit: boolean,
@@ -97,7 +97,7 @@ function UserSelectionView({
onEditUser, onEditUser,
onCreateUser, onCreateUser,
}: { }: {
users: User[], users: SafeUser[],
currentUserFromHook?: SafeUser, currentUserFromHook?: SafeUser,
onUserSelect: (userId: string) => void, onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void, onEditUser: (userId: string) => void,

View File

@@ -11,9 +11,10 @@ import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { User, WishlistItemType } from '@/lib/types' import { User, WishlistItemType } from '@/lib/types'
import { hasPermission } from '@/lib/utils' import { hasPermission } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import DrawingDisplay from './DrawingDisplay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' 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
@@ -29,13 +30,13 @@ interface WishlistItemProps {
} }
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => { const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
if (!item.userIds || item.userIds.length <= 1) return null; if (!item.userIds || item.userIds.length <= 1) return <></>;
return ( return (
<div className="flex -space-x-2 ml-2 flex-shrink-0"> <div className="flex -space-x-2 ml-2 flex-shrink-0">
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => { {item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId) const user = usersData.users.find(u => u.id === userId)
if (!user) return null if (!user) return <></>;
return ( return (
<Avatar key={user.id} className="h-6 w-6"> <Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} /> <AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
@@ -72,37 +73,51 @@ 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 === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })}) <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>
<div className="flex items-center justify-between">
<div className="flex-1">
{item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)} )}
</div> </div>
{renderUserAvatars(item, currentUser as User, usersData)} {renderUserAvatars(item, currentUser as User, usersData)}
</div> </div>
{(item.description || item.drawing) && (
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
{item.description && (
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
{item.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={item.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="flex-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} {t('coinsSuffix')} <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"}
@@ -179,4 +194,3 @@ export default function WishlistItem({
</Card> </Card>
) )
} }

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

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

View File

@@ -73,7 +73,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
) )
if (!colorConfig.length) { 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 (

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

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

View File

@@ -2,15 +2,13 @@ import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { import {
coinsAtom, coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom, coinsEarnedTodayAtom,
coinsSpentTodayAtom, coinsSpentTodayAtom,
currentUserAtom, currentUserAtom,
currentUserIdAtom,
settingsAtom, settingsAtom,
totalEarnedAtom, totalEarnedAtom,
totalSpentAtom, usersAtom
transactionsTodayAtom,
usersAtom,
} from '@/lib/atoms'; } from '@/lib/atoms';
import { MAX_COIN_LIMIT } from '@/lib/constants'; import { MAX_COIN_LIMIT } from '@/lib/constants';
import { CoinsData } from '@/lib/types'; import { CoinsData } from '@/lib/types';
@@ -24,27 +22,24 @@ export function useCoins(options?: { selectedUser?: string }) {
const tCommon = useTranslations('Common'); const tCommon = useTranslations('Common');
const [coins, setCoins] = useAtom(coinsAtom) const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [users] = useAtom(usersAtom) const [{users}] = useAtom(usersAtom)
const [currentUser] = useAtom(currentUserAtom) const [currentUser] = useAtom(currentUserAtom)
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions const [coinsData] = useAtom(coinsAtom) // All coin transactions
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user 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 [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
const [atomTotalEarned] = useAtom(totalEarnedAtom) const [atomTotalEarned] = useAtom(totalEarnedAtom)
const [atomTotalSpent] = useAtom(totalSpentAtom)
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom); const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
const [atomTransactionsToday] = useAtom(transactionsTodayAtom); const targetUser = options?.selectedUser ? users.find(u => u.id === options.selectedUser) : currentUser
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
const transactions = useMemo(() => { const transactions = useMemo(() => {
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id); return coinsData.transactions.filter(t => t.userId === targetUser?.id);
}, [allCoinsData, targetUser?.id]); }, [coinsData, targetUser?.id]);
const timezone = settings.system.timezone; const timezone = settings.system.timezone;
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0); const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
const [totalEarned, setTotalEarned] = useState(0); const [totalEarned, setTotalEarned] = useState(0);
const [totalSpent, setTotalSpent] = useState(0);
const [coinsSpentToday, setCoinsSpentToday] = useState(0); const [coinsSpentToday, setCoinsSpentToday] = useState(0);
const [transactionsToday, setTransactionsToday] = useState<number>(0);
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
useEffect(() => { useEffect(() => {
@@ -53,9 +48,7 @@ export function useCoins(options?: { selectedUser?: string }) {
// If the target user is the currently logged-in user, use the derived atom's value // If the target user is the currently logged-in user, use the derived atom's value
setCoinsEarnedToday(atomCoinsEarnedToday); setCoinsEarnedToday(atomCoinsEarnedToday);
setTotalEarned(atomTotalEarned); setTotalEarned(atomTotalEarned);
setTotalSpent(atomTotalSpent);
setCoinsSpentToday(atomCoinsSpentToday); setCoinsSpentToday(atomCoinsSpentToday);
setTransactionsToday(atomTransactionsToday);
setBalance(loggedInUserBalance); setBalance(loggedInUserBalance);
} else if (targetUser?.id) { } else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually // If an admin is viewing another user, calculate their metrics manually
@@ -65,14 +58,9 @@ export function useCoins(options?: { selectedUser?: string }) {
const totalEarnedVal = calculateTotalEarned(transactions); const totalEarnedVal = calculateTotalEarned(transactions);
setTotalEarned(roundToInteger(totalEarnedVal)); setTotalEarned(roundToInteger(totalEarnedVal));
const totalSpentVal = calculateTotalSpent(transactions);
setTotalSpent(roundToInteger(totalSpentVal));
const spentToday = calculateCoinsSpentToday(transactions, timezone); const spentToday = calculateCoinsSpentToday(transactions, timezone);
setCoinsSpentToday(roundToInteger(spentToday)); setCoinsSpentToday(roundToInteger(spentToday));
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0); const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
setBalance(roundToInteger(calculatedBalance)); setBalance(roundToInteger(calculatedBalance));
} }
@@ -84,26 +72,24 @@ export function useCoins(options?: { selectedUser?: string }) {
loggedInUserBalance, loggedInUserBalance,
atomCoinsEarnedToday, atomCoinsEarnedToday,
atomTotalEarned, atomTotalEarned,
atomTotalSpent, atomCoinsSpentToday
atomCoinsSpentToday,
atomTransactionsToday,
]); ]);
const add = async (amount: number, description: string, note?: string) => { const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
toast({ toast({
title: t("invalidAmountTitle"), title: t("invalidAmountTitle"),
description: t("invalidAmountDescription") description: t("invalidAmountDescription")
}) })
return null return <></>;
} }
if (amount > MAX_COIN_LIMIT) { if (amount > MAX_COIN_LIMIT) {
toast({ toast({
title: t("invalidAmountTitle"), title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT }) description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
}) })
return null return <></>;
} }
const data = await addCoins({ const data = await addCoins({
@@ -119,21 +105,21 @@ export function useCoins(options?: { selectedUser?: string }) {
} }
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 null 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: t("invalidAmountTitle"), title: t("invalidAmountTitle"),
description: t("invalidAmountDescription") description: t("invalidAmountDescription")
}) })
return null return <></>;
} }
if (numAmount > MAX_COIN_LIMIT) { if (numAmount > MAX_COIN_LIMIT) {
toast({ toast({
title: t("invalidAmountTitle"), title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT }) description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
}) })
return null return <></>;
} }
const data = await removeCoins({ const data = await removeCoins({
@@ -149,14 +135,14 @@ export function useCoins(options?: { selectedUser?: string }) {
} }
const updateNote = async (transactionId: string, note: string) => { const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const transaction = coins.transactions.find(t => t.id === transactionId) const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) { if (!transaction) {
toast({ toast({
title: tCommon("errorTitle"), title: tCommon("errorTitle"),
description: t("transactionNotFoundDescription") description: t("transactionNotFoundDescription")
}) })
return null return <></>;
} }
const updatedTransaction = { const updatedTransaction = {
@@ -186,8 +172,7 @@ export function useCoins(options?: { selectedUser?: string }) {
transactions: transactions, transactions: transactions,
coinsEarnedToday, coinsEarnedToday,
totalEarned, totalEarned,
totalSpent, totalSpent: calculateTotalSpent(coins.transactions),
coinsSpentToday, coinsSpentToday
transactionsToday
} }
} }

View File

@@ -1,12 +1,13 @@
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms' import { coinsAtom, currentUserAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types' import { Freq, Habit } from '@/lib/types'
import { import {
d2s, d2s,
d2t, d2t,
getCompletionsForDate, getCompletionsForDate,
getHabitFreq,
getISODate, getISODate,
getNow, getNow,
getTodayInTimezone, getTodayInTimezone,
@@ -23,12 +24,15 @@ import { useTranslations } from 'next-intl'
export function useHabits() { export function useHabits() {
const t = useTranslations('useHabits'); const t = useTranslations('useHabits');
const tCommon = useTranslations('Common'); const tCommon = useTranslations('Common');
const [usersData] = useAtom(usersAtom)
const [currentUser] = useAtom(currentUserAtom) 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] = 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 if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return

View File

@@ -2,11 +2,7 @@ import {
calculateCoinsEarnedToday, calculateCoinsEarnedToday,
calculateCoinsSpentToday, calculateCoinsSpentToday,
calculateTotalEarned, calculateTotalEarned,
calculateTotalSpent,
calculateTransactionsToday,
generateCryptoHash, generateCryptoHash,
getCompletionsForToday,
getHabitFreq,
isHabitDue, isHabitDue,
prepareDataForHashing, prepareDataForHashing,
roundToInteger, roundToInteger,
@@ -16,42 +12,41 @@ import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils"; import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import {
CoinsData, BrowserSettings,
CompletionCache, CompletionCache,
Freq,
getDefaultCoinsData, getDefaultCoinsData,
getDefaultHabitsData, getDefaultHabitsData,
getDefaultPublicUsersData,
getDefaultServerSettings, getDefaultServerSettings,
getDefaultSettings, getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData, getDefaultWishlistData,
Habit, Habit,
HabitsData, PomodoroAtom,
ServerSettings, UserId
Settings,
UserData,
UserId,
WishlistData
} from "./types"; } from "./types";
export interface BrowserSettings {
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}
export const browserSettingsAtom = atomWithStorage('browserSettings', { export const browserSettingsAtom = atomWithStorage('browserSettings', {
expandedHabits: false, expandedHabits: false,
expandedTasks: false, expandedTasks: false,
expandedWishlist: false expandedWishlist: false
} as BrowserSettings) } as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData<UserData>()) export const usersAtom = atom(getDefaultPublicUsersData())
export const settingsAtom = atom(getDefaultSettings<Settings>()); export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>()); export const settingsAtom = atom(getDefaultSettings());
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>()); export const habitsAtom = atom(getDefaultHabitsData());
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>()); export const coinsAtom = atom(getDefaultCoinsData());
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>()); export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings());
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
export const pomodoroAtom = atom<PomodoroAtom>({
show: false,
selectedHabitId: null,
autoStart: true,
minimized: false,
})
// Derived atom for coins earned today // Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => { export const coinsEarnedTodayAtom = atom((get) => {
@@ -68,13 +63,6 @@ export const totalEarnedAtom = atom((get) => {
return roundToInteger(value); return roundToInteger(value);
}); });
// Derived atom for total spent
export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom);
const value = calculateTotalSpent(coins.transactions);
return roundToInteger(value);
});
// Derived atom for coins spent today // Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => { export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
@@ -83,54 +71,12 @@ export const coinsSpentTodayAtom = atom((get) => {
return roundToInteger(value); return roundToInteger(value);
}); });
// 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);
});
// Atom to store the current logged-in user's ID.
// This should be set by your application when the user session is available.
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const currentUserAtom = atom((get) => { export const currentUserAtom = atom((get) => {
const currentUserId = get(currentUserIdAtom); const currentUserId = get(currentUserIdAtom);
const users = get(usersAtom); const users = get(usersAtom);
return users.users.find(user => user.id === currentUserId); return users.users.find(user => user.id === currentUserId);
}) })
// Derived atom for current balance for the logged-in user
export const coinsBalanceAtom = atom((get) => {
const loggedInUserId = get(currentUserIdAtom);
if (!loggedInUserId) {
return 0; // No user logged in or ID not set, so balance is 0
}
const coins = get(coinsAtom);
const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
});
/* transient atoms */
interface PomodoroAtom {
show: boolean
selectedHabitId: string | null
autoStart: boolean
minimized: boolean
}
export const pomodoroAtom = atom<PomodoroAtom>({
show: false,
selectedHabitId: null,
autoStart: true,
minimized: false,
})
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
/** /**
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data. * 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. * This token can be compared with a server-generated token to detect data discrepancies.
@@ -147,34 +93,26 @@ export const clientFreshnessTokenAtom = atom(async (get) => {
return hash; return hash;
}); });
// Derived atom for completion cache // Derived atom for completed habits by date, using the cache
export const completionCacheAtom = atom((get) => { export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits; const habits = get(habitsAtom).habits;
const completionCache: CompletionCache = {};
const map = new Map<string, Habit[]>();
const timezone = get(settingsAtom).system.timezone; const timezone = get(settingsAtom).system.timezone;
const cache: CompletionCache = {};
habits.forEach(habit => { habits.forEach(habit => {
habit.completions.forEach(utcTimestamp => { habit.completions.forEach(utcTimestamp => {
const localDate = t2d({ timestamp: utcTimestamp, timezone }) const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd'); .toFormat('yyyy-MM-dd');
if (!cache[localDate]) { if (!completionCache[localDate]) {
cache[localDate] = {}; completionCache[localDate] = {};
} }
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1; completionCache[localDate][habit.id] = (completionCache[localDate][habit.id] || 0) + 1;
}); });
}); });
return cache;
});
// Derived atom for completed habits by date, using the cache
export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const completionCache = get(completionCacheAtom);
const map = new Map<string, Habit[]>();
// For each date in the cache // For each date in the cache
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => { Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
const completedHabits = habits.filter(habit => { const completedHabits = habits.filter(habit => {
@@ -191,38 +129,6 @@ export const completedHabitsMapAtom = atom((get) => {
return map; return map;
}); });
// Derived atom for habit frequency map
export const habitFreqMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const map = new Map<string, Freq>();
habits.forEach(habit => {
map.set(habit.id, getHabitFreq(habit));
});
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
})
})
// Derived atom to check if any habits are tasks
export const hasTasksAtom = atom((get) => {
const habits = get(habitsAtom)
return habits.habits.some(habit => habit.isTask === true)
})
// Atom family for habits by specific date // Atom family for habits by specific date
export const habitsByDateFamily = atomFamily((dateString: string) => export const habitsByDateFamily = atomFamily((dateString: string) =>
atom((get) => { atom((get) => {

25
lib/avatar.ts Normal file
View File

@@ -0,0 +1,25 @@
export const ALLOWED_AVATAR_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.avif',
])
export const ALLOWED_AVATAR_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
])
export const AVATAR_CONTENT_TYPE: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
}

View File

@@ -1,8 +1,19 @@
import { auth } from '@/auth' import { auth } from '@/auth'
import 'server-only' import 'server-only'
import { User, UserId } from './types' import { User, UserData, UserId, getDefaultUsersData } from './types'
import { loadUsersData } from '@/app/actions/data'
import { randomBytes, scryptSync } from 'crypto' import { randomBytes, scryptSync } from 'crypto'
import fs from 'fs/promises'
import path from 'path'
async function loadUsersDataFromStore(): Promise<UserData> {
try {
const filePath = path.join(process.cwd(), 'data', 'auth.json')
const data = await fs.readFile(filePath, 'utf8')
return JSON.parse(data) as UserData
} catch {
return getDefaultUsersData()
}
}
export async function getCurrentUserId(): Promise<UserId | undefined> { export async function getCurrentUserId(): Promise<UserId | undefined> {
const session = await auth() const session = await auth()
@@ -15,7 +26,7 @@ export async function getCurrentUser(): Promise<User | undefined> {
if (!currentUserId) { if (!currentUserId) {
return undefined return undefined
} }
const usersData = await loadUsersData() const usersData = await loadUsersDataFromStore()
return usersData.users.find((u) => u.id === currentUserId) return usersData.users.find((u) => u.id === currentUserId)
} }
export function saltAndHashPassword(password: string, salt?: string): string { export function saltAndHashPassword(password: string, salt?: string): string {

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

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

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

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

View File

@@ -27,6 +27,7 @@ export type SafeUser = SessionUser & {
avatarPath?: string avatarPath?: string
permissions?: Permission[] permissions?: Permission[]
isAdmin?: boolean isAdmin?: boolean
hasPassword?: boolean
} }
export type User = SafeUser & { export type User = SafeUser & {
@@ -34,6 +35,10 @@ export type User = SafeUser & {
lastNotificationReadTimestamp?: string // UTC ISO date string lastNotificationReadTimestamp?: string // UTC ISO date string
} }
export type PublicUser = Omit<User, 'password'> & {
hasPassword: boolean
}
export type Habit = { export type Habit = {
id: string id: string
name: string name: string
@@ -46,6 +51,7 @@ export type Habit = {
archived?: boolean // mark the habit as archived archived?: boolean // mark the habit as archived
pinned?: boolean // mark the habit as pinned pinned?: boolean // mark the habit as pinned
userIds?: UserId[] userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
} }
@@ -60,6 +66,7 @@ export type WishlistItemType = {
targetCompletions?: number // Optional field, infinity when unset targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource link?: string // Optional URL to external resource
userIds?: UserId[] userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
} }
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO'; export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
@@ -79,6 +86,10 @@ export interface UserData {
users: User[] users: User[]
} }
export interface PublicUserData {
users: PublicUser[]
}
export interface HabitsData { export interface HabitsData {
habits: Habit[]; habits: Habit[];
} }
@@ -96,7 +107,7 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export function getDefaultUsersData<UserData>(): UserData { export function getDefaultUsersData(): UserData {
return { return {
users: [ users: [
{ {
@@ -110,23 +121,30 @@ export function getDefaultUsersData<UserData>(): UserData {
} as UserData; } as UserData;
}; };
export function getDefaultHabitsData<HabitsData>(): HabitsData { export const getDefaultPublicUsersData = (): PublicUserData => ({
return { habits: [] } as HabitsData; users: getDefaultUsersData().users.map(({ password, ...user }) => ({
} ...user,
hasPassword: !!password,
})),
});
export const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
export function getDefaultTasksData<TasksData>(): TasksData { export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData; return { tasks: [] } as TasksData;
}; };
export function getDefaultCoinsData<CoinsData>(): CoinsData { export function getDefaultCoinsData(): CoinsData {
return { balance: 0, transactions: [] } as CoinsData; return { balance: 0, transactions: [] } as CoinsData;
}; };
export function getDefaultWishlistData<WishlistData>(): WishlistData { export function getDefaultWishlistData(): WishlistData {
return { items: [] } as WishlistData; return { items: [] } as WishlistData;
} }
export function getDefaultSettings<Settings>(): Settings { export function getDefaultSettings(): Settings {
return { return {
ui: { ui: {
useNumberFormatting: true, useNumberFormatting: true,
@@ -142,12 +160,12 @@ export function getDefaultSettings<Settings>(): Settings {
} as Settings; } as Settings;
}; };
export function getDefaultServerSettings<ServerSettings>(): ServerSettings { export function getDefaultServerSettings(): ServerSettings {
return { isDemo: false } as 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: { [key: string]: <T>() => T } = { export const DATA_DEFAULTS = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData, habits: getDefaultHabitsData,
coins: getDefaultCoinsData, coins: getDefaultCoinsData,
@@ -193,7 +211,7 @@ export interface JotaiHydrateInitialValues {
coins: CoinsData; coins: CoinsData;
habits: HabitsData; habits: HabitsData;
wishlist: WishlistData; wishlist: WishlistData;
users: UserData; users: PublicUserData;
serverSettings: ServerSettings; serverSettings: ServerSettings;
} }
@@ -208,3 +226,16 @@ export interface ParsedFrequencyResult {
message: string | null message: string | null
result: ParsedResultType result: ParsedResultType
} }
export interface PomodoroAtom {
show: boolean
selectedHabitId: string | null
autoStart: boolean
minimized: boolean
}
export interface BrowserSettings {
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}

10
lib/user-sanitizer.ts Normal file
View File

@@ -0,0 +1,10 @@
import { PublicUserData, UserData } from './types'
export function sanitizeUserData(data: UserData): PublicUserData {
return {
users: data.users.map(({ password, ...user }) => ({
...user,
hasPassword: !!password,
})),
}
}

View File

@@ -942,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
}) })
describe('freshness utilities', () => { describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings<Settings>(); const mockSettings: Settings = getDefaultSettings();
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>(); const mockHabits: HabitsData = getDefaultHabitsData();
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>(); const mockCoins: CoinsData = getDefaultCoinsData();
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>(); const mockWishlist: WishlistData = getDefaultWishlistData();
const mockUsers: UserData = getDefaultUsersData<UserData>(); const mockUsers: UserData = getDefaultUsersData();
// Add a user to mockUsers for more realistic testing // Add a user to mockUsers for more realistic testing
mockUsers.users.push({ mockUsers.users.push({
@@ -991,11 +991,11 @@ describe('freshness utilities', () => {
}); });
test('should handle empty data consistently', () => { test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings<Settings>(); const emptySettings = getDefaultSettings();
const emptyHabits = getDefaultHabitsData<HabitsData>(); const emptyHabits = getDefaultHabitsData();
const emptyCoins = getDefaultCoinsData<CoinsData>(); const emptyCoins = getDefaultCoinsData();
const emptyWishlist = getDefaultWishlistData<WishlistData>(); const emptyWishlist = getDefaultWishlistData();
const emptyUsers = getDefaultUsersData<UserData>(); const emptyUsers = getDefaultUsersData();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);

View File

@@ -1,5 +1,5 @@
import { toast } from "@/hooks/use-toast" import { toast } from "@/hooks/use-toast"
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types' import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, PomodoroAtom, PublicUserData, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { DateTime, DateTimeFormatOptions } from "luxon" import { DateTime, DateTimeFormatOptions } from "luxon"
@@ -84,6 +84,20 @@ export function getCompletionsForToday({
return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone }) return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone })
} }
export function getTodayCompletions({ selectedHabitId }: PomodoroAtom, { habits }: HabitsData, { system: { timezone } }: Settings): number {
if (!selectedHabitId)
return 0;
const selectedHabit = habits.find(h => h.id === selectedHabitId!);
if (!selectedHabit)
return 0;
return getCompletionsForToday({
habit: selectedHabit,
timezone: timezone
});
}
export function getCompletedHabitsForDate({ export function getCompletedHabitsForDate({
habits, habits,
date, date,
@@ -448,7 +462,7 @@ export function prepareDataForHashing(
habits: HabitsData, habits: HabitsData,
coins: CoinsData, coins: CoinsData,
wishlist: WishlistData, wishlist: WishlistData,
users: UserData users: UserData | PublicUserData
): string { ): string {
return JSON.stringify({ return JSON.stringify({
settings, settings,

447
messages/ca.json Normal file
View File

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

View File

@@ -82,6 +82,9 @@
"timesSuffix": "mal", "timesSuffix": "mal",
"rewardLabel": "Belohnung", "rewardLabel": "Belohnung",
"coinsSuffix": "Münzen", "coinsSuffix": "Münzen",
"drawingLabel": "Zeichnung",
"addDrawing": "Zeichnung hinzufügen",
"editDrawing": "Zeichnung bearbeiten",
"shareLabel": "Teilen", "shareLabel": "Teilen",
"saveChangesButton": "Änderungen speichern", "saveChangesButton": "Änderungen speichern",
"addTaskButton": "Aufgabe hinzufügen", "addTaskButton": "Aufgabe hinzufügen",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein", "errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein", "errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
"linkLabel": "Link", "linkLabel": "Link",
"drawingLabel": "Zeichnung",
"addDrawing": "Zeichnung hinzufügen",
"editDrawing": "Zeichnung bearbeiten",
"shareLabel": "Teilen", "shareLabel": "Teilen",
"saveButton": "Änderungen speichern", "saveButton": "Änderungen speichern",
"addButton": "Belohnung hinzufügen" "addButton": "Belohnung hinzufügen"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.", "maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
"transactionNotFoundDescription": "Transaktion nicht gefunden", "transactionNotFoundDescription": "Transaktion nicht gefunden",
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten." "maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten."
},
"DrawingModal": {
"colorLabel": "Farbe:",
"thicknessLabel": "Dicke:",
"undoButton": "Rückgängig",
"clearButton": "Löschen",
"saveDrawingButton": "Zeichnung speichern",
"cancelButton": "Abbrechen"
} }
} }

View File

@@ -82,6 +82,9 @@
"timesSuffix": "times", "timesSuffix": "times",
"rewardLabel": "Reward", "rewardLabel": "Reward",
"coinsSuffix": "coins", "coinsSuffix": "coins",
"drawingLabel": "Drawing",
"addDrawing": "Add Drawing",
"editDrawing": "Edit Drawing",
"shareLabel": "Share", "shareLabel": "Share",
"saveChangesButton": "Save Changes", "saveChangesButton": "Save Changes",
"addTaskButton": "Add Task", "addTaskButton": "Add Task",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Target completions must be at least 1", "errorTargetCompletionsMin": "Target completions must be at least 1",
"errorInvalidUrl": "Please enter a valid URL", "errorInvalidUrl": "Please enter a valid URL",
"linkLabel": "Link", "linkLabel": "Link",
"drawingLabel": "Drawing",
"addDrawing": "Add Drawing",
"editDrawing": "Edit Drawing",
"shareLabel": "Share", "shareLabel": "Share",
"saveButton": "Save Changes", "saveButton": "Save Changes",
"addButton": "Add Reward" "addButton": "Add Reward"
@@ -288,7 +294,18 @@
"cancelButton": "Cancel", "cancelButton": "Cancel",
"saveChangesButton": "Save Changes", "saveChangesButton": "Save Changes",
"createUserButton": "Create User", "createUserButton": "Create User",
"deleteAccountButton": "Delete Account" "deleteAccountButton": "Delete Account",
"toastDemoDeleteDisabled": "Deletion is disabled in demo instance",
"toastCannotDeleteSelf": "You cannot delete your own account",
"confirmDeleteUser": "Are you sure you want to delete user {username}?",
"toastUserDeletedTitle": "User deleted",
"toastUserDeletedDescription": "User {username} has been deleted successfully",
"toastDeleteUserFailed": "Failed to delete user: {error}",
"deletingButtonText": "Deleting...",
"areYouSure": "Are you sure?",
"deleteUserConfirmation": "Are you sure you want to delete user {username}?",
"cancel": "Cancel",
"confirmDeleteButtonText": "Delete"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "Habits", "habitsLabel": "Habits",
@@ -413,12 +430,18 @@
"invalidAmountDescription": "Please enter a valid positive number", "invalidAmountDescription": "Please enter a valid positive number",
"successTitle": "Success", "successTitle": "Success",
"transactionNotFoundDescription": "Transaction not found", "transactionNotFoundDescription": "Transaction not found",
"maxAmountExceededDescription": "The amount cannot exceed {max}.",
"transactionNotFoundDescription": "Transaction not found",
"maxAmountExceededDescription": "The amount cannot exceed {max}." "maxAmountExceededDescription": "The amount cannot exceed {max}."
}, },
"Warning": { "Warning": {
"areYouSure": "Are you sure?", "areYouSure": "Are you sure?",
"cancel": "Cancel" "cancel": "Cancel"
},
"DrawingModal": {
"saveDrawingButton": "Save",
"cancelButton": "Cancel",
"clearButton": "Clear",
"undoButton": "Undo",
"colorLabel": "Color",
"thicknessLabel": "Thickness"
} }
} }

View File

@@ -82,6 +82,9 @@
"timesSuffix": "veces", "timesSuffix": "veces",
"rewardLabel": "Recompensa", "rewardLabel": "Recompensa",
"coinsSuffix": "monedas", "coinsSuffix": "monedas",
"drawingLabel": "Dibujo",
"addDrawing": "Añadir Dibujo",
"editDrawing": "Editar Dibujo",
"shareLabel": "Compartir", "shareLabel": "Compartir",
"saveChangesButton": "Guardar cambios", "saveChangesButton": "Guardar cambios",
"addTaskButton": "Añadir tarea", "addTaskButton": "Añadir tarea",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1", "errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
"errorInvalidUrl": "Por favor ingresa una URL válida", "errorInvalidUrl": "Por favor ingresa una URL válida",
"linkLabel": "Enlace", "linkLabel": "Enlace",
"drawingLabel": "Dibujo",
"addDrawing": "Añadir Dibujo",
"editDrawing": "Editar Dibujo",
"shareLabel": "Compartir", "shareLabel": "Compartir",
"saveButton": "Guardar cambios", "saveButton": "Guardar cambios",
"addButton": "Añadir recompensa" "addButton": "Añadir recompensa"
@@ -385,6 +391,14 @@
"languageChangedDescription": "Por favor actualiza la página para ver los cambios", "languageChangedDescription": "Por favor actualiza la página para ver los cambios",
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración." "languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
}, },
"DrawingModal": {
"colorLabel": "Color:",
"thicknessLabel": "Grosor:",
"undoButton": "Deshacer",
"clearButton": "Limpiar",
"saveDrawingButton": "Guardar Dibujo",
"cancelButton": "Cancelar"
},
"Common": { "Common": {
"authenticationRequiredTitle": "Autenticación requerida", "authenticationRequiredTitle": "Autenticación requerida",
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.", "authenticationRequiredDescription": "Por favor inicia sesión para continuar.",

View File

@@ -82,6 +82,9 @@
"timesSuffix": "fois", "timesSuffix": "fois",
"rewardLabel": "Récompense", "rewardLabel": "Récompense",
"coinsSuffix": "pièces", "coinsSuffix": "pièces",
"drawingLabel": "Dessin",
"addDrawing": "Ajouter un Dessin",
"editDrawing": "Modifier le Dessin",
"shareLabel": "Partager", "shareLabel": "Partager",
"saveChangesButton": "Sauvegarder les modifications", "saveChangesButton": "Sauvegarder les modifications",
"addTaskButton": "Ajouter une tâche", "addTaskButton": "Ajouter une tâche",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1", "errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
"errorInvalidUrl": "Veuillez entrer une URL valide", "errorInvalidUrl": "Veuillez entrer une URL valide",
"linkLabel": "Lien", "linkLabel": "Lien",
"drawingLabel": "Dessin",
"addDrawing": "Ajouter un Dessin",
"editDrawing": "Modifier le Dessin",
"shareLabel": "Partager", "shareLabel": "Partager",
"saveButton": "Sauvegarder les modifications", "saveButton": "Sauvegarder les modifications",
"addButton": "Ajouter une récompense" "addButton": "Ajouter une récompense"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.", "maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
"transactionNotFoundDescription": "Transaction non trouvée", "transactionNotFoundDescription": "Transaction non trouvée",
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}." "maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}."
},
"DrawingModal": {
"colorLabel": "Couleur :",
"thicknessLabel": "Épaisseur :",
"undoButton": "Annuler",
"clearButton": "Effacer",
"saveDrawingButton": "Enregistrer le dessin",
"cancelButton": "Annuler"
} }
} }

View File

@@ -82,6 +82,9 @@
"timesSuffix": "回", "timesSuffix": "回",
"rewardLabel": "報酬", "rewardLabel": "報酬",
"coinsSuffix": "コイン", "coinsSuffix": "コイン",
"drawingLabel": "描画",
"addDrawing": "描画を追加",
"editDrawing": "描画を編集",
"shareLabel": "共有", "shareLabel": "共有",
"saveChangesButton": "変更を保存", "saveChangesButton": "変更を保存",
"addTaskButton": "タスクを追加", "addTaskButton": "タスクを追加",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります", "errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
"errorInvalidUrl": "有効なURLを入力してください", "errorInvalidUrl": "有効なURLを入力してください",
"linkLabel": "リンク", "linkLabel": "リンク",
"drawingLabel": "描画",
"addDrawing": "描画を追加",
"editDrawing": "描画を編集",
"shareLabel": "共有", "shareLabel": "共有",
"saveButton": "変更を保存", "saveButton": "変更を保存",
"addButton": "報酬を追加" "addButton": "報酬を追加"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "金額は{max}を超えることはできません。", "maxAmountExceededDescription": "金額は{max}を超えることはできません。",
"transactionNotFoundDescription": "取引が見つかりません", "transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。" "maxAmountExceededDescription": "金額は{max}を超えることはできません。"
},
"DrawingModal": {
"colorLabel": "色:",
"thicknessLabel": "太さ:",
"undoButton": "元に戻す",
"clearButton": "クリア",
"saveDrawingButton": "描画を保存",
"cancelButton": "キャンセル"
} }
} }

449
messages/ko.json Normal file
View File

@@ -0,0 +1,449 @@
{
"Dashboard": {
"title": "대시보드"
},
"HabitList": {
"myTasks": "내 할 일",
"myHabits": "내 습관",
"addTaskButton": "할 일 추가",
"addHabitButton": "습관 추가",
"searchTasksPlaceholder": "할 일 검색...",
"searchHabitsPlaceholder": "습관 검색...",
"sortByLabel": "정렬 기준:",
"sortByName": "이름",
"sortByCoinReward": "코인 보상",
"sortByDueDate": "마감일",
"sortByFrequency": "빈도",
"toggleSortOrderAriaLabel": "정렬 순서 토글",
"noTasksFoundMessage": "검색과 일치하는 할 일이 없습니다.",
"noHabitsFoundMessage": "검색과 일치하는 습관이 없습니다.",
"emptyStateTasksTitle": "아직 할 일이 없습니다",
"emptyStateHabitsTitle": "아직 습관이 없습니다",
"emptyStateTasksDescription": "첫 할 일을 만들어 진행 상황을 추적해보세요",
"emptyStateHabitsDescription": "첫 습관을 만들어 진행 상황을 추적해보세요",
"archivedSectionTitle": "보관함",
"deleteTaskDialogTitle": "할 일 삭제",
"deleteHabitDialogTitle": "습관 삭제",
"deleteTaskDialogMessage": "이 할 일을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteHabitDialogMessage": "이 습관을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteButton": "삭제"
},
"DailyOverview": {
"addTaskButtonLabel": "할 일 추가",
"addHabitButtonLabel": "습관 추가",
"todaysOverviewTitle": "오늘의 개요",
"dailyTasksTitle": "오늘의 할 일",
"noTasksDueTodayMessage": "오늘 할 일이 없습니다. 할 일을 추가해서 시작하세요!",
"dailyHabitsTitle": "오늘의 습관",
"noHabitsDueTodayMessage": "오늘 습관이 없습니다. 습관을 추가해서 시작하세요!",
"wishlistGoalsTitle": "위시리스트 목표",
"redeemableBadgeLabel": "{count}/{total} 교환 가능",
"noWishlistItemsMessage": "아직 위시리스트 항목이 없습니다. 목표를 추가해서 노력해보세요!",
"readyToRedeemMessage": "교환할 준비 완료!",
"coinsToGoMessage": "{amount} 코인 남음",
"showLessButton": "적게 보기",
"showAllButton": "모두 보기",
"viewButton": "보기",
"deleteTaskDialogTitle": "할 일 삭제",
"deleteHabitDialogTitle": "습관 삭제",
"confirmDeleteDialogMessage": "정말로 \"{name}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteButton": "삭제",
"overdueTooltip": "기한이 지남"
},
"HabitContextMenuItems": {
"startPomodoro": "뽀모도로 시작",
"moveToToday": "오늘로 이동",
"moveToTomorrow": "내일로 이동",
"unpin": "고정 해제",
"pin": "고정",
"edit": "수정",
"archive": "보관",
"unarchive": "보관 해제",
"delete": "삭제"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "일일 달성 연속 기록",
"tooltipHabitsLabel": "습관",
"tooltipTasksLabel": "할 일",
"tooltipCompletedLabel": "완료됨"
},
"CoinBalance": {
"coinBalanceTitle": "코인 잔액"
},
"AddEditHabitModal": {
"editTaskTitle": "할 일 수정",
"editHabitTitle": "습관 수정",
"addNewTaskTitle": "새 할 일 추가",
"addNewHabitTitle": "새 습관 추가",
"nameLabel": "이름 *",
"descriptionLabel": "설명",
"whenLabel": "시기 *",
"completeLabel": "완료",
"timesSuffix": "회",
"rewardLabel": "보상",
"coinsSuffix": "코인",
"drawingLabel": "그림",
"addDrawing": "그림 추가",
"editDrawing": "그림 편집",
"shareLabel": "공유",
"saveChangesButton": "변경 사항 저장",
"addTaskButton": "할 일 추가",
"addHabitButton": "습관 추가"
},
"ConfirmDialog": {
"confirmButton": "확인",
"cancelButton": "취소"
},
"AddEditWishlistItemModal": {
"editTitle": "보상 수정",
"addTitle": "새 보상 추가",
"nameLabel": "이름 *",
"descriptionLabel": "설명",
"costLabel": "비용",
"coinsSuffix": "코인",
"redeemableLabel": "교환 가능",
"timesSuffix": "회",
"errorNameRequired": "이름은 필수입니다",
"errorCoinCostMin": "코인 비용은 최소 1이어야 합니다",
"errorTargetCompletionsMin": "목표 완료 횟수는 최소 1이어야 합니다",
"errorInvalidUrl": "유효한 URL을 입력하세요",
"linkLabel": "링크",
"drawingLabel": "그림",
"addDrawing": "그림 추가",
"editDrawing": "그림 편집",
"shareLabel": "공유",
"saveButton": "변경 사항 저장",
"addButton": "보상 추가"
},
"Navigation": {
"dashboard": "대시보드",
"tasks": "할 일",
"habits": "습관",
"calendar": "캘린더",
"wishlist": "위시리스트",
"coins": "코인"
},
"TodayEarnedCoins": {
"todaySuffix": "오늘"
},
"WishlistItem": {
"usesLeftSingular": "사용 횟수 남음",
"usesLeftPlural": "사용 횟수 남음",
"coinsSuffix": "코인",
"redeem": "교환",
"redeemedDone": "완료",
"redeemedExclamation": "교환 완료!",
"editButton": "수정",
"archiveButton": "보관",
"unarchiveButton": "보관 해제",
"deleteButton": "삭제"
},
"WishlistManager": {
"title": "내 위시리스트",
"addRewardButton": "보상 추가",
"emptyStateTitle": "위시리스트가 비어 있습니다",
"emptyStateDescription": "코인으로 얻고 싶은 보상을 추가하세요",
"archivedSectionTitle": "보관함",
"popupBlockedTitle": "팝업 차단됨",
"popupBlockedDescription": "링크를 열려면 팝업을 허용해주세요",
"deleteDialogTitle": "보상 삭제",
"deleteDialogMessage": "이 보상을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteButton": "삭제"
},
"UserSelectModal": {
"addUserButton": "사용자 추가",
"createNewUserTitle": "새 사용자 생성",
"selectUserTitle": "사용자 선택",
"signInSuccessTitle": "로그인 성공",
"signInSuccessDescription": "환영합니다, {username}님!",
"errorInvalidPassword": "잘못된 비밀번호",
"deleteUserConfirmation": "사용자 {username}을(를) 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"confirmDeleteButtonText": "삭제",
"deletingButtonText": "삭제 중...",
"deleteUserSuccessTitle": "사용자 삭제됨",
"deleteUserSuccessDescription": "사용자 {username}이(가) 성공적으로 삭제되었습니다.",
"deleteUserErrorTitle": "삭제 실패",
"genericError": "예기치 않은 오류가 발생했습니다.",
"networkError": "네트워크 오류가 발생했습니다. 다시 시도해주세요.",
"deleteUserTooltip": "사용자 삭제",
"editUserTooltip": "사용자 수정"
},
"CoinsManager": {
"title": "코인 관리",
"currentBalanceLabel": "현재 잔액",
"coinsSuffix": "코인",
"addCoinsButton": "코인 추가",
"removeCoinsButton": "코인 제거",
"statisticsTitle": "통계",
"totalEarnedLabel": "총 획득 코인",
"totalSpentLabel": "총 사용 코인",
"totalTransactionsLabel": "총 거래 횟수",
"todaysEarnedLabel": "오늘 획득 코인",
"todaysSpentLabel": "오늘 사용 코인",
"todaysTransactionsLabel": "오늘 거래 횟수",
"transactionHistoryTitle": "거래 내역",
"showLabel": "표시:",
"entriesSuffix": "개",
"showingEntries": "{from}개부터 {to}개까지 총 {total}개 항목 표시",
"noTransactionsTitle": "아직 거래 내역이 없습니다",
"noTransactionsDescription": "코인을 획득하거나 사용하기 시작하면 거래 내역이 여기에 나타납니다",
"pageLabel": "페이지",
"ofLabel": "중",
"transactionTypeHabitCompletion": "습관 완료",
"transactionTypeTaskCompletion": "할 일 완료",
"transactionTypeHabitUndo": "습관 되돌리기",
"transactionTypeTaskUndo": "할 일 되돌리기",
"transactionTypeWishRedemption": "위시리스트 교환",
"transactionTypeManualAdjustment": "수동 조정",
"transactionTypeCoinReset": "코인 초기화",
"transactionTypeInitialBalance": "초기 잔액"
},
"NotificationBell": {
"errorUpdateTimestamp": "알림 읽은 시각을 업데이트하지 못했습니다:"
},
"PomodoroTimer": {
"focusLabel1": "집중하세요",
"focusLabel2": "할 수 있어요",
"focusLabel3": "계속하세요",
"focusLabel4": "해내세요",
"focusLabel5": "실행하세요",
"focusLabel6": "강하게 유지하세요",
"focusLabel7": "밀어붙이세요",
"focusLabel8": "한 번에 한 걸음씩",
"focusLabel9": "당신은 해낼 수 있어요",
"focusLabel10": "집중하고 정복하세요",
"breakLabel1": "휴식을 취하세요",
"breakLabel2": "편안하게 재충전하세요",
"breakLabel3": "깊게 숨쉬세요",
"breakLabel4": "몸을 쭉 펴세요",
"breakLabel5": "기분 전환하세요",
"breakLabel6": "당신은 그럴 자격이 있어요",
"breakLabel7": "에너지를 재충전하세요",
"breakLabel8": "잠시 벗어나세요",
"breakLabel9": "마음을 비우세요",
"breakLabel10": "휴식하고 활력을 되찾으세요",
"focusType": "집중",
"breakType": "휴식",
"pauseButton": "일시 정지",
"startButton": "시작",
"resetButton": "초기화",
"skipButton": "건너뛰기",
"wakeLockNotSupported": "브라우저가 wakelock을 지원하지 않습니다",
"wakeLockInUse": "Wake lock이 이미 사용 중입니다",
"wakeLockRequestError": "wake lock 요청 오류:",
"wakeLockReleaseError": "wake lock 해제 오류:"
},
"HabitCalendar": {
"title": "습관 캘린더",
"calendarCardTitle": "캘린더",
"selectDatePrompt": "날짜 선택",
"tasksSectionTitle": "할 일",
"habitsSectionTitle": "습관",
"errorCompletingPastHabit": "과거 습관 완료 오류:"
},
"NotificationDropdown": {
"notLoggedIn": "로그인되지 않았습니다.",
"userCompletedItem": "{username}님이 {itemName}을(를) 완료했습니다.",
"userRedeemedItem": "{username}님이 {itemName}을(를) 교환했습니다.",
"activityRelatedToItem": "{username}님의 {itemName} 관련 활동.",
"defaultUsername": "어떤 사용자",
"defaultItemName": "공유된 항목",
"notificationsTitle": "알림",
"notificationsTooltip": "공유한 습관이나 위시리스트에 대해 다른 사용자의 완료 또는 교환 내역을 보여줍니다 (관리자여야 합니다)",
"noNotificationsYet": "아직 알림이 없습니다."
},
"AboutModal": {
"dialogArisLabel": "정보",
"changelogButton": "변경 로그",
"createdByPrefix": "❤️로 제작: ",
"starOnGitHubButton": "GitHub에서 별 누르기"
},
"PermissionSelector": {
"permissionsTitle": "권한",
"adminAccessLabel": "관리자 접근",
"adminAccessDescription": "관리자는 모든 사용자의 모든 데이터에 대한 전체 권한을 가집니다",
"resourceHabitTask": "습관 / 할 일",
"resourceWishlist": "위시리스트",
"resourceCoins": "코인",
"permissionWrite": "쓰기",
"permissionInteract": "상호 작용"
},
"UserForm": {
"toastUserUpdatedTitle": "사용자 업데이트됨",
"toastUserUpdatedDescription": "사용자 {username}이(가) 성공적으로 업데이트되었습니다",
"toastUserCreatedTitle": "사용자 생성됨",
"toastUserCreatedDescription": "사용자 {username}이(가) 성공적으로 생성되었습니다",
"actionUpdate": "업데이트",
"actionCreate": "생성",
"errorFailedUserAction": "사용자 {action} 실패",
"errorTitle": "오류",
"errorFileSizeLimit": "파일 크기는 5MB 미만이어야 합니다",
"toastAvatarUploadedTitle": "아바타 업로드됨",
"toastAvatarUploadedDescription": "아바타가 성공적으로 업로드되었습니다",
"errorFailedAvatarUpload": "아바타 업로드 실패",
"changeAvatarButton": "아바타 변경",
"uploadAvatarButton": "아바타 업로드",
"usernameLabel": "사용자 이름",
"usernamePlaceholder": "사용자 이름",
"newPasswordLabel": "새 비밀번호",
"passwordLabel": "비밀번호",
"passwordPlaceholderEdit": "현재 비밀번호를 유지하려면 비워두세요",
"passwordPlaceholderCreate": "비밀번호를 입력하세요",
"demoPasswordDisabledMessage": "데모 인스턴스에서는 비밀번호가 자동으로 비활성화됩니다",
"disablePasswordLabel": "비밀번호 비활성화",
"cancelButton": "취소",
"saveChangesButton": "변경 사항 저장",
"createUserButton": "사용자 생성",
"deleteAccountButton": "계정 삭제",
"toastDemoDeleteDisabled": "데모 인스턴스에서는 삭제가 비활성화되어 있습니다",
"toastCannotDeleteSelf": "본인의 계정을 삭제할 수 없습니다",
"confirmDeleteUser": "사용자 {username}을(를) 삭제하시겠습니까?",
"toastUserDeletedTitle": "사용자 삭제됨",
"toastUserDeletedDescription": "사용자 {username}이(가) 성공적으로 삭제되었습니다",
"toastDeleteUserFailed": "사용자 삭제 실패: {error}",
"deletingButtonText": "삭제 중...",
"areYouSure": "정말로 삭제하시겠습니까?",
"deleteUserConfirmation": "사용자 {username}을(를) 삭제하시겠습니까?",
"cancel": "취소",
"confirmDeleteButtonText": "삭제"
},
"ViewToggle": {
"habitsLabel": "습관",
"tasksLabel": "할 일"
},
"HabitItem": {
"overdue": "기한이 지남",
"whenLabel": "시기: {frequency}",
"coinsPerCompletion": "완료당 {count} 코인",
"completedStatus": "완료됨",
"completedStatusCount": "완료됨 ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "완료",
"completeButtonCount": "완료 ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "실행 취소",
"editButton": "수정"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "메모가 너무 깁니다",
"noteTooLongDescription": "메모는 200자 미만이어야 합니다",
"errorSavingNoteTitle": "메모 저장 오류",
"errorDeletingNoteTitle": "메모 삭제 오류",
"pleaseTryAgainDescription": "다시 시도해주세요",
"addNotePlaceholder": "메모 추가...",
"saveNoteTitle": "메모 저장",
"cancelButtonTitle": "취소",
"deleteNoteTitle": "메모 삭제",
"editNoteAriaLabel": "메모 수정"
},
"Profile": {
"guestUsername": "게스트",
"editProfileButton": "프로필 수정",
"signOutSuccessTitle": "로그아웃 성공",
"signOutSuccessDescription": "계정에서 로그아웃되었습니다",
"signOutErrorTitle": "로그아웃 오류",
"signOutErrorDescription": "로그아웃 실패",
"switchUserButton": "사용자 전환",
"settingsLink": "설정",
"aboutButton": "정보",
"themeLabel": "테마",
"editProfileModalTitle": "프로필 수정"
},
"PasswordEntryForm": {
"notYouButton": "당신이 아닙니까?",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"loginErrorToastTitle": "오류",
"loginFailedErrorToastDescription": "로그인 실패",
"cancelButton": "취소",
"loginButton": "로그인"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} 완료됨"
},
"SettingsPage": {
"title": "설정",
"uiSettingsTitle": "UI 설정",
"numberFormattingLabel": "숫자 형식 지정",
"numberFormattingDescription": "큰 숫자 형식 지정 (예: 1K, 1M, 1B)",
"numberGroupingLabel": "숫자 그룹화",
"numberGroupingDescription": "천 단위 구분 기호 사용 (예: 1,000 vs 1000)",
"systemSettingsTitle": "시스템 설정",
"timezoneLabel": "시간대",
"timezoneDescription": "정확한 날짜 추적을 위해 시간대를 선택하세요",
"weekStartDayLabel": "주 시작 요일",
"weekStartDayDescription": "선호하는 주의 첫째 날을 선택하세요",
"weekdays": {
"sunday": "일요일",
"monday": "월요일",
"tuesday": "화요일",
"wednesday": "수요일",
"thursday": "목요일",
"friday": "금요일",
"saturday": "토요일"
},
"autoBackupLabel": "자동 백업",
"autoBackupTooltip": "활성화하면 애플리케이션 데이터(습관, 코인, 설정 등)가 서버 시간으로 매일 새벽 2시경에 자동으로 백업됩니다. 백업은 프로젝트 루트의 `backups/` 디렉터리에 ZIP 파일로 저장됩니다. 최근 7개의 백업만 보관되며, 오래된 백업은 자동으로 삭제됩니다.",
"autoBackupDescription": "매일 데이터 자동 백업",
"languageLabel": "언어",
"languageDescription": "애플리케이션의 표시 언어를 선택하세요.",
"languageChangedTitle": "언어 변경됨",
"languageChangedDescription": "변경 사항을 보려면 페이지를 새로고침하세요",
"languageDisabledInDemoTooltip": "데모 버전에서는 언어 변경이 비활성화됩니다."
},
"Common": {
"authenticationRequiredTitle": "인증 필요",
"authenticationRequiredDescription": "계속하려면 로그인하세요.",
"permissionDeniedTitle": "권한 거부됨",
"permissionDeniedDescription": "{resource}에 대한 {action} 권한이 없습니다.",
"undoButton": "실행 취소",
"redoButton": "다시 실행",
"errorTitle": "오류"
},
"useHabits": {
"alreadyCompletedTitle": "이미 완료됨",
"alreadyCompletedDescription": "오늘 이미 이 습관을 완료했습니다.",
"completedTitle": "완료!",
"earnedCoinsDescription": "{coinReward} 코인을 획득했습니다.",
"progressTitle": "진행 중!",
"progressDescription": "오늘 {count}/{target}회 완료했습니다.",
"completionUndoneTitle": "완료 취소됨",
"completionUndoneDescription": "오늘 {count}/{target}회 완료했습니다.",
"noCompletionsToUndoTitle": "실행 취소할 완료 내역 없음",
"noCompletionsToUndoDescription": "이 습관은 오늘 완료되지 않았습니다.",
"alreadyCompletedPastDateTitle": "이미 완료됨",
"alreadyCompletedPastDateDescription": "이 습관은 {dateKey}에 이미 완료되었습니다.",
"earnedCoinsPastDateDescription": "{dateKey}에 {coinReward} 코인을 획득했습니다.",
"progressPastDateDescription": "{dateKey}에 {count}/{target}회 완료했습니다."
},
"useWishlist": {
"redemptionLimitReachedTitle": "교환 한도 도달",
"redemptionLimitReachedDescription": "\"{itemName}\"에 대한 최대 교환 횟수에 도달했습니다.",
"rewardRedeemedTitle": "🎉 보상 교환됨!",
"rewardRedeemedDescription": "보상 \"{itemName}\"을(를) {itemCoinCost} 코인으로 교환했습니다.",
"notEnoughCoinsTitle": "코인 부족",
"notEnoughCoinsDescription": "이 보상을 교환하려면 {coinsNeeded} 코인이 더 필요합니다."
},
"useCoins": {
"addedCoinsDescription": "{amount} 코인 추가됨",
"invalidAmountTitle": "유효하지 않은 금액",
"invalidAmountDescription": "유효한 양의 숫자를 입력하세요",
"successTitle": "성공",
"transactionNotFoundDescription": "거래를 찾을 수 없습니다",
"maxAmountExceededDescription": "금액은 {max}을(를) 초과할 수 없습니다.",
"transactionNotFoundDescription": "거래를 찾을 수 없습니다",
"maxAmountExceededDescription": "금액은 {max}을(를) 초과할 수 없습니다."
},
"Warning": {
"areYouSure": "확실합니까?",
"cancel": "취소"
},
"DrawingModal": {
"colorLabel": "색상:",
"thicknessLabel": "두께:",
"undoButton": "실행 취소",
"clearButton": "지우기",
"saveDrawingButton": "그림 저장",
"cancelButton": "취소"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "раз", "timesSuffix": "раз",
"rewardLabel": "Награда", "rewardLabel": "Награда",
"coinsSuffix": "монет", "coinsSuffix": "монет",
"drawingLabel": "Рисунок",
"addDrawing": "Добавить Рисунок",
"editDrawing": "Редактировать Рисунок",
"shareLabel": "Поделиться", "shareLabel": "Поделиться",
"saveChangesButton": "Сохранить", "saveChangesButton": "Сохранить",
"addTaskButton": "Добавить задачу", "addTaskButton": "Добавить задачу",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Минимум 1 выполнение", "errorTargetCompletionsMin": "Минимум 1 выполнение",
"errorInvalidUrl": "Некорректная ссылка", "errorInvalidUrl": "Некорректная ссылка",
"linkLabel": "Ссылка", "linkLabel": "Ссылка",
"drawingLabel": "Рисунок",
"addDrawing": "Добавить Рисунок",
"editDrawing": "Редактировать Рисунок",
"shareLabel": "Поделиться", "shareLabel": "Поделиться",
"saveButton": "Сохранить", "saveButton": "Сохранить",
"addButton": "Добавить цель" "addButton": "Добавить цель"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "Сумма не может превышать {max}.", "maxAmountExceededDescription": "Сумма не может превышать {max}.",
"transactionNotFoundDescription": "Транзакция не найдена", "transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}." "maxAmountExceededDescription": "Сумма не может превышать {max}."
},
"DrawingModal": {
"colorLabel": "Цвет:",
"thicknessLabel": "Толщина:",
"undoButton": "Отменить",
"clearButton": "Очистить",
"saveDrawingButton": "Сохранить рисунок",
"cancelButton": "Отмена"
} }
} }

View File

@@ -82,6 +82,9 @@
"timesSuffix": "次", "timesSuffix": "次",
"rewardLabel": "奖励", "rewardLabel": "奖励",
"coinsSuffix": "金币", "coinsSuffix": "金币",
"drawingLabel": "绘图",
"addDrawing": "添加绘图",
"editDrawing": "编辑绘图",
"shareLabel": "分享", "shareLabel": "分享",
"saveChangesButton": "保存更改", "saveChangesButton": "保存更改",
"addTaskButton": "添加任务", "addTaskButton": "添加任务",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "目标完成次数至少为 1", "errorTargetCompletionsMin": "目标完成次数至少为 1",
"errorInvalidUrl": "请输入有效的 URL", "errorInvalidUrl": "请输入有效的 URL",
"linkLabel": "链接", "linkLabel": "链接",
"drawingLabel": "绘图",
"addDrawing": "添加绘图",
"editDrawing": "编辑绘图",
"shareLabel": "分享", "shareLabel": "分享",
"saveButton": "保存更改", "saveButton": "保存更改",
"addButton": "添加奖励" "addButton": "添加奖励"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "金额不能超过 {max}。", "maxAmountExceededDescription": "金额不能超过 {max}。",
"transactionNotFoundDescription": "未找到交易记录", "transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。" "maxAmountExceededDescription": "金额不能超过 {max}。"
},
"DrawingModal": {
"colorLabel": "颜色:",
"thicknessLabel": "粗细:",
"undoButton": "撤销",
"clearButton": "清除",
"saveDrawingButton": "保存绘图",
"cancelButton": "取消"
} }
} }

13
middleware.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
if (process.env.NODE_ENV !== 'development' && request.nextUrl.pathname.startsWith('/debug')) {
return new NextResponse('Not Found', { status: 404 })
}
return NextResponse.next()
}
export const config = {
matcher: ['/debug/:path*'],
}

View File

@@ -30,24 +30,7 @@ const nextConfig: NextConfig = {
value: 'strict-origin-when-cross-origin', value: 'strict-origin-when-cross-origin',
}, },
], ],
}, }
{
source: '/sw.js',
headers: [
{
key: 'Content-Type',
value: 'application/javascript; charset=utf-8',
},
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'",
},
],
},
] ]
}, },
}; };

595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.23", "version": "0.2.31",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -48,7 +48,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"next": "15.2.3", "next": "^15.5.10",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0", "next-intl": "^4.1.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",

View File

@@ -1,22 +0,0 @@
self.addEventListener('push', function (event) {
if (event.data) {
const data = event.data.json()
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: '/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
}
event.waitUntil(self.registration.showNotification(data.title, options))
}
})
self.addEventListener('notificationclick', function (event) {
console.log('Notification click received.')
event.notification.close()
event.waitUntil(clients.openWindow('/'))
})