mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
25 Commits
v0.2.23.0
...
bb2e4be41b
| Author | SHA1 | Date | |
|---|---|---|---|
|
bb2e4be41b
|
|||
|
|
b01c5dcd6a | ||
|
3f9cd87c4d
|
|||
|
083fae020a
|
|||
|
38da61c6c2
|
|||
|
0689a5827f
|
|||
|
ab0c5e3e99
|
|||
|
3cc8543067
|
|||
|
06aa27af63
|
|||
|
c5bacb719c
|
|||
|
3ae2a3cb79
|
|||
|
|
3e6b4b75ec | ||
|
|
31700c9a45 | ||
|
|
e05b982307 | ||
|
|
ee2821b2bf | ||
|
|
8fb7cd1810 | ||
|
1b17d6b50a
|
|||
|
|
a6f5bf1baa | ||
|
|
8dda60b9b1 | ||
|
8269f3adad
|
|||
|
|
ad2504dc7f | ||
|
4cadf4cea7
|
|||
|
06e802f2f5
|
|||
|
6c0b196de2
|
|||
|
0f073760ee
|
@@ -7,3 +7,17 @@ Dockerfile
|
||||
node_modules
|
||||
npm-debug.log
|
||||
data
|
||||
CLAUDE.md
|
||||
docs/
|
||||
Budfile
|
||||
PLAN.md
|
||||
/backups/
|
||||
/data.bak/
|
||||
/coverage/
|
||||
*.md
|
||||
!README.md
|
||||
!CHANGELOG.md
|
||||
tags
|
||||
tsconfig.tsbuildinfo
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,5 +46,6 @@ next-env.d.ts
|
||||
Budfile
|
||||
certificates
|
||||
/backups/*
|
||||
CLAUDE.md
|
||||
|
||||
CHANGELOG.md.tmp
|
||||
|
||||
10
.husky/pre-commit
Normal file → Executable file
10
.husky/pre-commit
Normal file → Executable 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
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## Version 0.2.30
|
||||
|
||||
### Fixed
|
||||
|
||||
* Security: Updated Next.js from 15.2.3 to 15.5.7 to address CVE-2025-55182 (https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp)
|
||||
|
||||
## Version 0.2.29
|
||||
|
||||
### Added
|
||||
|
||||
* ✏️ Freehand drawing capability for habits and wishlist items
|
||||
|
||||
### Fixed
|
||||
|
||||
* Wishlist and Habit card layout - time and rewards sections now stay at bottom regardless of description length
|
||||
* Wishlist card user avatars now appear on same row as title for consistency with habit cards
|
||||
|
||||
## Version 0.2.28
|
||||
|
||||
### Added
|
||||
|
||||
* Server permission checking system to validate data directory access on startup
|
||||
* Permission error display with troubleshooting guidance and recheck functionality
|
||||
|
||||
## Version 0.2.27
|
||||
|
||||
### Fixed
|
||||
|
||||
* Mobile navigation text centering and sizing for multi-word translations
|
||||
|
||||
## Version 0.2.26
|
||||
|
||||
### Improved
|
||||
|
||||
* Docker build performance optimization with cache mounts
|
||||
|
||||
## Version 0.2.25
|
||||
|
||||
### Added
|
||||
|
||||
* 🌍 Added Catalan language support (Català)
|
||||
|
||||
### Fixed
|
||||
|
||||
* Translation files consistency: Added missing UserForm keys to English and Korean translations
|
||||
|
||||
## Version 0.2.24
|
||||
|
||||
### Added
|
||||
|
||||
* 🌍 Added Korean language support (한국어)
|
||||
|
||||
## Version 0.2.23
|
||||
|
||||
### Fixed
|
||||
|
||||
100
CLAUDE.md
Normal file
100
CLAUDE.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
HabitTrove is a gamified habit tracking PWA built with Next.js 15, TypeScript, and Jotai state management. Users earn coins for completing habits and can redeem them for rewards. Features multi-user support with admin capabilities and shared ownership of habits/wishlist items.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
- `npm run dev` - Start development server with Turbopack
|
||||
- `npm run setup:dev` - Full setup: installs bun, dependencies, runs typecheck and lint
|
||||
- `npm install --force` - Install dependencies (force flag required)
|
||||
|
||||
### Quality Assurance (Run these before committing)
|
||||
- `npm run typecheck` - TypeScript type checking (required)
|
||||
- `npm run lint` - ESLint code linting (required)
|
||||
- `npm test` - Run tests with Bun
|
||||
- `npm run build` - Build production version
|
||||
|
||||
### Docker Deployment
|
||||
- `npm run docker-build` - Build Docker image locally
|
||||
- `docker compose up -d` - Run with docker-compose (recommended)
|
||||
- Requires `AUTH_SECRET` environment variable: `openssl rand -base64 32`
|
||||
|
||||
## Version Management
|
||||
|
||||
### Creating a New Version
|
||||
1. Update version in `package.json`
|
||||
2. Update `CHANGELOG.md` with new version and changes (follow existing patterns in the file, keep entries concise - ideally 1 line per change)
|
||||
3. Run `npm run typecheck && npm run lint` to ensure code quality
|
||||
4. Commit changes: `git add . && git commit -m "feat: description"`
|
||||
* Follow Conventional Commits Standard: `<type>[scope]: <description>` (e.g., `feat(auth): add OAuth integration`, `fix: resolve memory leak in task loader`).
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### State Management (Jotai)
|
||||
- **Central atoms**: `habitsAtom`, `coinsAtom`, `wishlistAtom`, `usersAtom` in `lib/atoms.ts`
|
||||
- **Derived atoms**: Computed values like `dailyHabitsAtom`, `coinsBalanceAtom`
|
||||
- **Business logic hooks**: `useHabits`, `useCoins`, `useWishlist` in `/hooks`
|
||||
|
||||
### Data Models & Ownership
|
||||
- **Individual ownership**: `CoinTransaction` has single `userId`
|
||||
- **Shared ownership**: `Habit` and `WishlistItemType` have `userIds: string[]` array
|
||||
- **Admin features**: Admin users can view/manage any user's data via dropdown selectors
|
||||
- **Data persistence**: JSON files in `/data` directory with automatic `/backups`
|
||||
|
||||
### Key Components Structure
|
||||
- **Feature components**: `HabitList`, `CoinsManager`, `WishlistManager` - main page components
|
||||
- **Modal components**: `AddEditHabitModal`, `AddEditWishlistItemModal`, `UserSelectModal`
|
||||
- **UI components**: `/components/ui` - shadcn/ui based components
|
||||
|
||||
### Authentication & Users
|
||||
- NextAuth.js v5 with multi-user support
|
||||
- User permissions: regular users vs admin users
|
||||
- Admin dropdown patterns: Similar implementation across Habits/Wishlist pages (reference CoinsManager for pattern)
|
||||
|
||||
### Internationalization
|
||||
- `next-intl` with messages in `/messages/*.json`
|
||||
- Supported languages: English, Spanish, German, French, Russian, Chinese, Japanese
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Component Structure
|
||||
```typescript
|
||||
// Standard component pattern:
|
||||
export default function ComponentName() {
|
||||
const [data, setData] = useAtom(dataAtom)
|
||||
const { businessLogicFunction } = useCustomHook()
|
||||
// Component logic
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Patterns
|
||||
- Custom hooks accept options: `useHabits({ selectedUser?: string })`
|
||||
- Return destructured functions and computed values
|
||||
- Handle both individual and shared ownership models
|
||||
|
||||
### Shared Ownership Pattern
|
||||
```typescript
|
||||
// Filtering for shared ownership:
|
||||
const userItems = allItems.filter(item =>
|
||||
item.userIds && item.userIds.includes(targetUserId)
|
||||
)
|
||||
```
|
||||
|
||||
### Admin Dropdown Pattern
|
||||
Reference `CoinsManager.tsx:107-119` for admin user selection implementation. Similar pattern should be applied to Habits and Wishlist pages.
|
||||
|
||||
## Data Safety
|
||||
- Always backup `/data` before major changes
|
||||
- Test with existing data files to prevent data loss
|
||||
- Validate user permissions for all data operations
|
||||
- Handle migration scripts carefully (see PLAN.md for shared ownership migration)
|
||||
|
||||
## Performance Considerations
|
||||
- State updates use immutable patterns
|
||||
- Large dataset filtering happens at hook level
|
||||
- Derived atoms prevent unnecessary re-renders
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,18 +1,17 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
|
||||
FROM node:18-alpine AS base
|
||||
# Use build platform for base images to avoid emulation
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
# Use cache mounts for npm cache
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN \
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
# use --force flag until all deps supports react19
|
||||
elif [ -f package-lock.json ]; then npm ci --force; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
@@ -26,32 +25,28 @@ COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
# Use cache mount for Next.js cache
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# Production image - use target platform
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Create data and backups directories and set permissions
|
||||
RUN mkdir -p /app/data /app/backups \
|
||||
&& chown nextjs:nodejs /app/data /app/backups
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs && \
|
||||
mkdir -p /app/data /app/backups && \
|
||||
chown nextjs:nodejs /app/data /app/backups
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/CHANGELOG.md ./
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -1,14 +1,22 @@
|
||||
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
|
||||
|
||||

|
||||
|
||||
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
||||
|
||||
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
||||
## Differences to Upstream
|
||||
|
||||
## Try the Demo
|
||||
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
|
||||
|
||||
Want to try HabitTrove before installing? Visit the public [demo instance](https://demo.habittrove.com) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
|
||||
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
|
||||
|
||||
Differences (as of writing) are:
|
||||
- resolved linting problems so you can actually commit things
|
||||
- added missing dependency
|
||||
- refactored adding habit modal to cause less errors
|
||||
- resolved undefined error
|
||||
- replaced dockerhub release flow with github
|
||||
- miscellaneous refactorings
|
||||
- split habits & tasks page into two different pages
|
||||
- only display "show all" if there are more than 4 entries
|
||||
|
||||
## Features
|
||||
|
||||
@@ -16,8 +24,9 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
|
||||
- 🏆 Earn coins for completing habits
|
||||
- 💰 Create a wishlist of rewards to redeem with earned coins
|
||||
- 📊 View your habit completion streaks and statistics
|
||||
- ✏️ Add freehand drawings to habits and wishlist items for visual reminders
|
||||
- 📅 Calendar heatmap to visualize your progress (WIP)
|
||||
- 🌍 Multi-language support (English, Español, Deutsch, Français, Русский, 简体中文, 日本語)
|
||||
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
|
||||
- 🌙 Dark mode support
|
||||
- 📲 Progressive Web App (PWA) support
|
||||
- 💾 Automatic daily backups with rotation
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
WishlistData,
|
||||
WishlistItemType
|
||||
} from '@/lib/types';
|
||||
import { d2t, generateCryptoHash, getNow, prepareDataForHashing, uuid } from '@/lib/utils';
|
||||
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
||||
import { signInSchema } from '@/lib/zod';
|
||||
import fs from 'fs/promises';
|
||||
import _ from 'lodash';
|
||||
@@ -33,21 +33,6 @@ type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||
type ActionType = 'write' | 'interact'
|
||||
|
||||
|
||||
async function verifyPermission(
|
||||
resource: ResourceType,
|
||||
action: ActionType
|
||||
): Promise<void> {
|
||||
// const user = await getCurrentUser()
|
||||
|
||||
// if (!user) throw new PermissionError('User not authenticated')
|
||||
// if (user.isAdmin) return // Admins bypass permission checks
|
||||
|
||||
// if (!checkPermission(user.permissions, resource, action)) {
|
||||
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
|
||||
// }
|
||||
return
|
||||
}
|
||||
|
||||
function getDefaultData<T>(type: DataType): T {
|
||||
return DATA_DEFAULTS[type]() as T;
|
||||
}
|
||||
@@ -91,7 +76,7 @@ async function loadData<T>(type: DataType): Promise<T> {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
// File doesn't exist, create it with default data
|
||||
const initialData = getDefaultData(type)
|
||||
const initialData = getDefaultData<T>(type)
|
||||
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
||||
return initialData as T
|
||||
}
|
||||
@@ -126,11 +111,13 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
|
||||
*/
|
||||
async function calculateServerFreshnessToken(): Promise<string | null> {
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const habits = await loadHabitsData();
|
||||
const coins = await loadCoinsData();
|
||||
const wishlist = await loadWishlistData();
|
||||
const users = await loadUsersData();
|
||||
const [settings, habits, coins, wishlist, users] = await Promise.all([
|
||||
loadSettings(),
|
||||
loadHabitsData(),
|
||||
loadCoinsData(),
|
||||
loadWishlistData(),
|
||||
loadUsersData()
|
||||
]);
|
||||
|
||||
const dataString = prepareDataForHashing(
|
||||
settings,
|
||||
@@ -139,8 +126,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
|
||||
wishlist,
|
||||
users
|
||||
);
|
||||
const serverToken = await generateCryptoHash(dataString);
|
||||
return serverToken;
|
||||
return generateCryptoHash(dataString);
|
||||
} catch (error) {
|
||||
console.error("Error calculating server freshness token:", error);
|
||||
throw error;
|
||||
@@ -150,7 +136,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
|
||||
// Wishlist specific functions
|
||||
export async function loadWishlistData(): Promise<WishlistData> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return getDefaultWishlistData()
|
||||
if (!user) return getDefaultWishlistData<WishlistData>()
|
||||
|
||||
const data = await loadData<WishlistData>('wishlist')
|
||||
return {
|
||||
@@ -165,7 +151,6 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
||||
}
|
||||
|
||||
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
||||
await verifyPermission('wishlist', 'write')
|
||||
const user = await getCurrentUser()
|
||||
|
||||
data.items = data.items.map(wishlist => ({
|
||||
@@ -188,17 +173,14 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
||||
// Habits specific functions
|
||||
export async function loadHabitsData(): Promise<HabitsData> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return getDefaultHabitsData()
|
||||
if (!user) return getDefaultHabitsData<HabitsData>()
|
||||
const data = await loadData<HabitsData>('habits')
|
||||
return {
|
||||
...data,
|
||||
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||
await verifyPermission('habit', 'write')
|
||||
|
||||
const user = await getCurrentUser()
|
||||
// Create clone of input data
|
||||
const newData = _.cloneDeep(data)
|
||||
@@ -210,7 +192,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||
}))
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
const existingData = await loadData<HabitsData>('habits')
|
||||
const existingData = await loadHabitsData();
|
||||
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
||||
newData.habits = [
|
||||
...existingHabits,
|
||||
@@ -226,14 +208,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||
export async function loadCoinsData(): Promise<CoinsData> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return getDefaultCoinsData()
|
||||
if (!user) return getDefaultCoinsData<CoinsData>()
|
||||
const data = await loadData<CoinsData>('coins')
|
||||
return {
|
||||
...data,
|
||||
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
||||
}
|
||||
} catch {
|
||||
return getDefaultCoinsData()
|
||||
return getDefaultCoinsData<CoinsData>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,11 +255,10 @@ export async function addCoins({
|
||||
note?: string
|
||||
userId?: string
|
||||
}): Promise<CoinsData> {
|
||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||
const currentUser = await getCurrentUser()
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: uuid(),
|
||||
id: crypto.randomUUID(),
|
||||
amount,
|
||||
type,
|
||||
description,
|
||||
@@ -297,7 +278,7 @@ export async function addCoins({
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
const defaultSettings = getDefaultSettings()
|
||||
const defaultSettings = getDefaultSettings<Settings>()
|
||||
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
@@ -328,11 +309,10 @@ export async function removeCoins({
|
||||
note?: string
|
||||
userId?: string
|
||||
}): Promise<CoinsData> {
|
||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||
const currentUser = await getCurrentUser()
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: uuid(),
|
||||
id: crypto.randomUUID(),
|
||||
amount: -amount,
|
||||
type,
|
||||
description,
|
||||
@@ -390,7 +370,7 @@ export async function loadUsersData(): Promise<UserData> {
|
||||
try {
|
||||
return await loadData<UserData>('auth')
|
||||
} catch {
|
||||
return getDefaultUsersData()
|
||||
return getDefaultUsersData<UserData>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,7 +414,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
||||
|
||||
|
||||
const newUser: User = {
|
||||
id: uuid(),
|
||||
id: crypto.randomUUID(),
|
||||
username,
|
||||
password: hashedPassword,
|
||||
permissions,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function Debug({children}: {children: ReactNode}) {
|
||||
if (process.env.NODE_ENV !== 'development') return null
|
||||
if (process.env.NODE_ENV !== 'development') return <></>
|
||||
return (
|
||||
<div className="debug">
|
||||
{children}
|
||||
|
||||
@@ -12,20 +12,12 @@ import { Suspense } from 'react'
|
||||
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
||||
import './globals.css'
|
||||
|
||||
// Inter (clean, modern, excellent readability)
|
||||
// const inter = Inter({
|
||||
// subsets: ['latin'],
|
||||
// weight: ['400', '500', '600', '700']
|
||||
// })
|
||||
|
||||
// Clean and contemporary
|
||||
const dmSans = DM_Sans({
|
||||
const activeFont = DM_Sans({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700']
|
||||
})
|
||||
|
||||
const activeFont = dmSans
|
||||
|
||||
export const metadata = {
|
||||
title: 'HabitTrove',
|
||||
description: 'Track your habits and get rewarded',
|
||||
|
||||
@@ -14,11 +14,14 @@ import { toast } from '@/hooks/use-toast';
|
||||
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
|
||||
import { Settings, WeekDay } from '@/lib/types';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { Info } from 'lucide-react'; // Import Info icon
|
||||
import { useSession } from 'next-auth/react'; // signOut removed
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { saveSettings } from '../actions/data';
|
||||
|
||||
import { useSession } from 'next-auth/react'; // signOut removed
|
||||
import { useRouter } from 'next/navigation';
|
||||
// AlertDialog components and useState removed
|
||||
// Trash2 icon removed
|
||||
|
||||
@@ -38,7 +41,7 @@ export default function SettingsPage() {
|
||||
|
||||
// handleDeleteAccount function removed
|
||||
|
||||
if (!settings) return null
|
||||
if (!settings) return <></>
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -227,10 +230,12 @@ export default function SettingsPage() {
|
||||
{/* Add more languages as needed */}
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="ca">Català</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">简体中文</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="ja">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,13 @@ import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } fro
|
||||
import { Habit } from '@/lib/types'
|
||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Brush, Zap } from 'lucide-react'
|
||||
import { DateTime } from 'luxon'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { RRule } from 'rrule'
|
||||
import DrawingDisplay from './DrawingDisplay'
|
||||
import DrawingModal from './DrawingModal'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import ModalOverlay from './ModalOverlay'; // Import the new component
|
||||
|
||||
@@ -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 [usersData] = useAtom(usersAtom)
|
||||
const users = usersData.users
|
||||
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
|
||||
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
||||
|
||||
function getFrequencyUpdate() {
|
||||
if (ruleText === initialRuleText && habit?.frequency) {
|
||||
@@ -82,7 +86,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||
completions: habit?.completions || [],
|
||||
frequency: getFrequencyUpdate(),
|
||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
|
||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
|
||||
drawing: drawing && drawing !== '[]' ? drawing : undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,7 +96,11 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
return (
|
||||
<>
|
||||
<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 */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -123,7 +132,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>ohsimpson
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
{t('descriptionLabel')}
|
||||
@@ -275,6 +284,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">
|
||||
{t('drawingLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDrawingModalOpen(true)
|
||||
}}
|
||||
className="flex-1 justify-start"
|
||||
>
|
||||
<Brush className="h-4 w-4 mr-2" />
|
||||
{drawing ? t('editDrawing') : t('addDrawing')}
|
||||
</Button>
|
||||
{drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={drawing}
|
||||
width={80}
|
||||
height={53}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{users && users.length > 1 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -318,6 +359,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DrawingModal
|
||||
isOpen={isDrawingModalOpen}
|
||||
onClose={() => setIsDrawingModalOpen(false)}
|
||||
onSave={(drawingData) => setDrawing(drawingData)}
|
||||
initialDrawing={drawing}
|
||||
title={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Brush } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useState } from 'react'
|
||||
import DrawingDisplay from './DrawingDisplay'
|
||||
import DrawingModal from './DrawingModal'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import ModalOverlay from './ModalOverlay'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
@@ -38,6 +41,8 @@ export default function AddEditWishlistItemModal({
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
|
||||
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (editingItem) {
|
||||
@@ -46,12 +51,14 @@ export default function AddEditWishlistItemModal({
|
||||
setCoinCost(editingItem.coinCost)
|
||||
setTargetCompletions(editingItem.targetCompletions)
|
||||
setLink(editingItem.link || '')
|
||||
setDrawing(editingItem.drawing || '')
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setCoinCost(1)
|
||||
setTargetCompletions(undefined)
|
||||
setLink('')
|
||||
setDrawing('')
|
||||
}
|
||||
setErrors({})
|
||||
}, [editingItem])
|
||||
@@ -100,7 +107,8 @@ export default function AddEditWishlistItemModal({
|
||||
coinCost,
|
||||
targetCompletions: targetCompletions || undefined,
|
||||
link: link.trim() || undefined,
|
||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
|
||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
|
||||
drawing: drawing && drawing !== '[]' ? drawing : undefined
|
||||
}
|
||||
|
||||
if (editingItem) {
|
||||
@@ -116,7 +124,11 @@ export default function AddEditWishlistItemModal({
|
||||
return (
|
||||
<>
|
||||
<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 */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||
@@ -267,6 +279,38 @@ export default function AddEditWishlistItemModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">
|
||||
{t('drawingLabel')}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDrawingModalOpen(true)
|
||||
}}
|
||||
className="flex-1 justify-start"
|
||||
>
|
||||
<Brush className="h-4 w-4 mr-2" />
|
||||
{drawing ? t('editDrawing') : t('addDrawing')}
|
||||
</Button>
|
||||
{drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={drawing}
|
||||
width={80}
|
||||
height={53}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{usersData.users && usersData.users.length > 1 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -306,6 +350,13 @@ export default function AddEditWishlistItemModal({
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DrawingModal
|
||||
isOpen={isDrawingModalOpen}
|
||||
onClose={() => setIsDrawingModalOpen(false)}
|
||||
onSave={(drawingData) => setDrawing(drawingData)}
|
||||
initialDrawing={drawing}
|
||||
title={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
||||
import { Habit, WishlistItemType } from '@/lib/types'
|
||||
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
@@ -27,6 +28,7 @@ import ConfirmDialog from './ConfirmDialog'
|
||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||
import Linkify from './linkify'
|
||||
import { Button } from './ui/button'
|
||||
import DrawingDisplay from './DrawingDisplay'
|
||||
|
||||
interface UpcomingItemsProps {
|
||||
habits: Habit[]
|
||||
@@ -165,7 +167,7 @@ const ItemSection = ({
|
||||
const bTarget = b.targetCompletions || 1;
|
||||
return bTarget - aTarget;
|
||||
})
|
||||
.slice(0, currentExpanded ? undefined : 5)
|
||||
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||
.map((habit) => {
|
||||
const completionsToday = habit.completions.filter(completion =>
|
||||
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||
@@ -245,6 +247,16 @@ const ItemSection = ({
|
||||
{habit.name}
|
||||
</span>
|
||||
</Link>
|
||||
{habit.drawing && (
|
||||
<div className="ml-2 pr-2">
|
||||
<DrawingDisplay
|
||||
drawingData={habit.drawing}
|
||||
width={40}
|
||||
height={26}
|
||||
className="border-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -295,7 +307,7 @@ const ItemSection = ({
|
||||
onClick={() => setCurrentExpanded(!currentExpanded)}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{currentExpanded ? (
|
||||
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
|
||||
<>
|
||||
{t('showLessButton')}
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
@@ -305,7 +317,7 @@ const ItemSection = ({
|
||||
{t('showAllButton')}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</button>
|
||||
<Link
|
||||
href={viewLink}
|
||||
@@ -444,7 +456,7 @@ export default function DailyOverview({
|
||||
) : (
|
||||
<>
|
||||
{sortedWishlistItems
|
||||
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
|
||||
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||
.map((item) => {
|
||||
const isRedeemable = item.coinCost <= coinBalance
|
||||
return (
|
||||
@@ -457,9 +469,19 @@ export default function DailyOverview({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm">
|
||||
<Linkify>{item.name}</Linkify>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
<Linkify>{item.name}</Linkify>
|
||||
</span>
|
||||
{item.drawing && (
|
||||
<DrawingDisplay
|
||||
drawingData={item.drawing}
|
||||
width={40}
|
||||
height={26}
|
||||
className="border-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs flex items-center">
|
||||
<Coins className={cn(
|
||||
"h-3 w-3 mr-1 transition-all",
|
||||
@@ -501,7 +523,7 @@ export default function DailyOverview({
|
||||
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{browserSettings.expandedWishlist ? (
|
||||
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
|
||||
<>
|
||||
{t('showLessButton')}
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
@@ -511,7 +533,7 @@ export default function DailyOverview({
|
||||
{t('showAllButton')}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</button>
|
||||
<Link
|
||||
href="/wishlist"
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { NavDisplayProps } from './Navigation';
|
||||
|
||||
export default function DesktopNavDisplay({ navItems }: NavDisplayProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
||||
(pathname === (item.href) ?
|
||||
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
||||
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
components/DrawingCanvas.tsx
Normal file
241
components/DrawingCanvas.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Undo2, Trash2, Palette } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
initialDrawing?: string
|
||||
onSave: (drawingData: string) => void
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export default function DrawingCanvas({ initialDrawing, onSave, onClear }: DrawingCanvasProps) {
|
||||
const t = useTranslations('DrawingModal')
|
||||
const [drawingHistory, setDrawingHistory] = useState<Array<{
|
||||
color: string
|
||||
thickness: number
|
||||
points: Array<{ x: number; y: number }>
|
||||
}>>([])
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [color, setColor] = useState('#000000')
|
||||
const [thickness, setThickness] = useState(4)
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const contextRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
|
||||
context.lineCap = 'round'
|
||||
context.lineJoin = 'round'
|
||||
contextRef.current = context
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width
|
||||
canvas.height = rect.height
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
resizeCanvas()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialDrawing) {
|
||||
try {
|
||||
const loadedData = JSON.parse(initialDrawing)
|
||||
if (Array.isArray(loadedData)) {
|
||||
setDrawingHistory(loadedData)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load initial drawing data')
|
||||
}
|
||||
}
|
||||
}, [initialDrawing])
|
||||
|
||||
useEffect(() => {
|
||||
redrawCanvas()
|
||||
}, [drawingHistory])
|
||||
|
||||
const getMousePos = (event: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return { x: 0, y: 0 }
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = canvas.width / rect.width
|
||||
const scaleY = canvas.height / rect.height
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY
|
||||
}
|
||||
}
|
||||
|
||||
const startDrawing = (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const { x, y } = getMousePos(event)
|
||||
setIsDrawing(true)
|
||||
contextRef.current?.beginPath()
|
||||
contextRef.current?.moveTo(x, y)
|
||||
|
||||
setDrawingHistory(prevHistory => [
|
||||
...prevHistory,
|
||||
{ color, thickness, points: [{ x, y }] }
|
||||
])
|
||||
}
|
||||
|
||||
const draw = (event: React.MouseEvent) => {
|
||||
if (!isDrawing || !contextRef.current) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const { x, y } = getMousePos(event)
|
||||
contextRef.current.lineTo(x, y)
|
||||
contextRef.current.strokeStyle = color
|
||||
contextRef.current.lineWidth = thickness
|
||||
contextRef.current.stroke()
|
||||
|
||||
setDrawingHistory(prevHistory => {
|
||||
const lastStroke = prevHistory[prevHistory.length - 1]
|
||||
if (lastStroke) {
|
||||
lastStroke.points.push({ x, y })
|
||||
}
|
||||
return [...prevHistory]
|
||||
})
|
||||
}
|
||||
|
||||
const stopDrawing = (event?: React.MouseEvent) => {
|
||||
if (event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
setIsDrawing(false)
|
||||
contextRef.current?.closePath()
|
||||
}
|
||||
|
||||
const redrawCanvas = () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !contextRef.current) return
|
||||
|
||||
const context = contextRef.current
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
drawingHistory.forEach(stroke => {
|
||||
if (stroke.points.length === 0) return
|
||||
context.beginPath()
|
||||
context.strokeStyle = stroke.color
|
||||
context.lineWidth = stroke.thickness
|
||||
context.moveTo(stroke.points[0].x, stroke.points[0].y)
|
||||
stroke.points.forEach(point => {
|
||||
context.lineTo(point.x, point.y)
|
||||
})
|
||||
context.stroke()
|
||||
})
|
||||
}
|
||||
|
||||
const handleUndo = () => {
|
||||
setDrawingHistory(prevHistory => {
|
||||
const newHistory = [...prevHistory]
|
||||
newHistory.pop()
|
||||
return newHistory
|
||||
})
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setDrawingHistory([])
|
||||
onClear?.()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const jsonString = drawingHistory.length > 0 ? JSON.stringify(drawingHistory) : ''
|
||||
onSave(jsonString)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={(e) => stopDrawing(e)}
|
||||
onMouseLeave={(e) => stopDrawing(e)}
|
||||
className="border border-gray-300 rounded-lg bg-white touch-none w-full h-80 cursor-crosshair"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="colorPicker" className="text-sm font-medium">
|
||||
{t('colorLabel')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="color"
|
||||
id="colorPicker"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 border-2 border-gray-300 rounded cursor-pointer p-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="lineThickness" className="text-sm font-medium">
|
||||
{t('thicknessLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
type="range"
|
||||
id="lineThickness"
|
||||
min="1"
|
||||
max="20"
|
||||
value={thickness}
|
||||
onChange={(e) => setThickness(Number(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-6">{thickness}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUndo}
|
||||
disabled={drawingHistory.length === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4 mr-1" />
|
||||
{t('undoButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
disabled={drawingHistory.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{t('clearButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave}>
|
||||
{t('saveDrawingButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
components/DrawingDisplay.tsx
Normal file
113
components/DrawingDisplay.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface DrawingDisplayProps {
|
||||
drawingData?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface DrawingStroke {
|
||||
color: string
|
||||
thickness: number
|
||||
points: Array<{ x: number; y: number }>
|
||||
}
|
||||
|
||||
export default function DrawingDisplay({
|
||||
drawingData,
|
||||
width = 120,
|
||||
height = 80,
|
||||
className = ''
|
||||
}: DrawingDisplayProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !drawingData) return
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
|
||||
try {
|
||||
const strokes: DrawingStroke[] = JSON.parse(drawingData)
|
||||
|
||||
// Clear canvas
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Set up context for drawing
|
||||
context.lineCap = 'round'
|
||||
context.lineJoin = 'round'
|
||||
|
||||
// Calculate scaling to fit the drawing in the small canvas
|
||||
if (strokes.length === 0) return
|
||||
|
||||
// Find bounds of the drawing
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
|
||||
strokes.forEach(stroke => {
|
||||
stroke.points.forEach(point => {
|
||||
minX = Math.min(minX, point.x)
|
||||
minY = Math.min(minY, point.y)
|
||||
maxX = Math.max(maxX, point.x)
|
||||
maxY = Math.max(maxY, point.y)
|
||||
})
|
||||
})
|
||||
|
||||
// Add padding
|
||||
const padding = 10
|
||||
const drawingWidth = maxX - minX + padding * 2
|
||||
const drawingHeight = maxY - minY + padding * 2
|
||||
|
||||
// Calculate scale to fit in canvas
|
||||
const scaleX = canvas.width / drawingWidth
|
||||
const scaleY = canvas.height / drawingHeight
|
||||
const scale = Math.min(scaleX, scaleY, 1) // Don't scale up
|
||||
|
||||
// Center the drawing
|
||||
const offsetX = (canvas.width - drawingWidth * scale) / 2 - (minX - padding) * scale
|
||||
const offsetY = (canvas.height - drawingHeight * scale) / 2 - (minY - padding) * scale
|
||||
|
||||
// Draw each stroke
|
||||
strokes.forEach(stroke => {
|
||||
if (stroke.points.length === 0) return
|
||||
|
||||
context.beginPath()
|
||||
context.strokeStyle = stroke.color
|
||||
context.lineWidth = Math.max(1, stroke.thickness * scale) // Ensure minimum line width
|
||||
|
||||
const firstPoint = stroke.points[0]
|
||||
context.moveTo(
|
||||
firstPoint.x * scale + offsetX,
|
||||
firstPoint.y * scale + offsetY
|
||||
)
|
||||
|
||||
stroke.points.forEach(point => {
|
||||
context.lineTo(
|
||||
point.x * scale + offsetX,
|
||||
point.y * scale + offsetY
|
||||
)
|
||||
})
|
||||
|
||||
context.stroke()
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to render drawing:', error)
|
||||
}
|
||||
}, [drawingData, width, height])
|
||||
|
||||
if (!drawingData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`border-2 border-muted-foreground rounded bg-white ${className}`}
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
83
components/DrawingModal.tsx
Normal file
83
components/DrawingModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-react'
|
||||
import DrawingCanvas from './DrawingCanvas'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface DrawingModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (drawingData: string) => void
|
||||
initialDrawing?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function DrawingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialDrawing,
|
||||
title = 'Drawing'
|
||||
}: DrawingModalProps) {
|
||||
const t = useTranslations('DrawingModal')
|
||||
const [currentDrawing, setCurrentDrawing] = useState<string>(initialDrawing || '')
|
||||
|
||||
const handleSave = (drawingData: string) => {
|
||||
setCurrentDrawing(drawingData)
|
||||
onSave(drawingData)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setCurrentDrawing('')
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DrawingCanvas
|
||||
initialDrawing={currentDrawing}
|
||||
onSave={handleSave}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 p-6 border-t">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { 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 { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import DrawingDisplay from './DrawingDisplay'
|
||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
interface HabitItemProps {
|
||||
habit: Habit
|
||||
@@ -24,13 +25,13 @@ interface HabitItemProps {
|
||||
}
|
||||
|
||||
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
|
||||
if (!habit.userIds || habit.userIds.length <= 1) return null;
|
||||
if (!habit.userIds || habit.userIds.length <= 1) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||
const user = usersData.users.find(u => u.id === userId)
|
||||
if (!user) return null
|
||||
if (!user) return <></>;
|
||||
return (
|
||||
<Avatar key={user.id} className="h-6 w-6">
|
||||
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||
@@ -81,7 +82,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
id={`habit-${habit.id}`}
|
||||
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
|
||||
>
|
||||
<CardHeader className="flex-none">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -98,28 +99,44 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</CardTitle>
|
||||
{renderUserAvatars(habit, currentUser as User, usersData)}
|
||||
</div>
|
||||
{habit.description && (
|
||||
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{habit.description}
|
||||
</CardDescription>
|
||||
{(habit.description || habit.drawing) && (
|
||||
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
|
||||
{habit.description && (
|
||||
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{habit.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
{habit.drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={habit.drawing}
|
||||
width={120}
|
||||
height={80}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
{t('whenLabel', {
|
||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule: pathname.includes("habits"),
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
|
||||
<CardContent className="flex-grow flex flex-col justify-end">
|
||||
<div className="mt-auto">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
{t('whenLabel', {
|
||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule: pathname.includes("habits"),
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Button
|
||||
@@ -205,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAtom } from 'jotai';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
|
||||
interface HabitStreakProps {
|
||||
habits: Habit[]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import ClientWrapper from './ClientWrapper'
|
||||
import Header from './Header'
|
||||
import Navigation from './Navigation'
|
||||
import PermissionError from './PermissionError'
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||
<Header className="sticky top-0 z-50" />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Navigation position='main' />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||
{/* responsive container (optimized for mobile) */}
|
||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||
|
||||
<Header className="sticky top-0 z-50" />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Navigation position='main' />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||
{/* responsive container (optimized for mobile) */}
|
||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||
<ClientWrapper>
|
||||
<PermissionError />
|
||||
{children}
|
||||
</ClientWrapper>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useHelpers } from '@/lib/client-helpers';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { NavDisplayProps } from './Navigation';
|
||||
|
||||
export default function MobileNavDisplay({ navItems }: NavDisplayProps) {
|
||||
const pathname = usePathname();
|
||||
const { isIOS } = useHelpers()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={isIOS ? "pb-20" : "pb-16"} />
|
||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||
<div className="grid grid-cols-6 w-full">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
||||
(pathname === (item.href) ?
|
||||
"text-blue-500 dark:text-blue-500" :
|
||||
"text-gray-300 dark:text-gray-300")
|
||||
}
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
components/NavDisplay.tsx
Normal file
61
components/NavDisplay.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useHelpers } from '@/lib/client-helpers';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { NavItemType } from './Navigation';
|
||||
|
||||
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const { isIOS } = useHelpers()
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)}
|
||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||
<div className="grid grid-cols-6 w-full">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
||||
(pathname === (item.href) ?
|
||||
"text-blue-500 dark:text-blue-500" :
|
||||
"text-gray-300 dark:text-gray-300")
|
||||
}
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
||||
(pathname === (item.href) ?
|
||||
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
||||
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@ import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ElementType, useEffect, useState } from 'react'
|
||||
import DesktopNavDisplay from './DesktopNavDisplay'
|
||||
import MobileNavDisplay from './MobileNavDisplay'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
import NavDisplay from './NavDisplay'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
@@ -15,17 +12,15 @@ export interface NavItemType {
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface NavigationProps {
|
||||
position: ViewPort
|
||||
}
|
||||
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
|
||||
const t = useTranslations('Navigation');
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
|
||||
|
||||
export interface NavDisplayProps {
|
||||
navItems: NavItemType[];
|
||||
}
|
||||
|
||||
export default function Navigation({ position: viewPort }: NavigationProps) {
|
||||
const t = useTranslations('Navigation')
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [setIsMobile]);
|
||||
|
||||
const currentNavItems: NavItemType[] = [
|
||||
{ icon: Home, label: t('dashboard'), href: '/' },
|
||||
@@ -36,28 +31,10 @@ export default function Navigation({ position: viewPort }: NavigationProps) {
|
||||
{ icon: Coins, label: t('coins'), href: '/coins' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobileView(window.innerWidth < 1024)
|
||||
}
|
||||
|
||||
// Set initial value
|
||||
handleResize()
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// Cleanup
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
if (viewPort === 'mobile' && isMobileView) {
|
||||
return <MobileNavDisplay navItems={currentNavItems} />
|
||||
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
|
||||
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
|
||||
}
|
||||
|
||||
if (viewPort === 'main' && !isMobileView) {
|
||||
return <DesktopNavDisplay navItems={currentNavItems} />
|
||||
else {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return null // Explicitly return null if no view matches
|
||||
}
|
||||
|
||||
43
components/PermissionError.tsx
Normal file
43
components/PermissionError.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { checkStartupPermissions } from '@/lib/startup-checks'
|
||||
import RecheckButton from './RecheckButton'
|
||||
|
||||
export default async function PermissionError() {
|
||||
const permissionResult = await checkStartupPermissions()
|
||||
|
||||
// If everything is fine, render nothing
|
||||
if (permissionResult.success) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
// Get error message
|
||||
const getErrorMessage = () => {
|
||||
return permissionResult.error?.message || 'Unknown permission error occurred.'
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-bold">Permission Error</AlertTitle>
|
||||
<AlertDescription className="mt-2 flex flex-col">
|
||||
<div className="space-y-3">
|
||||
<span className="text-sm">
|
||||
{getErrorMessage()}{" "}
|
||||
<a
|
||||
href="https://docs.habittrove.com/troubleshooting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-red-300"
|
||||
>
|
||||
Troubleshooting Guide
|
||||
</a>
|
||||
</span>
|
||||
<div>
|
||||
<RecheckButton />
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -177,7 +177,7 @@ export default function PomodoroTimer() {
|
||||
|
||||
const progress = (timeLeft / currentTimerRef.current.duration) * 100
|
||||
|
||||
if (!show) return null
|
||||
if (!show) return <></>
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">
|
||||
|
||||
22
components/RecheckButton.tsx
Normal file
22
components/RecheckButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function RecheckButton() {
|
||||
const handleRecheck = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleRecheck}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-red-50 border-red-300 text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Recheck
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const { coinsEarnedToday } = useCoins()
|
||||
|
||||
if (coinsEarnedToday <= 0) return null
|
||||
if (coinsEarnedToday <= 0) return <></>;
|
||||
|
||||
return (
|
||||
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
|
||||
|
||||
@@ -15,6 +15,8 @@ import UserForm from './UserForm';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
|
||||
|
||||
function UserCard({
|
||||
user,
|
||||
onSelect,
|
||||
|
||||
@@ -11,9 +11,10 @@ import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||
import { User, WishlistItemType } from '@/lib/types'
|
||||
import { hasPermission } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import DrawingDisplay from './DrawingDisplay'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||
|
||||
interface WishlistItemProps {
|
||||
item: WishlistItemType
|
||||
@@ -29,13 +30,13 @@ interface WishlistItemProps {
|
||||
}
|
||||
|
||||
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
|
||||
if (!item.userIds || item.userIds.length <= 1) return null;
|
||||
if (!item.userIds || item.userIds.length <= 1) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||
const user = usersData.users.find(u => u.id === userId)
|
||||
if (!user) return null
|
||||
if (!user) return <></>;
|
||||
return (
|
||||
<Avatar key={user.id} className="h-6 w-6">
|
||||
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
|
||||
@@ -72,37 +73,51 @@ export default function WishlistItem({
|
||||
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
|
||||
} ${item.archived ? 'opacity-75' : ''}`}
|
||||
>
|
||||
<CardHeader className="flex-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.name}
|
||||
</CardTitle>
|
||||
{item.targetCompletions && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{item.description && (
|
||||
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.name}
|
||||
</CardTitle>
|
||||
{item.targetCompletions && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderUserAvatars(item, currentUser as User, usersData)}
|
||||
</div>
|
||||
{(item.description || item.drawing) && (
|
||||
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
|
||||
{item.description && (
|
||||
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
{item.drawing && (
|
||||
<div className="flex-shrink-0">
|
||||
<DrawingDisplay
|
||||
drawingData={item.drawing}
|
||||
width={120}
|
||||
height={80}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.coinCost} {t('coinsSuffix')}
|
||||
</span>
|
||||
<CardContent className="flex-grow flex flex-col justify-end">
|
||||
<div className="mt-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.coinCost} {t('coinsSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={canRedeem ? "default" : "secondary"}
|
||||
@@ -179,4 +194,3 @@ export default function WishlistItem({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
61
components/ui/alert.tsx
Normal file
61
components/ui/alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
warning:
|
||||
"border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -73,7 +73,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -135,7 +135,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
@@ -155,7 +155,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
@@ -170,7 +170,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
@@ -273,7 +273,7 @@ const ChartLegendContent = React.forwardRef<
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
77
docs/translation-guide.md
Normal file
77
docs/translation-guide.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Language Guide
|
||||
|
||||
## Adding/Updating Translations
|
||||
|
||||
### Adding a New Language
|
||||
|
||||
To add a new language translation to HabitTrove:
|
||||
|
||||
1. **Create translation file**:
|
||||
- Copy `messages/en.json` as a template
|
||||
- Save as `messages/{language-code}.json` (e.g., `ko.json` for Korean)
|
||||
- Translate all values while preserving keys and placeholder variables like `{username}`, `{count}`, etc.
|
||||
|
||||
2. **Validate translation structure**:
|
||||
```bash
|
||||
# Ensure JSON is valid
|
||||
jq empty messages/{language-code}.json
|
||||
|
||||
# Compare structure with English (should show no differences)
|
||||
diff <(jq -S . messages/en.json | jq -r 'keys | sort | .[]') <(jq -S . messages/{language-code}.json | jq -r 'keys | sort | .[]')
|
||||
```
|
||||
|
||||
3. **Add language option to UI**:
|
||||
- Edit `app/settings/page.tsx`
|
||||
- Add new `<option value="{language-code}">{Language Name}</option>` in alphabetical order
|
||||
|
||||
4. **Update documentation**:
|
||||
- Add language to README.md supported languages list
|
||||
- Create new changelog entry with version bump
|
||||
- Update package.json version
|
||||
|
||||
### Example: Adding Korean (한국어)
|
||||
|
||||
```bash
|
||||
# 1. Copy translation file
|
||||
cp /path/to/ko.json messages/ko.json
|
||||
|
||||
# 2. Add to settings page
|
||||
# Add: <option value="ko">한국어</option>
|
||||
|
||||
# 3. Update README.md
|
||||
# Change: 简体中文, 日본語
|
||||
# To: 简체中文, 한국어, 日본語
|
||||
|
||||
# 4. Add changelog entry
|
||||
# Create new version section with language addition
|
||||
|
||||
# 5. Bump package version
|
||||
# Update version in package.json
|
||||
```
|
||||
|
||||
### Translation Quality Guidelines
|
||||
|
||||
- Use natural, contextually appropriate expressions
|
||||
- Maintain consistent terminology throughout
|
||||
- Preserve all placeholder variables exactly: `{username}`, `{count}`, `{target}`, etc.
|
||||
- Use appropriate formality level for the target language
|
||||
- Ensure JSON structure matches English file exactly (385 total keys)
|
||||
|
||||
### Validation Commands
|
||||
|
||||
```bash
|
||||
# Check JSON validity
|
||||
jq empty messages/{lang}.json
|
||||
|
||||
# Compare key structure
|
||||
node -e "
|
||||
const en = require('./messages/en.json');
|
||||
const target = require('./messages/{lang}.json');
|
||||
// ... deep key comparison script
|
||||
"
|
||||
|
||||
# Verify placeholder consistency
|
||||
grep -o '{[^}]*}' messages/en.json | sort | uniq > en_vars.txt
|
||||
grep -o '{[^}]*}' messages/{lang}.json | sort | uniq > {lang}_vars.txt
|
||||
diff en_vars.txt {lang}_vars.txt
|
||||
```
|
||||
@@ -1,50 +1,23 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission, roundToInteger } from '@/lib/utils'
|
||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import {
|
||||
coinsAtom,
|
||||
coinsBalanceAtom,
|
||||
coinsEarnedTodayAtom,
|
||||
coinsSpentTodayAtom,
|
||||
currentUserAtom,
|
||||
settingsAtom,
|
||||
totalEarnedAtom,
|
||||
totalSpentAtom,
|
||||
coinsSpentTodayAtom,
|
||||
transactionsTodayAtom,
|
||||
coinsBalanceAtom,
|
||||
settingsAtom,
|
||||
usersAtom,
|
||||
currentUserAtom,
|
||||
} from '@/lib/atoms'
|
||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
||||
import { CoinsData, User } from '@/lib/types'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||
|
||||
function handlePermissionCheck(
|
||||
user: User | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact',
|
||||
tCommon: (key: string, values?: Record<string, any>) => string
|
||||
): boolean {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: tCommon("authenticationRequiredTitle"),
|
||||
description: tCommon("authenticationRequiredDescription"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
||||
toast({
|
||||
title: tCommon("permissionDeniedTitle"),
|
||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
} from '@/lib/atoms';
|
||||
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
||||
import { CoinsData } from '@/lib/types';
|
||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export function useCoins(options?: { selectedUser?: string }) {
|
||||
const t = useTranslations('useCoins');
|
||||
@@ -117,20 +90,20 @@ export function useCoins(options?: { selectedUser?: 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) {
|
||||
toast({
|
||||
title: t("invalidAmountTitle"),
|
||||
description: t("invalidAmountDescription")
|
||||
})
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
if (amount > MAX_COIN_LIMIT) {
|
||||
toast({
|
||||
title: t("invalidAmountTitle"),
|
||||
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
|
||||
})
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const data = await addCoins({
|
||||
@@ -146,21 +119,21 @@ export function useCoins(options?: { selectedUser?: string }) {
|
||||
}
|
||||
|
||||
const remove = async (amount: number, description: string, note?: string) => {
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
|
||||
const numAmount = Math.abs(amount)
|
||||
if (isNaN(numAmount) || numAmount <= 0) {
|
||||
toast({
|
||||
title: t("invalidAmountTitle"),
|
||||
description: t("invalidAmountDescription")
|
||||
})
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
if (numAmount > MAX_COIN_LIMIT) {
|
||||
toast({
|
||||
title: t("invalidAmountTitle"),
|
||||
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
|
||||
})
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const data = await removeCoins({
|
||||
@@ -176,14 +149,14 @@ export function useCoins(options?: { selectedUser?: string }) {
|
||||
}
|
||||
|
||||
const updateNote = async (transactionId: string, note: string) => {
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
|
||||
const transaction = coins.transactions.find(t => t.id === transactionId)
|
||||
if (!transaction) {
|
||||
toast({
|
||||
title: tCommon("errorTitle"),
|
||||
description: t("transactionNotFoundDescription")
|
||||
})
|
||||
return null
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const updatedTransaction = {
|
||||
|
||||
@@ -1,54 +1,24 @@
|
||||
import { useAtom, atom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { DateTime } from 'luxon'
|
||||
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { Habit } from '@/lib/types'
|
||||
import {
|
||||
getNowInMilliseconds,
|
||||
getTodayInTimezone,
|
||||
isSameDate,
|
||||
t2d,
|
||||
d2s,
|
||||
d2t,
|
||||
getNow,
|
||||
getCompletionsForDate,
|
||||
getISODate,
|
||||
d2s,
|
||||
getNow,
|
||||
getTodayInTimezone,
|
||||
handlePermissionCheck,
|
||||
isSameDate,
|
||||
playSound,
|
||||
checkPermission
|
||||
t2d
|
||||
} from '@/lib/utils'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
|
||||
|
||||
function handlePermissionCheck(
|
||||
user: SafeUser | User | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact',
|
||||
tCommon: (key: string, values?: Record<string, any>) => string
|
||||
): boolean {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: tCommon("authenticationRequiredTitle"),
|
||||
description: tCommon("authenticationRequiredDescription"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
||||
toast({
|
||||
title: tCommon("permissionDeniedTitle"),
|
||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
import { DateTime } from 'luxon'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
export function useHabits() {
|
||||
const t = useTranslations('useHabits');
|
||||
@@ -106,7 +76,7 @@ export function useHabits() {
|
||||
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
||||
relatedItemId: habit.id,
|
||||
})
|
||||
isTargetReached && playSound()
|
||||
playSound()
|
||||
toast({
|
||||
title: t("completedTitle"),
|
||||
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
|
||||
@@ -207,7 +177,7 @@ export function useHabits() {
|
||||
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
||||
const newHabit = {
|
||||
...habit,
|
||||
id: habit.id || getNowInMilliseconds().toString()
|
||||
id: habit.id || crypto.randomUUID()
|
||||
}
|
||||
const updatedHabits = habit.id
|
||||
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
|
||||
|
||||
@@ -1,40 +1,13 @@
|
||||
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { handlePermissionCheck } from '@/lib/utils'
|
||||
import { celebrations } from '@/utils/celebrations'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms'
|
||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { WishlistItemType, User, SafeUser } from '@/lib/types'
|
||||
import { celebrations } from '@/utils/celebrations'
|
||||
import { checkPermission } from '@/lib/utils'
|
||||
import { useCoins } from './useCoins'
|
||||
|
||||
function handlePermissionCheck(
|
||||
user: User | SafeUser | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact',
|
||||
tCommon: (key: string, values?: Record<string, any>) => string
|
||||
): boolean {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: tCommon("authenticationRequiredTitle"),
|
||||
description: tCommon("authenticationRequiredDescription"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
||||
toast({
|
||||
title: tCommon("permissionDeniedTitle"),
|
||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function useWishlist() {
|
||||
const t = useTranslations('useWishlist');
|
||||
const tCommon = useTranslations('Common');
|
||||
|
||||
32
lib/atoms.ts
32
lib/atoms.ts
@@ -4,10 +4,11 @@ import {
|
||||
calculateTotalEarned,
|
||||
calculateTotalSpent,
|
||||
calculateTransactionsToday,
|
||||
generateCryptoHash,
|
||||
getCompletionsForToday,
|
||||
getHabitFreq,
|
||||
getTodayInTimezone,
|
||||
isHabitDue,
|
||||
prepareDataForHashing,
|
||||
roundToInteger,
|
||||
t2d
|
||||
} from "@/lib/utils";
|
||||
@@ -15,6 +16,7 @@ import { atom } from "jotai";
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
CoinsData,
|
||||
CompletionCache,
|
||||
Freq,
|
||||
getDefaultCoinsData,
|
||||
@@ -24,7 +26,12 @@ import {
|
||||
getDefaultUsersData,
|
||||
getDefaultWishlistData,
|
||||
Habit,
|
||||
UserId
|
||||
HabitsData,
|
||||
ServerSettings,
|
||||
Settings,
|
||||
UserData,
|
||||
UserId,
|
||||
WishlistData
|
||||
} from "./types";
|
||||
|
||||
export interface BrowserSettings {
|
||||
@@ -39,12 +46,12 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||
expandedWishlist: false
|
||||
} as BrowserSettings)
|
||||
|
||||
export const usersAtom = atom(getDefaultUsersData())
|
||||
export const settingsAtom = atom(getDefaultSettings());
|
||||
export const habitsAtom = atom(getDefaultHabitsData());
|
||||
export const coinsAtom = atom(getDefaultCoinsData());
|
||||
export const wishlistAtom = atom(getDefaultWishlistData());
|
||||
export const serverSettingsAtom = atom(getDefaultServerSettings());
|
||||
export const usersAtom = atom(getDefaultUsersData<UserData>())
|
||||
export const settingsAtom = atom(getDefaultSettings<Settings>());
|
||||
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
|
||||
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
|
||||
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
|
||||
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
|
||||
|
||||
// Derived atom for coins earned today
|
||||
export const coinsEarnedTodayAtom = atom((get) => {
|
||||
@@ -121,8 +128,6 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
minimized: false,
|
||||
})
|
||||
|
||||
import { generateCryptoHash, prepareDataForHashing } from '@/lib/utils';
|
||||
|
||||
export const userSelectAtom = atom<boolean>(false)
|
||||
export const aboutOpenAtom = atom<boolean>(false)
|
||||
|
||||
@@ -229,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
|
||||
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
||||
})
|
||||
);
|
||||
|
||||
// Derived atom for daily habits
|
||||
export const dailyHabitsAtom = atom((get) => {
|
||||
const settings = get(settingsAtom);
|
||||
const today = getTodayInTimezone(settings.system.timezone);
|
||||
return get(habitsByDateFamily(today));
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useSession } from "next-auth/react"
|
||||
import { usersAtom } from './atoms'
|
||||
import { checkPermission } from './utils'
|
||||
import { hasPermission } from './utils'
|
||||
|
||||
export function useHelpers() {
|
||||
const { data: session, status } = useSession()
|
||||
@@ -30,8 +30,7 @@ export function useHelpers() {
|
||||
currentUser,
|
||||
usersData,
|
||||
status,
|
||||
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
|
||||
checkPermission(currentUser?.permissions, resource, action),
|
||||
hasPermission,
|
||||
isIOS: iOS(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,6 @@ export const QUICK_DATES = [
|
||||
{ label: 'Sunday', value: 'this sunday' },
|
||||
] as const
|
||||
|
||||
export const MAX_COIN_LIMIT = 9999
|
||||
export const MAX_COIN_LIMIT = 9999
|
||||
|
||||
export const DESKTOP_DISPLAY_ITEM_COUNT = 4
|
||||
205
lib/startup-checks.test.ts
Normal file
205
lib/startup-checks.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test, beforeEach, mock } from 'bun:test'
|
||||
import { checkStartupPermissions } from './startup-checks'
|
||||
|
||||
// Mock the fs promises module
|
||||
const mockStat = mock()
|
||||
const mockWriteFile = mock()
|
||||
const mockReadFile = mock()
|
||||
const mockUnlink = mock()
|
||||
|
||||
mock.module('fs', () => ({
|
||||
promises: {
|
||||
stat: mockStat,
|
||||
writeFile: mockWriteFile,
|
||||
readFile: mockReadFile,
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('checkStartupPermissions', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
mockStat.mockReset()
|
||||
mockWriteFile.mockReset()
|
||||
mockReadFile.mockReset()
|
||||
mockUnlink.mockReset()
|
||||
})
|
||||
|
||||
test('should return success when directory exists and has proper permissions', async () => {
|
||||
// Mock successful directory stat
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
|
||||
// Mock successful file operations
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockReadFile.mockResolvedValue('permission-test')
|
||||
mockUnlink.mockResolvedValue(undefined)
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||
})
|
||||
|
||||
test('should return error when directory does not exist', async () => {
|
||||
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when path exists but is not a directory', async () => {
|
||||
// Mock path exists but is a file, not directory
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
})
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when write permission fails', async () => {
|
||||
// Mock successful directory stat
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
|
||||
// Mock write failure
|
||||
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when read permission fails', async () => {
|
||||
// Mock successful directory stat and write
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
|
||||
// Mock read failure
|
||||
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
})
|
||||
|
||||
test('should return error when read content does not match written content', async () => {
|
||||
// Mock successful directory stat and write
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
|
||||
// Mock read with different content
|
||||
mockReadFile.mockResolvedValue('different-content')
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when cleanup (unlink) fails', async () => {
|
||||
// Mock successful directory stat, write, and read
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockReadFile.mockResolvedValue('permission-test')
|
||||
|
||||
// Mock cleanup failure
|
||||
mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
// Should return error since cleanup failed and is part of the try-catch block
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||
})
|
||||
|
||||
test('should use correct file paths', async () => {
|
||||
// Mock successful operations
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockReadFile.mockResolvedValue('permission-test')
|
||||
mockUnlink.mockResolvedValue(undefined)
|
||||
|
||||
await checkStartupPermissions()
|
||||
|
||||
// Verify the correct paths are used
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||
})
|
||||
})
|
||||
73
lib/startup-checks.ts
Normal file
73
lib/startup-checks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const DEFAULT_DATA_DIR = 'data'
|
||||
|
||||
/**
|
||||
* Checks startup permissions for the data directory
|
||||
*/
|
||||
interface StartupPermissionResult {
|
||||
success: boolean
|
||||
error?: { path: string; message: string; type?: 'writable_data_dir' }
|
||||
}
|
||||
|
||||
export async function checkStartupPermissions(): Promise<StartupPermissionResult> {
|
||||
const dirPath = DEFAULT_DATA_DIR
|
||||
|
||||
// Check if directory exists and is accessible
|
||||
try {
|
||||
const stats = await fs.stat(dirPath)
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Path '${dirPath}' exists but is not a directory. Please ensure the data directory is properly configured.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (statError) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Data directory '${dirPath}' does not exist or is not accessible. Check volume mounts and permissions.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test read/write permissions with a temporary file
|
||||
const testFilePath = join(dirPath, '.habittrove-permission-test')
|
||||
const testContent = 'permission-test'
|
||||
|
||||
try {
|
||||
await fs.writeFile(testFilePath, testContent)
|
||||
const readContent = await fs.readFile(testFilePath, 'utf8')
|
||||
|
||||
if (readContent !== testContent) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Data integrity check failed in '${dirPath}'. File system may be corrupted or have inconsistent behavior.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.unlink(testFilePath)
|
||||
return { success: true }
|
||||
|
||||
} catch (rwError) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Insufficient read/write permissions for data directory '${dirPath}'. Check file permissions and ownership.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
lib/types.ts
85
lib/types.ts
@@ -1,5 +1,4 @@
|
||||
import { RRule } from "rrule"
|
||||
import { uuid } from "./utils"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export type UserId = string
|
||||
@@ -47,6 +46,7 @@ export type Habit = {
|
||||
archived?: boolean // mark the habit as archived
|
||||
pinned?: boolean // mark the habit as pinned
|
||||
userIds?: UserId[]
|
||||
drawing?: string // Optional JSON string of drawing data
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export type WishlistItemType = {
|
||||
targetCompletions?: number // Optional field, infinity when unset
|
||||
link?: string // Optional URL to external resource
|
||||
userIds?: UserId[]
|
||||
drawing?: string // Optional JSON string of drawing data
|
||||
}
|
||||
|
||||
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
||||
@@ -97,52 +98,58 @@ export interface WishlistData {
|
||||
}
|
||||
|
||||
// Default value functions
|
||||
export const getDefaultUsersData = (): UserData => ({
|
||||
users: [
|
||||
{
|
||||
id: uuid(),
|
||||
username: 'admin',
|
||||
// password: '', // No default password for admin initially? Or set a secure default?
|
||||
isAdmin: true,
|
||||
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||
}
|
||||
]
|
||||
});
|
||||
export function getDefaultUsersData<UserData>(): UserData {
|
||||
return {
|
||||
users: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
username: 'admin',
|
||||
// password: '', // No default password for admin initially? Or set a secure default?
|
||||
isAdmin: true,
|
||||
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||
}
|
||||
]
|
||||
} as UserData;
|
||||
};
|
||||
|
||||
export const getDefaultHabitsData = (): HabitsData => ({
|
||||
habits: []
|
||||
});
|
||||
export function getDefaultHabitsData<HabitsData>(): HabitsData {
|
||||
return { habits: [] } as HabitsData;
|
||||
}
|
||||
|
||||
export function getDefaultTasksData<TasksData>(): TasksData {
|
||||
return { tasks: [] } as TasksData;
|
||||
};
|
||||
|
||||
export const getDefaultCoinsData = (): CoinsData => ({
|
||||
balance: 0,
|
||||
transactions: []
|
||||
});
|
||||
export function getDefaultCoinsData<CoinsData>(): CoinsData {
|
||||
return { balance: 0, transactions: [] } as CoinsData;
|
||||
};
|
||||
|
||||
export const getDefaultWishlistData = (): WishlistData => ({
|
||||
items: []
|
||||
});
|
||||
export function getDefaultWishlistData<WishlistData>(): WishlistData {
|
||||
return { items: [] } as WishlistData;
|
||||
}
|
||||
|
||||
export const getDefaultSettings = (): Settings => ({
|
||||
ui: {
|
||||
useNumberFormatting: true,
|
||||
useGrouping: true,
|
||||
},
|
||||
system: {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
weekStartDay: 1, // Monday
|
||||
autoBackupEnabled: true, // Add this line (default to true)
|
||||
language: 'en', // Default language
|
||||
},
|
||||
profile: {}
|
||||
});
|
||||
export function getDefaultSettings<Settings>(): Settings {
|
||||
return {
|
||||
ui: {
|
||||
useNumberFormatting: true,
|
||||
useGrouping: true,
|
||||
},
|
||||
system: {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
weekStartDay: 1, // Monday
|
||||
autoBackupEnabled: true, // Add this line (default to true)
|
||||
language: 'en', // Default language
|
||||
},
|
||||
profile: {}
|
||||
} as Settings;
|
||||
};
|
||||
|
||||
export const getDefaultServerSettings = (): ServerSettings => ({
|
||||
isDemo: false
|
||||
})
|
||||
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
|
||||
return { isDemo: false } as ServerSettings;
|
||||
}
|
||||
|
||||
// Map of data types to their default values
|
||||
export const DATA_DEFAULTS = {
|
||||
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
|
||||
wishlist: getDefaultWishlistData,
|
||||
habits: getDefaultHabitsData,
|
||||
coins: getDefaultCoinsData,
|
||||
|
||||
@@ -3,12 +3,9 @@ import {
|
||||
cn,
|
||||
getTodayInTimezone,
|
||||
getNow,
|
||||
getNowInMilliseconds,
|
||||
t2d,
|
||||
d2t,
|
||||
d2s,
|
||||
d2sDate,
|
||||
d2n,
|
||||
isSameDate,
|
||||
calculateCoinsEarnedToday,
|
||||
calculateTotalEarned,
|
||||
@@ -16,16 +13,15 @@ import {
|
||||
calculateCoinsSpentToday,
|
||||
isHabitDueToday,
|
||||
isHabitDue,
|
||||
uuid,
|
||||
isTaskOverdue,
|
||||
deserializeRRule,
|
||||
serializeRRule,
|
||||
convertHumanReadableFrequencyToMachineReadable,
|
||||
convertMachineReadableFrequencyToHumanReadable,
|
||||
prepareDataForHashing,
|
||||
generateCryptoHash,
|
||||
getUnsupportedRRuleReason,
|
||||
roundToInteger
|
||||
roundToInteger,
|
||||
generateCryptoHash
|
||||
} from './utils'
|
||||
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
|
||||
import { DateTime } from "luxon";
|
||||
@@ -178,32 +174,6 @@ describe('isTaskOverdue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('uuid', () => {
|
||||
test('should generate valid UUIDs', () => {
|
||||
const id = uuid()
|
||||
// UUID v4 format: 8-4-4-4-12 hex digits
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||
})
|
||||
|
||||
test('should generate unique UUIDs', () => {
|
||||
const ids = new Set()
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
ids.add(uuid())
|
||||
}
|
||||
// All 1000 UUIDs should be unique
|
||||
expect(ids.size).toBe(1000)
|
||||
})
|
||||
|
||||
test('should generate v4 UUIDs', () => {
|
||||
const id = uuid()
|
||||
// Version 4 UUID has specific bits set:
|
||||
// - 13th character is '4'
|
||||
// - 17th character is '8', '9', 'a', or 'b'
|
||||
expect(id.charAt(14)).toBe('4')
|
||||
expect('89ab').toContain(id.charAt(19))
|
||||
})
|
||||
})
|
||||
|
||||
describe('datetime utilities', () => {
|
||||
let fixedNow: DateTime;
|
||||
let currentDateIndex = 0;
|
||||
@@ -321,13 +291,6 @@ describe('getNow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNowInMilliseconds', () => {
|
||||
test('should return current time in milliseconds', () => {
|
||||
const now = DateTime.now().setZone('UTC')
|
||||
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('timestamp conversion utilities', () => {
|
||||
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
||||
const testDateTime = DateTime.fromISO(testTimestamp);
|
||||
@@ -351,16 +314,6 @@ describe('timestamp conversion utilities', () => {
|
||||
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
|
||||
expect(customFormat).toBe('2024-01-01')
|
||||
})
|
||||
|
||||
test('d2sDate should format DateTime as date string', () => {
|
||||
const result = d2sDate({ dateTime: testDateTime });
|
||||
expect(result).toBeString()
|
||||
})
|
||||
|
||||
test('d2n should convert DateTime to milliseconds string', () => {
|
||||
const result = d2n({ dateTime: testDateTime });
|
||||
expect(result).toBe('1704067200000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSameDate', () => {
|
||||
@@ -989,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
||||
})
|
||||
|
||||
describe('freshness utilities', () => {
|
||||
const mockSettings: Settings = getDefaultSettings();
|
||||
const mockHabits: HabitsData = getDefaultHabitsData();
|
||||
const mockCoins: CoinsData = getDefaultCoinsData();
|
||||
const mockWishlist: WishlistData = getDefaultWishlistData();
|
||||
const mockUsers: UserData = getDefaultUsersData();
|
||||
const mockSettings: Settings = getDefaultSettings<Settings>();
|
||||
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
|
||||
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
|
||||
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
|
||||
const mockUsers: UserData = getDefaultUsersData<UserData>();
|
||||
|
||||
// Add a user to mockUsers for more realistic testing
|
||||
mockUsers.users.push({
|
||||
@@ -1038,11 +991,11 @@ describe('freshness utilities', () => {
|
||||
});
|
||||
|
||||
test('should handle empty data consistently', () => {
|
||||
const emptySettings = getDefaultSettings();
|
||||
const emptyHabits = getDefaultHabitsData();
|
||||
const emptyCoins = getDefaultCoinsData();
|
||||
const emptyWishlist = getDefaultWishlistData();
|
||||
const emptyUsers = getDefaultUsersData();
|
||||
const emptySettings = getDefaultSettings<Settings>();
|
||||
const emptyHabits = getDefaultHabitsData<HabitsData>();
|
||||
const emptyCoins = getDefaultCoinsData<CoinsData>();
|
||||
const emptyWishlist = getDefaultWishlistData<WishlistData>();
|
||||
const emptyUsers = getDefaultUsersData<UserData>();
|
||||
|
||||
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||
|
||||
128
lib/utils.ts
128
lib/utils.ts
@@ -1,13 +1,12 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User, Settings, HabitsData, CoinsData, WishlistData, UserData } from '@/lib/types'
|
||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import { toast } from "@/hooks/use-toast"
|
||||
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
|
||||
import * as chrono from 'chrono-node'
|
||||
import _ from "lodash"
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import stableStringify from 'json-stable-stringify';
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { Formats } from "next-intl"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -33,12 +32,6 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
|
||||
return DateTime.now().setZone(timezone, { keepLocalTime });
|
||||
}
|
||||
|
||||
// get current time in epoch milliseconds
|
||||
export function getNowInMilliseconds() {
|
||||
const now = getNow({});
|
||||
return d2n({ dateTime: now });
|
||||
}
|
||||
|
||||
// iso timestamp to datetime object, most for storage read
|
||||
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
|
||||
return DateTime.fromISO(timestamp).setZone(timezone);
|
||||
@@ -61,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
|
||||
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
|
||||
}
|
||||
|
||||
// convert datetime object to date string, mostly for display
|
||||
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
|
||||
return dateTime.toLocaleString(DateTime.DATE_MED);
|
||||
}
|
||||
|
||||
// convert datetime object to epoch milliseconds string, mostly for storage write
|
||||
export function d2n({ dateTime }: { dateTime: DateTime }) {
|
||||
return dateTime.toMillis().toString();
|
||||
}
|
||||
|
||||
// compare the date portion of two datetime objects (i.e. same year, month, day)
|
||||
export function isSameDate(a: DateTime, b: DateTime) {
|
||||
return a.hasSame(b, 'day');
|
||||
}
|
||||
|
||||
export function normalizeCompletionDate(date: string, timezone: string): string {
|
||||
// If already in ISO format, return as is
|
||||
if (date.includes('T')) {
|
||||
return date;
|
||||
}
|
||||
// Convert from yyyy-MM-dd to ISO format
|
||||
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
|
||||
}
|
||||
|
||||
export function getCompletionsForDate({
|
||||
habit,
|
||||
date,
|
||||
@@ -438,22 +412,20 @@ export const openWindow = (url: string): boolean => {
|
||||
return true
|
||||
}
|
||||
|
||||
export function deepMerge<T>(a: T, b: T) {
|
||||
return _.merge(a, b, (x: unknown, y: unknown) => {
|
||||
if (_.isArray(a)) {
|
||||
return a.concat(b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function checkPermission(
|
||||
permissions: Permission[] | undefined,
|
||||
export function hasPermission(
|
||||
user: User | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact'
|
||||
): boolean {
|
||||
if (!permissions) return false
|
||||
|
||||
return permissions.some(permission => {
|
||||
if (!user || !user.permissions) {
|
||||
return false;
|
||||
}
|
||||
// If user is admin, they have all permissions.
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise, check specific permissions.
|
||||
return user.permissions.some(permission => {
|
||||
switch (resource) {
|
||||
case 'habit':
|
||||
return permission.habit[action]
|
||||
@@ -467,27 +439,6 @@ export function checkPermission(
|
||||
})
|
||||
}
|
||||
|
||||
export function uuid() {
|
||||
return uuidv4()
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
currentUser: User | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact'
|
||||
): boolean {
|
||||
// If no current user, no permissions.
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
// If user is admin, they have all permissions.
|
||||
if (currentUser.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise, check specific permissions.
|
||||
return checkPermission(currentUser.permissions, resource, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a consistent string representation of the data for hashing.
|
||||
* It combines all relevant data pieces into a single object and then stringifies it stably.
|
||||
@@ -499,22 +450,13 @@ export function prepareDataForHashing(
|
||||
wishlist: WishlistData,
|
||||
users: UserData
|
||||
): string {
|
||||
// Combine all data into a single object.
|
||||
// The order of keys in this object itself doesn't matter due to stableStringify,
|
||||
// but being explicit helps in understanding what's being hashed.
|
||||
const combinedData = {
|
||||
return JSON.stringify({
|
||||
settings,
|
||||
habits,
|
||||
coins,
|
||||
wishlist,
|
||||
users,
|
||||
};
|
||||
const stringifiedData = stableStringify(combinedData);
|
||||
// Handle cases where stringify might return undefined.
|
||||
if (stringifiedData === undefined) {
|
||||
throw new Error("Failed to stringify data for hashing. stableStringify returned undefined.");
|
||||
}
|
||||
return stringifiedData;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,3 +481,31 @@ export async function generateCryptoHash(dataString: string): Promise<string | n
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function handlePermissionCheck(
|
||||
user: User | SafeUser | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact',
|
||||
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
|
||||
): boolean {
|
||||
|
||||
if (!user) {
|
||||
toast({
|
||||
title: tCommon("authenticationRequiredTitle"),
|
||||
description: tCommon("authenticationRequiredDescription"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hasPermission(user, resource, action)) {
|
||||
toast({
|
||||
title: tCommon("permissionDeniedTitle"),
|
||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
447
messages/ca.json
Normal file
447
messages/ca.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "mal",
|
||||
"rewardLabel": "Belohnung",
|
||||
"coinsSuffix": "Münzen",
|
||||
"drawingLabel": "Zeichnung",
|
||||
"addDrawing": "Zeichnung hinzufügen",
|
||||
"editDrawing": "Zeichnung bearbeiten",
|
||||
"shareLabel": "Teilen",
|
||||
"saveChangesButton": "Änderungen speichern",
|
||||
"addTaskButton": "Aufgabe hinzufügen",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
|
||||
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
|
||||
"linkLabel": "Link",
|
||||
"drawingLabel": "Zeichnung",
|
||||
"addDrawing": "Zeichnung hinzufügen",
|
||||
"editDrawing": "Zeichnung bearbeiten",
|
||||
"shareLabel": "Teilen",
|
||||
"saveButton": "Änderungen speichern",
|
||||
"addButton": "Belohnung hinzufügen"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
|
||||
"transactionNotFoundDescription": "Transaktion nicht gefunden",
|
||||
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Farbe:",
|
||||
"thicknessLabel": "Dicke:",
|
||||
"undoButton": "Rückgängig",
|
||||
"clearButton": "Löschen",
|
||||
"saveDrawingButton": "Zeichnung speichern",
|
||||
"cancelButton": "Abbrechen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "times",
|
||||
"rewardLabel": "Reward",
|
||||
"coinsSuffix": "coins",
|
||||
"drawingLabel": "Drawing",
|
||||
"addDrawing": "Add Drawing",
|
||||
"editDrawing": "Edit Drawing",
|
||||
"shareLabel": "Share",
|
||||
"saveChangesButton": "Save Changes",
|
||||
"addTaskButton": "Add Task",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Target completions must be at least 1",
|
||||
"errorInvalidUrl": "Please enter a valid URL",
|
||||
"linkLabel": "Link",
|
||||
"drawingLabel": "Drawing",
|
||||
"addDrawing": "Add Drawing",
|
||||
"editDrawing": "Edit Drawing",
|
||||
"shareLabel": "Share",
|
||||
"saveButton": "Save Changes",
|
||||
"addButton": "Add Reward"
|
||||
@@ -288,7 +294,18 @@
|
||||
"cancelButton": "Cancel",
|
||||
"saveChangesButton": "Save Changes",
|
||||
"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": {
|
||||
"habitsLabel": "Habits",
|
||||
@@ -413,12 +430,18 @@
|
||||
"invalidAmountDescription": "Please enter a valid positive number",
|
||||
"successTitle": "Success",
|
||||
"transactionNotFoundDescription": "Transaction not found",
|
||||
"maxAmountExceededDescription": "The amount cannot exceed {max}.",
|
||||
"transactionNotFoundDescription": "Transaction not found",
|
||||
"maxAmountExceededDescription": "The amount cannot exceed {max}."
|
||||
},
|
||||
"Warning": {
|
||||
"areYouSure": "Are you sure?",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"DrawingModal": {
|
||||
"saveDrawingButton": "Save",
|
||||
"cancelButton": "Cancel",
|
||||
"clearButton": "Clear",
|
||||
"undoButton": "Undo",
|
||||
"colorLabel": "Color",
|
||||
"thicknessLabel": "Thickness"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "veces",
|
||||
"rewardLabel": "Recompensa",
|
||||
"coinsSuffix": "monedas",
|
||||
"drawingLabel": "Dibujo",
|
||||
"addDrawing": "Añadir Dibujo",
|
||||
"editDrawing": "Editar Dibujo",
|
||||
"shareLabel": "Compartir",
|
||||
"saveChangesButton": "Guardar cambios",
|
||||
"addTaskButton": "Añadir tarea",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
|
||||
"errorInvalidUrl": "Por favor ingresa una URL válida",
|
||||
"linkLabel": "Enlace",
|
||||
"drawingLabel": "Dibujo",
|
||||
"addDrawing": "Añadir Dibujo",
|
||||
"editDrawing": "Editar Dibujo",
|
||||
"shareLabel": "Compartir",
|
||||
"saveButton": "Guardar cambios",
|
||||
"addButton": "Añadir recompensa"
|
||||
@@ -385,6 +391,14 @@
|
||||
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
|
||||
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Color:",
|
||||
"thicknessLabel": "Grosor:",
|
||||
"undoButton": "Deshacer",
|
||||
"clearButton": "Limpiar",
|
||||
"saveDrawingButton": "Guardar Dibujo",
|
||||
"cancelButton": "Cancelar"
|
||||
},
|
||||
"Common": {
|
||||
"authenticationRequiredTitle": "Autenticación requerida",
|
||||
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "fois",
|
||||
"rewardLabel": "Récompense",
|
||||
"coinsSuffix": "pièces",
|
||||
"drawingLabel": "Dessin",
|
||||
"addDrawing": "Ajouter un Dessin",
|
||||
"editDrawing": "Modifier le Dessin",
|
||||
"shareLabel": "Partager",
|
||||
"saveChangesButton": "Sauvegarder les modifications",
|
||||
"addTaskButton": "Ajouter une tâche",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
|
||||
"errorInvalidUrl": "Veuillez entrer une URL valide",
|
||||
"linkLabel": "Lien",
|
||||
"drawingLabel": "Dessin",
|
||||
"addDrawing": "Ajouter un Dessin",
|
||||
"editDrawing": "Modifier le Dessin",
|
||||
"shareLabel": "Partager",
|
||||
"saveButton": "Sauvegarder les modifications",
|
||||
"addButton": "Ajouter une récompense"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
|
||||
"transactionNotFoundDescription": "Transaction non trouvée",
|
||||
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Couleur :",
|
||||
"thicknessLabel": "Épaisseur :",
|
||||
"undoButton": "Annuler",
|
||||
"clearButton": "Effacer",
|
||||
"saveDrawingButton": "Enregistrer le dessin",
|
||||
"cancelButton": "Annuler"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "回",
|
||||
"rewardLabel": "報酬",
|
||||
"coinsSuffix": "コイン",
|
||||
"drawingLabel": "描画",
|
||||
"addDrawing": "描画を追加",
|
||||
"editDrawing": "描画を編集",
|
||||
"shareLabel": "共有",
|
||||
"saveChangesButton": "変更を保存",
|
||||
"addTaskButton": "タスクを追加",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
|
||||
"errorInvalidUrl": "有効なURLを入力してください",
|
||||
"linkLabel": "リンク",
|
||||
"drawingLabel": "描画",
|
||||
"addDrawing": "描画を追加",
|
||||
"editDrawing": "描画を編集",
|
||||
"shareLabel": "共有",
|
||||
"saveButton": "変更を保存",
|
||||
"addButton": "報酬を追加"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
|
||||
"transactionNotFoundDescription": "取引が見つかりません",
|
||||
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "色:",
|
||||
"thicknessLabel": "太さ:",
|
||||
"undoButton": "元に戻す",
|
||||
"clearButton": "クリア",
|
||||
"saveDrawingButton": "描画を保存",
|
||||
"cancelButton": "キャンセル"
|
||||
}
|
||||
}
|
||||
|
||||
449
messages/ko.json
Normal file
449
messages/ko.json
Normal 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": "취소"
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "раз",
|
||||
"rewardLabel": "Награда",
|
||||
"coinsSuffix": "монет",
|
||||
"drawingLabel": "Рисунок",
|
||||
"addDrawing": "Добавить Рисунок",
|
||||
"editDrawing": "Редактировать Рисунок",
|
||||
"shareLabel": "Поделиться",
|
||||
"saveChangesButton": "Сохранить",
|
||||
"addTaskButton": "Добавить задачу",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "Минимум 1 выполнение",
|
||||
"errorInvalidUrl": "Некорректная ссылка",
|
||||
"linkLabel": "Ссылка",
|
||||
"drawingLabel": "Рисунок",
|
||||
"addDrawing": "Добавить Рисунок",
|
||||
"editDrawing": "Редактировать Рисунок",
|
||||
"shareLabel": "Поделиться",
|
||||
"saveButton": "Сохранить",
|
||||
"addButton": "Добавить цель"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
|
||||
"transactionNotFoundDescription": "Транзакция не найдена",
|
||||
"maxAmountExceededDescription": "Сумма не может превышать {max}."
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Цвет:",
|
||||
"thicknessLabel": "Толщина:",
|
||||
"undoButton": "Отменить",
|
||||
"clearButton": "Очистить",
|
||||
"saveDrawingButton": "Сохранить рисунок",
|
||||
"cancelButton": "Отмена"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"timesSuffix": "次",
|
||||
"rewardLabel": "奖励",
|
||||
"coinsSuffix": "金币",
|
||||
"drawingLabel": "绘图",
|
||||
"addDrawing": "添加绘图",
|
||||
"editDrawing": "编辑绘图",
|
||||
"shareLabel": "分享",
|
||||
"saveChangesButton": "保存更改",
|
||||
"addTaskButton": "添加任务",
|
||||
@@ -105,6 +108,9 @@
|
||||
"errorTargetCompletionsMin": "目标完成次数至少为 1",
|
||||
"errorInvalidUrl": "请输入有效的 URL",
|
||||
"linkLabel": "链接",
|
||||
"drawingLabel": "绘图",
|
||||
"addDrawing": "添加绘图",
|
||||
"editDrawing": "编辑绘图",
|
||||
"shareLabel": "分享",
|
||||
"saveButton": "保存更改",
|
||||
"addButton": "添加奖励"
|
||||
@@ -431,5 +437,13 @@
|
||||
"maxAmountExceededDescription": "金额不能超过 {max}。",
|
||||
"transactionNotFoundDescription": "未找到交易记录",
|
||||
"maxAmountExceededDescription": "金额不能超过 {max}。"
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "颜色:",
|
||||
"thicknessLabel": "粗细:",
|
||||
"undoButton": "撤销",
|
||||
"clearButton": "清除",
|
||||
"saveDrawingButton": "保存绘图",
|
||||
"cancelButton": "取消"
|
||||
}
|
||||
}
|
||||
|
||||
595
package-lock.json
generated
595
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.23",
|
||||
"version": "0.2.30",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -43,13 +43,12 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"jotai": "^2.8.0",
|
||||
"js-confetti": "^0.12.0",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"linkify": "^0.2.1",
|
||||
"linkify-react": "^4.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "15.2.3",
|
||||
"next": "^v15.5.7",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-intl": "^4.1.0",
|
||||
"next-themes": "^0.4.4",
|
||||
@@ -63,7 +62,6 @@
|
||||
"rrule": "^2.8.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.0.5",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
@@ -72,7 +70,6 @@
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/bun": "^1.1.14",
|
||||
"@types/json-stable-stringify": "^1.1.0",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.17.10",
|
||||
|
||||
Reference in New Issue
Block a user