Skip to content

Commit 9884d4b

Browse files
authored
refactor: clean up app layout and decouple core concerns
* refactor: extract CalendarSkeleton into a standalone component for reuse * refactor: encapsulate keyboard shortcut logic into a dedicated useAppShortcuts hook * refactor: remove unused desktop and mobile add menu state and associated overlay component * refactor: replace context-based FAB management with a portal-based MobileFAB component * refactor: move SyncListener from AppLayout to root provider for global scope * feat: implement ActionInputModalContext to centralize global action input management * refactor: replace hardcoded nav item ID checks with configurable variant property * feat: add MobileFAB context to conditionally hide default floating action button * refactor: modularize header components by extracting title area, search, and new action into separate files * feat: move database sync loading indicator from header to floating layout component * refactor: introduce HeaderPortalContext and IdleFadeWrapper to streamline header portal management and idle animations * chore: format and fix missing dependency in calendar view * feat: extract DbSyncStatus component and refactor keyboard shortcut handling to a custom event pattern
1 parent 24c4313 commit 9884d4b

21 files changed

Lines changed: 498 additions & 363 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export const CalendarSkeleton = () => (
2+
<div className="flex h-full flex-1 animate-pulse flex-col gap-4 p-4">
3+
{/* Header Skeleton */}
4+
<div className="flex items-center justify-between">
5+
<div className="flex gap-2">
6+
<div className="bg-muted h-9 w-20 rounded-full" />
7+
<div className="bg-muted h-9 w-24 rounded-lg" />
8+
</div>
9+
<div className="bg-muted h-9 w-32 rounded-lg" />
10+
</div>
11+
{/* Grid Skeleton */}
12+
<div className="border-border/40 flex-1 rounded-xl border p-4">
13+
<div className="grid grid-cols-7 gap-2">
14+
{Array.from({ length: 7 }).map((_, i) => (
15+
<div key={i} className="bg-muted/60 h-6 rounded-md" />
16+
))}
17+
</div>
18+
<div className="mt-4 grid h-[calc(100%-2rem)] grid-cols-7 grid-rows-5 gap-2">
19+
{Array.from({ length: 35 }).map((_, i) => (
20+
<div key={i} className="bg-muted/30 flex flex-col gap-2 rounded-lg p-2">
21+
<div className="bg-muted/50 h-4 w-6 rounded" />
22+
{i % 5 === 0 && <div className="bg-primary/20 h-5 w-full rounded" />}
23+
{i % 7 === 2 && <div className="bg-muted/40 h-5 w-full rounded" />}
24+
</div>
25+
))}
26+
</div>
27+
</div>
28+
</div>
29+
);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { createPortal } from "react-dom";
2+
import { useEffect, useState, createContext, useContext } from "react";
3+
import { Button, cn } from "@kreozalabs/kei-ui";
4+
5+
interface MobileFABContextType {
6+
hasCustomFab: boolean;
7+
setHasCustomFab: (has: boolean) => void;
8+
}
9+
10+
const MobileFABContext = createContext<MobileFABContextType>({
11+
hasCustomFab: false,
12+
setHasCustomFab: () => {},
13+
});
14+
15+
export function MobileFABProvider({ children }: { children: React.ReactNode }) {
16+
const [hasCustomFab, setHasCustomFab] = useState(false);
17+
return (
18+
<MobileFABContext.Provider value={{ hasCustomFab, setHasCustomFab }}>
19+
{children}
20+
</MobileFABContext.Provider>
21+
);
22+
}
23+
24+
export function useMobileFAB() {
25+
return useContext(MobileFABContext);
26+
}
27+
28+
interface MobileFABProps {
29+
onClick?: () => void;
30+
className?: string;
31+
children: React.ReactNode;
32+
"aria-label"?: string;
33+
}
34+
35+
export function MobileFAB({
36+
onClick,
37+
className,
38+
children,
39+
"aria-label": ariaLabel,
40+
}: MobileFABProps) {
41+
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
42+
const { setHasCustomFab } = useMobileFAB();
43+
44+
useEffect(() => {
45+
setHasCustomFab(true);
46+
return () => setHasCustomFab(false);
47+
}, [setHasCustomFab]);
48+
49+
useEffect(() => {
50+
const frameId = requestAnimationFrame(() => {
51+
setPortalTarget(document.getElementById("mobile-fab-content"));
52+
});
53+
return () => cancelAnimationFrame(frameId);
54+
}, []);
55+
56+
if (!portalTarget) return null;
57+
58+
return createPortal(
59+
<Button
60+
onClick={onClick}
61+
className={cn(
62+
"bg-primary hover:bg-primary/90 text-primary-foreground group shadow-primary/30 fixed right-6 bottom-24 z-50 flex size-14 items-center justify-center rounded-2xl border-none shadow-2xl transition-all duration-300 active:scale-95 md:hidden",
63+
className
64+
)}
65+
aria-label={ariaLabel}
66+
>
67+
{children}
68+
</Button>,
69+
portalTarget
70+
);
71+
}

apps/web/app/components/dashboard/DashboardHeader.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
// NOTE: On mobile order is different: {calendar that opens down moving page} {space} {search} {today} {view switcher}
22
// NOTE: Otherwise: {title} {space} {today} {arrows} {calendar that opens as popover}{space}{search}{view switcher}{new action}
33

4-
import {
5-
AppHeader,
6-
HeaderTitleArea,
7-
HeaderSearch,
8-
HeaderNewAction,
9-
} from "@/components/layout/AppHeader";
4+
import { AppHeader } from "@/components/layout/AppHeader";
5+
import { HeaderTitleArea } from "@/components/layout/HeaderTitleArea";
6+
import { HeaderSearch } from "@/components/layout/HeaderSearch";
7+
import { HeaderNewAction } from "@/components/layout/HeaderNewAction";
108
import { ViewSwitcher } from "@/routes/app/dashboard/components/ViewSwitcher";
119

