diff --git a/webapp/app.ironcalc.com/frontend/src/App.tsx b/webapp/app.ironcalc.com/frontend/src/App.tsx index d2c72fc..f3560c6 100644 --- a/webapp/app.ironcalc.com/frontend/src/App.tsx +++ b/webapp/app.ironcalc.com/frontend/src/App.tsx @@ -2,6 +2,7 @@ import "./App.css"; import styled from "@emotion/styled"; import { useEffect, useState } from "react"; import { FileBar } from "./components/FileBar"; +import LeftDrawer from "./components/LeftDrawer/LeftDrawer"; import { get_documentation_model, get_model, @@ -9,7 +10,10 @@ import { } from "./components/rpc"; import { createNewModel, + deleteModelByUuid, deleteSelectedModel, + // getModelsMetadata, + // getSelectedUuid, loadModelFromStorageOrCreate, saveModelToStorage, saveSelectedModelInStorage, @@ -21,6 +25,7 @@ import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook"; function App() { const [model, setModel] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); useEffect(() => { async function start() { @@ -79,48 +84,80 @@ function App() { // We could use context for model, but the problem is that it should initialized to null. // Passing the property down makes sure it is always defined. + // Handlers for model changes that also update our models state + const handleNewModel = () => { + const newModel = createNewModel(); + setModel(newModel); + }; + + const handleSetModel = (uuid: string) => { + const newModel = selectModelFromStorage(uuid); + if (newModel) { + setModel(newModel); + } + }; + + const handleDeleteModel = () => { + const newModel = deleteSelectedModel(); + if (newModel) { + setModel(newModel); + } + }; + + const handleDeleteModelByUuid = (uuid: string) => { + const newModel = deleteModelByUuid(uuid); + if (newModel) { + setModel(newModel); + } + }; + return ( - - { - const blob = await uploadFile(arrayBuffer, fileName); - - const bytes = new Uint8Array(await blob.arrayBuffer()); - const newModel = Model.from_bytes(bytes); - saveModelToStorage(newModel); - - setModel(newModel); - }} - newModel={() => { - setModel(createNewModel()); - }} - setModel={(uuid: string) => { - const newModel = selectModelFromStorage(uuid); - if (newModel) { - setModel(newModel); - } - }} - onDelete={() => { - const newModel = deleteSelectedModel(); - if (newModel) { - setModel(newModel); - } - }} + + setIsDrawerOpen(false)} + newModel={handleNewModel} + setModel={handleSetModel} + onDelete={handleDeleteModelByUuid} /> - - + + + { + const blob = await uploadFile(arrayBuffer, fileName); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const newModel = Model.from_bytes(bytes); + saveModelToStorage(newModel); + setModel(newModel); + }} + newModel={handleNewModel} + setModel={handleSetModel} + onDelete={handleDeleteModel} + isDrawerOpen={isDrawerOpen} + setIsDrawerOpen={setIsDrawerOpen} + /> + + + ); } -const Wrapper = styled("div")` - margin: 0px; - padding: 0px; +const AppContainer = styled("div")` + display: flex; width: 100%; height: 100%; + position: relative; + overflow: hidden; +`; + +const MainContent = styled("div")<{ isDrawerOpen: boolean }>` + margin-left: ${({ isDrawerOpen }) => (isDrawerOpen ? "0px" : "-264px")}; + transition: margin-left 0.3s ease; + width: ${({ isDrawerOpen }) => + isDrawerOpen ? "calc(100% - 264px)" : "100%"}; display: flex; flex-direction: column; - position: absolute; `; const Loading = styled("div")` diff --git a/webapp/app.ironcalc.com/frontend/src/components/DeleteWorkbookDialog.tsx b/webapp/app.ironcalc.com/frontend/src/components/DeleteWorkbookDialog.tsx index 6a65991..c50038a 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/DeleteWorkbookDialog.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/DeleteWorkbookDialog.tsx @@ -12,19 +12,9 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) { const deleteButtonRef = useRef(null); useEffect(() => { - const root = document.getElementById("root"); - if (root) { - root.style.filter = "blur(2px)"; - } if (deleteButtonRef.current) { deleteButtonRef.current.focus(); } - return () => { - const root = document.getElementById("root"); - if (root) { - root.style.filter = "none"; - } - }; }, []); return ( diff --git a/webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx b/webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx index 2f5136b..076739e 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx @@ -1,9 +1,9 @@ import styled from "@emotion/styled"; import type { Model } from "@ironcalc/workbook"; -import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook"; +import { Button, IconButton } from "@mui/material"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { useLayoutEffect, useRef, useState } from "react"; -import { FileMenu } from "./FileMenu"; -import { HelpMenu } from "./HelpMenu"; +import { DesktopMenu, MobileMenu } from "./FileMenu"; import { ShareButton } from "./ShareButton"; import ShareWorkbookDialog from "./ShareWorkbookDialog"; import { WorkbookTitle } from "./WorkbookTitle"; @@ -30,6 +30,8 @@ export function FileBar(properties: { setModel: (key: string) => void; onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise; onDelete: () => void; + isDrawerOpen: boolean; + setIsDrawerOpen: (open: boolean) => void; }) { const [isDialogOpen, setIsDialogOpen] = useState(false); const spacerRef = useRef(null); @@ -45,24 +47,49 @@ export function FileBar(properties: { } }, [width]); + // Common handler functions for both menu types + const handleDownload = async () => { + const model = properties.model; + const bytes = model.toBytes(); + const fileName = model.getName(); + await downloadModel(bytes, fileName); + }; + return ( - - - - { - const model = properties.model; - const bytes = model.toBytes(); - const fileName = model.getName(); - await downloadModel(bytes, fileName); - }} - onDelete={properties.onDelete} - /> - + properties.setIsDrawerOpen(!properties.isDrawerOpen)} + disableRipple + title="Toggle sidebar" + > + {properties.isDrawerOpen ? : } + + + + window.open("https://docs.ironcalc.com", "_blank")} + > + Help + + + + + + ` + margin-left: 8px; + height: 32px; + width: 32px; + padding: 8px; + border-radius: 4px; + cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")}; + svg { + stroke-width: 2px; + stroke: #757575; + width: 16px; + height: 16px; } -`; - -const StyledIronCalcIcon = styled(IronCalcIcon)` - width: 36px; - margin-left: 10px; - @media (min-width: 769px) { - display: none; + &:hover { + background-color: #f2f2f2; + } + &:active { + background-color: #e0e0e0; } -`; - -const Divider = styled("div")` - margin: 0px 8px 0px 16px; - height: 12px; - border-left: 1px solid #e0e0e0; `; // The container must be relative positioned so we can position the title absolutely const FileBarWrapper = styled("div")` position: relative; height: 60px; + min-height: 60px; width: 100%; background: #fff; display: flex; align-items: center; border-bottom: 1px solid #e0e0e0; justify-content: space-between; + box-sizing: border-box; +`; + +const DesktopButtonsWrapper = styled("div")` + display: flex; + gap: 4px; + margin-left: 8px; + @media (max-width: 600px) { + display: none; + } +`; + +const MobileButtonsWrapper = styled("div")` + display: flex; + gap: 4px; + @media (min-width: 601px) { + display: none; + } + @media (max-width: 600px) { + display: flex; + } +`; + +const FileBarButton = styled(Button)` + display: flex; + flex-direction: row; + align-items: center; + font-size: 12px; + height: 32px; + width: auto; + padding: 4px 8px; + font-weight: 400; + min-width: 0px; + text-transform: capitalize; + color: #333333; + &:hover { + background-color: #f2f2f2; + } + &:active { + background-color: #e0e0e0; + } `; const DialogContainer = styled("div")` diff --git a/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx b/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx index d292233..3edd7d1 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx @@ -1,93 +1,165 @@ import styled from "@emotion/styled"; -import { Menu, MenuItem, Modal } from "@mui/material"; -import { Check, FileDown, FileUp, Plus, Trash2 } from "lucide-react"; +import { Button, IconButton, Menu, MenuItem, Modal } from "@mui/material"; +import { + ChevronRight, + EllipsisVertical, + FileDown, + FileUp, + Plus, + Trash2, +} from "lucide-react"; import { useRef, useState } from "react"; import DeleteWorkbookDialog from "./DeleteWorkbookDialog"; import UploadFileDialog from "./UploadFileDialog"; import { getModelsMetadata, getSelectedUuid } from "./storage"; +export function DesktopMenu(props: { + newModel: () => void; + setModel: (key: string) => void; + onDownload: () => void; + onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise; + onDelete: () => void; +}) { + const [isFileMenuOpen, setFileMenuOpen] = useState(false); + const anchorElement = useRef( + null as unknown as HTMLButtonElement, + ); + + return ( + <> + setFileMenuOpen(!isFileMenuOpen)} + ref={anchorElement} + disableRipple + isOpen={isFileMenuOpen} + > + File + + {}} + anchorElement={anchorElement} + /> + + ); +} + +export function MobileMenu(props: { + newModel: () => void; + setModel: (key: string) => void; + onDownload: () => void; + onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise; + onDelete: () => void; +}) { + const [isMobileMenuOpen, setMobileMenuOpen] = useState(false); + const [isFileMenuOpen, setFileMenuOpen] = useState(false); + const anchorElement = useRef( + null as unknown as HTMLButtonElement, + ); + const [fileMenuAnchorEl, setFileMenuAnchorEl] = useState( + null, + ); + + return ( + <> + setMobileMenuOpen(true)} + ref={anchorElement} + disableRipple + > + + + setMobileMenuOpen(false)} + anchorEl={anchorElement.current} + > + { + setFileMenuOpen(true); + setFileMenuAnchorEl(event.currentTarget); + }} + disableRipple + > + File + + + + { + window.open("https://docs.ironcalc.com", "_blank"); + setMobileMenuOpen(false); + }} + disableRipple + > + Help + + + + + ); +} + export function FileMenu(props: { newModel: () => void; setModel: (key: string) => void; onDownload: () => void; onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise; onDelete: () => void; + isFileMenuOpen: boolean; + setFileMenuOpen: (open: boolean) => void; + setMobileMenuOpen: (open: boolean) => void; + anchorElement: React.RefObject; }) { - const [isMenuOpen, setMenuOpen] = useState(false); const [isImportMenuOpen, setImportMenuOpen] = useState(false); - const anchorElement = useRef(null); const models = getModelsMetadata(); - const uuids = Object.keys(models); const selectedUuid = getSelectedUuid(); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); - const elements = []; - for (const uuid of uuids) { - elements.push( - { - props.setModel(uuid); - setMenuOpen(false); - }} - > - - {uuid === selectedUuid ? ( - - - - ) : ( - "" - )} - - - {models[uuid]} - - , - ); - } - return ( <> - setMenuOpen(true)} - ref={anchorElement} - $isActive={isMenuOpen} - aria-haspopup="true" - > - File - - setMenuOpen(false)} - anchorEl={anchorElement.current} - autoFocus={false} - disableRestoreFocus={true} - sx={{ - "& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" }, - "& .MuiList-root": { padding: "0" }, - transform: "translate(-4px, 4px)", + props.setFileMenuOpen(false)} + anchorEl={props.anchorElement.current} + anchorOrigin={{ + vertical: "bottom", + horizontal: "left", }} - slotProps={{ - list: { - "aria-labelledby": "file-menu-button", - tabIndex: -1, - }, + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + // To prevent closing parent menu when interacting with submenu + onMouseLeave={() => { + if (!isImportMenuOpen && !isDeleteDialogOpen) { + props.setFileMenuOpen(false); + } }} > { props.newModel(); - setMenuOpen(false); + props.setFileMenuOpen(false); + props.setMobileMenuOpen(false); }} + disableRipple > @@ -97,34 +169,41 @@ export function FileMenu(props: { { setImportMenuOpen(true); - setMenuOpen(false); + props.setFileMenuOpen(false); + props.setMobileMenuOpen(false); }} + disableRipple > Import - - - - + { + props.onDownload(); + props.setMobileMenuOpen(false); + }} + disableRipple + > + Download (.xlsx) + { setDeleteDialogOpen(true); - setMenuOpen(false); + props.setFileMenuOpen(false); + props.setMobileMenuOpen(false); }} + disableRipple > Delete workbook - - {elements} - + { @@ -149,7 +228,7 @@ export function FileMenu(props: { setDeleteDialogOpen(false)} onConfirm={props.onDelete} - workbookName={selectedUuid ? models[selectedUuid] : ""} + workbookName={selectedUuid ? models[selectedUuid]?.name || "" : ""} /> @@ -167,7 +246,55 @@ const StyledIcon = styled.div` } `; -const MenuDivider = styled.div` +const MenuButton = styled(IconButton)` + height: 32px; + width: 32px; + padding: 8px; + border-radius: 4px; + svg { + stroke-width: 2px; + stroke: #757575; + width: 16px; + height: 16px; + } + &:hover { + background-color: #f2f2f2; + } + &:active { + background-color: #e0e0e0; + } +`; + +const FileBarButton = styled(Button)<{ isOpen: boolean }>` + display: flex; + flex-direction: row; + align-items: center; + font-size: 12px; + height: 32px; + width: auto; + padding: 4px 8px; + font-weight: 400; + min-width: 0px; + text-transform: capitalize; + color: #333333; + background-color: ${({ isOpen }) => (isOpen ? "#f2f2f2" : "none")}; + &:hover { + background-color: #f2f2f2; + } + &:active { + background-color: #e0e0e0; + } +`; + +const StyledFileDown = styled(FileDown)` + width: 16px; + height: 16px; + color: #333333; + padding-right: 10px; +`; + + +const MenuDivider = styled("div")` width: 100%; margin: auto; margin-top: 4px; @@ -178,6 +305,7 @@ const MenuDivider = styled.div` const MenuItemText = styled.div` color: #000; font-size: 12px; + flex-grow: 1; `; const MenuItemWrapper = styled(MenuItem)` @@ -190,26 +318,19 @@ const MenuItemWrapper = styled(MenuItem)` border-radius: 4px; padding: 8px; height: 32px; -`; - -const FileMenuWrapper = styled.button<{ $isActive: boolean }>` - display: flex; - align-items: center; - font-size: 12px; - font-family: Inter; - padding: 8px; - border-radius: 4px; - cursor: pointer; - background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")}; - border: none; - background: none; - &:hover { - background-color: #f2f2f2; + min-height: 32px; + svg { + width: 16px; + height: 16px; } `; -const CheckIndicator = styled.span` - display: flex; - justify-content: center; - min-width: 26px; +const StyledMenu = styled(Menu)` + .MuiPaper-root { + border-radius: 8px; + padding: 4px 0px; + }, + .MuiList-root { + padding: 0; + }, `; diff --git a/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerContent.tsx b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerContent.tsx new file mode 100644 index 0000000..807116d --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerContent.tsx @@ -0,0 +1,29 @@ +import styled from "@emotion/styled"; +import WorkbookList from "./WorkbookList"; + +interface DrawerContentProps { + setModel: (key: string) => void; + onDelete: (uuid: string) => void; +} + +function DrawerContent(props: DrawerContentProps) { + const { setModel, onDelete } = props; + + return ( + + + + ); +} + +const ContentContainer = styled("div")` + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 12px; + height: 100%; + overflow: scroll; + font-size: 12px; +`; + +export default DrawerContent; diff --git a/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerFooter.tsx b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerFooter.tsx new file mode 100644 index 0000000..d26cf3c --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerFooter.tsx @@ -0,0 +1,71 @@ +import styled from "@emotion/styled"; +import { BookOpen } from "lucide-react"; + +function DrawerFooter() { + return ( + + + + + + Documentation + + + ); +} + +const StyledDrawerFooter = styled("div")` + display: flex; + align-items: center; + padding: 12px; + justify-content: space-between; + max-height: 60px; + height: 60px; + border-top: 1px solid #e0e0e0; + box-sizing: border-box; +`; + +const FooterLink = styled("a")` + display: flex; + gap: 8px; + justify-content: flex-start; + font-size: 14px; + width: 100%; + min-width: 172px; + border-radius: 8px; + padding: 8px 4px 8px 8px; + transition: gap 0.5s; + background-color: transparent; + color: #000; + text-decoration: none; + align-items: center; + + &:hover { + background-color: #e0e0e0 !important; + } +`; + +const OpenBookIcon = styled("div")` + height: 16px; + width: 16px; + svg { + height: 16px; + width: 16px; + stroke: #9e9e9e; + } +`; + +const FooterLinkText = styled("div")` + color: #000; + font-size: 12px; + width: 100%; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; +`; + +export default DrawerFooter; diff --git a/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerHeader.tsx b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerHeader.tsx new file mode 100644 index 0000000..3ad3dd8 --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerHeader.tsx @@ -0,0 +1,61 @@ +import styled from "@emotion/styled"; +import { IronCalcLogo } from "@ironcalc/workbook"; +import { IconButton } from "@mui/material"; +import { Plus } from "lucide-react"; + +interface DrawerHeaderProps { + onNewModel: () => void; +} + +function DrawerHeader({ onNewModel }: DrawerHeaderProps) { + return ( + + + + + + + ); +} + +const HeaderContainer = styled("div")` + display: flex; + align-items: center; + padding: 12px 8px 12px 16px; + justify-content: space-between; + max-height: 60px; + min-height: 60px; + border-bottom: 1px solid #e0e0e0; + box-sizing: border-box; +`; + +const StyledDesktopLogo = styled(IronCalcLogo)` + width: 120px; + height: 28px; +`; + +const AddButton = styled(IconButton)` + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + height: 32px; + width: 32px; + border-radius: 4px; + margin-left: 10px; + color: #333333; + stroke-width: 2px; + &:hover { + background-color: #e0e0e0; + } +`; + +const PlusIcon = styled(Plus)` + width: 16px; + height: 16px; +`; + +export default DrawerHeader; diff --git a/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/LeftDrawer.tsx b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/LeftDrawer.tsx new file mode 100644 index 0000000..378bcde --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/LeftDrawer.tsx @@ -0,0 +1,51 @@ +import styled from "@emotion/styled"; +import { Drawer } from "@mui/material"; +import DrawerContent from "./DrawerContent"; +import DrawerFooter from "./DrawerFooter"; +import DrawerHeader from "./DrawerHeader"; + +interface LeftDrawerProps { + open: boolean; + onClose: () => void; + newModel: () => void; + setModel: (key: string) => void; + onDelete: (uuid: string) => void; +} + +function LeftDrawer({ + open, + onClose, + newModel, + setModel, + onDelete, +}: LeftDrawerProps) { + return ( + + + + + + + ); +} + +const DrawerWrapper = styled(Drawer)` + width: 264px; + height: 100%; + flex-shrink: 0; + font-family: "Inter", sans-serif; + + .MuiDrawer-paper { + width: 264px; + background-color: #f5f5f5; + overflow: hidden; + border-right: 1px solid #e0e0e0; + } +`; + +export default LeftDrawer; diff --git a/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/WorkbookList.tsx b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/WorkbookList.tsx new file mode 100644 index 0000000..75b943a --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/WorkbookList.tsx @@ -0,0 +1,379 @@ +import styled from "@emotion/styled"; +import { Menu, MenuItem, Modal } from "@mui/material"; +import { + EllipsisVertical, + FileDown, + FileSpreadsheet, + Trash2, +} from "lucide-react"; +import type React from "react"; +import { useEffect, useState } from "react"; +import DeleteWorkbookDialog from "../DeleteWorkbookDialog"; +import { downloadModel } from "../rpc"; +import { + getModelsMetadata, + getSelectedUuid, + selectModelFromStorage, +} from "../storage"; + +interface WorkbookListProps { + setModel: (key: string) => void; + onDelete: (uuid: string) => void; +} + +function WorkbookList({ setModel, onDelete }: WorkbookListProps) { + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [selectedWorkbookUuid, setSelectedWorkbookUuid] = useState< + string | null + >(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [workbookToDelete, setWorkbookToDelete] = useState(null); + const [intendedSelection, setIntendedSelection] = useState( + null, + ); + + const selectedUuid = getSelectedUuid(); + + // Clear intended selection when selectedUuid changes from outside + useEffect(() => { + if (intendedSelection && selectedUuid === intendedSelection) { + setIntendedSelection(null); + } + }, [selectedUuid, intendedSelection]); + + const handleMenuOpen = ( + event: React.MouseEvent, + uuid: string, + ) => { + console.log("Menu open", uuid); + event.stopPropagation(); + setSelectedWorkbookUuid(uuid); + setMenuAnchorEl(event.currentTarget); + setIntendedSelection(uuid); + setModel(uuid); + }; + + const handleMenuClose = () => { + console.log( + "Menu closing, selectedWorkbookUuid:", + selectedWorkbookUuid, + "intendedSelection:", + intendedSelection, + ); + setMenuAnchorEl(null); + // If we have an intended selection, make sure it's still selected + if (intendedSelection && intendedSelection !== selectedUuid) { + console.log("Re-selecting intended workbook:", intendedSelection); + setModel(intendedSelection); + } + // Don't reset selectedWorkbookUuid here - we want to keep track of which workbook was selected + // The selectedWorkbookUuid will be used for download/delete operations + }; + + const handleDeleteClick = (uuid: string) => { + console.log("Delete workbook:", uuid); + setWorkbookToDelete(uuid); + setIsDeleteDialogOpen(true); + setIntendedSelection(null); + handleMenuClose(); + }; + + const handleDeleteConfirm = () => { + if (workbookToDelete) { + onDelete(workbookToDelete); + setWorkbookToDelete(null); + } + setIsDeleteDialogOpen(false); + }; + + const handleDeleteCancel = () => { + setWorkbookToDelete(null); + setIsDeleteDialogOpen(false); + }; + + const handleDownload = async (uuid: string) => { + try { + const model = selectModelFromStorage(uuid); + if (model) { + const bytes = model.toBytes(); + const fileName = model.getName(); + await downloadModel(bytes, fileName); + } + } catch (error) { + console.error("Failed to download workbook:", error); + } + }; + + // Group workbooks by creation date + const groupWorkbooks = () => { + const now = Date.now(); + const millisecondsInDay = 24 * 60 * 60 * 1000; + const millisecondsIn30Days = 30 * millisecondsInDay; + + const modelsCreatedToday = []; + const modelsCreatedThisMonth = []; + const olderModels = []; + const modelsMetadata = getModelsMetadata(); + + for (const uuid in modelsMetadata) { + const createdAt = modelsMetadata[uuid].createdAt; + const age = now - createdAt; + + if (age < millisecondsInDay) { + modelsCreatedToday.push(uuid); + } else if (age < millisecondsIn30Days) { + modelsCreatedThisMonth.push(uuid); + } else { + olderModels.push(uuid); + } + } + + // Sort each group by creation timestamp (newest first) + const sortByNewest = (uuids: string[]) => + uuids.sort( + (a, b) => modelsMetadata[b].createdAt - modelsMetadata[a].createdAt, + ); + + return { + modelsCreatedToday: sortByNewest(modelsCreatedToday), + modelsCreatedThisMonth: sortByNewest(modelsCreatedThisMonth), + olderModels: sortByNewest(olderModels), + }; + }; + + const { modelsCreatedToday, modelsCreatedThisMonth, olderModels } = + groupWorkbooks(); + + const renderWorkbookItem = (uuid: string) => { + const isMenuOpen = menuAnchorEl !== null && selectedWorkbookUuid === uuid; + const isAnyMenuOpen = menuAnchorEl !== null; + const models = getModelsMetadata(); + return ( + { + // Prevent clicking on list items when any menu is open + if (isAnyMenuOpen) { + return; + } + setModel(uuid); + }} + selected={uuid === selectedUuid} + disableRipple + style={{ pointerEvents: isAnyMenuOpen ? "none" : "auto" }} + > + + + + {models[uuid].name} + handleMenuOpen(e, uuid)} + isOpen={isMenuOpen} + onMouseDown={(e) => e.stopPropagation()} + style={{ pointerEvents: "auto" }} + > + + + + ); + }; + + const renderSection = (title: string, uuids: string[]) => { + if (uuids.length === 0) return null; + + return ( + + {title} + {uuids.map(renderWorkbookItem)} + + ); + }; + + const models = getModelsMetadata(); + + return ( + <> + {renderSection("Today", modelsCreatedToday)} + {renderSection("Last 30 Days", modelsCreatedThisMonth)} + {renderSection("Older", olderModels)} + + + { + console.log( + "Download clicked, selectedWorkbookUuid:", + selectedWorkbookUuid, + ); + if (selectedWorkbookUuid) { + handleDownload(selectedWorkbookUuid); + } + setIntendedSelection(null); + handleMenuClose(); + }} + disableRipple + > + + Download (.xlsx) + + + { + if (selectedWorkbookUuid) { + handleDeleteClick(selectedWorkbookUuid); + } + }} + disableRipple + > + + Delete workbook + + + + + + + + ); +} + +const StorageIndicator = styled("div")` + height: 16px; + width: 16px; + svg { + height: 16px; + width: 16px; + stroke: #9e9e9e; + } +`; + +const EllipsisButton = styled("button")<{ isOpen: boolean }>` + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + height: 24px; + width: 24px; + border-radius: 4px; + color: #333333; + stroke-width: 2px; + background-color: ${({ isOpen }) => (isOpen ? "#E0E0E0" : "none")}; + opacity: ${({ isOpen }) => (isOpen ? "1" : "0.5")}; + transition: opacity 0.3s, background-color 0.3s; + &:hover { + background: none; + opacity: 1; + } + &:active { + background: #bdbdbd; + opacity: 1; + } +`; + +const WorkbookListItem = styled(MenuItem)<{ selected: boolean }>` + display: flex; + gap: 8px; + justify-content: flex-start; + font-size: 14px; + width: 100%; + min-width: 172px; + border-radius: 8px; + padding: 8px 4px 8px 8px; + height: 32px; + min-height: 32px; + transition: gap 0.5s; + background-color: ${({ selected }) => + selected ? "#e0e0e0 !important" : "transparent"}; + + /* Prevent hover effects when menu is open */ + &:hover { + background-color: ${({ selected }) => + selected ? "#e0e0e0 !important" : "transparent"}; + } +`; + +const WorkbookListText = styled("div")` + color: #000; + font-size: 12px; + width: 100%; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; +`; + +const StyledMenu = styled(Menu)` + .MuiPaper-root { + border-radius: 8px; + padding: 4px 0px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.01); + }, + .MuiList-root { + padding: 0; + }, +`; + +const MenuDivider = styled("div")` + width: 100%; + margin: auto; + margin-top: 4px; + margin-bottom: 4px; + border-top: 1px solid #eeeeee; +`; + +const MenuItemWrapper = styled(MenuItem)` + display: flex; + justify-content: flex-start; + font-size: 12px; + width: calc(100% - 8px); + min-width: 140px; + margin: 0px 4px; + border-radius: 4px; + padding: 8px; + height: 32px; + gap: 8px; + svg { + width: 16px; + height: 16px; + } +`; + +const SectionContainer = styled("div")` + margin-bottom: 16px; +`; + +const SectionTitle = styled("div")` + font-weight: 600; + color: #9e9e9e; + margin-bottom: 8px; + padding: 0px 8px; + font-size: 12px; +`; + +export default WorkbookList; diff --git a/webapp/app.ironcalc.com/frontend/src/components/WorkbookTitle.tsx b/webapp/app.ironcalc.com/frontend/src/components/WorkbookTitle.tsx index 1a151d4..bc12216 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/WorkbookTitle.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/WorkbookTitle.tsx @@ -72,10 +72,10 @@ export function WorkbookTitle(properties: { } const Container = styled("div")` - text-align: center; - padding: 8px; + text-align: left; + padding: 6px 4px; font-size: 14px; - font-weight: 700; + font-weight: 600; font-family: Inter; `; @@ -108,7 +108,7 @@ const TitleInput = styled("input")` background-color: #f2f2f2; } &:focus { - border: 1px solid grey; + outline: 1px solid grey; } font-weight: inherit; font-family: inherit; diff --git a/webapp/app.ironcalc.com/frontend/src/components/storage.ts b/webapp/app.ironcalc.com/frontend/src/components/storage.ts index 7e9a5e3..3af7c70 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/storage.ts +++ b/webapp/app.ironcalc.com/frontend/src/components/storage.ts @@ -3,7 +3,7 @@ import { base64ToBytes, bytesToBase64 } from "./util"; const MAX_WORKBOOKS = 50; -type ModelsMetadata = Record; +type ModelsMetadata = Record; export function updateNameSelectedWorkbook(model: Model, newName: string) { const uuid = localStorage.getItem("selected"); @@ -12,7 +12,11 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) { if (modelsJson) { try { const models = JSON.parse(modelsJson); - models[uuid] = newName; + if (models[uuid]) { + models[uuid].name = newName; + } else { + models[uuid] = { name: newName, createdAt: Date.now() }; + } localStorage.setItem("models", JSON.stringify(models)); } catch (e) { console.warn("Failed saving new name"); @@ -28,7 +32,26 @@ export function getModelsMetadata(): ModelsMetadata { if (!modelsJson) { modelsJson = "{}"; } - return JSON.parse(modelsJson); + const models = JSON.parse(modelsJson); + + // Migrate old format to new format + const migratedModels: ModelsMetadata = {}; + for (const [uuid, value] of Object.entries(models)) { + if (typeof value === "string") { + // Old format: just the name string + migratedModels[uuid] = { name: value, createdAt: Date.now() }; + } else if (typeof value === "object" && value !== null && "name" in value) { + // New format: object with name and createdAt + migratedModels[uuid] = value as { name: string; createdAt: number }; + } + } + + // Save migrated data back to localStorage + if (JSON.stringify(models) !== JSON.stringify(migratedModels)) { + localStorage.setItem("models", JSON.stringify(migratedModels)); + } + + return migratedModels; } // Pick a different name Workbook{N} where N = 1, 2, 3 @@ -48,14 +71,14 @@ function getNewName(existingNames: string[]): string { export function createNewModel(): Model { const models = getModelsMetadata(); - const name = getNewName(Object.values(models)); + const name = getNewName(Object.values(models).map((m) => m.name)); const model = new Model(name, "en", "UTC"); const uuid = crypto.randomUUID(); localStorage.setItem("selected", uuid); localStorage.setItem(uuid, bytesToBase64(model.toBytes())); - models[uuid] = name; + models[uuid] = { name, createdAt: Date.now() }; localStorage.setItem("models", JSON.stringify(models)); return model; } @@ -95,7 +118,7 @@ export function saveModelToStorage(model: Model) { modelsJson = "{}"; } const models = JSON.parse(modelsJson); - models[uuid] = model.getName(); + models[uuid] = { name: model.getName(), createdAt: Date.now() }; localStorage.setItem("models", JSON.stringify(models)); } @@ -127,3 +150,37 @@ export function deleteSelectedModel(): Model | null { } return selectModelFromStorage(uuids[0]); } + +export function deleteModelByUuid(uuid: string): Model | null { + localStorage.removeItem(uuid); + const metadata = getModelsMetadata(); + delete metadata[uuid]; + localStorage.setItem("models", JSON.stringify(metadata)); + + // If this was the selected model, we need to select a different one + const selectedUuid = localStorage.getItem("selected"); + if (selectedUuid === uuid) { + const uuids = Object.keys(metadata); + if (uuids.length === 0) { + return createNewModel(); + } + // Find the newest workbook by creation timestamp + const newestUuid = uuids.reduce((newest, current) => { + const newestTime = metadata[newest]?.createdAt || 0; + const currentTime = metadata[current]?.createdAt || 0; + return currentTime > newestTime ? current : newest; + }); + return selectModelFromStorage(newestUuid); + } + + // If it wasn't the selected model, return the currently selected model + if (selectedUuid) { + const modelBytesString = localStorage.getItem(selectedUuid); + if (modelBytesString) { + return Model.from_bytes(base64ToBytes(modelBytesString)); + } + } + + // Fallback to creating a new model if no valid selected model + return createNewModel(); +}