diff --git a/builder.json b/builder.json new file mode 100644 index 0000000..23581df --- /dev/null +++ b/builder.json @@ -0,0 +1 @@ +{"version": null} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f620c50..e1078a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/react": "18.0.35", "@types/react-dom": "18.0.11", "@vercel/analytics": "^1.0.1", + "ahooks": "^3.7.10", "axios": "^1.6.5", "eslint": "8.38.0", "eslint-config-next": "13.3.0", @@ -27,7 +28,8 @@ "react-dom": "18.2.0", "react-icons": "^4.8.0", "typescript": "5.0.4", - "universal-cookie": "^4.0.4" + "universal-cookie": "^4.0.4", + "zustand": "^4.5.2" }, "devDependencies": { "@faker-js/faker": "^7.6.0", @@ -1957,6 +1959,27 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ahooks": { + "version": "3.7.10", + "resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.7.10.tgz", + "integrity": "sha512-/HLYif7sFA/5qSuWKrwvjDbf3bq+sdaMrUWS7XGCDRWdC2FrG/i+u5LZdakMYc6UIgJTMQ7tGiJCV7sdU4kSIw==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "dayjs": "^1.9.1", + "intersection-observer": "^0.12.0", + "js-cookie": "^2.x.x", + "lodash": "^4.17.21", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.0.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2367,6 +2390,11 @@ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3581,6 +3609,11 @@ "node": ">= 0.4" } }, + "node_modules/intersection-observer": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz", + "integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==" + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -3898,6 +3931,11 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "node_modules/js-sdsl": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", @@ -4699,6 +4737,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -4789,6 +4832,17 @@ "loose-envify": "^1.1.0" } }, + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/semver": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz", @@ -5237,6 +5291,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5335,6 +5397,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz", + "integrity": "sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index bee632b..031bd46 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/react": "18.0.35", "@types/react-dom": "18.0.11", "@vercel/analytics": "^1.0.1", + "ahooks": "^3.7.10", "axios": "^1.6.5", "eslint": "8.38.0", "eslint-config-next": "13.3.0", @@ -28,7 +29,8 @@ "react-dom": "18.2.0", "react-icons": "^4.8.0", "typescript": "5.0.4", - "universal-cookie": "^4.0.4" + "universal-cookie": "^4.0.4", + "zustand": "^4.5.2" }, "devDependencies": { "@faker-js/faker": "^7.6.0", diff --git a/src/components/CollapseNavbar.tsx b/src/components/CollapseNavbar.tsx index baa9a15..a05c917 100644 --- a/src/components/CollapseNavbar.tsx +++ b/src/components/CollapseNavbar.tsx @@ -16,9 +16,14 @@ import { import { siteConfig } from '@/../../config/site.config'; import ThemeToggle from '@/components/ThemeToggle'; import { ChevronDownIcon, MinusIcon } from '@chakra-ui/icons'; +import { MdDashboard } from 'react-icons/md'; + import Link from 'next/link'; +import { useRouter } from 'next/router'; const CollapseNavbar = () => { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(true); const borderColor = useColorModeValue('gray.300', 'gray.400'); @@ -47,34 +52,39 @@ const CollapseNavbar = () => { - - }> - Pages - - - - Dashboard - - - Build Status - - - Ticket Status - - - Project Timeline - - - Owner Rotation - - - - + {/**/} + {/* }>*/} + {/* Pages*/} + {/* */} + {/* */} + {/* */} + {/* Dashboard*/} + {/* */} + {/* */} + {/* Build Status*/} + {/* */} + {/* */} + {/* Ticket Status*/} + {/* */} + {/* */} + {/* Project Timeline*/} + {/* */} + {/* */} + {/* Owner Rotation*/} + {/* */} + {/* */} + {/**/} } - onClick={() => setIsOpen(false)} + aria-label="Customize homepage" + icon={} + onClick={() => router.push('/builder')} /> + + {/*}*/} + {/* onClick={() => setIsOpen(false)}*/} + {/*/>*/} diff --git a/src/components/DashboardPreview.tsx b/src/components/DashboardPreview.tsx new file mode 100644 index 0000000..bc6b89d --- /dev/null +++ b/src/components/DashboardPreview.tsx @@ -0,0 +1,45 @@ +import { FC, ComponentProps, useEffect, useState } from 'react'; +import styled from '@emotion/styled'; +import { GridLayout } from '@/components/GridLayout'; +import { usePageConfigStore } from '@/stores/pageConfig'; +import { zBoardWidgets } from '@/widgets'; +import { Box } from '@chakra-ui/react'; +import GrayBox from '@/components/ui/GrayBox'; + +const Container = styled.div` + width: 100vw; + height: 100%; +`; + +const DashboardPreview: FC = () => { + const { + pageConfig: { rows, cols, rowGap, columnGap, layouts, padding }, + } = usePageConfigStore(); + + useEffect(() => { + usePageConfigStore.persist.rehydrate(); + }, []); + + return ( + // + { + const widget = zBoardWidgets.find(({ name }) => name === component); + return widget ? ( + + ) : ( + {component} Widget Not found + ); + }} + /> + // + ); +}; + +export default DashboardPreview; diff --git a/src/components/GridLayout.tsx b/src/components/GridLayout.tsx new file mode 100644 index 0000000..11ca345 --- /dev/null +++ b/src/components/GridLayout.tsx @@ -0,0 +1,496 @@ +import React, { + FC, + ReactElement, + Dispatch, + PropsWithChildren, + SetStateAction, + cloneElement, + createContext, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { useMemoizedFn } from 'ahooks'; +import styled from '@emotion/styled'; +import { CloseButton, IconButton } from '@chakra-ui/react'; +import { BsTrash3Fill } from 'react-icons/bs'; +import LayoutItemComponent from '@/components/LayoutItemComponent'; + +function getMouseOffset(event: MouseEvent, target?: HTMLElement) { + const bounds = (target || (event.target as HTMLElement)).getBoundingClientRect(); + return { + x: event.clientX - bounds.x, + y: event.clientY - bounds.y, + }; +} + +const bound = (value: number, min: number, max: number) => { + return Math.min(Math.max(value, min), max); +}; + +export const mutateLayout = ({ + container, + rows, + cols, + columnGap = 0, + rowGap = 0, + padding = 0, + layout, + offset, + type, +}: { + container: HTMLElement; + rows: number; + cols: number; + columnGap?: number; + rowGap?: number; + padding?: number; + layout: { + x: number; + y: number; + w: number; + minW?: number; + maxW?: number; + h: number; + minH?: number; + maxH?: number; + }; + offset: { + x: number; + y: number; + }; + type: 'move' | 'resize'; +}) => { + const containerBounds = container.getBoundingClientRect(); + const clientSpanWidth = (containerBounds.width - padding * 2 - columnGap * (cols - 1)) / cols; + const clientSpanHeight = (containerBounds.height - padding * 2 - rowGap * (rows - 1)) / rows; + + const getColSpan = (offsetX: number) => + Math.round((offsetX + columnGap) / (clientSpanWidth + columnGap)); + const getDistanceByColSpan = (colSpan: number) => + colSpan * clientSpanWidth + (colSpan - 1) * columnGap; + const getOffsetByColSpan = (colSpan: number) => colSpan * clientSpanWidth + colSpan * columnGap; + const getRowSpan = (offsetY: number) => + Math.round((offsetY + rowGap) / (clientSpanHeight + rowGap)); + const getDistanceByRowSpan = (rowSpan: number) => + rowSpan * clientSpanHeight + (rowSpan - 1) * rowGap; + const getOffsetByRowSpan = (rowSpan: number) => rowSpan * clientSpanHeight + rowSpan * rowGap; + + if (type === 'move') { + const boundOffsetX = bound( + offset.x, + getOffsetByColSpan(-layout.x), + getOffsetByColSpan(cols - (layout.x + layout.w)) + ); + const offsetXSpan = getColSpan(boundOffsetX); + const nextLayoutX = layout.x + offsetXSpan; + + const boundOffsetY = bound( + offset.y, + getOffsetByRowSpan(-layout.y), + getOffsetByRowSpan(rows - (layout.y + layout.h)) + ); + const offsetYSpan = getRowSpan(boundOffsetY); + const nextLayoutY = layout.y + offsetYSpan; + + return { + x: nextLayoutX, + y: nextLayoutY, + w: layout.w, + h: layout.h, + boundingRect: { + left: getOffsetByColSpan(layout.x) + boundOffsetX + padding, + top: getOffsetByRowSpan(layout.y) + boundOffsetY + padding, + width: getDistanceByColSpan(layout.w), + height: getDistanceByRowSpan(layout.h), + }, + }; + } + + const boundOffsetW = bound( + offset.x, + getDistanceByColSpan(Math.max(1, layout.minW || 1) - layout.w), + getDistanceByColSpan(Math.min(cols - (layout.x + layout.w), (layout.maxW || cols) - layout.w)) + ); + const offsetWSpan = getColSpan(boundOffsetW); + const nextLayoutW = layout.w + offsetWSpan; + + const boundOffsetH = bound( + offset.y, + getDistanceByRowSpan(Math.max(1, layout.minH || 1) - layout.h), + getDistanceByRowSpan(Math.min(rows - (layout.y + layout.h), (layout.maxH || rows) - layout.h)) + ); + const offsetHSpan = getRowSpan(boundOffsetH); + const nextLayoutH = layout.h + offsetHSpan; + + return { + x: layout.x, + y: layout.y, + w: nextLayoutW, + h: nextLayoutH, + boundingRect: { + left: getOffsetByColSpan(layout.x) + padding, + top: getOffsetByRowSpan(layout.y) + padding, + width: getDistanceByColSpan(layout.w) + boundOffsetW, + height: getDistanceByRowSpan(layout.h) + boundOffsetH, + }, + }; +}; + +type TransferData = { + layout: { + w: number; + h: number; + }; + component: string; + [key: string]: any; +}; + +interface ItemContext { + data: TransferData; + element: HTMLElement; + mouseOffset: { + x: number; + y: number; + }; +} + +export const DragDropContext = createContext({ + draggingItem: null as null | string, + setDraggingItem: (() => {}) as Dispatch>, + setContext: (() => {}) as (id: string, itemContext: ItemContext) => void, + getContext: (() => {}) as unknown as (id: string) => ItemContext, + removeContext: (() => {}) as (id: string) => void, +}); + +export const DragAndDropProvider: FC = ({ children }) => { + const [draggingItem, setDraggingItem] = useState(null); + const contextsRef = useRef>({}); + + const contextValue = useMemo( + () => ({ + draggingItem, + setDraggingItem, + setContext(id: string, data: any) { + contextsRef.current[id] = data; + }, + getContext(id: string) { + return contextsRef.current[id]; + }, + removeContext(id: string) { + delete contextsRef.current[id]; + }, + }), + [draggingItem] + ); + + return {children}; +}; + +interface DraggableProps { + dropData?: { + layout: { w: number; h: number; minW?: number; maxW?: number; minH?: number; maxH?: number }; + component: string; + } & T; +} + +export const Draggable: FC<{ children: JSX.Element } & DraggableProps> = ({ + children, + dropData, +}) => { + const { setContext, removeContext, setDraggingItem, draggingItem } = useContext(DragDropContext); + const id = useId(); + const mouseOffsetRef = useRef({ + x: 0, + y: 0, + }); + const mouseDownPosition = useRef(null); + + return cloneElement, HTMLElement>>( + children, + { + ...children.props, + style: { + ...children.props.style, + cursor: 'move', + }, + draggable: true, + onMouseDown(e) { + mouseOffsetRef.current = getMouseOffset(e as any); + mouseDownPosition.current = { + clientX: e.clientX, + clientY: e.clientY, + }; + }, + onDragStart(e) { + // this is a hack for firefox + // Firefox requires some kind of initialization + // which we can do by adding this attribute + // @see https://bugzilla.mozilla.org/show_bug.cgi?id=568313 + e.dataTransfer.setData('text/plain', ''); + + e.dataTransfer.effectAllowed = 'move'; + setDraggingItem(id); + setContext(id, { + data: dropData!, + element: e.target as HTMLElement, + mouseOffset: mouseOffsetRef.current, + }); + }, + onDragEnd() { + if (draggingItem === id) { + setDraggingItem(null); + } + removeContext(id); + }, + } + ); +}; + +const DroppingShadow = styled.div` + transition: 200ms; + grid-area: 1/1 / auto/auto; + background: linear-gradient(to left, #7928ca, #ff0080); + opacity: 0.1; + display: none; +`; + +export interface Layout { + component: string; + id: string; + x: number; + y: number; + w: number; + minW?: number; + maxW?: number; + h: number; + minH?: number; + maxH?: number; +} + +export const GridLayoutContext = createContext({ + layouts: [] as Layout[], + padding: 0, + cols: 12, + rows: 12, + columnGap: 0, + rowGap: 0, + getContainer: (() => null) as () => HTMLElement | null, +}); + +interface GridLayoutProps { + fullPage?: boolean; + cols: number; + rows: number; + padding?: number; + columnGap?: number; + rowGap?: number; + itemRender: (layout: Layout) => ReactElement; + droppable?: boolean; + draggable?: boolean; + resizable?: boolean; + layouts: Layout[]; + onLayoutsChange?: (newLayouts: Layout[]) => void; + style?: React.CSSProperties; +} + +export const GridLayout: FC = ({ + cols, + rows, + columnGap = 0, + rowGap = 0, + padding = 0, + itemRender, + fullPage = false, + droppable = false, + draggable = false, + resizable = false, + layouts, + onLayoutsChange, + style, +}) => { + const { getContext, draggingItem } = useContext(DragDropContext); + const containerRef = useRef(null); + const shadowRef = useRef(null); + const [dropping, setDropping] = useState(false); + const dropPositionRef = useRef({ + x: 0, + y: 0, + }); + const [selectedItem, setSelectedItem] = useState(null); + + const showShadow = ({ x, y, w, h }: { x: number; y: number; w: number; h: number }) => { + if (!shadowRef.current) return; + + Object.assign(shadowRef.current.style, { + display: 'block', + gridArea: `${y + 1}/${x + 1}/${y + h + 1}/${x + w + 1}`, + }); + }; + const hideShadow = () => { + if (!shadowRef.current) return; + + Object.assign(shadowRef.current.style, { + display: 'none', + }); + }; + + const droppableProps = droppable + ? { + onDragEnter: (e: React.DragEvent) => { + e.preventDefault(); + }, + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + if (!dropping) setDropping(true); + e.dataTransfer.dropEffect = 'move'; + + if (!draggingItem) return; + const { data, mouseOffset } = getContext(draggingItem!); + const dropZoneMouseOffset = getMouseOffset(e as any, containerRef.current!); + + const { x, y, w, h } = mutateLayout({ + container: containerRef.current!, + rows, + cols, + rowGap, + columnGap, + layout: { + ...data.layout, + x: 0, + y: 0, + }, + offset: { + x: dropZoneMouseOffset.x - mouseOffset.x, + y: dropZoneMouseOffset.y - mouseOffset.y, + }, + type: 'move', + }); + + dropPositionRef.current.x = x; + dropPositionRef.current.y = y; + + showShadow({ + x, + y, + w, + h, + }); + }, + onDragLeave: (e: React.DragEvent) => { + if (e.relatedTarget === shadowRef.current) return; + + hideShadow(); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + setDropping(false); + + if (!draggingItem) return; + const { data } = getContext(draggingItem); + onLayoutsChange?.([ + ...layouts, + { + id: `${data.component}-${Math.random()}`, // TODO: need redesign the computation? + component: data.component, + ...data.layout, + ...dropPositionRef.current, + }, + ]); + hideShadow(); + + dropPositionRef.current.x = 0; + dropPositionRef.current.y = 0; + }, + } + : null; + + const removeSelectedItem = useMemoizedFn(() => { + if (!selectedItem) return; + + onLayoutsChange?.(layouts.filter((layout) => layout.id !== selectedItem)); + setSelectedItem(null); + }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === 'Backspace') { + removeSelectedItem(); + } + }; + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + return ( + containerRef.current, + }} + > +
{ + setSelectedItem(null); + }} + > + {layouts.map((layout) => ( + { + showShadow({ + x, + y, + w, + h, + }); + document.body.style.userSelect = 'none'; + }} + onLayoutChangeEnd={(newLayout) => { + hideShadow(); + onLayoutsChange?.( + layouts.map((layout) => (layout.id === newLayout.id ? newLayout : layout)) + ); + document.body.style.userSelect = 'auto'; + }} + selected={selectedItem === layout.id} + onSelectedChange={(selected) => { + if (selected) return setSelectedItem(layout.id); + + setSelectedItem(null); + }} + onDeleted={removeSelectedItem} + /> + ))} + +
+
+ ); +}; diff --git a/src/components/LayoutItemComponent.tsx b/src/components/LayoutItemComponent.tsx new file mode 100644 index 0000000..e5f0fd1 --- /dev/null +++ b/src/components/LayoutItemComponent.tsx @@ -0,0 +1,248 @@ +import React, { + FC, + ReactElement, + Dispatch, + PropsWithChildren, + SetStateAction, + cloneElement, + createContext, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { useMemoizedFn } from 'ahooks'; +import styled from '@emotion/styled'; +import { CloseButton, IconButton } from '@chakra-ui/react'; +import { BsTrash3Fill } from 'react-icons/bs'; +import { GridLayoutContext, Layout, mutateLayout } from '@/components/GridLayout'; + +interface LayoutItemComponentProps { + layout: Layout; + render: (layout: Layout) => ReactElement; + draggable?: boolean; + resizable?: boolean; + onLayoutChange?: (newLayout: Layout) => void; + onLayoutChangeEnd?: (newLayout: Layout) => void; + selected?: boolean; + onSelectedChange?: (selected: boolean) => void; + onDeleted?: () => void; +} + +const LayoutWrapper = styled('div', { + shouldForwardProp: (propName) => !['draggable', 'layout'].includes(propName), +})<{ + layout: Layout; + draggable: boolean; + selected?: boolean; +}>` + position: relative; + grid-area: ${({ layout }) => + `${layout.y + 1}/${layout.x + 1}/${layout.y + layout.h + 1}/${layout.x + layout.w + 1}`}; + cursor: ${({ draggable, selected }) => (draggable && selected ? 'move' : 'inherit')}; + // ${({ selected }) => selected && 'filter: drop-shadow(#0bc5ea 0px 0px 1px);'}; + ${({ selected }) => selected && 'box-shadow: 1px 1px 1px #0bc5ea, -1px -1px 1px #0bc5ea;'}; +`; + +const DeleteButtonAnchor = styled.div` + position: absolute; + right: 0; + top: 0; + width: 32px; + height: 32px; + z-index: 1; +`; + +const ResizeHandle = styled.div` + position: absolute; + right: 0px; + bottom: 0px; + border: 0px solid black; + border-right-width: 5px; + border-bottom-width: 5px; + width: 15px; + height: 15px; + cursor: se-resize; +`; + +const LayoutItemComponent: FC = ({ + layout, + render, + draggable = false, + resizable = false, + onLayoutChange, + onLayoutChangeEnd, + selected, + onSelectedChange, + onDeleted, +}) => { + const { getContainer, cols, rows, columnGap, rowGap, padding } = useContext(GridLayoutContext); + const elRef = useRef(null); + const element = render(layout); + const mouseDownMetaRef = useRef(null); + + const draggableProps = draggable + ? { + onMouseDown(e: React.MouseEvent) { + e.stopPropagation(); + if (!selected) return; + mouseDownMetaRef.current = { + clientX: e.clientX, + clientY: e.clientY, + type: 'move', + }; + }, + } + : null; + + const getNewLayoutByOffset = useMemoizedFn( + ( + offset: { + x: number; + y: number; + }, + type: 'move' | 'resize' + ) => { + return mutateLayout({ + container: getContainer()!, + rows, + cols, + columnGap, + rowGap, + padding, + layout, + offset, + type, + }); + } + ); + + useEffect(() => { + if (!draggable && !resizable) return; + + const handleMouseMove = (e: MouseEvent) => { + e.stopPropagation(); + if (!mouseDownMetaRef.current) return; + + const { clientX, clientY, type } = mouseDownMetaRef.current; + const { x, y, w, h, boundingRect } = getNewLayoutByOffset( + { + x: e.clientX - clientX, + y: e.clientY - clientY, + }, + type + ); + + Object.assign(elRef.current!.style, { + position: 'absolute', + width: `${boundingRect.width}px`, + height: `${boundingRect.height}px`, + left: `${boundingRect.left}px`, + top: `${boundingRect.top}px`, + gridArea: 'auto', + }); + onLayoutChange?.({ + ...layout, + x, + y, + w, + h, + }); + }; + const handleMouseUp = (e: MouseEvent) => { + e.stopPropagation(); + if (!mouseDownMetaRef.current) return; + + const { clientX, clientY, type } = mouseDownMetaRef.current; + const { x, y, w, h } = getNewLayoutByOffset( + { + x: e.clientX - clientX, + y: e.clientY - clientY, + }, + type + ); + + Object.assign(elRef.current!.style, { + position: 'relative', + width: 'auto', + height: 'auto', + left: 'auto', + top: 'auto', + gridArea: `${y + 1}/${x + 1}/${y + h + 1}/${x + w + 1}`, + }); + onLayoutChangeEnd?.({ + ...layout, + x, + y, + w, + h, + }); + mouseDownMetaRef.current = null; + }; + + document.body.addEventListener('mousemove', handleMouseMove); + document.body.addEventListener('mouseup', handleMouseUp); + + return () => { + document.body.removeEventListener('mousemove', handleMouseMove); + document.body.removeEventListener('mouseup', handleMouseUp); + }; + }, [layout, draggable, resizable, onLayoutChange, onLayoutChangeEnd, getNewLayoutByOffset]); + + return ( + { + e.stopPropagation(); + if (!draggable && !resizable) return; + if (!selected) onSelectedChange?.(true); + }} + > + {selected && ( + + {/**/} + } + onClick={onDeleted} + /> + + )} + + {element} + {resizable && selected && ( + // TODO: adjust styles and allow custom resizeHandle + { + e.stopPropagation(); + + mouseDownMetaRef.current = { + clientX: e.clientX, + clientY: e.clientY, + type: 'resize', + }; + }} + /> + )} + + ); +}; + +export default LayoutItemComponent; diff --git a/src/components/RefreshWrapper.tsx b/src/components/RefreshWrapper.tsx index 82d546d..2540dae 100644 --- a/src/components/RefreshWrapper.tsx +++ b/src/components/RefreshWrapper.tsx @@ -66,6 +66,7 @@ const RefreshWrapper = ({ padding="12px" border="1px" borderColor={borderColor} + bgColor={useColorModeValue('white', 'gray.800')} borderRadius="16px" spacing="8px" w="100%" diff --git a/src/components/ui/GrayBox.tsx b/src/components/ui/GrayBox.tsx new file mode 100644 index 0000000..3934a9b --- /dev/null +++ b/src/components/ui/GrayBox.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Center } from '@chakra-ui/react'; + +const GrayBox = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +export default GrayBox; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..92bd2b7 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useForkRef' \ No newline at end of file diff --git a/src/hooks/useForkRef.ts b/src/hooks/useForkRef.ts new file mode 100644 index 0000000..a579673 --- /dev/null +++ b/src/hooks/useForkRef.ts @@ -0,0 +1,25 @@ +import { useMemo } from "react"; + +function setRef(ref: React.RefObject | ((instance: T | null) => void) | null | undefined, value: T | null): void { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + // @ts-ignore + ref.current = value; + } +} + + +export function useForkRef(refA: React.Ref, refB: React.Ref): React.Ref { + return useMemo(function () { + if (refA == null && refB == null) { + return null; + } + + return function (refValue) { + debugger + setRef(refA, refValue); + setRef(refB, refValue); + }; + }, [refA, refB]); +} \ No newline at end of file diff --git a/src/pages/api/owner_rotation.ts b/src/pages/api/owner_rotation.ts index 3fe7b53..91b3a05 100644 --- a/src/pages/api/owner_rotation.ts +++ b/src/pages/api/owner_rotation.ts @@ -1,6 +1,6 @@ import { NextApiHandler } from 'next'; import { ownerRotationConfig } from '../../../config/owner_rotation.config'; -import { Member, Rotation } from '@/components/OwnerRotationOverview'; +import { Member, Rotation } from '../../widgets/OwnerRotationWidget'; import { delay1s } from '@/lib/delay'; import { fetchFieldsFromApiTable } from '@/lib/apiTableFetcher'; import { fetchFieldsFromGoogleSheet } from '@/lib/googleSheetFetcher'; diff --git a/src/pages/api/page_config.ts b/src/pages/api/page_config.ts new file mode 100644 index 0000000..7d2a83e --- /dev/null +++ b/src/pages/api/page_config.ts @@ -0,0 +1,50 @@ +import path from 'path'; +import fs from 'fs'; +import { NextApiHandler } from 'next'; + +const filePath = path.join('/tmp/layouts.json'); + +const getLatestVersion = () => { + if (!fs.existsSync(filePath)) return 0 + + const pageConfigJSON = fs.readFileSync(filePath, 'utf-8') + const pageConfig = JSON.parse(pageConfigJSON) + return pageConfig.version || 0 +} + +const postPageConfigController: NextApiHandler = (req, res) => { + const data = JSON.parse(req.body) + + fs.writeFileSync(filePath, JSON.stringify({ + ...data, + version: getLatestVersion() + 1, + }), 'utf8'); + return res.status(200).json({ success: true }) +} + +export const getPageConfig = () => { + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, JSON.stringify({ version: null }), 'utf-8') + + return { + version: null + } + } + + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method === 'POST') return postPageConfigController(req, res) + + if (req.method === 'GET') { + const config = await getPageConfig() + return res.status(200).json(config.version ? config : null) + } + + return res.status(405).json({ + error: 'Method Not Allowed' + }) +} + +export default handler \ No newline at end of file diff --git a/src/pages/builder.tsx b/src/pages/builder.tsx new file mode 100644 index 0000000..07d8926 --- /dev/null +++ b/src/pages/builder.tsx @@ -0,0 +1,307 @@ +import React, { useEffect } from 'react'; +import Image from 'next/image'; +import styled from '@emotion/styled'; +import { + Box, + Button, + IconButton, + InputGroup, + InputLeftAddon, + InputRightAddon, + List, + ListItem, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, + useToast, +} from '@chakra-ui/react'; +import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import omit from 'lodash/omit'; +import { DragAndDropProvider, Draggable, GridLayout } from '@/components/GridLayout'; +import { GrConfigure } from 'react-icons/gr'; +import { PageConfigState, usePageConfigStore } from '@/stores/pageConfig'; +import { getPageConfig } from './api/page_config'; +import { ArrowBackIcon, SettingsIcon } from '@chakra-ui/icons'; + +import { useRouter } from 'next/router'; +import { zBoardWidgets } from '@/widgets'; +import GrayBox from '@/components/ui/GrayBox'; + +const ComponentItem = styled.div` + /* TODO: adjust styles */ + margin-bottom: 10px; + border-radius: 14px; + padding: 8px; + background: #f3f3f3; +`; +const HEADER_HEIGHT = 60; +const Header = styled.header` + height: ${HEADER_HEIGHT}px; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0px 30px; +`; + +const Container = styled.div` + display: flex; + height: calc(100vh - ${HEADER_HEIGHT}px); +`; +const LeftAside = styled.aside` + flex: none; + width: 250px; + border-right: 1px solid #eee; + padding: 10px; + overflow: auto; +`; +const Main = styled.main` + flex: 1; +`; +const RightAside = styled.aside` + flex: none; + width: 250px; + border-left: 1px solid #eee; + overflow: auto; +`; + +export const getServerSideProps: GetServerSideProps<{ + pageConfigFromServer: Partial & { version: number }; +}> = async () => { + return { + props: { + pageConfigFromServer: await getPageConfig(), + }, + }; +}; + +export default function Builder({ + pageConfigFromServer, +}: InferGetServerSidePropsType) { + const toast = useToast(); + const router = useRouter(); + const { pageConfig, setPageConfig, setVersion } = usePageConfigStore(); + + useEffect(() => { + usePageConfigStore.persist.rehydrate(); + const { version } = usePageConfigStore.getState(); + + if (pageConfigFromServer.version && pageConfigFromServer.version > version) { + setVersion(pageConfigFromServer.version); + setPageConfig(omit(pageConfigFromServer, 'version')); + } + }, []); + + const getConfig = async () => { + const res = await fetch('/api/page_config'); + return await res.json(); + }; + + const publish = async () => { + await fetch('/api/page_config', { + method: 'POST', + body: JSON.stringify(pageConfig), + }); + + const { version: newVersion } = await getConfig(); + setVersion(newVersion); + toast({ + title: 'Publish successfully!', + status: 'success', + duration: 3000, + position: 'top', + }); + + router.push('/'); + }; + + const LayoutSettings = () => ( + + + } /> + {/**/} + + + + + + + + rows + + setPageConfig({ rows })} + > + + + + + + + + + + + + cols + + setPageConfig({ cols })} + > + + + + + + + + + + + + rowGap + + setPageConfig({ rowGap })} + > + + + px + + + + + + colGap + + setPageConfig({ columnGap })} + > + + + px + + + + + + padding + + setPageConfig({ padding })} + > + + + px + + + + + + + ); + + const unusedComponents = zBoardWidgets.filter( + (component) => !pageConfig.layouts.some((layout) => layout.component === component.name) + ); + + return ( + +
+
+
+ } + onClick={() => router.push('/')} + /> + + Customize homepage + +
+
+ + {/**/} + +
+
+ + + {unusedComponents.length === 0 ? ( + No more component here... + ) : ( + unusedComponents.map(({ name, preview, layout }) => { + return ( + + + {name} + ProjectTimeline preview + + + ); + }) + )} + +
+ setPageConfig({ layouts })} + itemRender={({ component }) => { + const widget = zBoardWidgets.find(({ name }) => name === component); + return widget ? ( + + ) : ( + {component} Widget Not found + ); + }} + rowGap={pageConfig.rowGap} + columnGap={pageConfig.columnGap} + padding={pageConfig.padding} + droppable + draggable + resizable + /> +
+ {/*TODO*/} + {/**/} +
+
+
+ ); +} diff --git a/src/pages/example/build-status-overview.tsx b/src/pages/example/build-status-overview.tsx deleted file mode 100644 index a0697b8..0000000 --- a/src/pages/example/build-status-overview.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { VStack, Heading } from '@chakra-ui/react'; -import React from 'react'; -import BuildStatusOverview from '@/components/BuildStatusOverview'; -import CollapseNavbar from '@/components/CollapseNavbar'; - -const BuildStatusOverviewPage = () => { - return ( - - - - - ); -}; - -export default BuildStatusOverviewPage; diff --git a/src/pages/example/owner-rotation-overview.tsx b/src/pages/example/owner-rotation-overview.tsx deleted file mode 100644 index 18373f9..0000000 --- a/src/pages/example/owner-rotation-overview.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { VStack } from '@chakra-ui/react'; -import React from 'react'; -import CollapseNavbar from '@/components/CollapseNavbar'; -import OwnerRotationOverview from '@/components/OwnerRotationOverview'; - -const OwnerRotationOverviewPage = () => { - return ( - - - - - ); -}; - -export default OwnerRotationOverviewPage; diff --git a/src/pages/example/project-timeline.tsx b/src/pages/example/project-timeline.tsx deleted file mode 100644 index 7dd9ea6..0000000 --- a/src/pages/example/project-timeline.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { VStack, Heading } from '@chakra-ui/react'; -import React from 'react'; -import ProjectTimeline from '@/components/ProjectTimeline'; -import CollapseNavbar from '@/components/CollapseNavbar'; - -const ProjectTimelinePage = () => { - return ( - - - - - ); -}; - -export default ProjectTimelinePage; diff --git a/src/pages/example/ticket-status-overview.tsx b/src/pages/example/ticket-status-overview.tsx deleted file mode 100644 index 2034973..0000000 --- a/src/pages/example/ticket-status-overview.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { VStack, Heading } from '@chakra-ui/react'; -import React from 'react'; -import TicketStatusOverview from '@/components/TicketStatusOverview'; -import CollapseNavbar from '@/components/CollapseNavbar'; - -const TicketStatusOverviewPage = () => { - return ( - - - - - ); -}; - -export default TicketStatusOverviewPage; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index c583589..0f7a51e 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,28 +1,30 @@ -import { VStack, HStack } from '@chakra-ui/react'; -import BuildStatusOverview from '@/components/BuildStatusOverview'; -import TicketStatusOverview from '@/components/TicketStatusOverview'; -import ProjectTimeline from '@/components/ProjectTimeline'; +import { Flex, Box, VStack } from '@chakra-ui/react'; import CollapseNavbar from '@/components/CollapseNavbar'; import UpdateChecker from '@/components/UpdateChecker'; -import OwnerRotationOverview from '@/components/OwnerRotationOverview'; +import DashboardPreview from '@/components/DashboardPreview'; export default function Home() { return ( - + - - - - - - - - - - - - - + + + + + {/* fixed layout */} + {/**/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/**/} + ); } diff --git a/src/pages/preview.tsx b/src/pages/preview.tsx new file mode 100644 index 0000000..fab9af3 --- /dev/null +++ b/src/pages/preview.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react'; +import styled from '@emotion/styled'; +import DashboardPreview from '@/components/DashboardPreview'; + +const FullScreenContainer = styled.div` + width: 100vw; + height: 100vh; +`; +// @deprecated +const Preview: FC = () => { + return ( + + + + ); +}; + +export default Preview; diff --git a/src/stores/pageConfig.ts b/src/stores/pageConfig.ts new file mode 100644 index 0000000..9fc72e7 --- /dev/null +++ b/src/stores/pageConfig.ts @@ -0,0 +1,49 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { Layout } from '@/components/GridLayout'; + +export interface PageConfig { + rows: number; + cols: number; + rowGap: number; + columnGap: number; + padding: number; + layouts: Layout[]; +} +export interface PageConfigState { + version: number; + setVersion: (version: number) => void; + pageConfig: PageConfig; + setPageConfig: (config: Partial) => void; +} + +export const usePageConfigStore = create()( + persist( + (set, get) => ({ + version: 0, + pageConfig: { + rows: 12, + cols: 12, + rowGap: 8, + columnGap: 8, + padding: 8, + layouts: [], + }, + setVersion: (version) => { + set({ version }); + }, + setPageConfig: (config: Partial) => { + set({ + pageConfig: { + ...get().pageConfig, + ...config, + }, + }); + }, + }), + { + name: 'pageConfig', + skipHydration: true, + } + ) +); diff --git a/src/components/BuildStatusCard.tsx b/src/widgets/BuildStatusWidget/BuildStatusCard.tsx similarity index 100% rename from src/components/BuildStatusCard.tsx rename to src/widgets/BuildStatusWidget/BuildStatusCard.tsx diff --git a/src/components/BuildStatusOverview.tsx b/src/widgets/BuildStatusWidget/index.tsx similarity index 87% rename from src/components/BuildStatusOverview.tsx rename to src/widgets/BuildStatusWidget/index.tsx index 83d2f77..7c7d376 100644 --- a/src/components/BuildStatusOverview.tsx +++ b/src/widgets/BuildStatusWidget/index.tsx @@ -1,7 +1,7 @@ import { SystemProps, Grid } from '@chakra-ui/react'; -import BuildStatusCard, { BuildStatus } from '@/components/BuildStatusCard'; +import BuildStatusCard, { BuildStatus } from '@/widgets/BuildStatusWidget/BuildStatusCard'; import RefreshWrapper from '@/components/RefreshWrapper'; -import { buildStatusConfig } from '../../config/build_status.config'; +import { buildStatusConfig } from '@/../config/build_status.config'; import { useErrorToast } from '@/lib/customToast'; const BuildStatusOverview = (props: SystemProps) => { diff --git a/src/widgets/BuildStatusWidget/preview.png b/src/widgets/BuildStatusWidget/preview.png new file mode 100644 index 0000000..924939a Binary files /dev/null and b/src/widgets/BuildStatusWidget/preview.png differ diff --git a/src/components/OwnerRotationCard.tsx b/src/widgets/OwnerRotationWidget/OwnerRotationCard.tsx similarity index 96% rename from src/components/OwnerRotationCard.tsx rename to src/widgets/OwnerRotationWidget/OwnerRotationCard.tsx index 1ffba86..923c3af 100644 --- a/src/components/OwnerRotationCard.tsx +++ b/src/widgets/OwnerRotationWidget/OwnerRotationCard.tsx @@ -1,6 +1,6 @@ import React, { ReactNode } from 'react'; import { Box, Card, CardBody, Heading, HStack, Text } from '@chakra-ui/react'; -import { Member } from '@/components/OwnerRotationOverview'; +import { Member } from '@/widgets/OwnerRotationWidget/index'; import moment from 'moment'; interface OwnerRotationProps { diff --git a/src/components/OwnerRotationOverview.tsx b/src/widgets/OwnerRotationWidget/index.tsx similarity index 93% rename from src/components/OwnerRotationOverview.tsx rename to src/widgets/OwnerRotationWidget/index.tsx index 4e5cca8..6ccbc20 100644 --- a/src/components/OwnerRotationOverview.tsx +++ b/src/widgets/OwnerRotationWidget/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import OwnerRotationCard from '@/components/OwnerRotationCard'; +import OwnerRotationCard from '@/widgets/OwnerRotationWidget/OwnerRotationCard'; import { Flex, SystemProps } from '@chakra-ui/react'; import RefreshWrapper from '@/components/RefreshWrapper'; import { useErrorToast } from '@/lib/customToast'; @@ -41,7 +41,7 @@ const getIcon = (iconName: string, color: string) => { return ; }; -const OwnerRotationOverview = (props: SystemProps) => { +const Index = (props: SystemProps) => { const toastError = useErrorToast(); const fetchData = async () => { @@ -91,4 +91,4 @@ const OwnerRotationOverview = (props: SystemProps) => { ); }; -export default OwnerRotationOverview; +export default Index; diff --git a/src/widgets/OwnerRotationWidget/preview.png b/src/widgets/OwnerRotationWidget/preview.png new file mode 100644 index 0000000..508f06c Binary files /dev/null and b/src/widgets/OwnerRotationWidget/preview.png differ diff --git a/src/components/ProjectTimeline.tsx b/src/widgets/ProjectTimelineWidget/index.tsx similarity index 98% rename from src/components/ProjectTimeline.tsx rename to src/widgets/ProjectTimelineWidget/index.tsx index 559833e..7f8b2d1 100644 --- a/src/components/ProjectTimeline.tsx +++ b/src/widgets/ProjectTimelineWidget/index.tsx @@ -148,6 +148,7 @@ const Timeline = (props: SystemProps) => { borderRadius="20px" justifyContent="space-between" alignItems="center" + zIndex={1} > @@ -192,7 +193,7 @@ const Timeline = (props: SystemProps) => { height: '400px', width: '2px', backgroundColor: 'red.500', - zIndex: -1, + // zIndex: 3, }} > {dayNumber} @@ -214,7 +215,7 @@ const Timeline = (props: SystemProps) => { rowStart={1} rowEnd={cards.length + 1} backgroundColor={weekendGridBgColor} - zIndex="-3" + // zIndex="1" /> ); }); @@ -278,7 +279,7 @@ const Timeline = (props: SystemProps) => { colStart={index + 1} borderY="1px solid" borderColor={borderColor} - zIndex="-2" + // zIndex="4" h="40px" backgroundColor={isWeekend ? weekendGridBgColor : gridBgColor} > @@ -297,6 +298,7 @@ const Timeline = (props: SystemProps) => { border="1px solid" borderRadius="8px" borderColor={borderColor} + bgColor={useColorModeValue('white', 'gray.800')} {...props} > { const toastError = useErrorToast(); @@ -33,8 +33,8 @@ const TicketOverview = (props: SystemProps) => { return ( ) => ( + + ), + preview: ProjectTimelineImg, + layout: { + w: 8, + minW: 2, + h: 7, + minH: 2, + maxH: 9, + }, + }, + { + name: 'TicketStatus', + Component: (props: ComponentProps) => ( + + ), + preview: ZendeskStatusWidgetImg, + layout: { + w: 4, + minW: 2, + h: 6, + minH: 2, + maxH: 9, + }, + }, + { + name: 'BuildStatus', + Component: (props: ComponentProps) => ( + + ), + preview: BuildStatusWidgetImg, + layout: { + w: 9, + minW: 2, + h: 6, + minH: 2, + maxH: 9, + }, + }, + { + name: 'OwnerRotation', + Component: (props: ComponentProps) => ( + + ), + preview: OwnerRotationWidgetImg, + layout: { + w: 2, + minW: 2, + h: 6, + minH: 4, + }, + }, +];