Restructure file system to seperate Sidebar, Header, and Canvas components

This commit is contained in:
Daniel S. Hansen
2025-07-21 11:33:44 -07:00
parent a7bef5ddb3
commit 06edb481e9
12 changed files with 15 additions and 13 deletions
@@ -0,0 +1,34 @@
import { Flex, HStack, chakra, useRecipe } from "@chakra-ui/react";
import { CanvasHeaderNav } from "./CanvasHeaderNav.jsx";
import { SearchBar } from "./SearchBar.jsx";
import { SettingsButton } from "./SettingsButton.jsx";
export default function CanvasHeader() {
const recipe = useRecipe({ key: "canvas" });
const styles = recipe();
return (
<chakra.div css={styles}>
<Flex
as="header"
position="sticky"
top="0"
// zIndex="banner"
bg="base200"
borderBottom="1px solid"
borderColor="gray.200"
px={6}
py={3}
align="center"
justify="space-between"
>
<CanvasHeaderNav />
<HStack spacing={3}>
<SearchBar />
<SettingsButton />
</HStack>
</Flex>
</chakra.div>
);
}
@@ -0,0 +1,22 @@
import { HStack, Link } from "@chakra-ui/react"
const navItems = [
{ label: "My Dashboard", href: "#dashboard" },
{ label: "Gas", href: "#gas" },
{ label: "Seismic", href: "#seismic" },
{ label: "Remote Sensing", href: "#remote" },
{ label: "Daily Activity", href: "#daily" },
];
export function CanvasHeaderNav() {
return (
<HStack as="nav" gap="8">
{navItems.map(({ label, href }) => (
<Link key={href} href={href} fontWeight="medium">
{label}
</Link>
))}
</HStack>
);
}
@@ -0,0 +1,16 @@
import { Input, InputGroup } from "@chakra-ui/react";
import { FiSearch } from "react-icons/fi"
export function SearchBar({ placeholder="Enter a volcano to search", size="sm", width="210px" }) {
return (
<InputGroup startElement={<FiSearch />}>
<Input
placeholder={placeholder}
size={size}
width={width}
borderRadius="full"
/>
</InputGroup>
);
}
@@ -0,0 +1,54 @@
// This component displays a drop down menu when clicked, with custom functionality
// for 'Add Widget', 'Edit Widgets', and 'Remove Widgets'. This component will
// control settings related to the Widgets on the main canvas.
import { Button, IconButton, Menu, Portal } from "@chakra-ui/react";
import { FiSettings } from "react-icons/fi";
export function SettingsButton(props) {
const handleAddWidget = () => {
console.log("Add Widget clicked");
// TODO: Implement widget-adding logic
}
const handleEditWidgdets = () => {
console.log("Edit Widgets clicked")
// TODO: Implement widget-editing logic
}
const handleDeleteWidgets = () => {
console.log("Delete Widgets clicked");
// TODO: Implement widget-deletion logic
}
return (
// This uses Chakra's built in menu component
<Menu.Root>
<Menu.Trigger asChild>
<IconButton variant="ghost" size="md">
<FiSettings />
</IconButton>
</Menu.Trigger>
<Portal>
<Menu.Positioner>
<Menu.Content>
<Menu.Item onClick={handleAddWidget} value="addwidget">Add Widget</Menu.Item>
<Menu.Item onClick={handleEditWidgdets} value ="editwidget">Edit Widgets</Menu.Item>
<Menu.Item
onClick={handleDeleteWidgets}
color="fg.error"
_hover={{ bg: "bg.error", color: "fg.error "}}
>
Delete Widgets
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
}
@@ -0,0 +1,57 @@
// This component defines a generic Widget that can be dynamically resized and
// dragged inside the canvas
// Content not included, must be populated using custom charts plots and notifications
import { useState } from 'react';
import GridLayout from 'react-grid-layout';
export default function Widget() {
// Define widgets initial position and size
// TODO Add dynamic adding/deleting of widgets via options button
const [layout, setLayout] = useState([
{ i: 'myWidget1', x: 0, y: 0, w: 3, h: 3, minH: 3, static: false },
{ i: 'myWidget2', x: 3, y: 0, w: 3, h: 3, minH: 3, static: false },
{ i: 'myWidget3', x: 6, y: 0, w: 3, h: 3, minH: 3, static: false }
]);
const onLayoutChange = (newLayout) => {
setLayout(newLayout);
console.log('Updated layout:', newLayout)
};
return (
<GridLayout
className="layout"
layout={layout}
cols={12} // Total columns in grid
rowHeight={30} //Height of a single row in px
width={1200} // Total width of the grid
margin={[10, 10]} // [horizontal, vertical] gap
onLayoutChange={onLayoutChange}
draggableHandle=".widget-handle" // Make only the header draggable
>
<div key="myWidget1" style={{ background: '#f0f0f0', borderRadius: 4 }}>
<div className="widget-handle" style={{ padding: '4px', cursor: 'move', background: '#ddd' }}>
VDAP Widget
</div>
<div style={{ padding: '8px' }}>
This is the widget content - drag or resize me!
</div>
</div><div key="myWidget2" style={{ background: '#f0f0f0', borderRadius: 4 }}>
<div className="widget-handle" style={{ padding: '4px', cursor: 'move', background: '#ddd' }}>
VDAP Widget
</div>
<div style={{ padding: '8px' }}>
This is the widget content - drag or resize me!
</div>
</div><div key="myWidget3" style={{ background: '#f0f0f0', borderRadius: 4 }}>
<div className="widget-handle" style={{ padding: '4px', cursor: 'move', background: '#ddd' }}>
VDAP Widget
</div>
<div style={{ padding: '8px' }}>
This is the widget content - drag or resize me!
</div>
</div>
</GridLayout>
);
}
@@ -0,0 +1,28 @@
import { chakra, useRecipe, Box, Button, Stack, Text, Flex } from "@chakra-ui/react"
import UserAvatarMenu from "./UserAvatarMenu.jsx";
//TODO: Dynamically populate per each user
const user = {
name: "venessa kuchenik",
email: "vkuchenik@contractor.usgs.gov",
// avatar: "src\\assets\\user_profile.svg"
}
export default function Header() {
const recipe = useRecipe({ key: "header" });
const styles = recipe();
return (
<chakra.div css={styles}>
<Button color="color" variant="plain" textStyle="5xl" fontWeight="bold">VDAP</Button>
<Flex align="center" gap={3} pr={6}>
<Stack gap="0" textAlign="right">
<Text color="color" fontWeight="bold" textStyle="lg">{user.name}</Text>
<Text color="fg.muted" textStyle="sm">{user.email}</Text>
</Stack>
<UserAvatarMenu name={user.name} avatar={user.avatar} />
</Flex>
</chakra.div>
);
}
@@ -0,0 +1,30 @@
import { Avatar, Menu, Portal, Box } from "@chakra-ui/react"
export default function UserAvatarMenu({ name, avatar }) {
return (
<Menu.Root>
<Menu.Trigger focusRing="none">
<Box
rounded="full"
tabIndex={0}
_hover={{ boxShadow: "0 0 0 2px rgb(37, 77, 197)" }}
>
<Avatar.Root shape="full">
<Avatar.Fallback name={name} />
<Avatar.Image src={avatar} />
</Avatar.Root>
</Box>
</Menu.Trigger>
<Portal>
<Menu.Positioner zIndex="popover">
<Menu.Content>
<Menu.Item value="account">Account</Menu.Item>
<Menu.Item value="settings">Settings</Menu.Item>
<Menu.Item value="logout">Logout</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
}
@@ -0,0 +1,121 @@
import { chakra, useRecipe, useSlotRecipe, Box, Button, createIcon, CloseButton, IconButton, Stack, useDisclosure, Tooltip } from "@chakra-ui/react";
import { ColorModeButton } from "@/components/ui/color-mode";
import { FaVolcano } from "react-icons/fa6";
import { RxHamburgerMenu } from "react-icons/rx";
import { useState } from "react";
// ICONS
export const PushPin = createIcon({
displayName: "PushPin",
viewBox: "0 0 24 24",
fill: "none",
path: (
<>
<path xmlns="http://www.w3.org/2000/svg" d="M6.21967 0.21967C6.36032 0.0790176 6.55109 0 6.75 0H17.25C17.6642 0 18 0.335786 18 0.75C18 1.76907 17.4871 2.51108 17.0303 2.96783C16.8422 3.15598 16.6554 3.30457 16.5 3.41487V10.0621C16.5369 10.0844 16.5757 10.1082 16.6162 10.1335C16.9211 10.3241 17.3307 10.6053 17.7439 10.9668C18.5387 11.6623 19.5 12.7902 19.5 14.25C19.5 14.6642 19.1642 15 18.75 15H12.75V21.75C12.75 22.1642 12.4142 24 12 24C11.5858 24 11.25 22.1642 11.25 21.75V15H5.25C4.83579 15 4.5 14.6642 4.5 14.25C4.5 12.7902 5.46131 11.6623 6.25612 10.9668C6.66928 10.6053 7.07891 10.3241 7.38375 10.1335C7.42429 10.1082 7.46311 10.0844 7.5 10.0621V3.41487C7.34463 3.30457 7.15782 3.15598 6.96967 2.96783C6.51292 2.51108 6 1.76907 6 0.75C6 0.551088 6.07902 0.360322 6.21967 0.21967Z" stroke="currentColor" strokeWidth="1.5"/>
</>
),
});
export const Globe = createIcon({
displayName: "Globe",
viewBox: "0 0 24 24",
fill: "currentColor",
path: (
<>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5"/>
<path opacity="0.5" d="M6 4.71053C6.78024 5.42105 8.38755 7.36316 8.57481 9.44737C8.74984 11.3955 10.0357 12.9786 12 13C12.7549 13.0082 13.5183 12.4629 13.5164 11.708C13.5158 11.4745 13.4773 11.2358 13.417 11.0163C13.3331 10.7108 13.3257 10.3595 13.5 10C14.1099 8.74254 15.3094 8.40477 16.2599 7.72186C16.6814 7.41898 17.0659 7.09947 17.2355 6.84211C17.7037 6.13158 18.1718 4.71053 17.9377 4" stroke="currentColor" strokeWidth="1.5"/>
<path opacity="0.5" d="M22 13C21.6706 13.931 21.4375 16.375 17.7182 16.4138C17.7182 16.4138 14.4246 16.4138 13.4365 18.2759C12.646 19.7655 13.1071 21.3793 13.4365 22" stroke="currentColor" strokeWidth="1.5"/>
</>
),
});
export const MapMarker = createIcon({
displayName: "MapMarker",
viewBox: "0 0 24 24",
fill: "none",
path: (
<>
<path d="M21.2188 9.80011C21.2188 16.8001 12.2188 22.8001 12.2188 22.8001C12.2188 22.8001 3.21875 16.8001 3.21875 9.80011C3.21875 7.41316 4.16696 5.12398 5.85479 3.43615C7.54262 1.74832 9.8318 0.80011 12.2188 0.80011C14.6057 0.80011 16.8949 1.74832 18.5827 3.43615C20.2705 5.12398 21.2187 7.41316 21.2188 9.80011Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12.2188 12.8001C13.8756 12.8001 15.2188 11.457 15.2188 9.80011C15.2188 8.14326 13.8756 6.80011 12.2188 6.80011C10.5619 6.80011 9.21875 8.14326 9.21875 9.80011C9.21875 11.457 10.5619 12.8001 12.2188 12.8001Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</>
),
});
export const Users = createIcon({
displayName: "Users",
viewBox: "0 0 24 24",
fill: "currentColor",
path: (
<>
<path d="M17.888 10.8967C19.283 10.7007 20.357 9.50466 20.36 8.05566C20.36 6.62767 19.319 5.44367 17.954 5.21967" stroke="currentColor" strokeWidth="1.375" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M19.7278 14.2502C21.0788 14.4522 22.0218 14.9252 22.0218 15.9002C22.0218 16.5712 21.5778 17.0072 20.8598 17.2812" stroke="currentColor" strokeWidth="1.375" strokeLinecap="round" strokeLinejoin="round"/>
<path fillRule="evenodd" clipRule="evenodd" d="M11.886 14.6638C8.67203 14.6638 5.92702 15.1508 5.92702 17.0958C5.92702 19.0398 8.65503 19.5408 11.886 19.5408C15.1 19.5408 17.844 19.0588 17.844 17.1128C17.844 15.1668 15.117 14.6638 11.886 14.6638Z" stroke="currentColor" strokeWidth="1.375" strokeLinecap="round" strokeLinejoin="round"/>
<path fillRule="evenodd" clipRule="evenodd" d="M11.8867 11.8878C13.9957 11.8878 15.7057 10.1788 15.7057 8.06882C15.7057 5.95982 13.9957 4.24982 11.8867 4.24982C9.77768 4.24982 8.06768 5.95982 8.06768 8.06882C8.05968 10.1708 9.75668 11.8808 11.8587 11.8878H11.8867Z" stroke="currentColor" strokeWidth="1.375" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M5.8834 10.8967C4.4874 10.7007 3.4144 9.50466 3.4114 8.05566C3.4114 6.62767 4.4524 5.44367 5.8174 5.21967" stroke="currentColor" strokeWidth="1.375" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4.04329 14.2502C2.69229 14.4522 1.74929 14.9252 1.74929 15.9002C1.74929 16.5712 2.19329 17.0072 2.91129 17.2812" stroke="currentColor" strokeWidth="1.375" strokeLinecap="round" strokeLinejoin="round"/>
</>
),
});
export default function Sidebar() {
const [isOpen, setIsOpen] = useState(true);
const [hasFinishedTransitions, setFinishedTransitions] = useState(false);
const buttons = [
{icon: PushPin, name: "Dashboard"},
{icon: Globe, name: "Global Map"},
{icon: MapMarker, name: "Regional Map"},
{icon: FaVolcano, name: "Volcano"},
{icon: Users, name: "Admin"}
];
const [activeButton, setActiveButton] = useState(0)
const recipe = useSlotRecipe({ key: "sidebar" });
const styles = recipe();
const toggleSidebar = () => {
setFinishedTransitions(false);
if (isOpen) setIsOpen(false);
else setIsOpen(true);
setTimeout(() => setFinishedTransitions(true), 400); // Match sidebar transition duration
};
return (
<Box
css={styles.root}
width={isOpen ? "210px" : "71px"}
transition="width 0.4s ease"
>
<IconButton
aria-label="Toggle Sidebar"
variant="ghost"
size="md"
onClick={toggleSidebar}
>
<RxHamburgerMenu />
</IconButton>
{buttons.map((button, idx) => {
const Icon = button.icon;
return (
<Button key={idx} css={styles.buttons} onClick={() => setActiveButton(idx)} bg={activeButton === idx ? "primary":undefined} justifyContent="flex-start">
<Icon fill="base200" />
<span style={{
opacity: isOpen ? 1 : 0,
transition: "opacity 0.4s ease",
overflow: "hidden",
whiteSpace: "nowrap",
display: "inline-block",
lineHeight: 1.2
}}
>
{button.name}
</span>
</Button>
)
})}
<ColorModeButton position="absolute" bottom="2"/>
</Box>
);
}