mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 20:09:50 +01:00
Compare commits
26 Commits
v0.2.15.0
...
1b17d6b50a
| Author | SHA1 | Date | |
|---|---|---|---|
|
1b17d6b50a
|
|||
|
8269f3adad
|
|||
|
4cadf4cea7
|
|||
|
06e802f2f5
|
|||
|
6c0b196de2
|
|||
|
0f073760ee
|
|||
|
55c2e3577d
|
|||
|
043201217f
|
|||
|
4e11f17729
|
|||
|
faa6f4cb76
|
|||
|
84d6321153
|
|||
|
1af98fb233
|
|||
|
|
8d2bfaf62c | ||
|
9046d40a7a
|
|||
|
be0a5c48b3
|
|||
|
|
98b5d5eebb | ||
|
|
276e8a8a7b | ||
|
|
1967d154ed | ||
|
|
9e0ae1e0da | ||
|
|
5ae659469b | ||
|
|
6ef4aacfb8 | ||
|
|
95203426a3 | ||
|
|
b673d54ede | ||
|
|
42c8d14d6d | ||
|
|
3ac311c3fd | ||
|
|
1a286a99f4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ next-env.d.ts
|
|||||||
Budfile
|
Budfile
|
||||||
certificates
|
certificates
|
||||||
/backups/*
|
/backups/*
|
||||||
|
|
||||||
|
CHANGELOG.md.tmp
|
||||||
|
|||||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -1,5 +1,69 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.2.23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* floating number coin balance (#155)
|
||||||
|
* disable freshness check if browser does not support web crypto (#161)
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
* use transparent background PWA icon with correct text (#103)
|
||||||
|
* display icon in logo
|
||||||
|
|
||||||
|
## Version 0.2.22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* auto check data freshness on interval (#138)
|
||||||
|
* warn about out-of-sync data
|
||||||
|
|
||||||
|
## Version 0.2.21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* emoji picker overlay issue (#150)
|
||||||
|
|
||||||
|
## Version 0.2.20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* coin balance shows correct value for selected user in coin management view (#151)
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
* refactor code to remove client-helpers hook
|
||||||
|
|
||||||
|
## Version 0.2.19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* settings button not working
|
||||||
|
* fixed delete dialog modal blocks page interaction (#149)
|
||||||
|
* disable submit button when frequency is invaid
|
||||||
|
|
||||||
|
## Version 0.2.18
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
* nicer loading UI (#147)
|
||||||
|
* header and navigation code refactor
|
||||||
|
|
||||||
|
## Version 0.2.17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* fix emoji selector (#142)
|
||||||
|
* fix about modal (#145)
|
||||||
|
|
||||||
|
## Version 0.2.16
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
* move delete user button to user form
|
||||||
|
* disable deleting user on demo instance
|
||||||
|
|
||||||
## Version 0.2.15
|
## Version 0.2.15
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -1,12 +1,24 @@
|
|||||||
# HabitTrove
|
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
|
||||||
|
|
||||||
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
||||||
|
|
||||||
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
**⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
||||||
|
|
||||||
## Try the Demo
|
## Differences to Upstream
|
||||||
|
|
||||||
Want to try HabitTrove before installing? Visit the public [demo instance](https://demo.habittrove.com) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
|
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
|
||||||
|
|
||||||
|
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
|
||||||
|
|
||||||
|
Differences (as of writing) are:
|
||||||
|
- resolved linting problems so you can actually commit things
|
||||||
|
- added missing dependency
|
||||||
|
- refactored adding habit modal to cause less errors
|
||||||
|
- resolved undefined error
|
||||||
|
- replaced dockerhub release flow with github
|
||||||
|
- miscellaneous refactorings
|
||||||
|
- split habits & tasks page into two different pages
|
||||||
|
- only display "show all" if there are more than 4 entries
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -21,32 +21,18 @@ import {
|
|||||||
WishlistData,
|
WishlistData,
|
||||||
WishlistItemType
|
WishlistItemType
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { d2t, getNow, uuid } from '@/lib/utils';
|
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
||||||
import { signInSchema } from '@/lib/zod';
|
import { signInSchema } from '@/lib/zod';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||||
type ActionType = 'write' | 'interact'
|
type ActionType = 'write' | 'interact'
|
||||||
|
|
||||||
|
|
||||||
async function verifyPermission(
|
|
||||||
resource: ResourceType,
|
|
||||||
action: ActionType
|
|
||||||
): Promise<void> {
|
|
||||||
// const user = await getCurrentUser()
|
|
||||||
|
|
||||||
// if (!user) throw new PermissionError('User not authenticated')
|
|
||||||
// if (user.isAdmin) return // Admins bypass permission checks
|
|
||||||
|
|
||||||
// if (!checkPermission(user.permissions, resource, action)) {
|
|
||||||
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
|
|
||||||
// }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultData<T>(type: DataType): T {
|
function getDefaultData<T>(type: DataType): T {
|
||||||
return DATA_DEFAULTS[type]() as T;
|
return DATA_DEFAULTS[type]() as T;
|
||||||
}
|
}
|
||||||
@@ -90,7 +76,7 @@ async function loadData<T>(type: DataType): Promise<T> {
|
|||||||
await fs.access(filePath)
|
await fs.access(filePath)
|
||||||
} catch {
|
} catch {
|
||||||
// File doesn't exist, create it with default data
|
// File doesn't exist, create it with default data
|
||||||
const initialData = getDefaultData(type)
|
const initialData = getDefaultData<T>(type)
|
||||||
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
||||||
return initialData as T
|
return initialData as T
|
||||||
}
|
}
|
||||||
@@ -119,10 +105,38 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the server's global freshness token based on all core data files.
|
||||||
|
* This is an expensive operation as it reads all data files.
|
||||||
|
*/
|
||||||
|
async function calculateServerFreshnessToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const [settings, habits, coins, wishlist, users] = await Promise.all([
|
||||||
|
loadSettings(),
|
||||||
|
loadHabitsData(),
|
||||||
|
loadCoinsData(),
|
||||||
|
loadWishlistData(),
|
||||||
|
loadUsersData()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dataString = prepareDataForHashing(
|
||||||
|
settings,
|
||||||
|
habits,
|
||||||
|
coins,
|
||||||
|
wishlist,
|
||||||
|
users
|
||||||
|
);
|
||||||
|
return generateCryptoHash(dataString);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error calculating server freshness token:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wishlist specific functions
|
// Wishlist specific functions
|
||||||
export async function loadWishlistData(): Promise<WishlistData> {
|
export async function loadWishlistData(): Promise<WishlistData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultWishlistData()
|
if (!user) return getDefaultWishlistData<WishlistData>()
|
||||||
|
|
||||||
const data = await loadData<WishlistData>('wishlist')
|
const data = await loadData<WishlistData>('wishlist')
|
||||||
return {
|
return {
|
||||||
@@ -137,7 +151,6 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
||||||
await verifyPermission('wishlist', 'write')
|
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
|
|
||||||
data.items = data.items.map(wishlist => ({
|
data.items = data.items.map(wishlist => ({
|
||||||
@@ -160,17 +173,14 @@ 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()
|
if (!user) return getDefaultHabitsData<HabitsData>()
|
||||||
const data = await loadData<HabitsData>('habits')
|
const data = await loadData<HabitsData>('habits')
|
||||||
return {
|
return {
|
||||||
...data,
|
|
||||||
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||||
await verifyPermission('habit', 'write')
|
|
||||||
|
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
// Create clone of input data
|
// Create clone of input data
|
||||||
const newData = _.cloneDeep(data)
|
const newData = _.cloneDeep(data)
|
||||||
@@ -182,7 +192,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
if (!user?.isAdmin) {
|
if (!user?.isAdmin) {
|
||||||
const existingData = await loadData<HabitsData>('habits')
|
const existingData = await loadHabitsData();
|
||||||
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
||||||
newData.habits = [
|
newData.habits = [
|
||||||
...existingHabits,
|
...existingHabits,
|
||||||
@@ -198,14 +208,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()
|
if (!user) return getDefaultCoinsData<CoinsData>()
|
||||||
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()
|
return getDefaultCoinsData<CoinsData>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,11 +255,10 @@ export async function addCoins({
|
|||||||
note?: string
|
note?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
|
||||||
const currentUser = await getCurrentUser()
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: crypto.randomUUID(),
|
||||||
amount,
|
amount,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
@@ -269,7 +278,7 @@ export async function addCoins({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
const defaultSettings = getDefaultSettings()
|
const defaultSettings = getDefaultSettings<Settings>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
@@ -300,11 +309,10 @@ export async function removeCoins({
|
|||||||
note?: string
|
note?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
|
||||||
const currentUser = await getCurrentUser()
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: crypto.randomUUID(),
|
||||||
amount: -amount,
|
amount: -amount,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
@@ -362,7 +370,7 @@ export async function loadUsersData(): Promise<UserData> {
|
|||||||
try {
|
try {
|
||||||
return await loadData<UserData>('auth')
|
return await loadData<UserData>('auth')
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultUsersData()
|
return getDefaultUsersData<UserData>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +414,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
|
|
||||||
|
|
||||||
const newUser: User = {
|
const newUser: User = {
|
||||||
id: uuid(),
|
id: crypto.randomUUID(),
|
||||||
username,
|
username,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
permissions,
|
permissions,
|
||||||
@@ -591,3 +599,24 @@ export async function loadServerSettings(): Promise<ServerSettings> {
|
|||||||
isDemo: !!process.env.DEMO,
|
isDemo: !!process.env.DEMO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the client's data is fresh by comparing its token with the server's token.
|
||||||
|
* @param clientToken The freshness token calculated by the client.
|
||||||
|
* @returns A promise that resolves to an object { isFresh: boolean }.
|
||||||
|
*/
|
||||||
|
export async function checkDataFreshness(clientToken: string): Promise<{ isFresh: boolean }> {
|
||||||
|
try {
|
||||||
|
const serverToken = await calculateServerFreshnessToken();
|
||||||
|
const isFresh = clientToken === serverToken;
|
||||||
|
if (!isFresh) {
|
||||||
|
console.log(`Data freshness check: Stale. Client token: ${clientToken}, Server token: ${serverToken}`);
|
||||||
|
}
|
||||||
|
return { isFresh };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in checkDataFreshness:", error);
|
||||||
|
// If server fails to determine its token, assume client might be stale to be safe,
|
||||||
|
// or handle error reporting differently.
|
||||||
|
return { isFresh: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||||
import { JotaiProvider } from '@/components/jotai-providers'
|
import { JotaiProvider } from '@/components/jotai-providers'
|
||||||
import Layout from '@/components/Layout'
|
import Layout from '@/components/Layout'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { SessionProvider } from 'next-auth/react'
|
import { SessionProvider } from 'next-auth/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { getLocale, getMessages } from 'next-intl/server'
|
||||||
import { DM_Sans } from 'next/font/google'
|
import { DM_Sans } from 'next/font/google'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
|
||||||
import { getLocale, getMessages } from 'next-intl/server';
|
|
||||||
|
|
||||||
// Inter (clean, modern, excellent readability)
|
|
||||||
// const inter = Inter({
|
|
||||||
// subsets: ['latin'],
|
|
||||||
// weight: ['400', '500', '600', '700']
|
|
||||||
// })
|
|
||||||
|
|
||||||
// Clean and contemporary
|
// Clean and contemporary
|
||||||
const dmSans = DM_Sans({
|
const activeFont = DM_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '500', '600', '700']
|
weight: ['400', '500', '600', '700']
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeFont = dmSans
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'HabitTrove',
|
title: 'HabitTrove',
|
||||||
description: 'Track your habits and get rewarded',
|
description: 'Track your habits and get rewarded',
|
||||||
@@ -73,7 +66,7 @@ export default async function RootLayout({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<Suspense fallback="loading">
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<JotaiHydrate
|
<JotaiHydrate
|
||||||
initialValues={{
|
initialValues={{
|
||||||
settings: initialSettings,
|
settings: initialSettings,
|
||||||
|
|||||||
@@ -11,17 +11,16 @@ import ChangelogModal from "./ChangelogModal"
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
interface AboutModalProps {
|
interface AboutModalProps {
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
export default function AboutModal({ onClose }: AboutModalProps) {
|
||||||
const t = useTranslations('AboutModal')
|
const t = useTranslations('AboutModal')
|
||||||
const version = packageJson.version
|
const version = packageJson.version
|
||||||
const [changelogOpen, setChangelogOpen] = useState(false)
|
const [changelogOpen, setChangelogOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
|
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
|
||||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
|
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
||||||
import data from '@emoji-mart/data'
|
|
||||||
import Picker from '@emoji-mart/react'
|
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { SmilePlus, Zap } from 'lucide-react'
|
import { Zap } from 'lucide-react'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { RRule } from 'rrule'
|
import { RRule } from 'rrule'
|
||||||
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
import ModalOverlay from './ModalOverlay'; // Import the new component
|
||||||
|
|
||||||
|
|
||||||
interface AddEditHabitModalProps {
|
interface AddEditHabitModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -43,7 +43,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
timezone: settings.system.timezone
|
timezone: settings.system.timezone
|
||||||
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||||
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||||
const { currentUser } = useHelpers()
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
@@ -89,8 +89,10 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<>
|
||||||
<DialogContent>
|
<ModalOverlay />
|
||||||
|
<Dialog open={true} onOpenChange={onClose} modal={false}>
|
||||||
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{habit
|
{habit
|
||||||
@@ -111,33 +113,15 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Popover>
|
<EmojiPickerButton
|
||||||
<PopoverTrigger asChild>
|
inputIdToFocus="name"
|
||||||
<Button
|
onEmojiSelect={(emoji) => {
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<SmilePlus className="h-8 w-8" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[300px] p-0">
|
|
||||||
<Picker
|
|
||||||
data={data}
|
|
||||||
onEmojiSelect={(emoji: { native: string }) => {
|
|
||||||
setName(prev => {
|
setName(prev => {
|
||||||
// Add space before emoji if there isn't one already
|
|
||||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||||
return `${prev}${space}${emoji.native}`;
|
return `${prev}${space}${emoji}`;
|
||||||
})
|
})
|
||||||
// Focus back on input after selection
|
|
||||||
const input = document.getElementById('name') as HTMLInputElement
|
|
||||||
input?.focus()
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
@@ -325,7 +309,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit">
|
<Button type="submit" disabled={!!errorMessage}>
|
||||||
{habit
|
{habit
|
||||||
? t('saveChangesButton')
|
? t('saveChangesButton')
|
||||||
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
||||||
@@ -334,6 +318,6 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { usersAtom } from '@/lib/atoms'
|
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
import data from '@emoji-mart/data'
|
|
||||||
import Picker from '@emoji-mart/react'
|
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { SmilePlus } from 'lucide-react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
import ModalOverlay from './ModalOverlay'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
|
||||||
interface AddEditWishlistItemModalProps {
|
interface AddEditWishlistItemModalProps {
|
||||||
isOpen: boolean
|
|
||||||
setIsOpen: (isOpen: boolean) => void
|
setIsOpen: (isOpen: boolean) => void
|
||||||
editingItem: WishlistItemType | null
|
editingItem: WishlistItemType | null
|
||||||
setEditingItem: (item: WishlistItemType | null) => void
|
setEditingItem: (item: WishlistItemType | null) => void
|
||||||
@@ -27,7 +22,6 @@ interface AddEditWishlistItemModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddEditWishlistItemModal({
|
export default function AddEditWishlistItemModal({
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
editingItem,
|
editingItem,
|
||||||
setEditingItem,
|
setEditingItem,
|
||||||
@@ -40,7 +34,7 @@ export default function AddEditWishlistItemModal({
|
|||||||
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
|
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
|
||||||
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
|
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
|
||||||
const [link, setLink] = useState(editingItem?.link || '')
|
const [link, setLink] = useState(editingItem?.link || '')
|
||||||
const { currentUser } = useHelpers()
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
@@ -120,8 +114,10 @@ export default function AddEditWishlistItemModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<>
|
||||||
<DialogContent>
|
<ModalOverlay />
|
||||||
|
<Dialog open={true} onOpenChange={handleClose} modal={false}>
|
||||||
|
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -139,29 +135,15 @@ export default function AddEditWishlistItemModal({
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Popover>
|
<EmojiPickerButton
|
||||||
<PopoverTrigger asChild>
|
inputIdToFocus="name"
|
||||||
<Button
|
onEmojiSelect={(emoji) => {
|
||||||
type="button"
|
setName(prev => {
|
||||||
variant="ghost"
|
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||||
size="icon"
|
return `${prev}${space}${emoji}`;
|
||||||
className="h-8 w-8"
|
})
|
||||||
>
|
|
||||||
<SmilePlus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[300px] p-0">
|
|
||||||
<Picker
|
|
||||||
data={data}
|
|
||||||
onEmojiSelect={(emoji: { native: string }) => {
|
|
||||||
setName(prev => `${prev}${emoji.native}`)
|
|
||||||
// Focus back on input after selection
|
|
||||||
const input = document.getElementById('name') as HTMLInputElement
|
|
||||||
input?.focus()
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
@@ -324,6 +306,7 @@ export default function AddEditWishlistItemModal({
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ReactNode, useEffect } from 'react'
|
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
|
||||||
import { useAtom } from 'jotai'
|
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
|
||||||
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import PomodoroTimer from './PomodoroTimer'
|
import { useSession } from 'next-auth/react';
|
||||||
import UserSelectModal from './UserSelectModal'
|
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
import { useSession } from 'next-auth/react'
|
import AboutModal from './AboutModal';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
import PomodoroTimer from './PomodoroTimer';
|
||||||
|
import RefreshBanner from './RefreshBanner';
|
||||||
|
import UserSelectModal from './UserSelectModal';
|
||||||
|
|
||||||
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
function ClientWrapperContent({ children }: { children: ReactNode }) {
|
||||||
const [pomo] = useAtom(pomodoroAtom)
|
const [pomo] = useAtom(pomodoroAtom)
|
||||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||||
|
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||||
|
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const currentUserId = session?.user.id
|
const currentUserId = session?.user.id
|
||||||
|
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
|
||||||
|
|
||||||
|
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
|
||||||
|
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
|
||||||
|
const clientToken = useAtomValue(clientFreshnessTokenAtom);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'loading') return
|
if (status === 'loading') return
|
||||||
@@ -20,15 +32,66 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [currentUserId, status, userSelect, setUserSelect])
|
}, [currentUserId, status, userSelect, setUserSelect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentUserIdAtom(currentUserId)
|
||||||
|
}, [currentUserId, setCurrentUserIdAtom])
|
||||||
|
|
||||||
|
const performFreshnessCheck = useCallback(async () => {
|
||||||
|
if (!clientToken || status !== 'authenticated') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await checkServerDataFreshness(clientToken);
|
||||||
|
if (!result.isFresh) {
|
||||||
|
setShowRefreshBanner(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check data freshness with server:", error);
|
||||||
|
}
|
||||||
|
}, [clientToken, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Interval for polling data freshness
|
||||||
|
if (clientToken && !showRefreshBanner && status === 'authenticated') {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
performFreshnessCheck();
|
||||||
|
}, 30000); // Check every 30 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
}, [clientToken, performFreshnessCheck, showRefreshBanner, status]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setShowRefreshBanner(false);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{pomo.show && (
|
{pomo.show && <PomodoroTimer />}
|
||||||
<PomodoroTimer />
|
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
|
||||||
)}
|
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
||||||
{userSelect && (
|
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
|
||||||
<UserSelectModal onClose={() => setUserSelect(false)}/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load),
|
||||||
|
// to prevent SSR hydration errors in the children components
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<ClientWrapperContent>{children}</ClientWrapperContent>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
|
||||||
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 { d2s, t2d } from '@/lib/utils'
|
||||||
@@ -22,7 +21,7 @@ import { TransactionNoteEditor } from './TransactionNoteEditor'
|
|||||||
|
|
||||||
export default function CoinsManager() {
|
export default function CoinsManager() {
|
||||||
const t = useTranslations('CoinsManager')
|
const t = useTranslations('CoinsManager')
|
||||||
const { currentUser } = useHelpers()
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const [selectedUser, setSelectedUser] = useState<string>()
|
const [selectedUser, setSelectedUser] = useState<string>()
|
||||||
const {
|
const {
|
||||||
add,
|
add,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import ConfirmDialog from './ConfirmDialog'
|
|||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
|
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -165,7 +166,7 @@ const ItemSection = ({
|
|||||||
const bTarget = b.targetCompletions || 1;
|
const bTarget = b.targetCompletions || 1;
|
||||||
return bTarget - aTarget;
|
return bTarget - aTarget;
|
||||||
})
|
})
|
||||||
.slice(0, currentExpanded ? undefined : 5)
|
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||||
.map((habit) => {
|
.map((habit) => {
|
||||||
const completionsToday = habit.completions.filter(completion =>
|
const completionsToday = habit.completions.filter(completion =>
|
||||||
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||||
@@ -295,7 +296,7 @@ const ItemSection = ({
|
|||||||
onClick={() => setCurrentExpanded(!currentExpanded)}
|
onClick={() => setCurrentExpanded(!currentExpanded)}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{currentExpanded ? (
|
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
|
||||||
<>
|
<>
|
||||||
{t('showLessButton')}
|
{t('showLessButton')}
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -305,7 +306,7 @@ const ItemSection = ({
|
|||||||
{t('showAllButton')}
|
{t('showAllButton')}
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
)}
|
))}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={viewLink}
|
href={viewLink}
|
||||||
@@ -444,7 +445,7 @@ export default function DailyOverview({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sortedWishlistItems
|
{sortedWishlistItems
|
||||||
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
|
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const isRedeemable = item.coinCost <= coinBalance
|
const isRedeemable = item.coinCost <= coinBalance
|
||||||
return (
|
return (
|
||||||
@@ -501,7 +502,7 @@ export default function DailyOverview({
|
|||||||
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{browserSettings.expandedWishlist ? (
|
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
|
||||||
<>
|
<>
|
||||||
{t('showLessButton')}
|
{t('showLessButton')}
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -511,7 +512,7 @@ export default function DailyOverview({
|
|||||||
{t('showAllButton')}
|
{t('showAllButton')}
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
)}
|
))}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/wishlist"
|
href="/wishlist"
|
||||||
|
|||||||
51
components/EmojiPickerButton.tsx
Normal file
51
components/EmojiPickerButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { SmilePlus } from 'lucide-react'
|
||||||
|
import data from '@emoji-mart/data'
|
||||||
|
import Picker from '@emoji-mart/react'
|
||||||
|
|
||||||
|
interface EmojiPickerButtonProps {
|
||||||
|
onEmojiSelect: (emoji: string) => void
|
||||||
|
inputIdToFocus?: string // Optional: ID of the input to focus after selection
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: EmojiPickerButtonProps) {
|
||||||
|
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover modal={false} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8" // Consistent sizing
|
||||||
|
>
|
||||||
|
<SmilePlus className="h-4 w-4" /> {/* Consistent icon size */}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[300px] p-0"
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (inputIdToFocus) {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = document.getElementById(inputIdToFocus) as HTMLInputElement;
|
||||||
|
input?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={(emoji: { native: string }) => {
|
||||||
|
onEmojiSelect(emoji.native);
|
||||||
|
setIsEmojiPickerOpen(false);
|
||||||
|
// Focus is handled by onCloseAutoFocus
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
import { useHabits } from '@/hooks/useHabits';
|
import { useHabits } from '@/hooks/useHabits';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { pomodoroAtom, settingsAtom } from '@/lib/atoms';
|
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
|
||||||
import { d2t, getNow, isHabitDueToday } from '@/lib/utils';
|
import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
|
||||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||||
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
||||||
import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface HabitContextMenuItemsProps {
|
interface HabitContextMenuItemsProps {
|
||||||
@@ -28,10 +27,10 @@ export function HabitContextMenuItems({
|
|||||||
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
||||||
const [settings] = useAtom(settingsAtom);
|
const [settings] = useAtom(settingsAtom);
|
||||||
const [, setPomo] = useAtom(pomodoroAtom);
|
const [, setPomo] = useAtom(pomodoroAtom);
|
||||||
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
||||||
const canInteract = hasPermission('habit', 'interact');
|
const canInteract = hasPermission(currentUser, 'habit', 'interact');
|
||||||
|
|
||||||
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
|
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
|
||||||
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
|
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
|
||||||
import { Habit, User } from '@/lib/types'
|
import { Habit, User } from '@/lib/types'
|
||||||
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
|
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'; // Removed unused icons
|
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -45,7 +44,7 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: {
|
|||||||
|
|
||||||
|
|
||||||
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||||
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
||||||
const target = habit.targetCompletions || 1
|
const target = habit.targetCompletions || 1
|
||||||
@@ -53,10 +52,10 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
const t = useTranslations('HabitItem');
|
const t = useTranslations('HabitItem');
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const { currentUser, hasPermission } = useHelpers()
|
|
||||||
const canWrite = hasPermission('habit', 'write')
|
|
||||||
const canInteract = hasPermission('habit', 'interact')
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
|
const canWrite = hasPermission(currentUser, 'habit', 'write')
|
||||||
|
const canInteract = hasPermission(currentUser, 'habit', 'interact')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
@@ -107,11 +106,13 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-1">
|
||||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||||
{t('whenLabel', { frequency: convertMachineReadableFrequencyToHumanReadable({
|
{t('whenLabel', {
|
||||||
|
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||||
frequency: habit.frequency,
|
frequency: habit.frequency,
|
||||||
isRecurRule: pathname.includes("habits"),
|
isRecurRule: pathname.includes("habits"),
|
||||||
timezone: settings.system.timezone
|
timezone: settings.system.timezone
|
||||||
})})}
|
})
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center mt-2">
|
<div className="flex items-center mt-2">
|
||||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||||
@@ -184,7 +185,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
<span className="ml-2">{t('editButton')}</span>
|
<span className="ml-2">{t('editButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { Coins } from 'lucide-react'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import NotificationBell from './NotificationBell'
|
import HeaderActions from './HeaderActions'
|
||||||
import { Profile } from './Profile'
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
|
||||||
|
|
||||||
export default function Header({ className }: HeaderProps) {
|
export default function Header({ className }: HeaderProps) {
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
const { balance } = useCoins()
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
||||||
@@ -28,23 +16,7 @@ export default function Header({ className }: HeaderProps) {
|
|||||||
<Link href="/" className="mr-3 sm:mr-4">
|
<Link href="/" className="mr-3 sm:mr-4">
|
||||||
<Logo />
|
<Logo />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<HeaderActions />
|
||||||
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
|
||||||
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
|
|
||||||
<div className="flex items-baseline gap-1 sm:gap-2">
|
|
||||||
<FormattedNumber
|
|
||||||
amount={balance}
|
|
||||||
settings={settings}
|
|
||||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
|
||||||
/>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<TodayEarnedCoins />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<NotificationBell />
|
|
||||||
<Profile />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
38
components/HeaderActions.tsx
Normal file
38
components/HeaderActions.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
|
import { Coins } from 'lucide-react'
|
||||||
|
import NotificationBell from './NotificationBell'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { Profile } from './Profile'
|
||||||
|
|
||||||
|
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||||
|
|
||||||
|
export default function HeaderActions() {
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const { balance } = useCoins()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
||||||
|
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
|
||||||
|
<div className="flex items-baseline gap-1 sm:gap-2">
|
||||||
|
<FormattedNumber
|
||||||
|
amount={balance}
|
||||||
|
settings={settings}
|
||||||
|
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
||||||
|
/>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<TodayEarnedCoins />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<NotificationBell />
|
||||||
|
<Profile />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||||
<Header className="sticky top-0 z-50" />
|
<Header className="sticky top-0 z-50" />
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<Navigation viewPort='main' />
|
<Navigation position='main' />
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||||
{/* responsive container (optimized for mobile) */}
|
{/* responsive container (optimized for mobile) */}
|
||||||
@@ -17,7 +17,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</ClientWrapper>
|
</ClientWrapper>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Navigation viewPort='mobile' />
|
<Navigation position='mobile' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
61
components/LoadingSpinner.tsx
Normal file
61
components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Coins } from 'lucide-react';
|
||||||
|
import { Logo } from '@/components/Logo';
|
||||||
|
|
||||||
|
const subtexts = [
|
||||||
|
"Unearthing your treasures",
|
||||||
|
"Polishing your gems",
|
||||||
|
"Mining for good habits",
|
||||||
|
"Stumbling upon brilliance",
|
||||||
|
"Discovering your potential",
|
||||||
|
"Crafting your success story",
|
||||||
|
"Forging new paths",
|
||||||
|
"Summoning success",
|
||||||
|
"Brewing brilliance",
|
||||||
|
"Charging up your awesome",
|
||||||
|
"Assembling achievements",
|
||||||
|
"Leveling up your day",
|
||||||
|
"Questing for quality",
|
||||||
|
"Unlocking awesomeness",
|
||||||
|
"Plotting your progress",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LoadingSpinner: React.FC = () => {
|
||||||
|
const [currentSubtext, setCurrentSubtext] = useState<string>('Loading your data');
|
||||||
|
const [animatedDots, setAnimatedDots] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const randomIndex = Math.floor(Math.random() * subtexts.length);
|
||||||
|
setCurrentSubtext(subtexts[randomIndex]);
|
||||||
|
|
||||||
|
const dotAnimationInterval = setInterval(() => {
|
||||||
|
setAnimatedDots(prevDots => {
|
||||||
|
if (prevDots.length >= 3) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return prevDots + '.';
|
||||||
|
});
|
||||||
|
}, 200); // Adjust timing as needed
|
||||||
|
|
||||||
|
return () => clearInterval(dotAnimationInterval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<Coins className="h-12 w-12 animate-bounce text-yellow-500" />
|
||||||
|
<Logo />
|
||||||
|
{currentSubtext && (
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
{currentSubtext}{animatedDots}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* <Sparkles className="h-6 w-6 text-primary" /> */}
|
<Image src="/icons/icon.png" alt="HabitTrove Logo" width={96} height={96} className="h-12 w-12 hidden xs:inline" />
|
||||||
<span className="font-bold text-xl">HabitTrove</span>
|
<span className="font-bold text-xl">HabitTrove</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
9
components/ModalOverlay.tsx
Normal file
9
components/ModalOverlay.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* ModalOverlay provides a dimming effect for non-modal dialogs or popovers
|
||||||
|
* that need to appear modal (e.g., to prevent interaction with background elements).
|
||||||
|
* It should be rendered alongside the dialog/popover it's intended to overlay for.
|
||||||
|
* Ensure the dialog/popover has a z-index higher than this overlay (default z-40).
|
||||||
|
*/
|
||||||
|
export default function ModalOverlay() {
|
||||||
|
return <div className="fixed inset-0 z-50 bg-black/80" />
|
||||||
|
}
|
||||||
61
components/NavDisplay.tsx
Normal file
61
components/NavDisplay.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useHelpers } from '@/lib/client-helpers';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { NavItemType } from './Navigation';
|
||||||
|
|
||||||
|
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isIOS } = useHelpers()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)}
|
||||||
|
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||||
|
<div className="grid grid-cols-6 w-full">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
||||||
|
(pathname === (item.href) ?
|
||||||
|
"text-blue-500 dark:text-blue-500" :
|
||||||
|
"text-gray-300 dark:text-gray-300")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-6 w-6" />
|
||||||
|
<span className="text-xs mt-1">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||||
|
<div className="flex flex-col w-64">
|
||||||
|
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||||
|
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
|
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
||||||
|
(pathname === (item.href) ?
|
||||||
|
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
||||||
|
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,104 +1,40 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import { ElementType, useEffect, useState } from 'react'
|
||||||
import { usePathname } from 'next/navigation'
|
import NavDisplay from './NavDisplay'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import AboutModal from './AboutModal'
|
|
||||||
|
|
||||||
type ViewPort = 'main' | 'mobile'
|
export interface NavItemType {
|
||||||
|
icon: ElementType;
|
||||||
interface NavigationProps {
|
label: string;
|
||||||
viewPort: ViewPort
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navigation({ viewPort }: NavigationProps) {
|
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
|
||||||
const t = useTranslations('Navigation')
|
const t = useTranslations('Navigation');
|
||||||
const [showAbout, setShowAbout] = useState(false)
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
|
||||||
const [isMobileView, setIsMobileView] = useState(false)
|
|
||||||
const { isIOS } = useHelpers()
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
const navItems = () => [
|
|
||||||
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
|
|
||||||
{ icon: HabitIcon, label: t('habits'), href: '/habits', position: 'main' },
|
|
||||||
{ icon: TaskIcon, label: t('tasks'), href: '/tasks', position: 'main' },
|
|
||||||
{ icon: Calendar, label: t('calendar'), href: '/calendar', position: 'main' },
|
|
||||||
{ icon: Gift, label: t('wishlist'), href: '/wishlist', position: 'main' },
|
|
||||||
{ icon: Coins, label: t('coins'), href: '/coins', position: 'main' },
|
|
||||||
]
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
|
||||||
setIsMobileView(window.innerWidth < 1024)
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, [setIsMobile]);
|
||||||
|
|
||||||
|
const currentNavItems: NavItemType[] = [
|
||||||
|
{ icon: Home, label: t('dashboard'), href: '/' },
|
||||||
|
{ icon: HabitIcon, label: t('habits'), href: '/habits' },
|
||||||
|
{ icon: TaskIcon, label: t('tasks'), href: '/tasks' },
|
||||||
|
{ icon: Calendar, label: t('calendar'), href: '/calendar' },
|
||||||
|
{ icon: Gift, label: t('wishlist'), href: '/wishlist' },
|
||||||
|
{ icon: Coins, label: t('coins'), href: '/coins' },
|
||||||
|
]
|
||||||
|
|
||||||
|
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
|
||||||
|
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
// Set initial value
|
return <></>
|
||||||
handleResize()
|
|
||||||
|
|
||||||
// Add event listener
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (viewPort === 'mobile' && isMobileView) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
|
|
||||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
|
||||||
<div className="grid grid-cols-6 w-full">
|
|
||||||
{...navItems().map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.label}
|
|
||||||
href={item.href}
|
|
||||||
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
|
||||||
(pathname === (item.href) ?
|
|
||||||
"text-blue-500 dark:text-blue-500" :
|
|
||||||
"text-gray-300 dark:text-gray-300")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<item.icon className="h-6 w-6" />
|
|
||||||
<span className="text-xs mt-1">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewPort === 'main' && !isMobileView) {
|
|
||||||
return (
|
|
||||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
|
||||||
<div className="flex flex-col w-64">
|
|
||||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
|
||||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
|
||||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
|
||||||
{navItems().filter(item => item.position === 'main').map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.label}
|
|
||||||
href={item.href}
|
|
||||||
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
|
||||||
(pathname === (item.href) ?
|
|
||||||
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
|
||||||
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
|
||||||
>
|
|
||||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
|
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
||||||
import { Bell } from 'lucide-react';
|
import { Bell } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
@@ -14,12 +14,11 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
||||||
import { d2t, getNow, t2d } from '@/lib/utils';
|
import { d2t, getNow, t2d } from '@/lib/utils';
|
||||||
import { useHelpers } from '@/lib/client-helpers';
|
|
||||||
import { User, CoinTransaction } from '@/lib/types';
|
import { User, CoinTransaction } from '@/lib/types';
|
||||||
|
|
||||||
export default function NotificationBell() {
|
export default function NotificationBell() {
|
||||||
const t = useTranslations('NotificationBell');
|
const t = useTranslations('NotificationBell');
|
||||||
const { currentUser } = useHelpers();
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [coinsData] = useAtom(coinsAtom)
|
const [coinsData] = useAtom(coinsAtom)
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
const [wishlistData] = useAtom(wishlistAtom)
|
const [wishlistData] = useAtom(wishlistAtom)
|
||||||
@@ -122,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 useHelpers can return undefined initially
|
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
|
||||||
unreadNotifications={unreadNotifications}
|
unreadNotifications={unreadNotifications}
|
||||||
displayedReadNotifications={displayedReadNotifications}
|
displayedReadNotifications={displayedReadNotifications}
|
||||||
habitsData={habitsData} // Pass necessary data down
|
habitsData={habitsData} // Pass necessary data down
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { toast } from "@/hooks/use-toast"
|
import { toast } from "@/hooks/use-toast"
|
||||||
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
|
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||||
import { useHelpers } from "@/lib/client-helpers"
|
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import AboutModal from "./AboutModal"
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||||
import UserForm from './UserForm'
|
import UserForm from './UserForm'
|
||||||
|
|
||||||
@@ -22,9 +20,9 @@ export function Profile() {
|
|||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [showAbout, setShowAbout] = useState(false)
|
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const { currentUser: user } = useHelpers()
|
const [user] = useAtom(currentUserAtom)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
@@ -67,7 +65,7 @@ export function Profile() {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col mr-4">
|
<div className="flex flex-col mr-4">
|
||||||
<span className="text-sm font-semibold flex items-center gap-1">
|
<span className="text-sm font-semibold flex items-center gap-1 break-all">
|
||||||
{user?.username || t('guestUsername')}
|
{user?.username || t('guestUsername')}
|
||||||
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
||||||
</span>
|
</span>
|
||||||
@@ -111,27 +109,33 @@ export function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||||
|
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
aria-label={t('settingsLink')}
|
aria-label={t('settingsLink')}
|
||||||
className="flex items-center w-full gap-3"
|
className="flex items-center justify-between w-full"
|
||||||
|
onClick={() => setOpen(false)} // Ensure dropdown closes on click
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
<span>{t('settingsLink')}</span>
|
<span>{t('settingsLink')}</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
|
||||||
<button
|
setOpen(false); // Close the dropdown
|
||||||
onClick={() => setShowAbout(true)}
|
setAboutOpen(true); // Open the about modal
|
||||||
className="flex items-center w-full gap-3"
|
}}>
|
||||||
>
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<span>{t('aboutButton')}</span>
|
<span>{t('aboutButton')}</span>
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
||||||
<div className="flex items-center justify-between w-full gap-3">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<Palette className="h-4 w-4" />
|
<Palette className="h-4 w-4" />
|
||||||
<span>{t('themeLabel')}</span>
|
<span>{t('themeLabel')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,8 +173,6 @@ export function Profile() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
|
||||||
|
|
||||||
{/* Add the UserForm dialog */}
|
{/* Add the UserForm dialog */}
|
||||||
{isEditing && user && (
|
{isEditing && user && (
|
||||||
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
||||||
|
|||||||
27
components/RefreshBanner.tsx
Normal file
27
components/RefreshBanner.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { AlertTriangle } from "lucide-react"
|
||||||
|
|
||||||
|
interface RefreshBannerProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RefreshBanner({ onRefresh }: RefreshBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[100] bg-yellow-400 dark:bg-yellow-500 text-black dark:text-gray-900 p-4 rounded-lg shadow-lg flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Data out of sync</p>
|
||||||
|
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRefresh}
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700 border-yellow-600 dark:border-yellow-700 text-white dark:text-gray-900"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
import { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||||
import { useHelpers } from '@/lib/client-helpers';
|
|
||||||
import { Permission } from '@/lib/types';
|
import { Permission } from '@/lib/types';
|
||||||
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
@@ -18,6 +28,7 @@ import { Input } from './ui/input';
|
|||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Switch } from './ui/switch';
|
import { Switch } from './ui/switch';
|
||||||
|
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
userId?: string; // if provided, we're editing; if not, we're creating
|
userId?: string; // if provided, we're editing; if not, we're creating
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -29,7 +40,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
const [users, setUsersData] = useAtom(usersAtom);
|
const [users, setUsersData] = useAtom(usersAtom);
|
||||||
const serverSettings = useAtomValue(serverSettingsAtom)
|
const serverSettings = useAtomValue(serverSettingsAtom)
|
||||||
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
||||||
const { currentUser } = useHelpers()
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const getDefaultPermissions = (): Permission[] => [{
|
const getDefaultPermissions = (): Permission[] => [{
|
||||||
habit: {
|
habit: {
|
||||||
write: true,
|
write: true,
|
||||||
@@ -57,6 +68,69 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
);
|
);
|
||||||
const isEditing = !!user;
|
const isEditing = !!user;
|
||||||
|
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (serverSettings.isDemo) {
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('toastDemoDeleteDisabled'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser && currentUser.id === user.id) {
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('toastCannotDeleteSelf'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: user.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setUsersData(prev => ({
|
||||||
|
...prev,
|
||||||
|
users: prev.users.filter(u => u.id !== user.id),
|
||||||
|
}));
|
||||||
|
toast({
|
||||||
|
title: t('toastUserDeletedTitle'),
|
||||||
|
description: t('toastUserDeletedDescription', { username: user.username }),
|
||||||
|
variant: 'default'
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: errorData.error || t('genericError'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('errorTitle'),
|
||||||
|
description: t('networkError'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -274,6 +348,38 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
{isEditing && (
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
className="mr-auto"
|
||||||
|
disabled={serverSettings.isDemo || isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? t('deletingButtonText') : t('deleteAccountButton')}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('deleteUserConfirmation', { username: user.username })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteUser}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { signIn } from '@/app/actions/user';
|
import { signIn } from '@/app/actions/user';
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { usersAtom } from '@/lib/atoms';
|
import { currentUserAtom, usersAtom } from '@/lib/atoms';
|
||||||
import { useHelpers } from '@/lib/client-helpers';
|
|
||||||
import { SafeUser, User } from '@/lib/types';
|
import { SafeUser, User } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Description } from '@radix-ui/react-dialog';
|
import { Description } from '@radix-ui/react-dialog';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { Crown, Plus, Trash2, User as UserIcon, UserRoundPen } from 'lucide-react';
|
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import PasswordEntryForm from './PasswordEntryForm';
|
import PasswordEntryForm from './PasswordEntryForm';
|
||||||
@@ -33,62 +21,19 @@ function UserCard({
|
|||||||
onEdit,
|
onEdit,
|
||||||
showEdit,
|
showEdit,
|
||||||
isCurrentUser,
|
isCurrentUser,
|
||||||
currentLoggedInUserId, // For "don't delete self" check
|
|
||||||
onUserDeleted // Callback to update usersAtom
|
|
||||||
}: {
|
}: {
|
||||||
user: User,
|
user: User,
|
||||||
onSelect: () => void,
|
onSelect: () => void,
|
||||||
onEdit: () => void,
|
onEdit: () => void,
|
||||||
showEdit: boolean,
|
showEdit: boolean,
|
||||||
isCurrentUser: boolean,
|
isCurrentUser: boolean,
|
||||||
currentLoggedInUserId?: string,
|
|
||||||
onUserDeleted: (userId: string) => void,
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('UserSelectModal');
|
const t = useTranslations('UserSelectModal');
|
||||||
const tWarning = useTranslations('Warning');
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
const handleDeleteUser = async () => {
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user/delete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ userId: user.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
toast({
|
|
||||||
title: t('deleteUserSuccessTitle'),
|
|
||||||
description: t('deleteUserSuccessDescription', { username: user.username }),
|
|
||||||
});
|
|
||||||
onUserDeleted(user.id);
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json();
|
|
||||||
toast({
|
|
||||||
title: t('deleteUserErrorTitle'),
|
|
||||||
description: errorData.error || t('genericError'),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t('deleteUserErrorTitle'),
|
|
||||||
description: t('networkError'),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={user.id} className="relative group">
|
<div key={user.id} className="relative group">
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={isDeleting} // Disable main button while deleting this user
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
|
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
|
||||||
isCurrentUser && "ring-2 ring-primary"
|
isCurrentUser && "ring-2 ring-primary"
|
||||||
@@ -116,48 +61,12 @@ function UserCard({
|
|||||||
e.stopPropagation(); // Prevent card selection
|
e.stopPropagation(); // Prevent card selection
|
||||||
onEdit();
|
onEdit();
|
||||||
}}
|
}}
|
||||||
disabled={isDeleting}
|
|
||||||
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||||
title={t('editUserTooltip')}
|
title={t('editUserTooltip')}
|
||||||
>
|
>
|
||||||
<UserRoundPen className="h-4 w-4" />
|
<UserRoundPen className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{showEdit && user.id !== currentLoggedInUserId && (
|
|
||||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation(); // Prevent card selection
|
|
||||||
setShowDeleteConfirm(true);
|
|
||||||
}}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="p-1 rounded-full bg-red-200 hover:bg-red-300 dark:bg-red-700 dark:hover:bg-red-600 transition-colors text-red-600 dark:text-red-300"
|
|
||||||
title={t('deleteUserTooltip')}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{tWarning('areYouSure')}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t('deleteUserConfirmation', { username: user.username })}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(false);}} disabled={isDeleting}>{tWarning('cancel')}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteUser();}}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,14 +96,12 @@ function UserSelectionView({
|
|||||||
onUserSelect,
|
onUserSelect,
|
||||||
onEditUser,
|
onEditUser,
|
||||||
onCreateUser,
|
onCreateUser,
|
||||||
onUserDeleted, // Pass through the delete handler
|
|
||||||
}: {
|
}: {
|
||||||
users: User[],
|
users: User[],
|
||||||
currentUserFromHook?: SafeUser,
|
currentUserFromHook?: SafeUser,
|
||||||
onUserSelect: (userId: string) => void,
|
onUserSelect: (userId: string) => void,
|
||||||
onEditUser: (userId: string) => void,
|
onEditUser: (userId: string) => void,
|
||||||
onCreateUser: () => void,
|
onCreateUser: () => void,
|
||||||
onUserDeleted: (userId: string) => void,
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||||
@@ -208,8 +115,6 @@ function UserSelectionView({
|
|||||||
onEdit={() => onEditUser(user.id)}
|
onEdit={() => onEditUser(user.id)}
|
||||||
showEdit={!!currentUserFromHook?.isAdmin}
|
showEdit={!!currentUserFromHook?.isAdmin}
|
||||||
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
|
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
|
||||||
currentLoggedInUserId={currentUserFromHook?.id} // For the "don't delete self" check
|
|
||||||
onUserDeleted={onUserDeleted}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
|
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
|
||||||
@@ -225,14 +130,8 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [usersData, setUsersData] = useAtom(usersAtom);
|
const [usersData, setUsersData] = useAtom(usersAtom);
|
||||||
const users = usersData.users;
|
const users = usersData.users;
|
||||||
const { currentUser } = useHelpers();
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
const handleUserDeleted = (userIdToDelete: string) => {
|
|
||||||
setUsersData(prevData => ({
|
|
||||||
...prevData,
|
|
||||||
users: prevData.users.filter(u => u.id !== userIdToDelete)
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUserSelect = (userId: string) => {
|
const handleUserSelect = (userId: string) => {
|
||||||
setSelectedUser(userId);
|
setSelectedUser(userId);
|
||||||
@@ -278,7 +177,6 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
|||||||
onUserSelect={handleUserSelect}
|
onUserSelect={handleUserSelect}
|
||||||
onEditUser={handleEditUser}
|
onEditUser={handleEditUser}
|
||||||
onCreateUser={handleCreateUser}
|
onCreateUser={handleCreateUser}
|
||||||
onUserDeleted={handleUserDeleted}
|
|
||||||
/>
|
/>
|
||||||
) : isCreating || isEditing ? (
|
) : isCreating || isEditing ? (
|
||||||
<UserForm
|
<UserForm
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { usersAtom } from '@/lib/atoms'
|
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
|
||||||
import { User, WishlistItemType } from '@/lib/types'
|
import { User, WishlistItemType } from '@/lib/types'
|
||||||
|
import { hasPermission } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
@@ -59,9 +59,9 @@ export default function WishlistItem({
|
|||||||
isRecentlyRedeemed
|
isRecentlyRedeemed
|
||||||
}: WishlistItemProps) {
|
}: WishlistItemProps) {
|
||||||
const t = useTranslations('WishlistItem')
|
const t = useTranslations('WishlistItem')
|
||||||
const { currentUser, hasPermission } = useHelpers()
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const canWrite = hasPermission('wishlist', 'write')
|
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
|
||||||
const canInteract = hasPermission('wishlist', 'interact')
|
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ export default function WishlistItem({
|
|||||||
<span className="ml-2">{t('editButton')}</span>
|
<span className="ml-2">{t('editButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
@@ -165,7 +165,7 @@ export default function WishlistItem({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="sm:hidden" />
|
<DropdownMenuSeparator className="sm:hidden" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -152,14 +152,15 @@ export default function WishlistManager() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isModalOpen &&
|
||||||
<AddEditWishlistItemModal
|
<AddEditWishlistItemModal
|
||||||
isOpen={isModalOpen}
|
|
||||||
setIsOpen={setIsModalOpen}
|
setIsOpen={setIsModalOpen}
|
||||||
editingItem={editingItem}
|
editingItem={editingItem}
|
||||||
setEditingItem={setEditingItem}
|
setEditingItem={setEditingItem}
|
||||||
addWishlistItem={addWishlistItem}
|
addWishlistItem={addWishlistItem}
|
||||||
editWishlistItem={editWishlistItem}
|
editWishlistItem={editWishlistItem}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirmation.isOpen}
|
isOpen={deleteConfirmation.isOpen}
|
||||||
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function JotaiHydrate({
|
|||||||
[coinsAtom, initialValues.coins],
|
[coinsAtom, initialValues.coins],
|
||||||
[wishlistAtom, initialValues.wishlist],
|
[wishlistAtom, initialValues.wishlist],
|
||||||
[usersAtom, initialValues.users],
|
[usersAtom, initialValues.users],
|
||||||
[serverSettingsAtom, initialValues.serverSettings]
|
[serverSettingsAtom, initialValues.serverSettings],
|
||||||
])
|
])
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,23 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
|
||||||
import { useTranslations } from 'next-intl'
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
|
coinsBalanceAtom,
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
|
coinsSpentTodayAtom,
|
||||||
|
currentUserAtom,
|
||||||
|
settingsAtom,
|
||||||
totalEarnedAtom,
|
totalEarnedAtom,
|
||||||
totalSpentAtom,
|
totalSpentAtom,
|
||||||
coinsSpentTodayAtom,
|
|
||||||
transactionsTodayAtom,
|
transactionsTodayAtom,
|
||||||
coinsBalanceAtom,
|
|
||||||
settingsAtom,
|
|
||||||
usersAtom,
|
usersAtom,
|
||||||
} from '@/lib/atoms'
|
} from '@/lib/atoms';
|
||||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
||||||
import { CoinsData, User } from '@/lib/types'
|
import { CoinsData } from '@/lib/types';
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
import { useAtom } from 'jotai';
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
function handlePermissionCheck(
|
|
||||||
user: User | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCoins(options?: { selectedUser?: string }) {
|
export function useCoins(options?: { selectedUser?: string }) {
|
||||||
const t = useTranslations('useCoins');
|
const t = useTranslations('useCoins');
|
||||||
@@ -51,23 +25,69 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
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 } = useHelpers()
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
let user: User | undefined;
|
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
|
||||||
if (!options?.selectedUser) {
|
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
|
||||||
user = currentUser;
|
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
|
||||||
} else {
|
const [atomTotalEarned] = useAtom(totalEarnedAtom)
|
||||||
user = users.users.find(u => u.id === options.selectedUser)
|
const [atomTotalSpent] = useAtom(totalSpentAtom)
|
||||||
|
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
|
||||||
|
const [atomTransactionsToday] = useAtom(transactionsTodayAtom);
|
||||||
|
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
|
||||||
|
|
||||||
|
const transactions = useMemo(() => {
|
||||||
|
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id);
|
||||||
|
}, [allCoinsData, targetUser?.id]);
|
||||||
|
|
||||||
|
const timezone = settings.system.timezone;
|
||||||
|
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
|
||||||
|
const [totalEarned, setTotalEarned] = useState(0);
|
||||||
|
const [totalSpent, setTotalSpent] = useState(0);
|
||||||
|
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
|
||||||
|
const [transactionsToday, setTransactionsToday] = useState<number>(0);
|
||||||
|
const [balance, setBalance] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Calculate other metrics
|
||||||
|
if (targetUser?.id && targetUser.id === currentUser?.id) {
|
||||||
|
// If the target user is the currently logged-in user, use the derived atom's value
|
||||||
|
setCoinsEarnedToday(atomCoinsEarnedToday);
|
||||||
|
setTotalEarned(atomTotalEarned);
|
||||||
|
setTotalSpent(atomTotalSpent);
|
||||||
|
setCoinsSpentToday(atomCoinsSpentToday);
|
||||||
|
setTransactionsToday(atomTransactionsToday);
|
||||||
|
setBalance(loggedInUserBalance);
|
||||||
|
} else if (targetUser?.id) {
|
||||||
|
// If an admin is viewing another user, calculate their metrics manually
|
||||||
|
const earnedToday = calculateCoinsEarnedToday(transactions, timezone);
|
||||||
|
setCoinsEarnedToday(roundToInteger(earnedToday));
|
||||||
|
|
||||||
|
const totalEarnedVal = calculateTotalEarned(transactions);
|
||||||
|
setTotalEarned(roundToInteger(totalEarnedVal));
|
||||||
|
|
||||||
|
const totalSpentVal = calculateTotalSpent(transactions);
|
||||||
|
setTotalSpent(roundToInteger(totalSpentVal));
|
||||||
|
|
||||||
|
const spentToday = calculateCoinsSpentToday(transactions, timezone);
|
||||||
|
setCoinsSpentToday(roundToInteger(spentToday));
|
||||||
|
|
||||||
|
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
|
||||||
|
|
||||||
|
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
|
||||||
|
setBalance(roundToInteger(calculatedBalance));
|
||||||
}
|
}
|
||||||
|
}, [
|
||||||
// Filter transactions for the selectd user
|
targetUser?.id,
|
||||||
const transactions = coins.transactions.filter(t => t.userId === user?.id)
|
currentUser?.id,
|
||||||
|
transactions, // Memoized: depends on allCoinsData and targetUser?.id
|
||||||
const [balance] = useAtom(coinsBalanceAtom)
|
timezone,
|
||||||
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
loggedInUserBalance,
|
||||||
const [totalEarned] = useAtom(totalEarnedAtom)
|
atomCoinsEarnedToday,
|
||||||
const [totalSpent] = useAtom(totalSpentAtom)
|
atomTotalEarned,
|
||||||
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
atomTotalSpent,
|
||||||
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
atomCoinsSpentToday,
|
||||||
|
atomTransactionsToday,
|
||||||
|
]);
|
||||||
|
|
||||||
const add = async (amount: number, description: string, note?: string) => {
|
const add = async (amount: number, description: string, note?: string) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
|
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
|
||||||
@@ -91,7 +111,7 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note,
|
note,
|
||||||
userId: user?.id
|
userId: targetUser?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
|
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
|
||||||
@@ -121,7 +141,7 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note,
|
note,
|
||||||
userId: user?.id
|
userId: targetUser?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
|
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
|
||||||
|
|||||||
@@ -1,60 +1,30 @@
|
|||||||
import { useAtom, atom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
|
|
||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||||
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { DateTime } from 'luxon'
|
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { Habit } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
getNowInMilliseconds,
|
d2s,
|
||||||
getTodayInTimezone,
|
|
||||||
isSameDate,
|
|
||||||
t2d,
|
|
||||||
d2t,
|
d2t,
|
||||||
getNow,
|
|
||||||
getCompletionsForDate,
|
getCompletionsForDate,
|
||||||
getISODate,
|
getISODate,
|
||||||
d2s,
|
getNow,
|
||||||
|
getTodayInTimezone,
|
||||||
|
handlePermissionCheck,
|
||||||
|
isSameDate,
|
||||||
playSound,
|
playSound,
|
||||||
checkPermission
|
t2d
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { useAtom } from 'jotai'
|
||||||
import { Undo2 } from 'lucide-react'
|
import { Undo2 } from 'lucide-react'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
import { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
function handlePermissionCheck(
|
|
||||||
user: SafeUser | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function 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 [usersData] = useAtom(usersAtom)
|
||||||
const { currentUser } = useHelpers()
|
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)
|
||||||
@@ -106,7 +76,7 @@ export function useHabits() {
|
|||||||
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
||||||
relatedItemId: habit.id,
|
relatedItemId: habit.id,
|
||||||
})
|
})
|
||||||
isTargetReached && playSound()
|
playSound()
|
||||||
toast({
|
toast({
|
||||||
title: t("completedTitle"),
|
title: t("completedTitle"),
|
||||||
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
|
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
|
||||||
@@ -207,7 +177,7 @@ export function useHabits() {
|
|||||||
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
||||||
const newHabit = {
|
const newHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
id: habit.id || getNowInMilliseconds().toString()
|
id: habit.id || crypto.randomUUID()
|
||||||
}
|
}
|
||||||
const updatedHabits = habit.id
|
const updatedHabits = habit.id
|
||||||
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
|
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
|
||||||
|
|||||||
@@ -1,45 +1,17 @@
|
|||||||
|
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
|
||||||
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { handlePermissionCheck } from '@/lib/utils'
|
||||||
|
import { celebrations } from '@/utils/celebrations'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
|
|
||||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
|
||||||
import { toast } from '@/hooks/use-toast'
|
|
||||||
import { WishlistItemType } from '@/lib/types'
|
|
||||||
import { celebrations } from '@/utils/celebrations'
|
|
||||||
import { checkPermission } from '@/lib/utils'
|
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
|
||||||
import { useCoins } from './useCoins'
|
import { useCoins } from './useCoins'
|
||||||
|
|
||||||
function handlePermissionCheck(
|
|
||||||
user: any, // Consider using a more specific type like SafeUser | User | undefined
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWishlist() {
|
export function useWishlist() {
|
||||||
const t = useTranslations('useWishlist');
|
const t = useTranslations('useWishlist');
|
||||||
const tCommon = useTranslations('Common');
|
const tCommon = useTranslations('Common');
|
||||||
const { currentUser: user } = useHelpers()
|
const [user] = useAtom(currentUserAtom)
|
||||||
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const { balance } = useCoins()
|
const { balance } = useCoins()
|
||||||
|
|||||||
82
lib/atoms.ts
82
lib/atoms.ts
@@ -4,16 +4,19 @@ import {
|
|||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
calculateTotalSpent,
|
calculateTotalSpent,
|
||||||
calculateTransactionsToday,
|
calculateTransactionsToday,
|
||||||
|
generateCryptoHash,
|
||||||
getCompletionsForToday,
|
getCompletionsForToday,
|
||||||
getHabitFreq,
|
getHabitFreq,
|
||||||
getTodayInTimezone,
|
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
|
prepareDataForHashing,
|
||||||
|
roundToInteger,
|
||||||
t2d
|
t2d
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import {
|
import {
|
||||||
|
CoinsData,
|
||||||
CompletionCache,
|
CompletionCache,
|
||||||
Freq,
|
Freq,
|
||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
@@ -22,7 +25,13 @@ import {
|
|||||||
getDefaultSettings,
|
getDefaultSettings,
|
||||||
getDefaultUsersData,
|
getDefaultUsersData,
|
||||||
getDefaultWishlistData,
|
getDefaultWishlistData,
|
||||||
Habit
|
Habit,
|
||||||
|
HabitsData,
|
||||||
|
ServerSettings,
|
||||||
|
Settings,
|
||||||
|
UserData,
|
||||||
|
UserId,
|
||||||
|
WishlistData
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface BrowserSettings {
|
export interface BrowserSettings {
|
||||||
@@ -37,37 +46,41 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
|||||||
expandedWishlist: false
|
expandedWishlist: false
|
||||||
} as BrowserSettings)
|
} as BrowserSettings)
|
||||||
|
|
||||||
export const usersAtom = atom(getDefaultUsersData())
|
export const usersAtom = atom(getDefaultUsersData<UserData>())
|
||||||
export const settingsAtom = atom(getDefaultSettings());
|
export const settingsAtom = atom(getDefaultSettings<Settings>());
|
||||||
export const habitsAtom = atom(getDefaultHabitsData());
|
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
|
||||||
export const coinsAtom = atom(getDefaultCoinsData());
|
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
|
||||||
export const wishlistAtom = atom(getDefaultWishlistData());
|
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
|
||||||
export const serverSettingsAtom = atom(getDefaultServerSettings());
|
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
|
||||||
|
|
||||||
// Derived atom for coins earned today
|
// Derived atom for coins earned today
|
||||||
export const coinsEarnedTodayAtom = atom((get) => {
|
export const coinsEarnedTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||||
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total earned
|
// Derived atom for total earned
|
||||||
export const totalEarnedAtom = atom((get) => {
|
export const totalEarnedAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return calculateTotalEarned(coins.transactions);
|
const value = calculateTotalEarned(coins.transactions);
|
||||||
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total spent
|
// Derived atom for total spent
|
||||||
export const totalSpentAtom = atom((get) => {
|
export const totalSpentAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return calculateTotalSpent(coins.transactions);
|
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);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
||||||
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for transactions today
|
// Derived atom for transactions today
|
||||||
@@ -77,10 +90,27 @@ export const transactionsTodayAtom = atom((get) => {
|
|||||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for current balance from all transactions
|
// 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) => {
|
||||||
|
const currentUserId = get(currentUserIdAtom);
|
||||||
|
const users = get(usersAtom);
|
||||||
|
return users.users.find(user => user.id === currentUserId);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Derived atom for current balance for the logged-in user
|
||||||
export const coinsBalanceAtom = atom((get) => {
|
export const coinsBalanceAtom = atom((get) => {
|
||||||
|
const loggedInUserId = get(currentUserIdAtom);
|
||||||
|
if (!loggedInUserId) {
|
||||||
|
return 0; // No user logged in or ID not set, so balance is 0
|
||||||
|
}
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
|
const balance = coins.transactions
|
||||||
|
.filter(transaction => transaction.userId === loggedInUserId)
|
||||||
|
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||||
|
return roundToInteger(balance);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* transient atoms */
|
/* transient atoms */
|
||||||
@@ -99,6 +129,23 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const userSelectAtom = atom<boolean>(false)
|
export const userSelectAtom = atom<boolean>(false)
|
||||||
|
export const aboutOpenAtom = atom<boolean>(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
|
||||||
|
* This token can be compared with a server-generated token to detect data discrepancies.
|
||||||
|
*/
|
||||||
|
export const clientFreshnessTokenAtom = atom(async (get) => {
|
||||||
|
const settings = get(settingsAtom);
|
||||||
|
const habits = get(habitsAtom);
|
||||||
|
const coins = get(coinsAtom);
|
||||||
|
const wishlist = get(wishlistAtom);
|
||||||
|
const users = get(usersAtom);
|
||||||
|
|
||||||
|
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
|
||||||
|
const hash = await generateCryptoHash(dataString);
|
||||||
|
return hash;
|
||||||
|
});
|
||||||
|
|
||||||
// Derived atom for completion cache
|
// Derived atom for completion cache
|
||||||
export const completionCacheAtom = atom((get) => {
|
export const completionCacheAtom = atom((get) => {
|
||||||
@@ -187,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
|
|||||||
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derived atom for daily habits
|
|
||||||
export const dailyHabitsAtom = atom((get) => {
|
|
||||||
const settings = get(settingsAtom);
|
|
||||||
const today = getTodayInTimezone(settings.system.timezone);
|
|
||||||
return get(habitsByDateFamily(today));
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
import { usersAtom } from './atoms'
|
import { usersAtom } from './atoms'
|
||||||
import { checkPermission } from './utils'
|
import { hasPermission } from './utils'
|
||||||
|
|
||||||
export function useHelpers() {
|
export function useHelpers() {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
@@ -30,8 +30,7 @@ export function useHelpers() {
|
|||||||
currentUser,
|
currentUser,
|
||||||
usersData,
|
usersData,
|
||||||
status,
|
status,
|
||||||
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
|
hasPermission,
|
||||||
checkPermission(currentUser?.permissions, resource, action),
|
|
||||||
isIOS: iOS(),
|
isIOS: iOS(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,3 +32,5 @@ export const QUICK_DATES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const MAX_COIN_LIMIT = 9999
|
export const MAX_COIN_LIMIT = 9999
|
||||||
|
|
||||||
|
export const DESKTOP_DISPLAY_ITEM_COUNT = 4
|
||||||
45
lib/types.ts
45
lib/types.ts
@@ -1,5 +1,4 @@
|
|||||||
import { RRule } from "rrule"
|
import { RRule } from "rrule"
|
||||||
import { uuid } from "./utils"
|
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
export type UserId = string
|
export type UserId = string
|
||||||
@@ -97,33 +96,38 @@ export interface WishlistData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default value functions
|
// Default value functions
|
||||||
export const getDefaultUsersData = (): UserData => ({
|
export function getDefaultUsersData<UserData>(): UserData {
|
||||||
|
return {
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
id: uuid(),
|
id: crypto.randomUUID(),
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
// password: '', // No default password for admin initially? Or set a secure default?
|
// password: '', // No default password for admin initially? Or set a secure default?
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
} as UserData;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultHabitsData = (): HabitsData => ({
|
export function getDefaultHabitsData<HabitsData>(): HabitsData {
|
||||||
habits: []
|
return { habits: [] } as HabitsData;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export function getDefaultTasksData<TasksData>(): TasksData {
|
||||||
|
return { tasks: [] } as TasksData;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultCoinsData = (): CoinsData => ({
|
export function getDefaultCoinsData<CoinsData>(): CoinsData {
|
||||||
balance: 0,
|
return { balance: 0, transactions: [] } as CoinsData;
|
||||||
transactions: []
|
};
|
||||||
});
|
|
||||||
|
|
||||||
export const getDefaultWishlistData = (): WishlistData => ({
|
export function getDefaultWishlistData<WishlistData>(): WishlistData {
|
||||||
items: []
|
return { items: [] } as WishlistData;
|
||||||
});
|
}
|
||||||
|
|
||||||
export const getDefaultSettings = (): Settings => ({
|
export function getDefaultSettings<Settings>(): Settings {
|
||||||
|
return {
|
||||||
ui: {
|
ui: {
|
||||||
useNumberFormatting: true,
|
useNumberFormatting: true,
|
||||||
useGrouping: true,
|
useGrouping: true,
|
||||||
@@ -135,14 +139,15 @@ export const getDefaultSettings = (): Settings => ({
|
|||||||
language: 'en', // Default language
|
language: 'en', // Default language
|
||||||
},
|
},
|
||||||
profile: {}
|
profile: {}
|
||||||
});
|
} as Settings;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultServerSettings = (): ServerSettings => ({
|
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
|
||||||
isDemo: false
|
return { isDemo: false } as ServerSettings;
|
||||||
})
|
}
|
||||||
|
|
||||||
// Map of data types to their default values
|
// Map of data types to their default values
|
||||||
export const DATA_DEFAULTS = {
|
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
|
||||||
wishlist: getDefaultWishlistData,
|
wishlist: getDefaultWishlistData,
|
||||||
habits: getDefaultHabitsData,
|
habits: getDefaultHabitsData,
|
||||||
coins: getDefaultCoinsData,
|
coins: getDefaultCoinsData,
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import {
|
|||||||
cn,
|
cn,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
getNow,
|
getNow,
|
||||||
getNowInMilliseconds,
|
|
||||||
t2d,
|
t2d,
|
||||||
d2t,
|
d2t,
|
||||||
d2s,
|
d2s,
|
||||||
d2sDate,
|
|
||||||
d2n,
|
|
||||||
isSameDate,
|
isSameDate,
|
||||||
calculateCoinsEarnedToday,
|
calculateCoinsEarnedToday,
|
||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
@@ -16,16 +13,19 @@ import {
|
|||||||
calculateCoinsSpentToday,
|
calculateCoinsSpentToday,
|
||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
uuid,
|
|
||||||
isTaskOverdue,
|
isTaskOverdue,
|
||||||
deserializeRRule,
|
deserializeRRule,
|
||||||
serializeRRule,
|
serializeRRule,
|
||||||
convertHumanReadableFrequencyToMachineReadable,
|
convertHumanReadableFrequencyToMachineReadable,
|
||||||
convertMachineReadableFrequencyToHumanReadable,
|
convertMachineReadableFrequencyToHumanReadable,
|
||||||
getUnsupportedRRuleReason
|
prepareDataForHashing,
|
||||||
|
getUnsupportedRRuleReason,
|
||||||
|
roundToInteger,
|
||||||
|
generateCryptoHash
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction, ParsedResultType } from './types'
|
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
|
||||||
import { RRule, Weekday } from 'rrule';
|
import { RRule, Weekday } from 'rrule';
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
import { INITIAL_DUE } from './constants';
|
import { INITIAL_DUE } from './constants';
|
||||||
@@ -39,6 +39,33 @@ describe('cn utility', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('roundToInteger', () => {
|
||||||
|
test('should round positive numbers correctly', () => {
|
||||||
|
expect(roundToInteger(10.123)).toBe(10);
|
||||||
|
expect(roundToInteger(10.5)).toBe(11);
|
||||||
|
expect(roundToInteger(10.75)).toBe(11);
|
||||||
|
expect(roundToInteger(10.49)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should round negative numbers correctly', () => {
|
||||||
|
expect(roundToInteger(-10.123)).toBe(-10);
|
||||||
|
expect(roundToInteger(-10.5)).toBe(-10); // Math.round rounds -x.5 to -(x-1) e.g. -10.5 to -10
|
||||||
|
expect(roundToInteger(-10.75)).toBe(-11);
|
||||||
|
expect(roundToInteger(-10.49)).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle zero correctly', () => {
|
||||||
|
expect(roundToInteger(0)).toBe(0);
|
||||||
|
expect(roundToInteger(0.0)).toBe(0);
|
||||||
|
expect(roundToInteger(-0.0)).toBe(-0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle integers correctly', () => {
|
||||||
|
expect(roundToInteger(15)).toBe(15);
|
||||||
|
expect(roundToInteger(-15)).toBe(-15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getUnsupportedRRuleReason', () => {
|
describe('getUnsupportedRRuleReason', () => {
|
||||||
test('should return message for HOURLY frequency', () => {
|
test('should return message for HOURLY frequency', () => {
|
||||||
const rrule = new RRule({ freq: RRule.HOURLY });
|
const rrule = new RRule({ freq: RRule.HOURLY });
|
||||||
@@ -147,32 +174,6 @@ describe('isTaskOverdue', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('uuid', () => {
|
|
||||||
test('should generate valid UUIDs', () => {
|
|
||||||
const id = uuid()
|
|
||||||
// UUID v4 format: 8-4-4-4-12 hex digits
|
|
||||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should generate unique UUIDs', () => {
|
|
||||||
const ids = new Set()
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
ids.add(uuid())
|
|
||||||
}
|
|
||||||
// All 1000 UUIDs should be unique
|
|
||||||
expect(ids.size).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should generate v4 UUIDs', () => {
|
|
||||||
const id = uuid()
|
|
||||||
// Version 4 UUID has specific bits set:
|
|
||||||
// - 13th character is '4'
|
|
||||||
// - 17th character is '8', '9', 'a', or 'b'
|
|
||||||
expect(id.charAt(14)).toBe('4')
|
|
||||||
expect('89ab').toContain(id.charAt(19))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('datetime utilities', () => {
|
describe('datetime utilities', () => {
|
||||||
let fixedNow: DateTime;
|
let fixedNow: DateTime;
|
||||||
let currentDateIndex = 0;
|
let currentDateIndex = 0;
|
||||||
@@ -290,13 +291,6 @@ describe('getNow', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNowInMilliseconds', () => {
|
|
||||||
test('should return current time in milliseconds', () => {
|
|
||||||
const now = DateTime.now().setZone('UTC')
|
|
||||||
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('timestamp conversion utilities', () => {
|
describe('timestamp conversion utilities', () => {
|
||||||
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
||||||
const testDateTime = DateTime.fromISO(testTimestamp);
|
const testDateTime = DateTime.fromISO(testTimestamp);
|
||||||
@@ -320,16 +314,6 @@ describe('timestamp conversion utilities', () => {
|
|||||||
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
|
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
|
||||||
expect(customFormat).toBe('2024-01-01')
|
expect(customFormat).toBe('2024-01-01')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('d2sDate should format DateTime as date string', () => {
|
|
||||||
const result = d2sDate({ dateTime: testDateTime });
|
|
||||||
expect(result).toBeString()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('d2n should convert DateTime to milliseconds string', () => {
|
|
||||||
const result = d2n({ dateTime: testDateTime });
|
|
||||||
expect(result).toBe('1704067200000')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isSameDate', () => {
|
describe('isSameDate', () => {
|
||||||
@@ -594,7 +578,7 @@ describe('isHabitDueToday', () => {
|
|||||||
|
|
||||||
test('should return false for invalid recurrence rule', () => {
|
test('should return false for invalid recurrence rule', () => {
|
||||||
const habit = testHabit('INVALID_RRULE')
|
const habit = testHabit('INVALID_RRULE')
|
||||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
||||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -707,7 +691,7 @@ describe('isHabitDue', () => {
|
|||||||
test('should return false for invalid recurrence rule', () => {
|
test('should return false for invalid recurrence rule', () => {
|
||||||
const habit = testHabit('INVALID_RRULE')
|
const habit = testHabit('INVALID_RRULE')
|
||||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
|
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
|
||||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
||||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -956,3 +940,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
|||||||
expect(humanReadable).toBe('invalid')
|
expect(humanReadable).toBe('invalid')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('freshness utilities', () => {
|
||||||
|
const mockSettings: Settings = getDefaultSettings<Settings>();
|
||||||
|
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
|
||||||
|
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
|
||||||
|
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
|
||||||
|
const mockUsers: UserData = getDefaultUsersData<UserData>();
|
||||||
|
|
||||||
|
// Add a user to mockUsers for more realistic testing
|
||||||
|
mockUsers.users.push({
|
||||||
|
id: 'user-123',
|
||||||
|
username: 'testuser',
|
||||||
|
isAdmin: false,
|
||||||
|
});
|
||||||
|
mockHabits.habits.push({
|
||||||
|
id: 'habit-123',
|
||||||
|
name: 'Test Habit',
|
||||||
|
description: 'A habit for testing',
|
||||||
|
frequency: 'FREQ=DAILY',
|
||||||
|
coinReward: 10,
|
||||||
|
completions: [],
|
||||||
|
userIds: ['user-123']
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('prepareDataForHashing', () => {
|
||||||
|
test('should produce a consistent string for the same data', () => {
|
||||||
|
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
|
||||||
|
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
|
||||||
|
|
||||||
|
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
|
||||||
|
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
|
||||||
|
|
||||||
|
expect(string1).toBe(string2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should produce a different string if settings data changes', () => {
|
||||||
|
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
|
||||||
|
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
expect(string1).not.toBe(string2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should produce a different string if habits data changes', () => {
|
||||||
|
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
|
||||||
|
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
expect(string1).not.toBe(string2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty data consistently', () => {
|
||||||
|
const emptySettings = getDefaultSettings<Settings>();
|
||||||
|
const emptyHabits = getDefaultHabitsData<HabitsData>();
|
||||||
|
const emptyCoins = getDefaultCoinsData<CoinsData>();
|
||||||
|
const emptyWishlist = getDefaultWishlistData<WishlistData>();
|
||||||
|
const emptyUsers = getDefaultUsersData<UserData>();
|
||||||
|
|
||||||
|
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
|
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
|
expect(string1).toBe(string2);
|
||||||
|
expect(string1).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCryptoHash', () => {
|
||||||
|
test('should generate a SHA-256 hex string', async () => {
|
||||||
|
const dataString = 'test string';
|
||||||
|
const hash = await generateCryptoHash(dataString);
|
||||||
|
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate different hashes for different strings', async () => {
|
||||||
|
const hash1 = await generateCryptoHash('test string 1');
|
||||||
|
const hash2 = await generateCryptoHash('test string 2');
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate the same hash for the same string', async () => {
|
||||||
|
const hash1 = await generateCryptoHash('consistent string');
|
||||||
|
const hash2 = await generateCryptoHash('consistent string');
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
|
||||||
|
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||||
|
test('should generate correct hash for a known string', async () => {
|
||||||
|
const knownString = "hello world";
|
||||||
|
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||||
|
const actualHash = await generateCryptoHash(knownString);
|
||||||
|
expect(actualHash).toBe(expectedHash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|||||||
141
lib/utils.ts
141
lib/utils.ts
@@ -1,12 +1,12 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { toast } from "@/hooks/use-toast"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
|
||||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
|
||||||
import { datetime, RRule } from 'rrule'
|
|
||||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
|
|
||||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import _ from "lodash"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||||
|
import { Formats } from "next-intl"
|
||||||
|
import { datetime, RRule } from 'rrule'
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -18,6 +18,11 @@ export function getTodayInTimezone(timezone: string): string {
|
|||||||
return getISODate({ dateTime: now, timezone });
|
return getISODate({ dateTime: now, timezone });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// round a number to the nearest integer
|
||||||
|
export function roundToInteger(value: number): number {
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
|
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
|
||||||
return dateTime.setZone(timezone).toISODate()!;
|
return dateTime.setZone(timezone).toISODate()!;
|
||||||
}
|
}
|
||||||
@@ -27,12 +32,6 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
|
|||||||
return DateTime.now().setZone(timezone, { keepLocalTime });
|
return DateTime.now().setZone(timezone, { keepLocalTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
// get current time in epoch milliseconds
|
|
||||||
export function getNowInMilliseconds() {
|
|
||||||
const now = getNow({});
|
|
||||||
return d2n({ dateTime: now });
|
|
||||||
}
|
|
||||||
|
|
||||||
// iso timestamp to datetime object, most for storage read
|
// iso timestamp to datetime object, most for storage read
|
||||||
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
|
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
|
||||||
return DateTime.fromISO(timestamp).setZone(timezone);
|
return DateTime.fromISO(timestamp).setZone(timezone);
|
||||||
@@ -55,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
|
|||||||
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
|
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// convert datetime object to date string, mostly for display
|
|
||||||
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
|
|
||||||
return dateTime.toLocaleString(DateTime.DATE_MED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert datetime object to epoch milliseconds string, mostly for storage write
|
|
||||||
export function d2n({ dateTime }: { dateTime: DateTime }) {
|
|
||||||
return dateTime.toMillis().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// compare the date portion of two datetime objects (i.e. same year, month, day)
|
// compare the date portion of two datetime objects (i.e. same year, month, day)
|
||||||
export function isSameDate(a: DateTime, b: DateTime) {
|
export function isSameDate(a: DateTime, b: DateTime) {
|
||||||
return a.hasSame(b, 'day');
|
return a.hasSame(b, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCompletionDate(date: string, timezone: string): string {
|
|
||||||
// If already in ISO format, return as is
|
|
||||||
if (date.includes('T')) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
// Convert from yyyy-MM-dd to ISO format
|
|
||||||
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCompletionsForDate({
|
export function getCompletionsForDate({
|
||||||
habit,
|
habit,
|
||||||
date,
|
date,
|
||||||
@@ -432,22 +412,20 @@ export const openWindow = (url: string): boolean => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deepMerge<T>(a: T, b: T) {
|
export function hasPermission(
|
||||||
return _.merge(a, b, (x: unknown, y: unknown) => {
|
user: User | undefined,
|
||||||
if (_.isArray(a)) {
|
|
||||||
return a.concat(b)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkPermission(
|
|
||||||
permissions: Permission[] | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
resource: 'habit' | 'wishlist' | 'coins',
|
||||||
action: 'write' | 'interact'
|
action: 'write' | 'interact'
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!permissions) return false
|
if (!user || !user.permissions) {
|
||||||
|
return false;
|
||||||
return permissions.some(permission => {
|
}
|
||||||
|
// If user is admin, they have all permissions.
|
||||||
|
if (user.isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Otherwise, check specific permissions.
|
||||||
|
return user.permissions.some(permission => {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
case 'habit':
|
case 'habit':
|
||||||
return permission.habit[action]
|
return permission.habit[action]
|
||||||
@@ -461,6 +439,73 @@ export function checkPermission(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uuid() {
|
/**
|
||||||
return uuidv4()
|
* Prepares a consistent string representation of the data for hashing.
|
||||||
|
* It combines all relevant data pieces into a single object and then stringifies it stably.
|
||||||
|
*/
|
||||||
|
export function prepareDataForHashing(
|
||||||
|
settings: Settings,
|
||||||
|
habits: HabitsData,
|
||||||
|
coins: CoinsData,
|
||||||
|
wishlist: WishlistData,
|
||||||
|
users: UserData
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
settings,
|
||||||
|
habits,
|
||||||
|
coins,
|
||||||
|
wishlist,
|
||||||
|
users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a SHA-256 hash for a given string using the Web Crypto API.
|
||||||
|
* This function is suitable for both client-side and server-side (Node.js 19+) environments.
|
||||||
|
* @param dataString The string to hash.
|
||||||
|
* @returns A promise that resolves to the hex string of the hash.
|
||||||
|
*/
|
||||||
|
export async function generateCryptoHash(dataString: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(dataString);
|
||||||
|
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
|
||||||
|
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
|
||||||
|
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
// Convert buffer to hex string
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return hashHex;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate hash: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePermissionCheck(
|
||||||
|
user: User | SafeUser | undefined,
|
||||||
|
resource: 'habit' | 'wishlist' | 'coins',
|
||||||
|
action: 'write' | 'interact',
|
||||||
|
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
|
||||||
|
): boolean {
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
toast({
|
||||||
|
title: tCommon("authenticationRequiredTitle"),
|
||||||
|
description: tCommon("authenticationRequiredDescription"),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(user, resource, action)) {
|
||||||
|
toast({
|
||||||
|
title: tCommon("permissionDeniedTitle"),
|
||||||
|
description: tCommon("permissionDeniedDescription", { action, resource }),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
@@ -270,6 +270,12 @@
|
|||||||
"actionUpdate": "aktualisieren",
|
"actionUpdate": "aktualisieren",
|
||||||
"actionCreate": "erstellen",
|
"actionCreate": "erstellen",
|
||||||
"errorFailedUserAction": "Fehler beim {action} des Benutzers",
|
"errorFailedUserAction": "Fehler beim {action} des Benutzers",
|
||||||
|
"toastDemoDeleteDisabled": "Löschen ist in der Demo-Instanz deaktiviert",
|
||||||
|
"toastCannotDeleteSelf": "Sie können Ihr eigenes Konto nicht löschen",
|
||||||
|
"confirmDeleteUser": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
|
||||||
|
"toastUserDeletedTitle": "Benutzer gelöscht",
|
||||||
|
"toastUserDeletedDescription": "Benutzer {username} wurde erfolgreich gelöscht",
|
||||||
|
"toastDeleteUserFailed": "Fehler beim Löschen des Benutzers: {error}",
|
||||||
"errorTitle": "Fehler",
|
"errorTitle": "Fehler",
|
||||||
"errorFileSizeLimit": "Die Dateigröße muss kleiner als 5MB sein",
|
"errorFileSizeLimit": "Die Dateigröße muss kleiner als 5MB sein",
|
||||||
"toastAvatarUploadedTitle": "Avatar hochgeladen",
|
"toastAvatarUploadedTitle": "Avatar hochgeladen",
|
||||||
@@ -287,7 +293,13 @@
|
|||||||
"disablePasswordLabel": "Passwort deaktivieren",
|
"disablePasswordLabel": "Passwort deaktivieren",
|
||||||
"cancelButton": "Abbrechen",
|
"cancelButton": "Abbrechen",
|
||||||
"saveChangesButton": "Änderungen speichern",
|
"saveChangesButton": "Änderungen speichern",
|
||||||
"createUserButton": "Benutzer erstellen"
|
"createUserButton": "Benutzer erstellen",
|
||||||
|
"deleteAccountButton": "Konto löschen",
|
||||||
|
"deletingButtonText": "Wird gelöscht...",
|
||||||
|
"areYouSure": "Sind Sie sicher?",
|
||||||
|
"deleteUserConfirmation": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"confirmDeleteButtonText": "Löschen"
|
||||||
},
|
},
|
||||||
"ViewToggle": {
|
"ViewToggle": {
|
||||||
"habitsLabel": "Gewohnheiten",
|
"habitsLabel": "Gewohnheiten",
|
||||||
@@ -411,6 +423,7 @@
|
|||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen"
|
||||||
},
|
},
|
||||||
"useCoins": {
|
"useCoins": {
|
||||||
|
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
|
||||||
"invalidAmountTitle": "Ungültiger Betrag",
|
"invalidAmountTitle": "Ungültiger Betrag",
|
||||||
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
|
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
|
||||||
"successTitle": "Erfolg",
|
"successTitle": "Erfolg",
|
||||||
|
|||||||
@@ -287,7 +287,8 @@
|
|||||||
"disablePasswordLabel": "Disable password",
|
"disablePasswordLabel": "Disable password",
|
||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
"saveChangesButton": "Save Changes",
|
"saveChangesButton": "Save Changes",
|
||||||
"createUserButton": "Create User"
|
"createUserButton": "Create User",
|
||||||
|
"deleteAccountButton": "Delete Account"
|
||||||
},
|
},
|
||||||
"ViewToggle": {
|
"ViewToggle": {
|
||||||
"habitsLabel": "Habits",
|
"habitsLabel": "Habits",
|
||||||
@@ -407,6 +408,7 @@
|
|||||||
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
|
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
|
||||||
},
|
},
|
||||||
"useCoins": {
|
"useCoins": {
|
||||||
|
"addedCoinsDescription": "Added {amount} coins",
|
||||||
"invalidAmountTitle": "Invalid amount",
|
"invalidAmountTitle": "Invalid amount",
|
||||||
"invalidAmountDescription": "Please enter a valid positive number",
|
"invalidAmountDescription": "Please enter a valid positive number",
|
||||||
"successTitle": "Success",
|
"successTitle": "Success",
|
||||||
|
|||||||
@@ -270,6 +270,12 @@
|
|||||||
"actionUpdate": "actualizar",
|
"actionUpdate": "actualizar",
|
||||||
"actionCreate": "crear",
|
"actionCreate": "crear",
|
||||||
"errorFailedUserAction": "Error al {action} usuario",
|
"errorFailedUserAction": "Error al {action} usuario",
|
||||||
|
"toastDemoDeleteDisabled": "La eliminación está deshabilitada en la instancia demo",
|
||||||
|
"toastCannotDeleteSelf": "No puedes eliminar tu propia cuenta",
|
||||||
|
"confirmDeleteUser": "¿Estás seguro de que deseas eliminar al usuario {username}?",
|
||||||
|
"toastUserDeletedTitle": "Usuario eliminado",
|
||||||
|
"toastUserDeletedDescription": "El usuario {username} ha sido eliminado correctamente",
|
||||||
|
"toastDeleteUserFailed": "Error al eliminar el usuario: {error}",
|
||||||
"errorTitle": "Error",
|
"errorTitle": "Error",
|
||||||
"errorFileSizeLimit": "El tamaño del archivo debe ser menor a 5MB",
|
"errorFileSizeLimit": "El tamaño del archivo debe ser menor a 5MB",
|
||||||
"toastAvatarUploadedTitle": "Avatar subido",
|
"toastAvatarUploadedTitle": "Avatar subido",
|
||||||
@@ -287,7 +293,13 @@
|
|||||||
"disablePasswordLabel": "Desactivar contraseña",
|
"disablePasswordLabel": "Desactivar contraseña",
|
||||||
"cancelButton": "Cancelar",
|
"cancelButton": "Cancelar",
|
||||||
"saveChangesButton": "Guardar cambios",
|
"saveChangesButton": "Guardar cambios",
|
||||||
"createUserButton": "Crear usuario"
|
"createUserButton": "Crear usuario",
|
||||||
|
"deleteAccountButton": "Eliminar cuenta",
|
||||||
|
"deletingButtonText": "Eliminando...",
|
||||||
|
"areYouSure": "¿Estás seguro?",
|
||||||
|
"deleteUserConfirmation": "¿Estás seguro de que deseas eliminar al usuario {username}?",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"confirmDeleteButtonText": "Eliminar"
|
||||||
},
|
},
|
||||||
"ViewToggle": {
|
"ViewToggle": {
|
||||||
"habitsLabel": "Hábitos",
|
"habitsLabel": "Hábitos",
|
||||||
@@ -411,6 +423,7 @@
|
|||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar"
|
||||||
},
|
},
|
||||||
"useCoins": {
|
"useCoins": {
|
||||||
|
"addedCoinsDescription": "Se añadieron {amount} monedas",
|
||||||
"invalidAmountTitle": "Cantidad inválida",
|
"invalidAmountTitle": "Cantidad inválida",
|
||||||
"invalidAmountDescription": "Por favor ingresa un número positivo válido",
|
"invalidAmountDescription": "Por favor ingresa un número positivo válido",
|
||||||
"successTitle": "Éxito",
|
"successTitle": "Éxito",
|
||||||
|
|||||||
@@ -270,6 +270,12 @@
|
|||||||
"actionUpdate": "mise à jour",
|
"actionUpdate": "mise à jour",
|
||||||
"actionCreate": "création",
|
"actionCreate": "création",
|
||||||
"errorFailedUserAction": "Échec de la {action} de l'utilisateur",
|
"errorFailedUserAction": "Échec de la {action} de l'utilisateur",
|
||||||
|
"toastDemoDeleteDisabled": "La suppression est désactivée dans la version de démonstration",
|
||||||
|
"toastCannotDeleteSelf": "Vous ne pouvez pas supprimer votre propre compte",
|
||||||
|
"confirmDeleteUser": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username}?",
|
||||||
|
"toastUserDeletedTitle": "Utilisateur supprimé",
|
||||||
|
"toastUserDeletedDescription": "L'utilisateur {username} a été supprimé avec succès",
|
||||||
|
"toastDeleteUserFailed": "Échec de la suppression de l'utilisateur : {error}",
|
||||||
"errorTitle": "Erreur",
|
"errorTitle": "Erreur",
|
||||||
"errorFileSizeLimit": "La taille du fichier doit être inférieure à 5MB",
|
"errorFileSizeLimit": "La taille du fichier doit être inférieure à 5MB",
|
||||||
"toastAvatarUploadedTitle": "Avatar téléchargé",
|
"toastAvatarUploadedTitle": "Avatar téléchargé",
|
||||||
@@ -287,7 +293,13 @@
|
|||||||
"disablePasswordLabel": "Désactiver le mot de passe",
|
"disablePasswordLabel": "Désactiver le mot de passe",
|
||||||
"cancelButton": "Annuler",
|
"cancelButton": "Annuler",
|
||||||
"saveChangesButton": "Sauvegarder les modifications",
|
"saveChangesButton": "Sauvegarder les modifications",
|
||||||
"createUserButton": "Créer un utilisateur"
|
"createUserButton": "Créer un utilisateur",
|
||||||
|
"deleteAccountButton": "Supprimer le compte",
|
||||||
|
"deletingButtonText": "Suppression en cours...",
|
||||||
|
"areYouSure": "Êtes-vous sûr ?",
|
||||||
|
"deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username} ?",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirmDeleteButtonText": "Supprimer"
|
||||||
},
|
},
|
||||||
"ViewToggle": {
|
"ViewToggle": {
|
||||||
"habitsLabel": "Habitudes",
|
"habitsLabel": "Habitudes",
|
||||||
@@ -411,6 +423,7 @@
|
|||||||
"cancel": "Annuler"
|
"cancel": "Annuler"
|
||||||
},
|
},
|
||||||
"useCoins": {
|
"useCoins": {
|
||||||
|
"addedCoinsDescription": "{amount} pièces ajoutées",
|
||||||
"invalidAmountTitle": "Montant invalide",
|
"invalidAmountTitle": "Montant invalide",
|
||||||
"invalidAmountDescription": "Veuillez entrer un nombre positif valide",
|
"invalidAmountDescription": "Veuillez entrer un nombre positif valide",
|
||||||
"successTitle": "Succès",
|
"successTitle": "Succès",
|
||||||
|
|||||||
@@ -270,6 +270,12 @@
|
|||||||
"actionUpdate": "更新",
|
"actionUpdate": "更新",
|
||||||
"actionCreate": "作成",
|
"actionCreate": "作成",
|
||||||
"errorFailedUserAction": "ユーザーの{action}に失敗しました",
|
"errorFailedUserAction": "ユーザーの{action}に失敗しました",
|
||||||
|
"toastDemoDeleteDisabled": "デモインスタンスでは削除が無効になっています",
|
||||||
|
"toastCannotDeleteSelf": "自分のアカウントは削除できません",
|
||||||
|
"confirmDeleteUser": "ユーザー {username} を削除してもよろしいですか?",
|
||||||
|
"toastUserDeletedTitle": "ユーザーが削除されました",
|
||||||
|
"toastUserDeletedDescription": "ユーザー {username} は正常に削除されました",
|
||||||
|
"toastDeleteUserFailed": "ユーザーの削除に失敗しました: {error}",
|
||||||
"errorTitle": "エラー",
|
"errorTitle": "エラー",
|
||||||
"errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります",
|
"errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります",
|
||||||
"toastAvatarUploadedTitle": "アバターをアップロードしました",
|
"toastAvatarUploadedTitle": "アバターをアップロードしました",
|
||||||
@@ -287,7 +293,13 @@
|
|||||||
"disablePasswordLabel": "パスワードを無効化",
|
"disablePasswordLabel": "パスワードを無効化",
|
||||||
"cancelButton": "キャンセル",
|
"cancelButton": "キャンセル",
|
||||||
"saveChangesButton": "変更を保存",
|
"saveChangesButton": "変更を保存",
|
||||||
"createUserButton": "ユーザーを作成"
|
"createUserButton": "ユーザーを作成",
|
||||||
|
"deleteAccountButton": "アカウントを削除",
|
||||||
|
"deletingButtonText": "削除中...",
|
||||||
|
"areYouSure": "本当によろしいですか?",
|
||||||
|
"deleteUserConfirmation": "ユーザー {username} を削除してもよろしいですか?",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"confirmDeleteButtonText": "削除"
|
||||||
},
|
},
|
||||||
"ViewToggle": {
|
"ViewToggle": {
|
||||||
"habitsLabel": "習慣",
|
"habitsLabel": "習慣",
|
||||||
@@ -411,6 +423,7 @@
|
|||||||
"cancel": "キャンセル"
|
"cancel": "キャンセル"
|
||||||
},
|
},
|
||||||
"useCoins": {
|
"useCoins": {
|
||||||
|
"addedCoinsDescription": "{amount}コインを追加しました",
|
||||||
"invalidAmountTitle": "無効な値です",
|
"invalidAmountTitle": "無効な値です",
|
||||||
"invalidAmountDescription": "有効な正の数を入力してください",
|
"invalidAmountDescription": "有効な正の数を入力してください",
|
||||||
"successTitle": "成功しました",
|
"successTitle": "成功しました",
|
||||||
|
|||||||
@@ -270,6 +270,12 @@
|
|||||||
"actionUpdate": "обновить",
|
"actionUpdate": "обновить",
|
||||||
"actionCreate": "создать",
|
"actionCreate": "создать",
|
||||||
"errorFailedUserAction": "Не удалось {action} пользователя",
|
"errorFailedUserAction": "Не удалось {action} пользователя",
|
||||||
|
"toastDemoDeleteDisabled": "Удаление отключено в демо-версии",
|
||||||
|
"toastCannotDeleteSelf": "Вы не можете удалить свою учетную запись",
|
||||||
|
"confirmDeleteUser": "Вы уверены, что хотите удалить пользователя {username}?",
|
||||||
|
"toastUserDeletedTitle": "Пользователь удален",
|
||||||
|
"toastUserDeletedDescription": "Пользователь {username} успешно удален",
|
||||||
|
"toastDeleteUserFailed": "Не удалось удалить пользователя: {error}",
|
||||||
"errorTitle": "Ошибка",
|
"errorTitle": "Ошибка",
|
||||||
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
|
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
|
||||||
"toastAvatarUploadedTitle": "Аватар загружен",
|
"toastAvatarUploadedTitle": "Аватар загружен",
|
||||||
@@ -287,7 +293,13 @@
|
|||||||
"disablePasswordLabel": "Отключить пароль",
|
"disablePasswordLabel": "Отключить пароль",
|
||||||
"cancelButton": "Отмена",
|
"cancelButton": "Отмена",
|
||||||
"saveChangesButton": "Сохранить изменения",
|
"saveChangesButton": "Сохранить изменения",
|
||||||
"createUserButton": "Создать пользователя"
|
"createUserButton": "Создать пользователя",
|
||||||
|
"deleteAccountButton": "Удалить аккаунт",
|
||||||
|
"deletingButtonText": "Удаление...",
|
||||||
|
"areYouSure": "Вы уверены?",
|
||||||
|
"deleteUserConfirmation": "Вы уверены, что хотите удалить пользователя {username}?",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"confirmDeleteButtonText": "Удалить"
|
||||||
},
|
},
|
||||||
"ViewToggle": {
|
"ViewToggle": {
|
||||||
"habitsLabel": "Привычки",
|
"habitsLabel": "Привычки",
|
||||||
@@ -411,6 +423,7 @@
|
|||||||
"cancel": "Отмена"
|
"cancel": "Отмена"
|
||||||
},
|
},
|
||||||
"useCoins": {
|
"useCoins": {
|
||||||
|
"addedCoinsDescription": "Добавлено {amount} монет",
|
||||||
"invalidAmountTitle": "Неверная сумма",
|
"invalidAmountTitle": "Неверная сумма",
|
||||||
"invalidAmountDescription": "Пожалуйста, введите положительное число",
|
"invalidAmountDescription": "Пожалуйста, введите положительное число",
|
||||||
"successTitle": "Успех",
|
"successTitle": "Успех",
|
||||||
|
|||||||
@@ -270,6 +270,12 @@
|
|||||||
"actionUpdate": "更新",
|
"actionUpdate": "更新",
|
||||||
"actionCreate": "创建",
|
"actionCreate": "创建",
|
||||||
"errorFailedUserAction": "用户 {action} 失败",
|
"errorFailedUserAction": "用户 {action} 失败",
|
||||||
|
"toastDemoDeleteDisabled": "在演示实例中删除已禁用",
|
||||||
|
"toastCannotDeleteSelf": "您不能删除自己的帐户",
|
||||||
|
"confirmDeleteUser": "您确定要删除用户 {username} 吗?",
|
||||||
|
"toastUserDeletedTitle": "用户已删除",
|
||||||
|
"toastUserDeletedDescription": "用户 {username} 已成功删除",
|
||||||
|
"toastDeleteUserFailed": "删除用户失败: {error}",
|
||||||
"errorTitle": "错误",
|
"errorTitle": "错误",
|
||||||
"errorFileSizeLimit": "文件大小必须小于 5MB",
|
"errorFileSizeLimit": "文件大小必须小于 5MB",
|
||||||
"toastAvatarUploadedTitle": "头像已上传",
|
"toastAvatarUploadedTitle": "头像已上传",
|
||||||
@@ -287,7 +293,13 @@
|
|||||||
"disablePasswordLabel": "禁用密码",
|
"disablePasswordLabel": "禁用密码",
|
||||||
"cancelButton": "取消",
|
"cancelButton": "取消",
|
||||||
"saveChangesButton": "保存更改",
|
"saveChangesButton": "保存更改",
|
||||||
"createUserButton": "创建用户"
|
"createUserButton": "创建用户",
|
||||||
|
"deleteAccountButton": "删除账户",
|
||||||
|
"deletingButtonText": "正在删除...",
|
||||||
|
"areYouSure": "您确定吗?",
|
||||||
|
"deleteUserConfirmation": "您确定要删除用户 {username} 吗?",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirmDeleteButtonText": "删除"
|
||||||
},
|
},
|
||||||
"ViewToggle": {
|
"ViewToggle": {
|
||||||
"habitsLabel": "习惯",
|
"habitsLabel": "习惯",
|
||||||
@@ -411,6 +423,7 @@
|
|||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
"useCoins": {
|
"useCoins": {
|
||||||
|
"addedCoinsDescription": "已添加 {amount} 个金币",
|
||||||
"invalidAmountTitle": "无效金额",
|
"invalidAmountTitle": "无效金额",
|
||||||
"invalidAmountDescription": "请输入有效的正数",
|
"invalidAmountDescription": "请输入有效的正数",
|
||||||
"successTitle": "成功",
|
"successTitle": "成功",
|
||||||
|
|||||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.13",
|
"version": "0.2.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.13",
|
"version": "0.2.20",
|
||||||
"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",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"js-confetti": "^0.12.0",
|
"js-confetti": "^0.12.0",
|
||||||
|
"json-stable-stringify": "^1.3.0",
|
||||||
"linkify": "^0.2.1",
|
"linkify": "^0.2.1",
|
||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/archiver": "^6.0.3",
|
"@types/archiver": "^6.0.3",
|
||||||
"@types/bun": "^1.1.14",
|
"@types/bun": "^1.1.14",
|
||||||
|
"@types/json-stable-stringify": "^1.1.0",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^20.17.10",
|
"@types/node": "^20.17.10",
|
||||||
@@ -2851,6 +2853,13 @@
|
|||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/json-stable-stringify": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json5": {
|
"node_modules/@types/json5": {
|
||||||
"version": "0.0.29",
|
"version": "0.0.29",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||||
@@ -3999,7 +4008,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.0",
|
"call-bind-apply-helpers": "^1.0.0",
|
||||||
"es-define-property": "^1.0.0",
|
"es-define-property": "^1.0.0",
|
||||||
@@ -4014,10 +4022,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -4027,13 +4035,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/call-bound": {
|
"node_modules/call-bound": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
"get-intrinsic": "^1.2.6"
|
"get-intrinsic": "^1.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4604,7 +4612,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0",
|
"es-define-property": "^1.0.0",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -4703,7 +4710,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -4831,7 +4837,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -4840,7 +4845,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -4880,10 +4884,10 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
},
|
},
|
||||||
@@ -5644,21 +5648,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.2.6",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
"dunder-proto": "^1.0.0",
|
|
||||||
"es-define-property": "^1.0.1",
|
"es-define-property": "^1.0.1",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"es-object-atoms": "^1.0.0",
|
"es-object-atoms": "^1.1.1",
|
||||||
"function-bind": "^1.1.2",
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
"gopd": "^1.2.0",
|
"gopd": "^1.2.0",
|
||||||
"has-symbols": "^1.1.0",
|
"has-symbols": "^1.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.2",
|
||||||
"math-intrinsics": "^1.0.0"
|
"math-intrinsics": "^1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5675,6 +5679,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-symbol-description": {
|
"node_modules/get-symbol-description": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
||||||
@@ -5795,7 +5812,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
@@ -5839,7 +5855,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0"
|
"es-define-property": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -5866,7 +5881,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
@@ -6523,8 +6537,7 @@
|
|||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -6671,6 +6684,25 @@
|
|||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stable-stringify": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"isarray": "^2.0.5",
|
||||||
|
"jsonify": "^0.0.1",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -6689,6 +6721,15 @@
|
|||||||
"json5": "lib/cli.js"
|
"json5": "lib/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonify": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||||
|
"license": "Public Domain",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -6966,7 +7007,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -7896,7 +7936,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -8958,7 +8997,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.1.4",
|
"define-data-property": "^1.1.4",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.15",
|
"version": "0.2.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -62,7 +62,6 @@
|
|||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^11.0.5",
|
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 101 KiB |
Reference in New Issue
Block a user