mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Added improved loading screen (#148)
This commit is contained in:
@@ -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])
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
42
components/DesktopNavDisplay.tsx
Normal file
42
components/DesktopNavDisplay.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Link from 'next/link'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface DesktopNavDisplayProps {
|
||||
navItems: NavItemType[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DesktopNavDisplay({ navItems, className }: DesktopNavDisplayProps) {
|
||||
// Filter for items relevant to desktop view, typically 'main' position
|
||||
const desktopNavItems = navItems.filter(item => item.position === 'main');
|
||||
|
||||
return (
|
||||
<div className={`hidden lg:flex lg:flex-shrink-0 ${className || ''}`}>
|
||||
<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">
|
||||
{desktopNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label} // Assuming labels are unique
|
||||
href={item.href}
|
||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Profile } from './Profile'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
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 [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const { balance } = useCoins()
|
||||
return (
|
||||
<>
|
||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
||||
@@ -39,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>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
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;
|
||||
45
components/MobileNavDisplay.tsx
Normal file
45
components/MobileNavDisplay.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Link from 'next/link'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface MobileNavDisplayProps {
|
||||
navItems: NavItemType[];
|
||||
isIOS: boolean;
|
||||
}
|
||||
|
||||
export default function MobileNavDisplay({ navItems, isIOS }: MobileNavDisplayProps) {
|
||||
// Filter for items relevant to mobile view, typically 'main' and 'bottom' positions
|
||||
const mobileNavItems = navItems.filter(item => item.position === 'main' || item.position === 'bottom');
|
||||
// The original code spread main and bottom items separately, effectively concatenating them.
|
||||
// If specific ordering or duplication was intended, that logic would be here.
|
||||
// For now, a simple filter and map should suffice if all items are distinct.
|
||||
// The original code: [...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')]
|
||||
// This implies that items could be in 'main' or 'bottom'. The current navItems only have 'main'.
|
||||
// A simple combined list is fine.
|
||||
|
||||
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-5 w-full">
|
||||
{mobileNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label} // Assuming labels are unique
|
||||
href={item.href}
|
||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
|
||||
import { Home, Calendar, Gift, Coins } from 'lucide-react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, ElementType } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import MobileNavDisplay from './MobileNavDisplay'
|
||||
import DesktopNavDisplay from './DesktopNavDisplay'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface NavigationProps {
|
||||
className?: string
|
||||
viewPort: ViewPort
|
||||
@@ -18,13 +26,12 @@ interface NavigationProps {
|
||||
|
||||
export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
const t = useTranslations('Navigation')
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const { isIOS } = useHelpers()
|
||||
|
||||
const navItems = (isTasksView: boolean) => [
|
||||
const currentNavItems: NavItemType[] = [
|
||||
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
|
||||
{
|
||||
icon: isTasksView ? TaskIcon : HabitIcon,
|
||||
@@ -53,49 +60,12 @@ export default function Navigation({ className, 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-5 w-full">
|
||||
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
return <MobileNavDisplay navItems={currentNavItems} isIOS={isIOS} />
|
||||
}
|
||||
|
||||
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(isTasksView).filter(item => item.position === 'main').map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <DesktopNavDisplay navItems={currentNavItems} className={className} />
|
||||
}
|
||||
|
||||
return null // Explicitly return null if no view matches
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user