mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 20:09:50 +01:00
Compare commits
6 Commits
v0.2.23.0
...
1b17d6b50a
| Author | SHA1 | Date | |
|---|---|---|---|
|
1b17d6b50a
|
|||
|
8269f3adad
|
|||
|
4cadf4cea7
|
|||
|
06e802f2f5
|
|||
|
6c0b196de2
|
|||
|
0f073760ee
|
20
README.md
20
README.md
@@ -1,14 +1,24 @@
|
|||||||
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> 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,7 +21,7 @@ import {
|
|||||||
WishlistData,
|
WishlistData,
|
||||||
WishlistItemType
|
WishlistItemType
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { d2t, generateCryptoHash, getNow, prepareDataForHashing, uuid } from '@/lib/utils';
|
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
||||||
import { signInSchema } from '@/lib/zod';
|
import { signInSchema } from '@/lib/zod';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
@@ -33,21 +33,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -91,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
|
||||||
}
|
}
|
||||||
@@ -126,11 +111,13 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
async function calculateServerFreshnessToken(): Promise<string | null> {
|
async function calculateServerFreshnessToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const settings = await loadSettings();
|
const [settings, habits, coins, wishlist, users] = await Promise.all([
|
||||||
const habits = await loadHabitsData();
|
loadSettings(),
|
||||||
const coins = await loadCoinsData();
|
loadHabitsData(),
|
||||||
const wishlist = await loadWishlistData();
|
loadCoinsData(),
|
||||||
const users = await loadUsersData();
|
loadWishlistData(),
|
||||||
|
loadUsersData()
|
||||||
|
]);
|
||||||
|
|
||||||
const dataString = prepareDataForHashing(
|
const dataString = prepareDataForHashing(
|
||||||
settings,
|
settings,
|
||||||
@@ -139,8 +126,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
|
|||||||
wishlist,
|
wishlist,
|
||||||
users
|
users
|
||||||
);
|
);
|
||||||
const serverToken = await generateCryptoHash(dataString);
|
return generateCryptoHash(dataString);
|
||||||
return serverToken;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error calculating server freshness token:", error);
|
console.error("Error calculating server freshness token:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -150,7 +136,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
|
|||||||
// Wishlist specific functions
|
// Wishlist specific functions
|
||||||
export async function loadWishlistData(): Promise<WishlistData> {
|
export async function loadWishlistData(): Promise<WishlistData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultWishlistData()
|
if (!user) return getDefaultWishlistData<WishlistData>()
|
||||||
|
|
||||||
const data = await loadData<WishlistData>('wishlist')
|
const data = await loadData<WishlistData>('wishlist')
|
||||||
return {
|
return {
|
||||||
@@ -165,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 => ({
|
||||||
@@ -188,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)
|
||||||
@@ -210,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,
|
||||||
@@ -226,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>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,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,
|
||||||
@@ -297,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()
|
||||||
@@ -328,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,
|
||||||
@@ -390,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>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,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,
|
||||||
|
|||||||
@@ -12,20 +12,12 @@ 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'
|
||||||
|
|
||||||
// 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',
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>ohsimpson
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="description" className="text-right">
|
<Label htmlFor="description" className="text-right">
|
||||||
{t('descriptionLabel')}
|
{t('descriptionLabel')}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { NavDisplayProps } from './Navigation';
|
|
||||||
|
|
||||||
export default function DesktopNavDisplay({ navItems }: NavDisplayProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
|
||||||
<div className="flex flex-col w-64">
|
|
||||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
|
||||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
|
||||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.label}
|
|
||||||
href={item.href}
|
|
||||||
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
|
||||||
(pathname === (item.href) ?
|
|
||||||
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
|
||||||
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
|
||||||
>
|
|
||||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useHelpers } from '@/lib/client-helpers';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { NavDisplayProps } from './Navigation';
|
|
||||||
|
|
||||||
export default function MobileNavDisplay({ navItems }: NavDisplayProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { isIOS } = useHelpers()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={isIOS ? "pb-20" : "pb-16"} />
|
|
||||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
|
||||||
<div className="grid grid-cols-6 w-full">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.label}
|
|
||||||
href={item.href}
|
|
||||||
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
|
||||||
(pathname === (item.href) ?
|
|
||||||
"text-blue-500 dark:text-blue-500" :
|
|
||||||
"text-gray-300 dark:text-gray-300")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<item.icon className="h-6 w-6" />
|
|
||||||
<span className="text-xs mt-1">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
61
components/NavDisplay.tsx
Normal file
61
components/NavDisplay.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useHelpers } from '@/lib/client-helpers';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { NavItemType } from './Navigation';
|
||||||
|
|
||||||
|
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isIOS } = useHelpers()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)}
|
||||||
|
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||||
|
<div className="grid grid-cols-6 w-full">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
||||||
|
(pathname === (item.href) ?
|
||||||
|
"text-blue-500 dark:text-blue-500" :
|
||||||
|
"text-gray-300 dark:text-gray-300")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-6 w-6" />
|
||||||
|
<span className="text-xs mt-1">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||||
|
<div className="flex flex-col w-64">
|
||||||
|
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||||
|
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
|
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
||||||
|
(pathname === (item.href) ?
|
||||||
|
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
||||||
|
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,7 @@ import { HabitIcon, TaskIcon } from '@/lib/constants'
|
|||||||
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { ElementType, useEffect, useState } from 'react'
|
import { ElementType, useEffect, useState } from 'react'
|
||||||
import DesktopNavDisplay from './DesktopNavDisplay'
|
import NavDisplay from './NavDisplay'
|
||||||
import MobileNavDisplay from './MobileNavDisplay'
|
|
||||||
|
|
||||||
type ViewPort = 'main' | 'mobile'
|
|
||||||
|
|
||||||
export interface NavItemType {
|
export interface NavItemType {
|
||||||
icon: ElementType;
|
icon: ElementType;
|
||||||
@@ -15,17 +12,15 @@ export interface NavItemType {
|
|||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigationProps {
|
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
|
||||||
position: ViewPort
|
const t = useTranslations('Navigation');
|
||||||
}
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
|
||||||
|
|
||||||
export interface NavDisplayProps {
|
useEffect(() => {
|
||||||
navItems: NavItemType[];
|
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
|
||||||
}
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
export default function Navigation({ position: viewPort }: NavigationProps) {
|
}, [setIsMobile]);
|
||||||
const t = useTranslations('Navigation')
|
|
||||||
const [isMobileView, setIsMobileView] = useState(false)
|
|
||||||
|
|
||||||
const currentNavItems: NavItemType[] = [
|
const currentNavItems: NavItemType[] = [
|
||||||
{ icon: Home, label: t('dashboard'), href: '/' },
|
{ icon: Home, label: t('dashboard'), href: '/' },
|
||||||
@@ -36,28 +31,10 @@ export default function Navigation({ position: viewPort }: NavigationProps) {
|
|||||||
{ icon: Coins, label: t('coins'), href: '/coins' },
|
{ icon: Coins, label: t('coins'), href: '/coins' },
|
||||||
]
|
]
|
||||||
|
|
||||||
useEffect(() => {
|
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
|
||||||
const handleResize = () => {
|
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
|
||||||
setIsMobileView(window.innerWidth < 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial value
|
|
||||||
handleResize()
|
|
||||||
|
|
||||||
// Add event listener
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (viewPort === 'mobile' && isMobileView) {
|
|
||||||
return <MobileNavDisplay navItems={currentNavItems} />
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
if (viewPort === 'main' && !isMobileView) {
|
return <></>
|
||||||
return <DesktopNavDisplay navItems={currentNavItems} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null // Explicitly return null if no view matches
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,23 @@
|
|||||||
import { useAtom } from 'jotai';
|
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission, roundToInteger } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
|
coinsBalanceAtom,
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
|
coinsSpentTodayAtom,
|
||||||
|
currentUserAtom,
|
||||||
|
settingsAtom,
|
||||||
totalEarnedAtom,
|
totalEarnedAtom,
|
||||||
totalSpentAtom,
|
totalSpentAtom,
|
||||||
coinsSpentTodayAtom,
|
|
||||||
transactionsTodayAtom,
|
transactionsTodayAtom,
|
||||||
coinsBalanceAtom,
|
|
||||||
settingsAtom,
|
|
||||||
usersAtom,
|
usersAtom,
|
||||||
currentUserAtom,
|
} from '@/lib/atoms';
|
||||||
} from '@/lib/atoms'
|
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
||||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
import { CoinsData } from '@/lib/types';
|
||||||
import { CoinsData, User } from '@/lib/types'
|
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
|
||||||
import { toast } from '@/hooks/use-toast'
|
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');
|
||||||
|
|||||||
@@ -1,54 +1,24 @@
|
|||||||
import { useAtom, atom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { 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 { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
function handlePermissionCheck(
|
|
||||||
user: SafeUser | User | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useHabits() {
|
export function useHabits() {
|
||||||
const t = useTranslations('useHabits');
|
const t = useTranslations('useHabits');
|
||||||
@@ -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,40 +1,13 @@
|
|||||||
|
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
|
||||||
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { handlePermissionCheck } from '@/lib/utils'
|
||||||
|
import { celebrations } from '@/utils/celebrations'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
|
||||||
import { toast } from '@/hooks/use-toast'
|
|
||||||
import { WishlistItemType, User, SafeUser } from '@/lib/types'
|
|
||||||
import { celebrations } from '@/utils/celebrations'
|
|
||||||
import { checkPermission } from '@/lib/utils'
|
|
||||||
import { useCoins } from './useCoins'
|
import { useCoins } from './useCoins'
|
||||||
|
|
||||||
function handlePermissionCheck(
|
|
||||||
user: User | SafeUser | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWishlist() {
|
export function useWishlist() {
|
||||||
const t = useTranslations('useWishlist');
|
const t = useTranslations('useWishlist');
|
||||||
const tCommon = useTranslations('Common');
|
const tCommon = useTranslations('Common');
|
||||||
|
|||||||
32
lib/atoms.ts
32
lib/atoms.ts
@@ -4,10 +4,11 @@ import {
|
|||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
calculateTotalSpent,
|
calculateTotalSpent,
|
||||||
calculateTransactionsToday,
|
calculateTransactionsToday,
|
||||||
|
generateCryptoHash,
|
||||||
getCompletionsForToday,
|
getCompletionsForToday,
|
||||||
getHabitFreq,
|
getHabitFreq,
|
||||||
getTodayInTimezone,
|
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
|
prepareDataForHashing,
|
||||||
roundToInteger,
|
roundToInteger,
|
||||||
t2d
|
t2d
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
@@ -15,6 +16,7 @@ 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,
|
||||||
@@ -24,7 +26,12 @@ import {
|
|||||||
getDefaultUsersData,
|
getDefaultUsersData,
|
||||||
getDefaultWishlistData,
|
getDefaultWishlistData,
|
||||||
Habit,
|
Habit,
|
||||||
UserId
|
HabitsData,
|
||||||
|
ServerSettings,
|
||||||
|
Settings,
|
||||||
|
UserData,
|
||||||
|
UserId,
|
||||||
|
WishlistData
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface BrowserSettings {
|
export interface BrowserSettings {
|
||||||
@@ -39,12 +46,12 @@ 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) => {
|
||||||
@@ -121,8 +128,6 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
|||||||
minimized: false,
|
minimized: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
import { generateCryptoHash, prepareDataForHashing } from '@/lib/utils';
|
|
||||||
|
|
||||||
export const userSelectAtom = atom<boolean>(false)
|
export const userSelectAtom = atom<boolean>(false)
|
||||||
export const aboutOpenAtom = atom<boolean>(false)
|
export const aboutOpenAtom = atom<boolean>(false)
|
||||||
|
|
||||||
@@ -229,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
|
|||||||
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,6 @@ export const QUICK_DATES = [
|
|||||||
{ label: 'Sunday', value: 'this sunday' },
|
{ label: 'Sunday', value: 'this sunday' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const MAX_COIN_LIMIT = 9999
|
export const MAX_COIN_LIMIT = 9999
|
||||||
|
|
||||||
|
export const DESKTOP_DISPLAY_ITEM_COUNT = 4
|
||||||
83
lib/types.ts
83
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,52 +96,58 @@ export interface WishlistData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default value functions
|
// Default value functions
|
||||||
export const getDefaultUsersData = (): UserData => ({
|
export function getDefaultUsersData<UserData>(): UserData {
|
||||||
users: [
|
return {
|
||||||
{
|
users: [
|
||||||
id: uuid(),
|
{
|
||||||
username: 'admin',
|
id: crypto.randomUUID(),
|
||||||
// password: '', // No default password for admin initially? Or set a secure default?
|
username: 'admin',
|
||||||
isAdmin: true,
|
// password: '', // No default password for admin initially? Or set a secure default?
|
||||||
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
isAdmin: true,
|
||||||
}
|
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 {
|
||||||
ui: {
|
return {
|
||||||
useNumberFormatting: true,
|
ui: {
|
||||||
useGrouping: true,
|
useNumberFormatting: true,
|
||||||
},
|
useGrouping: true,
|
||||||
system: {
|
},
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
system: {
|
||||||
weekStartDay: 1, // Monday
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
autoBackupEnabled: true, // Add this line (default to true)
|
weekStartDay: 1, // Monday
|
||||||
language: 'en', // Default language
|
autoBackupEnabled: true, // Add this line (default to true)
|
||||||
},
|
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,15 @@ import {
|
|||||||
calculateCoinsSpentToday,
|
calculateCoinsSpentToday,
|
||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
uuid,
|
|
||||||
isTaskOverdue,
|
isTaskOverdue,
|
||||||
deserializeRRule,
|
deserializeRRule,
|
||||||
serializeRRule,
|
serializeRRule,
|
||||||
convertHumanReadableFrequencyToMachineReadable,
|
convertHumanReadableFrequencyToMachineReadable,
|
||||||
convertMachineReadableFrequencyToHumanReadable,
|
convertMachineReadableFrequencyToHumanReadable,
|
||||||
prepareDataForHashing,
|
prepareDataForHashing,
|
||||||
generateCryptoHash,
|
|
||||||
getUnsupportedRRuleReason,
|
getUnsupportedRRuleReason,
|
||||||
roundToInteger
|
roundToInteger,
|
||||||
|
generateCryptoHash
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
|
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
@@ -178,32 +174,6 @@ describe('isTaskOverdue', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('uuid', () => {
|
|
||||||
test('should generate valid UUIDs', () => {
|
|
||||||
const id = uuid()
|
|
||||||
// UUID v4 format: 8-4-4-4-12 hex digits
|
|
||||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should generate unique UUIDs', () => {
|
|
||||||
const ids = new Set()
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
ids.add(uuid())
|
|
||||||
}
|
|
||||||
// All 1000 UUIDs should be unique
|
|
||||||
expect(ids.size).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should generate v4 UUIDs', () => {
|
|
||||||
const id = uuid()
|
|
||||||
// Version 4 UUID has specific bits set:
|
|
||||||
// - 13th character is '4'
|
|
||||||
// - 17th character is '8', '9', 'a', or 'b'
|
|
||||||
expect(id.charAt(14)).toBe('4')
|
|
||||||
expect('89ab').toContain(id.charAt(19))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('datetime utilities', () => {
|
describe('datetime utilities', () => {
|
||||||
let fixedNow: DateTime;
|
let fixedNow: DateTime;
|
||||||
let currentDateIndex = 0;
|
let currentDateIndex = 0;
|
||||||
@@ -321,13 +291,6 @@ describe('getNow', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNowInMilliseconds', () => {
|
|
||||||
test('should return current time in milliseconds', () => {
|
|
||||||
const now = DateTime.now().setZone('UTC')
|
|
||||||
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('timestamp conversion utilities', () => {
|
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);
|
||||||
@@ -351,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', () => {
|
||||||
@@ -989,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('freshness utilities', () => {
|
describe('freshness utilities', () => {
|
||||||
const mockSettings: Settings = getDefaultSettings();
|
const mockSettings: Settings = getDefaultSettings<Settings>();
|
||||||
const mockHabits: HabitsData = getDefaultHabitsData();
|
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
|
||||||
const mockCoins: CoinsData = getDefaultCoinsData();
|
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
|
||||||
const mockWishlist: WishlistData = getDefaultWishlistData();
|
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
|
||||||
const mockUsers: UserData = getDefaultUsersData();
|
const mockUsers: UserData = getDefaultUsersData<UserData>();
|
||||||
|
|
||||||
// Add a user to mockUsers for more realistic testing
|
// Add a user to mockUsers for more realistic testing
|
||||||
mockUsers.users.push({
|
mockUsers.users.push({
|
||||||
@@ -1038,11 +991,11 @@ describe('freshness utilities', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle empty data consistently', () => {
|
test('should handle empty data consistently', () => {
|
||||||
const emptySettings = getDefaultSettings();
|
const emptySettings = getDefaultSettings<Settings>();
|
||||||
const emptyHabits = getDefaultHabitsData();
|
const emptyHabits = getDefaultHabitsData<HabitsData>();
|
||||||
const emptyCoins = getDefaultCoinsData();
|
const emptyCoins = getDefaultCoinsData<CoinsData>();
|
||||||
const emptyWishlist = getDefaultWishlistData();
|
const emptyWishlist = getDefaultWishlistData<WishlistData>();
|
||||||
const emptyUsers = getDefaultUsersData();
|
const emptyUsers = getDefaultUsersData<UserData>();
|
||||||
|
|
||||||
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
|
|||||||
128
lib/utils.ts
128
lib/utils.ts
@@ -1,13 +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, User, Settings, HabitsData, CoinsData, WishlistData, UserData } 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 stableStringify from 'json-stable-stringify';
|
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))
|
||||||
@@ -33,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);
|
||||||
@@ -61,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,
|
||||||
@@ -438,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]
|
||||||
@@ -467,27 +439,6 @@ export function checkPermission(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uuid() {
|
|
||||||
return uuidv4()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasPermission(
|
|
||||||
currentUser: User | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact'
|
|
||||||
): boolean {
|
|
||||||
// If no current user, no permissions.
|
|
||||||
if (!currentUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// If user is admin, they have all permissions.
|
|
||||||
if (currentUser.isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Otherwise, check specific permissions.
|
|
||||||
return checkPermission(currentUser.permissions, resource, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares a consistent string representation of the data for hashing.
|
* 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.
|
* It combines all relevant data pieces into a single object and then stringifies it stably.
|
||||||
@@ -499,22 +450,13 @@ export function prepareDataForHashing(
|
|||||||
wishlist: WishlistData,
|
wishlist: WishlistData,
|
||||||
users: UserData
|
users: UserData
|
||||||
): string {
|
): string {
|
||||||
// Combine all data into a single object.
|
return JSON.stringify({
|
||||||
// The order of keys in this object itself doesn't matter due to stableStringify,
|
|
||||||
// but being explicit helps in understanding what's being hashed.
|
|
||||||
const combinedData = {
|
|
||||||
settings,
|
settings,
|
||||||
habits,
|
habits,
|
||||||
coins,
|
coins,
|
||||||
wishlist,
|
wishlist,
|
||||||
users,
|
users,
|
||||||
};
|
});
|
||||||
const stringifiedData = stableStringify(combinedData);
|
|
||||||
// Handle cases where stringify might return undefined.
|
|
||||||
if (stringifiedData === undefined) {
|
|
||||||
throw new Error("Failed to stringify data for hashing. stableStringify returned undefined.");
|
|
||||||
}
|
|
||||||
return stringifiedData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -539,3 +481,31 @@ export async function generateCryptoHash(dataString: string): Promise<string | n
|
|||||||
return null;
|
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
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@
|
|||||||
"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",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -63,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"
|
||||||
},
|
},
|
||||||
@@ -72,7 +70,6 @@
|
|||||||
"@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",
|
||||||
|
|||||||
Reference in New Issue
Block a user