diff --git a/apps/web/package.json b/apps/web/package.json index c2ea5dd..daddb18 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,7 +36,6 @@ "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", - "@fontsource-variable/playfair-display": "5.2.8", "@radix-ui/react-alert-dialog": "1.1.16", "@radix-ui/react-avatar": "1.1.12", "@radix-ui/react-checkbox": "1.3.4", diff --git a/apps/web/src/app/dashboard/_components/active-timer-widget.tsx b/apps/web/src/app/dashboard/_components/active-timer-widget.tsx index f00e7ba..d0a6f16 100644 --- a/apps/web/src/app/dashboard/_components/active-timer-widget.tsx +++ b/apps/web/src/app/dashboard/_components/active-timer-widget.tsx @@ -46,7 +46,11 @@ export function ActiveTimerWidget({ if (intervalRef.current) clearInterval(intervalRef.current); if (running) { const tick = () => - setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); + setElapsed( + Math.floor( + (Date.now() - new Date(running.startedAt).getTime()) / 1000, + ), + ); tick(); intervalRef.current = setInterval(tick, 1000); } @@ -73,7 +77,10 @@ export function ActiveTimerWidget({ window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), }, }); - } else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") { + } else if ( + data.outcome === "saved_no_invoice" || + data.outcome === "saved_no_client" + ) { toast.warning("Time saved", { description: message }); } else { toast.success(message); @@ -96,12 +103,19 @@ export function ActiveTimerWidget({ ); @@ -133,18 +147,18 @@ export function ActiveTimerWidget({ - - - - + + + +