1210
export const DashboardHeader = () => {
Lines changed: 7 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import { forwardRef, useState, useEffect } from "react";
21
import { createPortal } from "react-dom";
3-
import { PlusIcon, SearchIcon, Loader2Icon } from "lucide-react";
4-
import { Button, cn, useMediaQuery } from "@kreozalabs/kei-ui";
5-
import { useSettings } from "@/providers/SettingsContext";
6-
import { useSubtleOnIdle } from "@/hooks/useSubtleOnIdle";
7-
import { useDb } from "@/providers/DbContext";
8-
import { useOutletContext } from "react-router";
9-
import type { AppLayoutContext } from "./AppLayout";
2+
import { cn, useMediaQuery } from "@kreozalabs/kei-ui";
3+
import { useHeaderPortalTarget } from "./HeaderPortalContext";
4+
import { IdleFadeWrapper } from "./IdleFadeWrapper";
5+
import { HeaderTitleArea } from "./HeaderTitleArea";
106

117
interface AppHeaderProps {
128
title?: string;
@@ -17,46 +13,17 @@ interface AppHeaderProps {
1713
}
1814

1915
export function AppHeader({ title, subtitle, icon, className, children }: AppHeaderProps) {
20-
const { settings } = useSettings();
21-
const { isSubtle, show, hide } = useSubtleOnIdle({
22-
initialDelay: 3000,
23-
idleDelay: 2000,
24-
disableOnMobile: true,
25-
disabled: !settings.subtle_on_idle,
26-
});
27-
2816
const isMobile = useMediaQuery("(max-width: 768px)");
29-
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
30-
31-
useEffect(() => {
32-
if (typeof window !== "undefined") {
33-
const frameId = requestAnimationFrame(() => {
34-
if (!isMobile) {
35-
setPortalTarget(document.getElementById("global-header-content"));
36-
} else {
37-
setPortalTarget(null);
38-
}
39-
});
40-
return () => cancelAnimationFrame(frameId);
41-
}
42-
}, [isMobile]);
17+
const portalTarget = useHeaderPortalTarget();
4318

4419
const headerContent = (
45-
<div
46-
className={cn(
47-
"flex w-full cursor-default items-center gap-4 transition-[opacity,transform] duration-1000 ease-in-out",
48-
isSubtle ? "translate-y-0.5 opacity-20" : "opacity-100"
49-
)}
50-
onMouseEnter={show}
51-
onMouseMove={show}
52-
onMouseLeave={hide}
53-
>
20+
<IdleFadeWrapper>
5421
{children ? (
5522
children
5623
) : (
5724
<HeaderTitleArea title={title || ""} subtitle={subtitle} icon={icon} />
5825
)}
59-
</div>
26+
</IdleFadeWrapper>
6027
);
6128

6229
if (isMobile) {
@@ -75,73 +42,3 @@ export function AppHeader({ title, subtitle, icon, className, children }: AppHea
7542
if (!portalTarget) return null;
7643
return createPortal(headerContent, portalTarget);
7744
}
78-
79-
interface HeaderTitleAreaProps {
80-
title: string;
81-
subtitle?: string;
82-
icon?: React.ReactNode;
83-
className?: string;
84-
}
85-
86-
export function HeaderTitleArea({ title, subtitle, icon, className }: HeaderTitleAreaProps) {
87-
const { isDbReady, isWriting } = useDb();
88-
const isLoading = !isDbReady || isWriting;
89-
90-
return (
91-
<div className={cn("flex min-w-0 items-center gap-2.5", className)}>
92-
{icon && (
93-
<div className="text-primary/80 flex shrink-0 items-center justify-center">{icon}</div>
94-
)}
95-
<div className="flex min-w-0 flex-col justify-center">
96-
<h1 className="flex items-center gap-2 text-base font-bold tracking-tight md:text-lg">
97-
<span>{title}</span>
98-
{isLoading && (
99-
<Loader2Icon
100-
className="text-muted-foreground/60 size-3.5 shrink-0 animate-spin"
101-
aria-hidden="true"
102-
/>
103-
)}
104-
</h1>
105-
{subtitle && (
106-
<p className="text-muted-foreground/60 mt-0.5 truncate text-xs font-normal">{subtitle}</p>
107-
)}
108-
</div>
109-
</div>
110-
);
111-
}
112-
113-
export function HeaderSearch() {
114-
return (
115-
<Button variant="ghost" size="icon" className="size-10">
116-
<SearchIcon className="size-5" />
117-
</Button>
118-
);
119-
}
120-
121-
export const HeaderNewAction = forwardRef<HTMLButtonElement, { onClick?: () => void }>(
122-
({ onClick, ...props }, ref) => {
123-
const context = useOutletContext<AppLayoutContext | null>();
124-
125-
// If the FAB is explicitly disabled (undefined), hide the header button too
126-
if (context && context.onFabClick === undefined && !onClick) {
127-
return null;
128-
}
129-
130-
const handleClick = onClick || context?.onFabClick || context?.openActionInput;
131-
132-
return (
133-
<Button
134-
ref={ref}
135-
variant="default"
136-
size="icon"
137-
onClick={handleClick}
138-
className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-primary/30 hidden h-10 w-20 items-center justify-center rounded-xl border-none shadow-lg transition-all active:scale-95 md:flex"
139-
{...props}
140-
>
141-
<PlusIcon className="size-5" />
142-
</Button>
143-
);
144-
}
145-
);
146-
147-
HeaderNewAction.displayName = "HeaderNewAction";

0 commit comments

Comments
 (0)