mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 20:09:50 +01:00
Compare commits
8 Commits
c397f40239
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f177d6448d
|
|||
|
08dbca81b2
|
|||
|
8c9698c048
|
|||
|
|
5144d6106d | ||
|
|
62b5ea41b3 | ||
|
630363af1f
|
|||
|
f7034116a3
|
|||
|
c418bddd9e
|
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.2.31
|
||||||
|
|
||||||
|
**This Release contains important security patches, please update as soon as possible**
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Security: Updated Next.js from 15.5.7 to 15.5.10
|
||||||
|
* Security: Hardened avatar route against path traversal attacks
|
||||||
|
* Security: Sanitized user data in client-facing payloads
|
||||||
|
* Security: Hardened debug actions and avatar validation
|
||||||
|
* Fixed missing English translations for DrawingModal
|
||||||
|
|
||||||
|
Thank you @1ARdotNO for the security audit!
|
||||||
|
|
||||||
## Version 0.2.30
|
## Version 0.2.30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -181,3 +181,26 @@ This project is licensed under the GNU Affero General Public License v3.0 - see
|
|||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you encounter any issues or have questions, please file an issue on the GitHub repository.
|
If you encounter any issues or have questions, please file an issue on the GitHub repository.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
### Missing Permissions
|
||||||
|
|
||||||
|
Especially when updating from older versions, it may be that the permissions used in the newer versions have never been set. This causes numerous `missing permissions` errors to appear. The solution is to update the `auth.json` in the `data` directory for each user to include the following json:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"permissions": [{
|
||||||
|
"habit": {
|
||||||
|
"write": true,
|
||||||
|
"interact": true
|
||||||
|
},
|
||||||
|
"wishlist": {
|
||||||
|
"write": true,
|
||||||
|
"interact": true
|
||||||
|
},
|
||||||
|
"coins": {
|
||||||
|
"write": true,
|
||||||
|
"interact": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
88
app/actions/data.test.ts
Normal file
88
app/actions/data.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { sanitizeUserData } from '@/lib/user-sanitizer'
|
||||||
|
import { UserData } from '@/lib/types'
|
||||||
|
|
||||||
|
describe('sanitizeUserData', () => {
|
||||||
|
test('removes password field from every user', () => {
|
||||||
|
const input: UserData = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'u1',
|
||||||
|
username: 'admin',
|
||||||
|
password: 'abcd1234:ef567890',
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u2',
|
||||||
|
username: 'no-pass',
|
||||||
|
isAdmin: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = sanitizeUserData(input)
|
||||||
|
|
||||||
|
expect(output.users).toHaveLength(2)
|
||||||
|
expect(output.users[0]).not.toHaveProperty('password')
|
||||||
|
expect(output.users[1]).not.toHaveProperty('password')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds hasPassword metadata based on stored password', () => {
|
||||||
|
const input: UserData = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'u1',
|
||||||
|
username: 'with-hash',
|
||||||
|
password: 'abcd1234:ef567890',
|
||||||
|
isAdmin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u2',
|
||||||
|
username: 'empty-pass',
|
||||||
|
password: '',
|
||||||
|
isAdmin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u3',
|
||||||
|
username: 'no-pass',
|
||||||
|
isAdmin: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = sanitizeUserData(input)
|
||||||
|
|
||||||
|
expect(output.users[0].hasPassword).toBe(true)
|
||||||
|
expect(output.users[1].hasPassword).toBe(false)
|
||||||
|
expect(output.users[2].hasPassword).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves other user properties', () => {
|
||||||
|
const input: UserData = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'u1',
|
||||||
|
username: 'user',
|
||||||
|
password: 'hash',
|
||||||
|
avatarPath: '/data/avatars/u1.png',
|
||||||
|
isAdmin: false,
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
habit: { write: true, interact: true },
|
||||||
|
wishlist: { write: true, interact: true },
|
||||||
|
coins: { write: true, interact: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = sanitizeUserData(input)
|
||||||
|
|
||||||
|
expect(output.users[0].id).toBe('u1')
|
||||||
|
expect(output.users[0].username).toBe('user')
|
||||||
|
expect(output.users[0].avatarPath).toBe('/data/avatars/u1.png')
|
||||||
|
expect(output.users[0].isAdmin).toBe(false)
|
||||||
|
expect(output.users[0].permissions?.[0].habit.write).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
|
import { ALLOWED_AVATAR_EXTENSIONS, ALLOWED_AVATAR_MIME_TYPES } from '@/lib/avatar';
|
||||||
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
|
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
|
||||||
import {
|
import {
|
||||||
CoinsData,
|
CoinsData,
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
getDefaultWishlistData,
|
getDefaultWishlistData,
|
||||||
HabitsData,
|
HabitsData,
|
||||||
Permission,
|
Permission,
|
||||||
|
PublicUser,
|
||||||
|
PublicUserData,
|
||||||
ServerSettings,
|
ServerSettings,
|
||||||
Settings,
|
Settings,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
@@ -21,8 +24,10 @@ import {
|
|||||||
WishlistData,
|
WishlistData,
|
||||||
WishlistItemType
|
WishlistItemType
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
import { sanitizeUserData } from '@/lib/user-sanitizer';
|
||||||
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
||||||
import { signInSchema } from '@/lib/zod';
|
import { signInSchema } from '@/lib/zod';
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -32,6 +37,20 @@ import path from 'path';
|
|||||||
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||||
type ActionType = 'write' | 'interact'
|
type ActionType = 'write' | 'interact'
|
||||||
|
|
||||||
|
async function verifyPermission(
|
||||||
|
resource: ResourceType,
|
||||||
|
action: ActionType
|
||||||
|
): Promise<void> {
|
||||||
|
// const user = await getCurrentUser()
|
||||||
|
|
||||||
|
// if (!user) throw new PermissionError('User not authenticated')
|
||||||
|
// if (user.isAdmin) return // Admins bypass permission checks
|
||||||
|
|
||||||
|
// if (!checkPermission(user.permissions, resource, action)) {
|
||||||
|
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
|
||||||
|
// }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
function getDefaultData<T>(type: DataType): T {
|
function getDefaultData<T>(type: DataType): T {
|
||||||
return DATA_DEFAULTS[type]() as T;
|
return DATA_DEFAULTS[type]() as T;
|
||||||
@@ -48,22 +67,28 @@ async function ensureDataDir() {
|
|||||||
|
|
||||||
// --- Backup Debug Action ---
|
// --- Backup Debug Action ---
|
||||||
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
|
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
|
||||||
// Optional: Add extra permission check if needed for debug actions
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
// const user = await getCurrentUser();
|
return { success: false, message: 'Permission denied.' }
|
||||||
// if (!user?.isAdmin) {
|
}
|
||||||
// return { success: false, message: "Permission denied." };
|
|
||||||
// }
|
|
||||||
|
|
||||||
console.log("Manual backup trigger requested...");
|
const user = await getCurrentUser()
|
||||||
|
if (!user?.isAdmin) {
|
||||||
|
return { success: false, message: 'Permission denied.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Manual backup trigger requested...')
|
||||||
try {
|
try {
|
||||||
// Import runBackup locally to avoid potential circular dependencies if moved
|
// Import runBackup locally to avoid potential circular dependencies if moved
|
||||||
const { runBackup } = await import('@/lib/backup');
|
const { runBackup } = await import('@/lib/backup')
|
||||||
await runBackup();
|
await runBackup()
|
||||||
console.log("Manual backup trigger completed successfully.");
|
console.log('Manual backup trigger completed successfully.')
|
||||||
return { success: true, message: "Backup process completed successfully." };
|
return { success: true, message: 'Backup process completed successfully.' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Manual backup trigger failed:", error);
|
console.error('Manual backup trigger failed:', error)
|
||||||
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +161,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
|
|||||||
// Wishlist specific functions
|
// Wishlist specific functions
|
||||||
export async function loadWishlistData(): Promise<WishlistData> {
|
export async function loadWishlistData(): Promise<WishlistData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultWishlistData<WishlistData>()
|
if (!user) return getDefaultWishlistData()
|
||||||
|
|
||||||
const data = await loadData<WishlistData>('wishlist')
|
const data = await loadData<WishlistData>('wishlist')
|
||||||
return {
|
return {
|
||||||
@@ -173,7 +198,7 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
|||||||
// Habits specific functions
|
// Habits specific functions
|
||||||
export async function loadHabitsData(): Promise<HabitsData> {
|
export async function loadHabitsData(): Promise<HabitsData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultHabitsData<HabitsData>()
|
if (!user) return getDefaultHabitsData()
|
||||||
const data = await loadData<HabitsData>('habits')
|
const data = await loadData<HabitsData>('habits')
|
||||||
return {
|
return {
|
||||||
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||||
@@ -208,14 +233,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
|||||||
export async function loadCoinsData(): Promise<CoinsData> {
|
export async function loadCoinsData(): Promise<CoinsData> {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultCoinsData<CoinsData>()
|
if (!user) return getDefaultCoinsData()
|
||||||
const data = await loadData<CoinsData>('coins')
|
const data = await loadData<CoinsData>('coins')
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultCoinsData<CoinsData>()
|
return getDefaultCoinsData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +303,7 @@ export async function addCoins({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
const defaultSettings = getDefaultSettings<Settings>()
|
const defaultSettings = getDefaultSettings()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
@@ -339,13 +364,22 @@ export async function uploadAvatar(formData: FormData): Promise<string> {
|
|||||||
throw new Error('File size must be less than 5MB')
|
throw new Error('File size must be less than 5MB')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mimeType = file.type.toLowerCase()
|
||||||
|
if (!ALLOWED_AVATAR_MIME_TYPES.has(mimeType)) {
|
||||||
|
throw new Error('Unsupported avatar MIME type')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(file.name).toLowerCase()
|
||||||
|
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
|
||||||
|
throw new Error('Unsupported avatar file extension')
|
||||||
|
}
|
||||||
|
|
||||||
// Create avatars directory if it doesn't exist
|
// Create avatars directory if it doesn't exist
|
||||||
const avatarsDir = path.join(process.cwd(), 'data', 'avatars')
|
const avatarsDir = path.join(process.cwd(), 'data', 'avatars')
|
||||||
await fs.mkdir(avatarsDir, { recursive: true })
|
await fs.mkdir(avatarsDir, { recursive: true })
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
const ext = file.name.split('.').pop()
|
const filename = `${Date.now()}-${randomUUID()}${ext}`
|
||||||
const filename = `${Date.now()}.${ext}`
|
|
||||||
const filePath = path.join(avatarsDir, filename)
|
const filePath = path.join(avatarsDir, filename)
|
||||||
|
|
||||||
// Save file
|
// Save file
|
||||||
@@ -366,14 +400,19 @@ export async function getChangelog(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// user logic
|
// user logic
|
||||||
export async function loadUsersData(): Promise<UserData> {
|
async function loadUsersData(): Promise<UserData> {
|
||||||
try {
|
try {
|
||||||
return await loadData<UserData>('auth')
|
return await loadData<UserData>('auth')
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultUsersData<UserData>()
|
return getDefaultUsersData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadUsersPublicData(): Promise<PublicUserData> {
|
||||||
|
const data = await loadUsersData()
|
||||||
|
return sanitizeUserData(data)
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveUsersData(data: UserData): Promise<void> {
|
export async function saveUsersData(data: UserData): Promise<void> {
|
||||||
return saveData('auth', data)
|
return saveData('auth', data)
|
||||||
}
|
}
|
||||||
@@ -391,7 +430,7 @@ export async function getUser(username: string, plainTextPassword?: string): Pro
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(formData: FormData): Promise<User> {
|
export async function createUser(formData: FormData): Promise<PublicUser> {
|
||||||
const username = formData.get('username') as string;
|
const username = formData.get('username') as string;
|
||||||
let password = formData.get('password') as string | undefined;
|
let password = formData.get('password') as string | undefined;
|
||||||
const avatarPath = formData.get('avatarPath') as string;
|
const avatarPath = formData.get('avatarPath') as string;
|
||||||
@@ -428,10 +467,10 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await saveUsersData(newData);
|
await saveUsersData(newData);
|
||||||
return newUser;
|
return sanitizeUserData({ users: [newUser] }).users[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> {
|
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<PublicUser> {
|
||||||
const data = await loadUsersData()
|
const data = await loadUsersData()
|
||||||
const userIndex = data.users.findIndex(user => user.id === userId)
|
const userIndex = data.users.findIndex(user => user.id === userId)
|
||||||
|
|
||||||
@@ -463,7 +502,7 @@ export async function updateUser(userId: string, updates: Partial<Omit<User, 'id
|
|||||||
}
|
}
|
||||||
|
|
||||||
await saveUsersData(newData)
|
await saveUsersData(newData)
|
||||||
return updatedUser
|
return sanitizeUserData({ users: [updatedUser] }).users[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {
|
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {
|
||||||
|
|||||||
197
app/api/avatars/[...path]/route.test.ts
Normal file
197
app/api/avatars/[...path]/route.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const mockReadFile = mock()
|
||||||
|
const mockRealpath = mock()
|
||||||
|
const mockLstat = mock()
|
||||||
|
|
||||||
|
mock.module('fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
readFile: mockReadFile,
|
||||||
|
realpath: mockRealpath,
|
||||||
|
lstat: mockLstat,
|
||||||
|
},
|
||||||
|
readFile: mockReadFile,
|
||||||
|
realpath: mockRealpath,
|
||||||
|
lstat: mockLstat,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let GET: typeof import('./route').GET
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
;({ GET } = await import('./route'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/avatars/[...path]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReadFile.mockReset()
|
||||||
|
mockRealpath.mockReset()
|
||||||
|
mockLstat.mockReset()
|
||||||
|
|
||||||
|
spyOn(process, 'cwd').mockReturnValue('/app')
|
||||||
|
|
||||||
|
mockRealpath.mockImplementation(async (value: string) => value)
|
||||||
|
mockLstat.mockResolvedValue({ isSymbolicLink: () => false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns avatar image for valid file path', async () => {
|
||||||
|
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
||||||
|
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||||
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.headers.get('Content-Type')).toBe('image/png')
|
||||||
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'avatar.png'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows nested valid avatar paths', async () => {
|
||||||
|
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
||||||
|
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/user-1/avatar.png'), {
|
||||||
|
params: Promise.resolve({ path: ['user-1', 'avatar.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'user-1', 'avatar.png'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports uppercase extensions', async () => {
|
||||||
|
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
||||||
|
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.PNG'), {
|
||||||
|
params: Promise.resolve({ path: ['avatar.PNG'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.headers.get('Content-Type')).toBe('image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects traversal segments', async () => {
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/../auth.json'), {
|
||||||
|
params: Promise.resolve({ path: ['..', 'auth.json'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects encoded traversal payloads', async () => {
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/%2e%2e%2fauth.json'), {
|
||||||
|
params: Promise.resolve({ path: ['%2e%2e%2fauth.json'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects encoded backslash traversal payloads', async () => {
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/..%5cauth.png'), {
|
||||||
|
params: Promise.resolve({ path: ['..%5cauth.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects null byte payloads', async () => {
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png%00'), {
|
||||||
|
params: Promise.resolve({ path: ['avatar.png%00'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects dot-only segments', async () => {
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/./avatar.png'), {
|
||||||
|
params: Promise.resolve({ path: ['.', 'avatar.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects malformed encoded segments', async () => {
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/%E0%A4%A'), {
|
||||||
|
params: Promise.resolve({ path: ['%E0%A4%A'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects unsupported file extensions', async () => {
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/config.json'), {
|
||||||
|
params: Promise.resolve({ path: ['config.json'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Unsupported file type' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects symlinked avatar files', async () => {
|
||||||
|
mockLstat.mockResolvedValue({ isSymbolicLink: () => true })
|
||||||
|
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||||
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects files whose real path escapes avatars directory', async () => {
|
||||||
|
mockRealpath.mockImplementation(async (value: string) => {
|
||||||
|
if (value === path.resolve('/app', 'data', 'avatars')) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.resolve('/app', 'data', 'auth.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||||
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||||
|
expect(mockReadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns 404 when file is missing', async () => {
|
||||||
|
mockLstat.mockRejectedValue({ code: 'ENOENT' })
|
||||||
|
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/missing.png'), {
|
||||||
|
params: Promise.resolve({ path: ['missing.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
expect(await response.json()).toEqual({ error: 'File not found' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns 500 for non-ENOENT read errors', async () => {
|
||||||
|
mockReadFile.mockRejectedValue({ code: 'EACCES' })
|
||||||
|
|
||||||
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||||
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(500)
|
||||||
|
expect(await response.json()).toEqual({ error: 'Internal server error' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,26 +1,109 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { ALLOWED_AVATAR_EXTENSIONS, AVATAR_CONTENT_TYPE } from '@/lib/avatar'
|
||||||
|
|
||||||
|
function sanitizePathSegments(pathSegments?: string[]): string[] | null {
|
||||||
|
if (!pathSegments || pathSegments.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeSegments: string[] = []
|
||||||
|
|
||||||
|
for (const rawSegment of pathSegments) {
|
||||||
|
let segment = rawSegment
|
||||||
|
|
||||||
|
try {
|
||||||
|
segment = decodeURIComponent(rawSegment)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!segment || segment === '.' || segment === '..') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.includes('/') || segment.includes('\\') || segment.includes('\0')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSegments.push(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeSegments
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInsideBase(basePath: string, targetPath: string): boolean {
|
||||||
|
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorCode(error: unknown): string | null {
|
||||||
|
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = error as { code?: unknown }
|
||||||
|
return typeof code === 'string' ? code : null
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
_request: Request,
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
) {
|
) {
|
||||||
|
const { path: pathSegments } = await Promise.resolve(params)
|
||||||
|
const safeSegments = sanitizePathSegments(pathSegments)
|
||||||
|
|
||||||
|
if (!safeSegments) {
|
||||||
|
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarsDir = path.resolve(process.cwd(), 'data', 'avatars')
|
||||||
|
const filePath = path.resolve(avatarsDir, ...safeSegments)
|
||||||
|
|
||||||
|
if (!isPathInsideBase(avatarsDir, filePath)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
|
||||||
|
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
|
||||||
|
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { path: pathSegments } = await Promise.resolve(params)
|
const realAvatarsDir = await fs.realpath(avatarsDir)
|
||||||
const filePath = path.join(process.cwd(), 'data', 'avatars', ...(pathSegments || []))
|
const fileStats = await fs.lstat(filePath)
|
||||||
const file = await fs.readFile(filePath)
|
|
||||||
const ext = path.extname(filePath).slice(1)
|
if (fileStats.isSymbolicLink()) {
|
||||||
|
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const realFilePath = await fs.realpath(filePath)
|
||||||
|
|
||||||
|
if (!isPathInsideBase(realAvatarsDir, realFilePath)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await fs.readFile(realFilePath)
|
||||||
|
|
||||||
return new NextResponse(file, {
|
return new NextResponse(file, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': `image/${ext}`,
|
'Content-Type': AVATAR_CONTENT_TYPE[ext] ?? 'application/octet-stream',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (getErrorCode(error) === 'ENOENT') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error reading avatar file:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'File not found' },
|
{ error: 'Internal server error' },
|
||||||
{ status: 404 }
|
{ status: 500 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export default function Debug({children}: {children: ReactNode}) {
|
export default function Debug({children}: {children: ReactNode}) {
|
||||||
if (process.env.NODE_ENV !== 'development') return <></>
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="debug">
|
<div className="debug">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { NextIntlClientProvider } from 'next-intl'
|
|||||||
import { getLocale, getMessages } from 'next-intl/server'
|
import { getLocale, getMessages } from 'next-intl/server'
|
||||||
import { DM_Sans } from 'next/font/google'
|
import { DM_Sans } from 'next/font/google'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersPublicData, loadWishlistData } from './actions/data'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
// Clean and contemporary
|
// Clean and contemporary
|
||||||
@@ -40,7 +40,7 @@ export default async function RootLayout({
|
|||||||
loadHabitsData(),
|
loadHabitsData(),
|
||||||
loadCoinsData(),
|
loadCoinsData(),
|
||||||
loadWishlistData(),
|
loadWishlistData(),
|
||||||
loadUsersData(),
|
loadUsersPublicData(),
|
||||||
loadServerSettings(),
|
loadServerSettings(),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -48,23 +48,6 @@ export default async function RootLayout({
|
|||||||
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
|
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<body className={activeFont.className}>
|
<body className={activeFont.className}>
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/sw.js')
|
|
||||||
.then(registration => {
|
|
||||||
console.log('ServiceWorker registration successful');
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.log('ServiceWorker registration failed: ', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<JotaiHydrate
|
<JotaiHydrate
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Coins } from 'lucide-react'
|
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Coins } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||||
@@ -21,9 +20,7 @@ export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
|
|||||||
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
|
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-4xl font-bold">
|
<span className="text-4xl font-bold">{coinBalance}</span>
|
||||||
<FormattedNumber amount={coinBalance} settings={settings} />
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TodayEarnedCoins longFormat={true} />
|
<TodayEarnedCoins longFormat={true} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ import { useCoins } from '@/hooks/useCoins'
|
|||||||
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
import { TransactionType } from '@/lib/types'
|
import { TransactionType } from '@/lib/types'
|
||||||
import { d2s, t2d } from '@/lib/utils'
|
import { calculateTransactionsToday, d2s, t2d } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { History } from 'lucide-react'
|
import { History } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
@@ -33,8 +32,7 @@ export default function CoinsManager() {
|
|||||||
coinsEarnedToday,
|
coinsEarnedToday,
|
||||||
totalEarned,
|
totalEarned,
|
||||||
totalSpent,
|
totalSpent,
|
||||||
coinsSpentToday,
|
coinsSpentToday
|
||||||
transactionsToday
|
|
||||||
} = useCoins({ selectedUser })
|
} = useCoins({ selectedUser })
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
@@ -128,7 +126,7 @@ export default function CoinsManager() {
|
|||||||
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
|
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
|
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
|
||||||
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div>
|
<div className="text-3xl font-bold">{balance} {t('coinsSuffix')}</div>
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -215,16 +213,12 @@ export default function CoinsManager() {
|
|||||||
{/* Top Row - Totals */}
|
{/* Top Row - Totals */}
|
||||||
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
|
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
|
||||||
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div>
|
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
|
<div className="text-2xl font-bold text-green-900 dark:text-green-50">{totalEarned} 🪙</div>
|
||||||
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
|
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
|
||||||
<div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div>
|
<div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
|
<div className="text-2xl font-bold text-red-900 dark:text-red-50">{totalSpent} 💸</div>
|
||||||
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900">
|
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900">
|
||||||
@@ -237,22 +231,18 @@ export default function CoinsManager() {
|
|||||||
{/* Bottom Row - Today */}
|
{/* Bottom Row - Today */}
|
||||||
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
|
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||||
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div>
|
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
|
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">{coinsEarnedToday} 🪙</div>
|
||||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900">
|
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||||
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div>
|
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
|
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">{coinsSpentToday} 💸</div>
|
||||||
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900">
|
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||||
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div>
|
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div>
|
||||||
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
|
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
|
||||||
{transactionsToday} 📊
|
{calculateTransactionsToday(transactions, settings.system.timezone)} 📊
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,22 +13,22 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
import { browserSettingsAtom, completedHabitsMapAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
||||||
import { Habit, WishlistItemType } from '@/lib/types'
|
import { Habit, WishlistItemType } from '@/lib/types'
|
||||||
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons
|
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import AddEditHabitModal from './AddEditHabitModal'
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
import CompletionCountBadge from './CompletionCountBadge'
|
import CompletionCountBadge from './CompletionCountBadge'
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
|
import DrawingDisplay from './DrawingDisplay'
|
||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import DrawingDisplay from './DrawingDisplay'
|
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -54,8 +54,7 @@ const ItemSection = ({
|
|||||||
addNewItem,
|
addNewItem,
|
||||||
}: ItemSectionProps) => {
|
}: ItemSectionProps) => {
|
||||||
const t = useTranslations('DailyOverview');
|
const t = useTranslations('DailyOverview');
|
||||||
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits();
|
const { completeHabit, undoComplete, saveHabit, deleteHabit, habitFreqMap } = useHabits();
|
||||||
const [_, setPomo] = useAtom(pomodoroAtom);
|
|
||||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
|
||||||
const [settings] = useAtom(settingsAtom);
|
const [settings] = useAtom(settingsAtom);
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom);
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom);
|
||||||
@@ -397,8 +396,6 @@ export default function DailyOverview({
|
|||||||
return a.coinCost - b.coinCost
|
return a.coinCost - b.coinCost
|
||||||
})
|
})
|
||||||
|
|
||||||
const [hasTasks] = useAtom(hasTasksAtom)
|
|
||||||
const [, setPomo] = useAtom(pomodoroAtom)
|
|
||||||
const [modalConfig, setModalConfig] = useState<{
|
const [modalConfig, setModalConfig] = useState<{
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
isTask: boolean
|
isTask: boolean
|
||||||
@@ -416,7 +413,7 @@ export default function DailyOverview({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tasks Section */}
|
{/* Tasks Section */}
|
||||||
{hasTasks && (
|
{habits.some(habit => habit.isTask === true) && (
|
||||||
<ItemSection
|
<ItemSection
|
||||||
title={t('dailyTasksTitle')}
|
title={t('dailyTasksTitle')}
|
||||||
items={dailyTasks}
|
items={dailyTasks}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { formatNumber } from '@/lib/utils/formatNumber'
|
|
||||||
import { Settings } from '@/lib/types'
|
|
||||||
|
|
||||||
interface FormattedNumberProps {
|
|
||||||
amount: number
|
|
||||||
settings: Settings
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormattedNumber({ amount, settings, className }: FormattedNumberProps) {
|
|
||||||
return (
|
|
||||||
<span className={`break-all ${className || ''}`}>
|
|
||||||
{formatNumber({ amount, settings })}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import CompletionCountBadge from '@/components/CompletionCountBadge'
|
|||||||
import { Calendar } from '@/components/ui/calendar'
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'
|
import { completedHabitsMapAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
|
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
@@ -29,7 +29,6 @@ export default function HabitCalendar() {
|
|||||||
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
||||||
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
|
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
const [hasTasks] = useAtom(hasTasksAtom)
|
|
||||||
const habits = habitsData.habits
|
const habits = habitsData.habits
|
||||||
|
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||||
@@ -83,7 +82,7 @@ export default function HabitCalendar() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{selectedDateTime && (
|
{selectedDateTime && (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{hasTasks && (
|
{habits.some(habit => habit.isTask === true) && (
|
||||||
<div className="pt-2 border-t">
|
<div className="pt-2 border-t">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
|
import { completedHabitsMapAtom, settingsAtom } from '@/lib/atoms';
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
|
import { d2s, getNow } from '@/lib/utils';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
export default function HabitStreak({ habits }: { habits: Habit[] }) {
|
||||||
interface HabitStreakProps {
|
|
||||||
habits: Habit[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HabitStreak({ habits }: HabitStreakProps) {
|
|
||||||
const t = useTranslations('HabitStreak');
|
const t = useTranslations('HabitStreak');
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [hasTasks] = useAtom(hasTasksAtom)
|
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
||||||
|
|
||||||
// Get the last 7 days of data
|
// Get the last 7 days of data
|
||||||
@@ -72,7 +66,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
{hasTasks && (
|
{habits.some(habit => habit.isTask === true) && (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
name={t('tooltipTasksLabel')}
|
name={t('tooltipTasksLabel')}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
import { Coins } from 'lucide-react'
|
import { Coins } from 'lucide-react'
|
||||||
import NotificationBell from './NotificationBell'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import NotificationBell from './NotificationBell'
|
||||||
import { Profile } from './Profile'
|
import { Profile } from './Profile'
|
||||||
|
|
||||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||||
@@ -21,11 +20,7 @@ export default function HeaderActions() {
|
|||||||
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
||||||
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
|
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
|
||||||
<div className="flex items-baseline gap-1 sm:gap-2">
|
<div className="flex items-baseline gap-1 sm:gap-2">
|
||||||
<FormattedNumber
|
<span className="text-gray-800 dark:text-gray-100 font-medium text-lg">{balance}</span>
|
||||||
amount={balance}
|
|
||||||
settings={settings}
|
|
||||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
|
||||||
/>
|
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<TodayEarnedCoins />
|
<TodayEarnedCoins />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
||||||
import { d2t, getNow, t2d } from '@/lib/utils';
|
import { d2t, getNow, t2d } from '@/lib/utils';
|
||||||
import { User, CoinTransaction } from '@/lib/types';
|
import { CoinTransaction } from '@/lib/types';
|
||||||
|
|
||||||
export default function NotificationBell() {
|
export default function NotificationBell() {
|
||||||
const t = useTranslations('NotificationBell');
|
const t = useTranslations('NotificationBell');
|
||||||
@@ -121,7 +121,7 @@ export default function NotificationBell() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
|
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
|
||||||
<NotificationDropdown
|
<NotificationDropdown
|
||||||
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
|
currentUser={currentUser ?? null}
|
||||||
unreadNotifications={unreadNotifications}
|
unreadNotifications={unreadNotifications}
|
||||||
displayedReadNotifications={displayedReadNotifications}
|
displayedReadNotifications={displayedReadNotifications}
|
||||||
habitsData={habitsData} // Pass necessary data down
|
habitsData={habitsData} // Pass necessary data down
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
|
import { CoinTransaction, HabitsData, PublicUser, PublicUserData, WishlistData } from '@/lib/types';
|
||||||
import { t2d } from '@/lib/utils';
|
import { t2d } from '@/lib/utils';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface NotificationDropdownProps {
|
interface NotificationDropdownProps {
|
||||||
currentUser: User | null;
|
currentUser: PublicUser | null;
|
||||||
unreadNotifications: CoinTransaction[];
|
unreadNotifications: CoinTransaction[];
|
||||||
displayedReadNotifications: CoinTransaction[];
|
displayedReadNotifications: CoinTransaction[];
|
||||||
habitsData: HabitsData;
|
habitsData: HabitsData;
|
||||||
wishlistData: WishlistData;
|
wishlistData: WishlistData;
|
||||||
usersData: UserData;
|
usersData: PublicUserData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get the name of the related item
|
// Helper function to get the name of the related item
|
||||||
@@ -47,7 +47,7 @@ export default function NotificationDropdown({
|
|||||||
const t = useTranslations('NotificationDropdown');
|
const t = useTranslations('NotificationDropdown');
|
||||||
|
|
||||||
// Helper function to generate notification message, now using t
|
// Helper function to generate notification message, now using t
|
||||||
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
|
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: PublicUser, relatedItemName?: string): string => {
|
||||||
const username = triggeringUser?.username || t('defaultUsername');
|
const username = triggeringUser?.username || t('defaultUsername');
|
||||||
const itemName = relatedItemName || t('defaultItemName');
|
const itemName = relatedItemName || t('defaultItemName');
|
||||||
switch (tx.type) {
|
switch (tx.type) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { User } from '@/lib/types';
|
import { SafeUser } from '@/lib/types';
|
||||||
import { User as UserIcon } from 'lucide-react';
|
import { User as UserIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -11,7 +11,7 @@ import { Input } from './ui/input';
|
|||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
interface PasswordEntryFormProps {
|
interface PasswordEntryFormProps {
|
||||||
user: User;
|
user: SafeUser;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (password: string) => Promise<void>;
|
onSubmit: (password: string) => Promise<void>;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -24,7 +24,7 @@ export default function PasswordEntryForm({
|
|||||||
error
|
error
|
||||||
}: PasswordEntryFormProps) {
|
}: PasswordEntryFormProps) {
|
||||||
const t = useTranslations('PasswordEntryForm');
|
const t = useTranslations('PasswordEntryForm');
|
||||||
const hasPassword = !!user.password;
|
const hasPassword = user.hasPassword ?? false;
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
|
import { habitsAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, getTodayCompletions } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
@@ -41,12 +41,13 @@ export default function PomodoroTimer() {
|
|||||||
const [pomo, setPomo] = useAtom(pomodoroAtom)
|
const [pomo, setPomo] = useAtom(pomodoroAtom)
|
||||||
const { show, selectedHabitId, autoStart, minimized } = pomo
|
const { show, selectedHabitId, autoStart, minimized } = pomo
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
|
const [settingsData] = useAtom(settingsAtom)
|
||||||
const { completeHabit } = useHabits()
|
const { completeHabit } = useHabits()
|
||||||
const selectedHabit = selectedHabitId ? habitsData.habits.find(habit => habit.id === selectedHabitId) : null
|
const selectedHabit = selectedHabitId ? habitsData.habits.find(habit => habit.id === selectedHabitId) : null
|
||||||
const [timeLeft, setTimeLeft] = useState(PomoConfigs.focus.duration)
|
const [timeLeft, setTimeLeft] = useState(PomoConfigs.focus.duration)
|
||||||
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
|
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
|
||||||
const wakeLock = useRef<WakeLockSentinel | null>(null)
|
const wakeLock = useRef<WakeLockSentinel | null>(null)
|
||||||
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
|
const todayCompletions = getTodayCompletions(pomo, habitsData, settingsData);
|
||||||
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
|
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
|
||||||
const [currentLabel, setCurrentLabel] = useState(() => {
|
const [currentLabel, setCurrentLabel] = useState(() => {
|
||||||
const labels = currentTimerRef.current.getLabels();
|
const labels = currentTimerRef.current.getLabels();
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
|
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
|
||||||
const t = useTranslations('TodayEarnedCoins')
|
const t = useTranslations('TodayEarnedCoins')
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
const { coinsEarnedToday } = useCoins()
|
const { coinsEarnedToday } = useCoins()
|
||||||
|
|
||||||
if (coinsEarnedToday <= 0) return <></>;
|
if (coinsEarnedToday <= 0) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
|
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
|
||||||
{"+"}
|
{"+"}{coinsEarnedToday}
|
||||||
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
|
|
||||||
{longFormat ?
|
{longFormat ?
|
||||||
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
|
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
|
||||||
: null}
|
: null}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
|
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
|
||||||
const [username, setUsername] = useState(user?.username || '');
|
const [username, setUsername] = useState(user?.username || '');
|
||||||
const [password, setPassword] = useState<string | undefined>('');
|
const [password, setPassword] = useState<string | undefined>('');
|
||||||
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo);
|
const [disablePassword, setDisablePassword] = useState(user ? !user.hasPassword : serverSettings.isDemo);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
|
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
|
||||||
@@ -173,7 +173,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
avatarPath,
|
avatarPath,
|
||||||
permissions,
|
permissions,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
|
hasPassword: disablePassword ? false : (password ? true : !!u.hasPassword)
|
||||||
} : u
|
} : u
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { signIn } from '@/app/actions/user';
|
import { signIn } from '@/app/actions/user';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { currentUserAtom, usersAtom } from '@/lib/atoms';
|
import { currentUserAtom, usersAtom } from '@/lib/atoms';
|
||||||
import { SafeUser, User } from '@/lib/types';
|
import { SafeUser } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Description } from '@radix-ui/react-dialog';
|
import { Description } from '@radix-ui/react-dialog';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
@@ -15,8 +15,6 @@ import UserForm from './UserForm';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function UserCard({
|
function UserCard({
|
||||||
user,
|
user,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -24,7 +22,7 @@ function UserCard({
|
|||||||
showEdit,
|
showEdit,
|
||||||
isCurrentUser,
|
isCurrentUser,
|
||||||
}: {
|
}: {
|
||||||
user: User,
|
user: SafeUser,
|
||||||
onSelect: () => void,
|
onSelect: () => void,
|
||||||
onEdit: () => void,
|
onEdit: () => void,
|
||||||
showEdit: boolean,
|
showEdit: boolean,
|
||||||
@@ -99,7 +97,7 @@ function UserSelectionView({
|
|||||||
onEditUser,
|
onEditUser,
|
||||||
onCreateUser,
|
onCreateUser,
|
||||||
}: {
|
}: {
|
||||||
users: User[],
|
users: SafeUser[],
|
||||||
currentUserFromHook?: SafeUser,
|
currentUserFromHook?: SafeUser,
|
||||||
onUserSelect: (userId: string) => void,
|
onUserSelect: (userId: string) => void,
|
||||||
onEditUser: (userId: string) => void,
|
onEditUser: (userId: string) => void,
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
coinsBalanceAtom,
|
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
coinsSpentTodayAtom,
|
coinsSpentTodayAtom,
|
||||||
currentUserAtom,
|
currentUserAtom,
|
||||||
|
currentUserIdAtom,
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
totalEarnedAtom,
|
totalEarnedAtom,
|
||||||
totalSpentAtom,
|
usersAtom
|
||||||
transactionsTodayAtom,
|
|
||||||
usersAtom,
|
|
||||||
} from '@/lib/atoms';
|
} from '@/lib/atoms';
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
||||||
import { CoinsData } from '@/lib/types';
|
import { CoinsData } from '@/lib/types';
|
||||||
@@ -24,27 +22,24 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
const tCommon = useTranslations('Common');
|
const tCommon = useTranslations('Common');
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [users] = useAtom(usersAtom)
|
const [{users}] = useAtom(usersAtom)
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
|
const [coinsData] = useAtom(coinsAtom) // All coin transactions
|
||||||
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
|
const [loggedInUserId] = useAtom(currentUserIdAtom);
|
||||||
|
const loggedInUserBalance = loggedInUserId ? coins.transactions.filter(transaction => transaction.userId === loggedInUserId).reduce((sum, transaction) => sum + transaction.amount, 0) : 0;
|
||||||
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
|
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
|
||||||
const [atomTotalEarned] = useAtom(totalEarnedAtom)
|
const [atomTotalEarned] = useAtom(totalEarnedAtom)
|
||||||
const [atomTotalSpent] = useAtom(totalSpentAtom)
|
|
||||||
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
|
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
|
||||||
const [atomTransactionsToday] = useAtom(transactionsTodayAtom);
|
const targetUser = options?.selectedUser ? users.find(u => u.id === options.selectedUser) : currentUser
|
||||||
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
|
|
||||||
|
|
||||||
const transactions = useMemo(() => {
|
const transactions = useMemo(() => {
|
||||||
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id);
|
return coinsData.transactions.filter(t => t.userId === targetUser?.id);
|
||||||
}, [allCoinsData, targetUser?.id]);
|
}, [coinsData, targetUser?.id]);
|
||||||
|
|
||||||
const timezone = settings.system.timezone;
|
const timezone = settings.system.timezone;
|
||||||
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
|
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
|
||||||
const [totalEarned, setTotalEarned] = useState(0);
|
const [totalEarned, setTotalEarned] = useState(0);
|
||||||
const [totalSpent, setTotalSpent] = useState(0);
|
|
||||||
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
|
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
|
||||||
const [transactionsToday, setTransactionsToday] = useState<number>(0);
|
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,9 +48,7 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
// If the target user is the currently logged-in user, use the derived atom's value
|
// If the target user is the currently logged-in user, use the derived atom's value
|
||||||
setCoinsEarnedToday(atomCoinsEarnedToday);
|
setCoinsEarnedToday(atomCoinsEarnedToday);
|
||||||
setTotalEarned(atomTotalEarned);
|
setTotalEarned(atomTotalEarned);
|
||||||
setTotalSpent(atomTotalSpent);
|
|
||||||
setCoinsSpentToday(atomCoinsSpentToday);
|
setCoinsSpentToday(atomCoinsSpentToday);
|
||||||
setTransactionsToday(atomTransactionsToday);
|
|
||||||
setBalance(loggedInUserBalance);
|
setBalance(loggedInUserBalance);
|
||||||
} else if (targetUser?.id) {
|
} else if (targetUser?.id) {
|
||||||
// If an admin is viewing another user, calculate their metrics manually
|
// If an admin is viewing another user, calculate their metrics manually
|
||||||
@@ -65,14 +58,9 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
const totalEarnedVal = calculateTotalEarned(transactions);
|
const totalEarnedVal = calculateTotalEarned(transactions);
|
||||||
setTotalEarned(roundToInteger(totalEarnedVal));
|
setTotalEarned(roundToInteger(totalEarnedVal));
|
||||||
|
|
||||||
const totalSpentVal = calculateTotalSpent(transactions);
|
|
||||||
setTotalSpent(roundToInteger(totalSpentVal));
|
|
||||||
|
|
||||||
const spentToday = calculateCoinsSpentToday(transactions, timezone);
|
const spentToday = calculateCoinsSpentToday(transactions, timezone);
|
||||||
setCoinsSpentToday(roundToInteger(spentToday));
|
setCoinsSpentToday(roundToInteger(spentToday));
|
||||||
|
|
||||||
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
|
|
||||||
|
|
||||||
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
|
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
|
||||||
setBalance(roundToInteger(calculatedBalance));
|
setBalance(roundToInteger(calculatedBalance));
|
||||||
}
|
}
|
||||||
@@ -84,9 +72,7 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
loggedInUserBalance,
|
loggedInUserBalance,
|
||||||
atomCoinsEarnedToday,
|
atomCoinsEarnedToday,
|
||||||
atomTotalEarned,
|
atomTotalEarned,
|
||||||
atomTotalSpent,
|
atomCoinsSpentToday
|
||||||
atomCoinsSpentToday,
|
|
||||||
atomTransactionsToday,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const add = async (amount: number, description: string, note?: string) => {
|
const add = async (amount: number, description: string, note?: string) => {
|
||||||
@@ -186,8 +172,7 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
coinsEarnedToday,
|
coinsEarnedToday,
|
||||||
totalEarned,
|
totalEarned,
|
||||||
totalSpent,
|
totalSpent: calculateTotalSpent(coins.transactions),
|
||||||
coinsSpentToday,
|
coinsSpentToday
|
||||||
transactionsToday
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { coinsAtom, currentUserAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { Habit } from '@/lib/types'
|
import { Freq, Habit } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
d2s,
|
d2s,
|
||||||
d2t,
|
d2t,
|
||||||
getCompletionsForDate,
|
getCompletionsForDate,
|
||||||
|
getHabitFreq,
|
||||||
getISODate,
|
getISODate,
|
||||||
getNow,
|
getNow,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
@@ -23,12 +24,15 @@ import { useTranslations } from 'next-intl'
|
|||||||
export function useHabits() {
|
export function useHabits() {
|
||||||
const t = useTranslations('useHabits');
|
const t = useTranslations('useHabits');
|
||||||
const tCommon = useTranslations('Common');
|
const tCommon = useTranslations('Common');
|
||||||
const [usersData] = useAtom(usersAtom)
|
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [habitFreqMap] = useAtom(habitFreqMapAtom)
|
// const [habitFreqMap] = useAtom(habitFreqMapAtom)
|
||||||
|
const habitFreqMap = new Map<string, Freq>();
|
||||||
|
habitsData.habits.forEach(habit => {
|
||||||
|
habitFreqMap.set(habit.id, getHabitFreq(habit));
|
||||||
|
})
|
||||||
|
|
||||||
const completeHabit = async (habit: Habit) => {
|
const completeHabit = async (habit: Habit) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
|
||||||
|
|||||||
148
lib/atoms.ts
148
lib/atoms.ts
@@ -2,11 +2,7 @@ import {
|
|||||||
calculateCoinsEarnedToday,
|
calculateCoinsEarnedToday,
|
||||||
calculateCoinsSpentToday,
|
calculateCoinsSpentToday,
|
||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
calculateTotalSpent,
|
|
||||||
calculateTransactionsToday,
|
|
||||||
generateCryptoHash,
|
generateCryptoHash,
|
||||||
getCompletionsForToday,
|
|
||||||
getHabitFreq,
|
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
prepareDataForHashing,
|
prepareDataForHashing,
|
||||||
roundToInteger,
|
roundToInteger,
|
||||||
@@ -16,42 +12,41 @@ import { atom } from "jotai";
|
|||||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import {
|
import {
|
||||||
CoinsData,
|
BrowserSettings,
|
||||||
CompletionCache,
|
CompletionCache,
|
||||||
Freq,
|
|
||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
getDefaultHabitsData,
|
getDefaultHabitsData,
|
||||||
|
getDefaultPublicUsersData,
|
||||||
getDefaultServerSettings,
|
getDefaultServerSettings,
|
||||||
getDefaultSettings,
|
getDefaultSettings,
|
||||||
getDefaultUsersData,
|
|
||||||
getDefaultWishlistData,
|
getDefaultWishlistData,
|
||||||
Habit,
|
Habit,
|
||||||
HabitsData,
|
PomodoroAtom,
|
||||||
ServerSettings,
|
UserId
|
||||||
Settings,
|
|
||||||
UserData,
|
|
||||||
UserId,
|
|
||||||
WishlistData
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface BrowserSettings {
|
|
||||||
expandedHabits: boolean
|
|
||||||
expandedTasks: boolean
|
|
||||||
expandedWishlist: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||||
expandedHabits: false,
|
expandedHabits: false,
|
||||||
expandedTasks: false,
|
expandedTasks: false,
|
||||||
expandedWishlist: false
|
expandedWishlist: false
|
||||||
} as BrowserSettings)
|
} as BrowserSettings)
|
||||||
|
|
||||||
export const usersAtom = atom(getDefaultUsersData<UserData>())
|
export const usersAtom = atom(getDefaultPublicUsersData())
|
||||||
export const settingsAtom = atom(getDefaultSettings<Settings>());
|
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
|
||||||
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
|
export const settingsAtom = atom(getDefaultSettings());
|
||||||
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
|
export const habitsAtom = atom(getDefaultHabitsData());
|
||||||
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
|
export const coinsAtom = atom(getDefaultCoinsData());
|
||||||
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
|
export const wishlistAtom = atom(getDefaultWishlistData());
|
||||||
|
export const serverSettingsAtom = atom(getDefaultServerSettings());
|
||||||
|
export const userSelectAtom = atom<boolean>(false)
|
||||||
|
export const aboutOpenAtom = atom<boolean>(false)
|
||||||
|
|
||||||
|
export const pomodoroAtom = atom<PomodoroAtom>({
|
||||||
|
show: false,
|
||||||
|
selectedHabitId: null,
|
||||||
|
autoStart: true,
|
||||||
|
minimized: false,
|
||||||
|
})
|
||||||
|
|
||||||
// Derived atom for coins earned today
|
// Derived atom for coins earned today
|
||||||
export const coinsEarnedTodayAtom = atom((get) => {
|
export const coinsEarnedTodayAtom = atom((get) => {
|
||||||
@@ -68,13 +63,6 @@ export const totalEarnedAtom = atom((get) => {
|
|||||||
return roundToInteger(value);
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total spent
|
|
||||||
export const totalSpentAtom = atom((get) => {
|
|
||||||
const coins = get(coinsAtom);
|
|
||||||
const value = calculateTotalSpent(coins.transactions);
|
|
||||||
return roundToInteger(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived atom for coins spent today
|
// Derived atom for coins spent today
|
||||||
export const coinsSpentTodayAtom = atom((get) => {
|
export const coinsSpentTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
@@ -83,54 +71,12 @@ export const coinsSpentTodayAtom = atom((get) => {
|
|||||||
return roundToInteger(value);
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for transactions today
|
|
||||||
export const transactionsTodayAtom = atom((get) => {
|
|
||||||
const coins = get(coinsAtom);
|
|
||||||
const settings = get(settingsAtom);
|
|
||||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Atom to store the current logged-in user's ID.
|
|
||||||
// This should be set by your application when the user session is available.
|
|
||||||
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
|
|
||||||
|
|
||||||
export const currentUserAtom = atom((get) => {
|
export const currentUserAtom = atom((get) => {
|
||||||
const currentUserId = get(currentUserIdAtom);
|
const currentUserId = get(currentUserIdAtom);
|
||||||
const users = get(usersAtom);
|
const users = get(usersAtom);
|
||||||
return users.users.find(user => user.id === currentUserId);
|
return users.users.find(user => user.id === currentUserId);
|
||||||
})
|
})
|
||||||
|
|
||||||
// Derived atom for current balance for the logged-in user
|
|
||||||
export const coinsBalanceAtom = atom((get) => {
|
|
||||||
const loggedInUserId = get(currentUserIdAtom);
|
|
||||||
if (!loggedInUserId) {
|
|
||||||
return 0; // No user logged in or ID not set, so balance is 0
|
|
||||||
}
|
|
||||||
const coins = get(coinsAtom);
|
|
||||||
const balance = coins.transactions
|
|
||||||
.filter(transaction => transaction.userId === loggedInUserId)
|
|
||||||
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
|
||||||
return roundToInteger(balance);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* transient atoms */
|
|
||||||
interface PomodoroAtom {
|
|
||||||
show: boolean
|
|
||||||
selectedHabitId: string | null
|
|
||||||
autoStart: boolean
|
|
||||||
minimized: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pomodoroAtom = atom<PomodoroAtom>({
|
|
||||||
show: false,
|
|
||||||
selectedHabitId: null,
|
|
||||||
autoStart: true,
|
|
||||||
minimized: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const userSelectAtom = atom<boolean>(false)
|
|
||||||
export const aboutOpenAtom = atom<boolean>(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
|
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
|
||||||
* This token can be compared with a server-generated token to detect data discrepancies.
|
* This token can be compared with a server-generated token to detect data discrepancies.
|
||||||
@@ -147,34 +93,26 @@ export const clientFreshnessTokenAtom = atom(async (get) => {
|
|||||||
return hash;
|
return hash;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for completion cache
|
// Derived atom for completed habits by date, using the cache
|
||||||
export const completionCacheAtom = atom((get) => {
|
export const completedHabitsMapAtom = atom((get) => {
|
||||||
const habits = get(habitsAtom).habits;
|
const habits = get(habitsAtom).habits;
|
||||||
|
const completionCache: CompletionCache = {};
|
||||||
|
const map = new Map<string, Habit[]>();
|
||||||
const timezone = get(settingsAtom).system.timezone;
|
const timezone = get(settingsAtom).system.timezone;
|
||||||
const cache: CompletionCache = {};
|
|
||||||
|
|
||||||
habits.forEach(habit => {
|
habits.forEach(habit => {
|
||||||
habit.completions.forEach(utcTimestamp => {
|
habit.completions.forEach(utcTimestamp => {
|
||||||
const localDate = t2d({ timestamp: utcTimestamp, timezone })
|
const localDate = t2d({ timestamp: utcTimestamp, timezone })
|
||||||
.toFormat('yyyy-MM-dd');
|
.toFormat('yyyy-MM-dd');
|
||||||
|
|
||||||
if (!cache[localDate]) {
|
if (!completionCache[localDate]) {
|
||||||
cache[localDate] = {};
|
completionCache[localDate] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
|
completionCache[localDate][habit.id] = (completionCache[localDate][habit.id] || 0) + 1;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived atom for completed habits by date, using the cache
|
|
||||||
export const completedHabitsMapAtom = atom((get) => {
|
|
||||||
const habits = get(habitsAtom).habits;
|
|
||||||
const completionCache = get(completionCacheAtom);
|
|
||||||
const map = new Map<string, Habit[]>();
|
|
||||||
|
|
||||||
// For each date in the cache
|
// For each date in the cache
|
||||||
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
|
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
|
||||||
const completedHabits = habits.filter(habit => {
|
const completedHabits = habits.filter(habit => {
|
||||||
@@ -191,38 +129,6 @@ export const completedHabitsMapAtom = atom((get) => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for habit frequency map
|
|
||||||
export const habitFreqMapAtom = atom((get) => {
|
|
||||||
const habits = get(habitsAtom).habits;
|
|
||||||
const map = new Map<string, Freq>();
|
|
||||||
habits.forEach(habit => {
|
|
||||||
map.set(habit.id, getHabitFreq(habit));
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
|
||||||
const pomo = get(pomodoroAtom)
|
|
||||||
const habits = get(habitsAtom)
|
|
||||||
const settings = get(settingsAtom)
|
|
||||||
|
|
||||||
if (!pomo.selectedHabitId) return 0
|
|
||||||
|
|
||||||
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId!)
|
|
||||||
if (!selectedHabit) return 0
|
|
||||||
|
|
||||||
return getCompletionsForToday({
|
|
||||||
habit: selectedHabit,
|
|
||||||
timezone: settings.system.timezone
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Derived atom to check if any habits are tasks
|
|
||||||
export const hasTasksAtom = atom((get) => {
|
|
||||||
const habits = get(habitsAtom)
|
|
||||||
return habits.habits.some(habit => habit.isTask === true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Atom family for habits by specific date
|
// Atom family for habits by specific date
|
||||||
export const habitsByDateFamily = atomFamily((dateString: string) =>
|
export const habitsByDateFamily = atomFamily((dateString: string) =>
|
||||||
atom((get) => {
|
atom((get) => {
|
||||||
|
|||||||
25
lib/avatar.ts
Normal file
25
lib/avatar.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const ALLOWED_AVATAR_EXTENSIONS = new Set([
|
||||||
|
'.png',
|
||||||
|
'.jpg',
|
||||||
|
'.jpeg',
|
||||||
|
'.gif',
|
||||||
|
'.webp',
|
||||||
|
'.avif',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const ALLOWED_AVATAR_MIME_TYPES = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'image/avif',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const AVATAR_CONTENT_TYPE: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import 'server-only'
|
import 'server-only'
|
||||||
import { User, UserId } from './types'
|
import { User, UserData, UserId, getDefaultUsersData } from './types'
|
||||||
import { loadUsersData } from '@/app/actions/data'
|
|
||||||
import { randomBytes, scryptSync } from 'crypto'
|
import { randomBytes, scryptSync } from 'crypto'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
async function loadUsersDataFromStore(): Promise<UserData> {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(process.cwd(), 'data', 'auth.json')
|
||||||
|
const data = await fs.readFile(filePath, 'utf8')
|
||||||
|
return JSON.parse(data) as UserData
|
||||||
|
} catch {
|
||||||
|
return getDefaultUsersData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCurrentUserId(): Promise<UserId | undefined> {
|
export async function getCurrentUserId(): Promise<UserId | undefined> {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@@ -15,7 +26,7 @@ export async function getCurrentUser(): Promise<User | undefined> {
|
|||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const usersData = await loadUsersData()
|
const usersData = await loadUsersDataFromStore()
|
||||||
return usersData.users.find((u) => u.id === currentUserId)
|
return usersData.users.find((u) => u.id === currentUserId)
|
||||||
}
|
}
|
||||||
export function saltAndHashPassword(password: string, salt?: string): string {
|
export function saltAndHashPassword(password: string, salt?: string): string {
|
||||||
|
|||||||
49
lib/types.ts
49
lib/types.ts
@@ -27,6 +27,7 @@ export type SafeUser = SessionUser & {
|
|||||||
avatarPath?: string
|
avatarPath?: string
|
||||||
permissions?: Permission[]
|
permissions?: Permission[]
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
|
hasPassword?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = SafeUser & {
|
export type User = SafeUser & {
|
||||||
@@ -34,6 +35,10 @@ export type User = SafeUser & {
|
|||||||
lastNotificationReadTimestamp?: string // UTC ISO date string
|
lastNotificationReadTimestamp?: string // UTC ISO date string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PublicUser = Omit<User, 'password'> & {
|
||||||
|
hasPassword: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type Habit = {
|
export type Habit = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -81,6 +86,10 @@ export interface UserData {
|
|||||||
users: User[]
|
users: User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicUserData {
|
||||||
|
users: PublicUser[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface HabitsData {
|
export interface HabitsData {
|
||||||
habits: Habit[];
|
habits: Habit[];
|
||||||
}
|
}
|
||||||
@@ -98,7 +107,7 @@ export interface WishlistData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default value functions
|
// Default value functions
|
||||||
export function getDefaultUsersData<UserData>(): UserData {
|
export function getDefaultUsersData(): UserData {
|
||||||
return {
|
return {
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
@@ -112,23 +121,30 @@ export function getDefaultUsersData<UserData>(): UserData {
|
|||||||
} as UserData;
|
} as UserData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDefaultHabitsData<HabitsData>(): HabitsData {
|
export const getDefaultPublicUsersData = (): PublicUserData => ({
|
||||||
return { habits: [] } as HabitsData;
|
users: getDefaultUsersData().users.map(({ password, ...user }) => ({
|
||||||
}
|
...user,
|
||||||
|
hasPassword: !!password,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDefaultHabitsData = (): HabitsData => ({
|
||||||
|
habits: []
|
||||||
|
});
|
||||||
|
|
||||||
export function getDefaultTasksData<TasksData>(): TasksData {
|
export function getDefaultTasksData<TasksData>(): TasksData {
|
||||||
return { tasks: [] } as TasksData;
|
return { tasks: [] } as TasksData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDefaultCoinsData<CoinsData>(): CoinsData {
|
export function getDefaultCoinsData(): CoinsData {
|
||||||
return { balance: 0, transactions: [] } as CoinsData;
|
return { balance: 0, transactions: [] } as CoinsData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDefaultWishlistData<WishlistData>(): WishlistData {
|
export function getDefaultWishlistData(): WishlistData {
|
||||||
return { items: [] } as WishlistData;
|
return { items: [] } as WishlistData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultSettings<Settings>(): Settings {
|
export function getDefaultSettings(): Settings {
|
||||||
return {
|
return {
|
||||||
ui: {
|
ui: {
|
||||||
useNumberFormatting: true,
|
useNumberFormatting: true,
|
||||||
@@ -144,12 +160,12 @@ export function getDefaultSettings<Settings>(): Settings {
|
|||||||
} as Settings;
|
} as Settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
|
export function getDefaultServerSettings(): ServerSettings {
|
||||||
return { isDemo: false } as ServerSettings;
|
return { isDemo: false } as ServerSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map of data types to their default values
|
// Map of data types to their default values
|
||||||
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
|
export const DATA_DEFAULTS = {
|
||||||
wishlist: getDefaultWishlistData,
|
wishlist: getDefaultWishlistData,
|
||||||
habits: getDefaultHabitsData,
|
habits: getDefaultHabitsData,
|
||||||
coins: getDefaultCoinsData,
|
coins: getDefaultCoinsData,
|
||||||
@@ -195,7 +211,7 @@ export interface JotaiHydrateInitialValues {
|
|||||||
coins: CoinsData;
|
coins: CoinsData;
|
||||||
habits: HabitsData;
|
habits: HabitsData;
|
||||||
wishlist: WishlistData;
|
wishlist: WishlistData;
|
||||||
users: UserData;
|
users: PublicUserData;
|
||||||
serverSettings: ServerSettings;
|
serverSettings: ServerSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,3 +226,16 @@ export interface ParsedFrequencyResult {
|
|||||||
message: string | null
|
message: string | null
|
||||||
result: ParsedResultType
|
result: ParsedResultType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PomodoroAtom {
|
||||||
|
show: boolean
|
||||||
|
selectedHabitId: string | null
|
||||||
|
autoStart: boolean
|
||||||
|
minimized: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserSettings {
|
||||||
|
expandedHabits: boolean
|
||||||
|
expandedTasks: boolean
|
||||||
|
expandedWishlist: boolean
|
||||||
|
}
|
||||||
10
lib/user-sanitizer.ts
Normal file
10
lib/user-sanitizer.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { PublicUserData, UserData } from './types'
|
||||||
|
|
||||||
|
export function sanitizeUserData(data: UserData): PublicUserData {
|
||||||
|
return {
|
||||||
|
users: data.users.map(({ password, ...user }) => ({
|
||||||
|
...user,
|
||||||
|
hasPassword: !!password,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -942,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('freshness utilities', () => {
|
describe('freshness utilities', () => {
|
||||||
const mockSettings: Settings = getDefaultSettings<Settings>();
|
const mockSettings: Settings = getDefaultSettings();
|
||||||
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
|
const mockHabits: HabitsData = getDefaultHabitsData();
|
||||||
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
|
const mockCoins: CoinsData = getDefaultCoinsData();
|
||||||
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
|
const mockWishlist: WishlistData = getDefaultWishlistData();
|
||||||
const mockUsers: UserData = getDefaultUsersData<UserData>();
|
const mockUsers: UserData = getDefaultUsersData();
|
||||||
|
|
||||||
// Add a user to mockUsers for more realistic testing
|
// Add a user to mockUsers for more realistic testing
|
||||||
mockUsers.users.push({
|
mockUsers.users.push({
|
||||||
@@ -991,11 +991,11 @@ describe('freshness utilities', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle empty data consistently', () => {
|
test('should handle empty data consistently', () => {
|
||||||
const emptySettings = getDefaultSettings<Settings>();
|
const emptySettings = getDefaultSettings();
|
||||||
const emptyHabits = getDefaultHabitsData<HabitsData>();
|
const emptyHabits = getDefaultHabitsData();
|
||||||
const emptyCoins = getDefaultCoinsData<CoinsData>();
|
const emptyCoins = getDefaultCoinsData();
|
||||||
const emptyWishlist = getDefaultWishlistData<WishlistData>();
|
const emptyWishlist = getDefaultWishlistData();
|
||||||
const emptyUsers = getDefaultUsersData<UserData>();
|
const emptyUsers = getDefaultUsersData();
|
||||||
|
|
||||||
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
|
|||||||
18
lib/utils.ts
18
lib/utils.ts
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from "@/hooks/use-toast"
|
import { toast } from "@/hooks/use-toast"
|
||||||
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
|
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, PomodoroAtom, PublicUserData, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||||
@@ -84,6 +84,20 @@ export function getCompletionsForToday({
|
|||||||
return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone })
|
return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTodayCompletions({ selectedHabitId }: PomodoroAtom, { habits }: HabitsData, { system: { timezone } }: Settings): number {
|
||||||
|
if (!selectedHabitId)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
const selectedHabit = habits.find(h => h.id === selectedHabitId!);
|
||||||
|
if (!selectedHabit)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return getCompletionsForToday({
|
||||||
|
habit: selectedHabit,
|
||||||
|
timezone: timezone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getCompletedHabitsForDate({
|
export function getCompletedHabitsForDate({
|
||||||
habits,
|
habits,
|
||||||
date,
|
date,
|
||||||
@@ -448,7 +462,7 @@ export function prepareDataForHashing(
|
|||||||
habits: HabitsData,
|
habits: HabitsData,
|
||||||
coins: CoinsData,
|
coins: CoinsData,
|
||||||
wishlist: WishlistData,
|
wishlist: WishlistData,
|
||||||
users: UserData
|
users: UserData | PublicUserData
|
||||||
): string {
|
): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
13
middleware.ts
Normal file
13
middleware.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
if (process.env.NODE_ENV !== 'development' && request.nextUrl.pathname.startsWith('/debug')) {
|
||||||
|
return new NextResponse('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/debug/:path*'],
|
||||||
|
}
|
||||||
@@ -30,24 +30,7 @@ const nextConfig: NextConfig = {
|
|||||||
value: 'strict-origin-when-cross-origin',
|
value: 'strict-origin-when-cross-origin',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
}
|
||||||
{
|
|
||||||
source: '/sw.js',
|
|
||||||
headers: [
|
|
||||||
{
|
|
||||||
key: 'Content-Type',
|
|
||||||
value: 'application/javascript; charset=utf-8',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Cache-Control',
|
|
||||||
value: 'no-cache, no-store, must-revalidate',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Content-Security-Policy',
|
|
||||||
value: "default-src 'self'; script-src 'self'",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
84
package-lock.json
generated
84
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.30",
|
"version": "0.2.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.30",
|
"version": "0.2.31",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "^v15.5.7",
|
"next": "^15.5.10",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.1.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
@@ -969,9 +969,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
|
||||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
"integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -992,9 +992,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
|
||||||
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
|
"integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1008,9 +1008,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
|
||||||
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
|
"integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1024,9 +1024,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
|
||||||
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
|
"integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1040,9 +1040,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
|
||||||
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
|
"integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1056,9 +1056,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
|
||||||
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
|
"integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1072,9 +1072,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
|
||||||
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
|
"integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1088,9 +1088,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
|
||||||
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
|
"integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1104,9 +1104,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
|
||||||
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
|
"integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -7767,13 +7767,13 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
|
||||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.7",
|
"@next/env": "15.5.12",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -7786,14 +7786,14 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.5.7",
|
"@next/swc-darwin-arm64": "15.5.12",
|
||||||
"@next/swc-darwin-x64": "15.5.7",
|
"@next/swc-darwin-x64": "15.5.12",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.7",
|
"@next/swc-linux-arm64-gnu": "15.5.12",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.7",
|
"@next/swc-linux-arm64-musl": "15.5.12",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.7",
|
"@next/swc-linux-x64-gnu": "15.5.12",
|
||||||
"@next/swc-linux-x64-musl": "15.5.7",
|
"@next/swc-linux-x64-musl": "15.5.12",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.7",
|
"@next/swc-win32-arm64-msvc": "15.5.12",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.7",
|
"@next/swc-win32-x64-msvc": "15.5.12",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.30",
|
"version": "0.2.31",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "^v15.5.7",
|
"next": "^15.5.10",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.1.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
|||||||
22
public/sw.js
22
public/sw.js
@@ -1,22 +0,0 @@
|
|||||||
self.addEventListener('push', function (event) {
|
|
||||||
if (event.data) {
|
|
||||||
const data = event.data.json()
|
|
||||||
const options = {
|
|
||||||
body: data.body,
|
|
||||||
icon: data.icon || '/icon.png',
|
|
||||||
badge: '/badge.png',
|
|
||||||
vibrate: [100, 50, 100],
|
|
||||||
data: {
|
|
||||||
dateOfArrival: Date.now(),
|
|
||||||
primaryKey: '2',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
event.waitUntil(self.registration.showNotification(data.title, options))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', function (event) {
|
|
||||||
console.log('Notification click received.')
|
|
||||||
event.notification.close()
|
|
||||||
event.waitUntil(clients.openWindow('/'))
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user