{description} @@ -169,10 +183,17 @@ export function ActiveTimerWidget({

) : ( -

No invoice selected

+

+ No invoice selected +

)}
- {renderStopButton()} @@ -185,58 +206,41 @@ export function ActiveTimerWidget({ } return ( - - -
- - - + + + + + + + + + Timer running -
-

- {description} - {running.client && ( - - {" "} - · {running.client.name} - - )} -

-

- {invoiceLabel ? ( - <> - Billing to{" "} - - {invoiceLabel} - - - ) : ( - <>No invoice selected — open time clock to assign - )} - {" · "} - - Time clock - -

-
-
- -
- + {formatElapsedSeconds(elapsed)} -
- - {renderStopButton("w-full")} -
+ + +

+ {description} + {running.client ? ` · ${running.client.name}` : ""} + {invoiceLabel ? ` · ${invoiceLabel}` : ""} +

+ +
+ + {renderStopButton("flex-1")}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f32c57b..887ceec 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -41,12 +41,6 @@ const geistSans = localFont({ display: "swap", }); -const playfair = localFont({ - src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2", - variable: "--font-playfair", - display: "swap", -}); - const geistMono = localFont({ src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf", variable: "--font-geist-mono", @@ -61,7 +55,7 @@ export default function RootLayout({ suppressHydrationWarning lang="en" data-color-mode="system" - className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`} + className={`${geistSans.variable} ${geistMono.variable}`} > diff --git a/apps/web/src/components/branding/dashboard-brand.tsx b/apps/web/src/components/branding/dashboard-brand.tsx index 064bea7..53073b1 100644 --- a/apps/web/src/components/branding/dashboard-brand.tsx +++ b/apps/web/src/components/branding/dashboard-brand.tsx @@ -1,21 +1,7 @@ "use client"; -import { BusinessBrandImage } from "~/components/branding/business-brand-image"; import { Logo } from "~/components/branding/logo"; -import { hasBusinessBrandAsset } from "~/lib/business-branding"; -import { api } from "~/trpc/react"; export function DashboardBrand({ compact = false }: { compact?: boolean }) { - const { data: business } = api.businesses.getDefault.useQuery(); - if (business && hasBusinessBrandAsset(business)) { - return ( - - ); - } return ; } diff --git a/apps/web/src/components/layout/dashboard-shell.tsx b/apps/web/src/components/layout/dashboard-shell.tsx index 46075af..7f08be9 100644 --- a/apps/web/src/components/layout/dashboard-shell.tsx +++ b/apps/web/src/components/layout/dashboard-shell.tsx @@ -11,7 +11,12 @@ import { cn } from "~/lib/utils"; import { Menu } from "lucide-react"; import { DashboardBrand } from "~/components/branding/dashboard-brand"; import { Button } from "~/components/ui/button"; -import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; +import { + Sheet, + SheetContent, + SheetTitle, + SheetTrigger, +} from "~/components/ui/sheet"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { OnboardingGuard } from "~/components/layout/onboarding-guard"; @@ -40,10 +45,10 @@ function DashboardContent({ children }: { children: React.ReactNode }) { @@ -51,10 +56,12 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
- -
-

Navigation Menu

-
+ + Navigation menu setIsMobileOpen(false)} /> @@ -64,7 +71,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) { suppressHydrationWarning className={cn( "min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0", - !isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"), + !isOnboarding && (isCollapsed ? "md:ml-20" : "md:ml-72"), )} > {isOnboarding ? ( diff --git a/apps/web/src/components/layout/sidebar-provider.tsx b/apps/web/src/components/layout/sidebar-provider.tsx index a11115a..b525ab5 100644 --- a/apps/web/src/components/layout/sidebar-provider.tsx +++ b/apps/web/src/components/layout/sidebar-provider.tsx @@ -13,29 +13,52 @@ const SidebarContext = React.createContext( undefined, ); +const SIDEBAR_STORAGE_KEY = "sidebar-collapsed"; +const SIDEBAR_CHANGE_EVENT = "beenvoice:sidebar-change"; + +function getSidebarSnapshot() { + return localStorage.getItem(SIDEBAR_STORAGE_KEY) === "true"; +} + +function getServerSidebarSnapshot() { + return false; +} + +function subscribeToSidebar(callback: () => void) { + const handleStorage = (event: StorageEvent) => { + if (event.key === SIDEBAR_STORAGE_KEY) callback(); + }; + + window.addEventListener("storage", handleStorage); + window.addEventListener(SIDEBAR_CHANGE_EVENT, callback); + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener(SIDEBAR_CHANGE_EVENT, callback); + }; +} + +function saveSidebarState(collapsed: boolean) { + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(collapsed)); + window.dispatchEvent(new Event(SIDEBAR_CHANGE_EVENT)); +} + export function SidebarProvider({ children }: { children: React.ReactNode }) { - const [isCollapsed, setIsCollapsed] = React.useState(() => { - if (typeof window === "undefined") return false; - const saved = localStorage.getItem("sidebar-collapsed"); - return saved ? (JSON.parse(saved) as boolean) : false; - }); + const isCollapsed = React.useSyncExternalStore( + subscribeToSidebar, + getSidebarSnapshot, + getServerSidebarSnapshot, + ); const toggleCollapse = React.useCallback(() => { - setIsCollapsed((prev) => { - const next = !prev; - localStorage.setItem("sidebar-collapsed", JSON.stringify(next)); - return next; - }); - }, []); + saveSidebarState(!isCollapsed); + }, [isCollapsed]); const expand = React.useCallback(() => { - setIsCollapsed(false); - localStorage.setItem("sidebar-collapsed", JSON.stringify(false)); + saveSidebarState(false); }, []); const collapse = React.useCallback(() => { - setIsCollapsed(true); - localStorage.setItem("sidebar-collapsed", JSON.stringify(true)); + saveSidebarState(true); }, []); return ( diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index bf448b5..066251f 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -1,279 +1,358 @@ "use client"; +import type { ComponentType } from "react"; +import { + ChevronsUpDown, + LogOut, + PanelLeftClose, + PanelLeftOpen, + Plus, +} from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { authClient } from "~/lib/auth-client"; -import { Skeleton } from "~/components/ui/skeleton"; -import { Button } from "~/components/ui/button"; -import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation"; -import { useSidebar } from "./sidebar-provider"; -import { cn } from "~/lib/utils"; + +import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { DashboardBrand } from "~/components/branding/dashboard-brand"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { Button } from "~/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { Separator } from "~/components/ui/separator"; +import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "~/components/ui/tooltip"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; -import { getGravatarUrl } from "~/lib/gravatar"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { useAuthSession } from "~/hooks/use-auth-session"; -import { useDashboardUser } from "~/components/layout/dashboard-user-context"; -import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; +import { authClient } from "~/lib/auth-client"; +import { getGravatarUrl } from "~/lib/gravatar"; +import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation"; +import { cn } from "~/lib/utils"; +import { useDashboardUser } from "./dashboard-user-context"; +import { useSidebar } from "./sidebar-provider"; interface SidebarProps { mobile?: boolean; onClose?: () => void; } -export function Sidebar({ mobile, onClose }: SidebarProps) { +interface SidebarLinkProps { + name: string; + href: string; + icon: ComponentType<{ className?: string }>; + active: boolean; + collapsed: boolean; + mobile: boolean; + onClose?: () => void; +} + +function SidebarLink({ + name, + href, + icon: Icon, + active, + collapsed, + mobile, + onClose, +}: SidebarLinkProps) { + const link = ( + + + {!collapsed ? ( + <> + {name} + {active ? ( + + ) : null} + + ) : null} + + ); + + if (!collapsed) return link; + + return ( + + {link} + + {name} + + + ); +} + +export function Sidebar({ mobile = false, onClose }: SidebarProps) { const pathname = usePathname(); const { data: session, isPending } = useAuthSession(); const { isAdmin } = useDashboardUser(); const { isCollapsed, toggleCollapse } = useSidebar(); const navSections = getNavigationForUser(isAdmin); - - // If mobile, always expanded const collapsed = mobile ? false : isCollapsed; - const SidebarContent = ( -
-
- {/* Header / Logo */} -
+
+
- {!collapsed && ( -
- -
- )} - {collapsed && } - - {!mobile && !collapsed && ( -
// Spacer to keep alignment if needed, or just remove - )} -
- - {/* Navigation */} - -
- - {/* Footer / User */} -
- {!mobile && ( -
- -
- )} + + + - +
+ + + {!mobile ? ( +
+ + + + + + {collapsed ? "Expand sidebar" : "Collapse sidebar"} + + +
+ ) : null} + +
+ + + {isPending ? ( +
+ + {!collapsed ? ( +
+ + +
+ ) : null} +
+ ) : session?.user ? ( + + + - - - -
-

- {session.user.name} -

-

- {session.user.email} -

-
-
- - { - await authClient.signOut(); - window.location.href = "/"; - }} - className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20" + + + ) : null} + + + - - Sign Out - -
-
- ) : null} + + {session.user.name} + + {session.user.email} + + + + + { + await authClient.signOut(); + window.location.href = "/"; + }} + > + + Sign out + + + + + ) : null} + +
-
+ ); if (mobile) { - return
{SidebarContent}
; + return
{sidebarContent}
; } return ( ); } diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index e60d6e6..6654ef8 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -4,7 +4,8 @@ @layer base { :root { --app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; - --app-font-heading: var(--font-playfair), ui-serif, Georgia, serif; + --app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, + sans-serif; --background: 0 0% 100%; --foreground: 240 10% 3.9%; @@ -100,7 +101,7 @@ --color-ring: hsl(var(--ring)); --font-sans: var(--app-font-sans), ui-sans-serif, system-ui, sans-serif; - --font-heading: var(--app-font-heading), ui-serif, Georgia, serif; + --font-heading: var(--app-font-heading), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, monospace; --radius-sm: calc(var(--radius) - 4px); diff --git a/bun.lock b/bun.lock index f759e0b..35859b2 100644 --- a/bun.lock +++ b/bun.lock @@ -86,7 +86,6 @@ "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", - "@fontsource-variable/playfair-display": "5.2.8", "@radix-ui/react-alert-dialog": "1.1.16", "@radix-ui/react-avatar": "1.1.12", "@radix-ui/react-checkbox": "1.3.4", @@ -664,8 +663,6 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], - "@fontsource-variable/playfair-display": ["@fontsource-variable/playfair-display@5.2.8", "", {}, "sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A=="], - "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],