Finish interrupted features: tab prefetch, pet viewport, and home logo.
Add shared quiz/habits tab cache with nav hover prefetch and AppDataPrefetcher, make the pet screen fill and center the viewport, and point the HaBit logo to /.
This commit is contained in:
36
components/layout/app-data-prefetcher.tsx
Normal file
36
components/layout/app-data-prefetcher.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||
import {
|
||||
invalidateAppTabDataCache,
|
||||
prefetchAppTabData,
|
||||
} from "@/lib/app-tab-data-cache";
|
||||
|
||||
type AppDataPrefetcherProps = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function AppDataPrefetcher({ enabled }: AppDataPrefetcherProps) {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void prefetchAppTabData();
|
||||
|
||||
const handleDataUpdated = () => {
|
||||
invalidateAppTabDataCache();
|
||||
void prefetchAppTabData({ force: true });
|
||||
};
|
||||
|
||||
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appNavItems, routes, type NavItem } from "@/lib/routes";
|
||||
import {
|
||||
prefetchHabitsTabData,
|
||||
prefetchQuizTabData,
|
||||
} from "@/lib/app-tab-data-cache";
|
||||
|
||||
const gridColsClass = {
|
||||
1: "grid-cols-1",
|
||||
@@ -31,6 +35,15 @@ const navIcons = {
|
||||
[routes.profile]: UserRound,
|
||||
} as const;
|
||||
|
||||
const prefetchByRoute: Partial<Record<string, () => void>> = {
|
||||
[routes.habits]: () => {
|
||||
void prefetchHabitsTabData();
|
||||
},
|
||||
[routes.dailyQuiz]: () => {
|
||||
void prefetchQuizTabData();
|
||||
},
|
||||
};
|
||||
|
||||
type AppNavProps = {
|
||||
items?: readonly NavItem[];
|
||||
};
|
||||
@@ -76,6 +89,9 @@ export const AppNav = forwardRef<HTMLElement, AppNavProps>(function AppNav(
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
prefetch
|
||||
onPointerEnter={() => prefetchByRoute[item.href]?.()}
|
||||
onFocus={() => prefetchByRoute[item.href]?.()}
|
||||
className={cn(
|
||||
"bottom-nav-item flex flex-col items-center justify-center gap-1.5 overflow-hidden border-2 px-1 py-2 text-[9px] leading-none transition-colors duration-75",
|
||||
isActive
|
||||
|
||||
@@ -7,6 +7,10 @@ type AppShellProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
wide?: boolean;
|
||||
/** Use remaining viewport height between top bar and bottom nav. */
|
||||
fillViewport?: boolean;
|
||||
/** Center page content horizontally (pairs well with fillViewport). */
|
||||
centered?: boolean;
|
||||
};
|
||||
|
||||
const contentWidthClass = {
|
||||
@@ -19,20 +23,33 @@ export function AppShell({
|
||||
title,
|
||||
description,
|
||||
wide = false,
|
||||
fillViewport = false,
|
||||
centered = false,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div className="relative min-h-dvh bg-background bg-[radial-gradient(circle_at_20%_0%,hsl(var(--primary)/0.12)_0%,transparent_45%),radial-gradient(circle_at_80%_100%,hsl(var(--secondary)/0.1)_0%,transparent_40%)] pt-topbar">
|
||||
<div className="relative flex min-h-dvh flex-col bg-background bg-[radial-gradient(circle_at_20%_0%,hsl(var(--primary)/0.12)_0%,transparent_45%),radial-gradient(circle_at_80%_100%,hsl(var(--secondary)/0.1)_0%,transparent_40%)] pt-topbar">
|
||||
<AppTopBar />
|
||||
<main
|
||||
className={cn(
|
||||
"mx-auto px-4 py-6 lg:px-5",
|
||||
"mx-auto flex w-full flex-1 flex-col px-4 py-6 lg:px-5",
|
||||
fillViewport
|
||||
? "min-h-[calc(100dvh-var(--app-topbar-height)-var(--app-nav-offset)-env(safe-area-inset-top,0px))]"
|
||||
: null,
|
||||
centered ? "items-center" : null,
|
||||
wide ? contentWidthClass.wide : contentWidthClass.default,
|
||||
fillViewport && "max-w-none lg:max-w-none",
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<AppPageHeader title={title} description={description} />
|
||||
) : null}
|
||||
{children}
|
||||
<div
|
||||
className={cn(
|
||||
fillViewport ? "pet-viewport-stage flex w-full flex-1 flex-col" : "contents",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ type AppTopBarProps = {
|
||||
|
||||
export function AppTopBar({ className }: AppTopBarProps) {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname === routes.avatar;
|
||||
const isHome =
|
||||
pathname === routes.home || pathname === routes.avatar;
|
||||
|
||||
return (
|
||||
<header
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { AppNav } from "@/components/layout/app-nav";
|
||||
import { AppDataPrefetcher } from "@/components/layout/app-data-prefetcher";
|
||||
import {
|
||||
NavOffsetProvider,
|
||||
useNavOffsetRef,
|
||||
@@ -55,6 +56,7 @@ export function GlobalBottomNav() {
|
||||
|
||||
return (
|
||||
<NavOffsetProvider enabled={showNav}>
|
||||
<AppDataPrefetcher enabled={showNav} />
|
||||
{showNav ? <MeasuredAppNav /> : null}
|
||||
</NavOffsetProvider>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ type HabitBrandProps = {
|
||||
|
||||
export function HabitBrand({
|
||||
className,
|
||||
href = routes.avatar,
|
||||
href = routes.home,
|
||||
"aria-current": ariaCurrent,
|
||||
}: HabitBrandProps) {
|
||||
const [useText, setUseText] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user