Merge Tag v0.2.18

This commit is contained in:
2025-06-13 21:22:11 +02:00
11 changed files with 221 additions and 104 deletions

View File

@@ -1,12 +1,13 @@
'use client'
import { ReactNode, useEffect } from 'react'
import { ReactNode, Suspense, useEffect, useState } from 'react'
import { useAtom } from 'jotai'
import { aboutOpenAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms'
import PomodoroTimer from './PomodoroTimer'
import UserSelectModal from './UserSelectModal'
import { useSession } from 'next-auth/react'
import AboutModal from './AboutModal'
import LoadingSpinner from './LoadingSpinner'
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)
@@ -14,6 +15,12 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const { data: session, status } = useSession()
const currentUserId = session?.user.id
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);
}, []);
useEffect(() => {
if (status === 'loading') return
@@ -22,6 +29,9 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
}
}, [currentUserId, status, userSelect, setUserSelect])
if (!isMounted) {
return <LoadingSpinner />
}
return (
<>
{children}

View File

@@ -0,0 +1,33 @@
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>
);
}

View File

@@ -1,25 +1,13 @@
'use client'
import { FormattedNumber } from '@/components/FormattedNumber'
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 NotificationBell from './NotificationBell'
import { Profile } from './Profile'
import HeaderActions from './HeaderActions'
interface HeaderProps {
className?: string
}
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function Header({ className }: HeaderProps) {
const [settings] = useAtom(settingsAtom)
const { balance } = useCoins()
return (
<>
<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">
<Logo />
</Link>
<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>
<HeaderActions />
</div>
</div>
</header>

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

View File

@@ -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">
<Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden">
<Navigation viewPort='main' />
<Navigation position='main' />
<div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
{/* responsive container (optimized for mobile) */}
@@ -17,7 +17,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</ClientWrapper>
</div>
</main>
<Navigation viewPort='mobile' />
<Navigation position='mobile' />
</div>
</div>
</div>

View 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;

View File

@@ -0,0 +1,33 @@
import Link from 'next/link'
import { NavDisplayProps, NavItemType } from './Navigation';
import { usePathname } from 'next/navigation';
import { useHelpers } from '@/lib/client-helpers';
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>
</>
);
}

View File

@@ -1,33 +1,39 @@
'use client'
import { useHelpers } from '@/lib/client-helpers'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { ElementType, useEffect, useState } from 'react'
import DesktopNavDisplay from './DesktopNavDisplay'
import MobileNavDisplay from './MobileNavDisplay'
type ViewPort = 'main' | 'mobile'
interface NavigationProps {
viewPort: ViewPort
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
}
export default function Navigation({ viewPort }: NavigationProps) {
const t = useTranslations('Navigation')
const [showAbout, setShowAbout] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
const { isIOS } = useHelpers()
const pathname = usePathname();
interface NavigationProps {
position: ViewPort
}
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' },
export interface NavDisplayProps {
navItems: NavItemType[];
}
export default function Navigation({ position: viewPort }: NavigationProps) {
const t = useTranslations('Navigation')
const [isMobileView, setIsMobileView] = useState(false)
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' },
]
useEffect(() => {
@@ -46,56 +52,12 @@ export default function Navigation({ viewPort }: NavigationProps) {
}, [])
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>
</>
)
return <MobileNavDisplay navItems={currentNavItems} />
}
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>
</div>
)
return <DesktopNavDisplay navItems={currentNavItems} />
}
return null // Explicitly return null if no view matches
}