From f2da24326ba9e8433d580ff21ee6fb15ec65407b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez-Albo?= <94169588+dg-ac@users.noreply.github.com> Date: Sun, 19 Oct 2025 10:20:31 +0200 Subject: [PATCH] update: Add a left drawer to improve workbook management (#453) * update: add leftbar to app * style: a few cosmetic changes * update: allow pinning workbooks * style: show ellipsis button only on hover * update: add basic responsiveness * style: use active state when file and help menus are open * style: increase transition time * update: allow duplication of workbooks * chore: standardize menus --- webapp/app.ironcalc.com/frontend/src/App.tsx | 128 ++++-- .../src/components/DeleteWorkbookDialog.tsx | 10 - .../frontend/src/components/FileBar.tsx | 98 ++-- .../frontend/src/components/FileMenu.tsx | 116 ++--- .../frontend/src/components/HelpMenu.tsx | 45 +- .../components/LeftDrawer/DrawerContent.tsx | 29 ++ .../components/LeftDrawer/DrawerFooter.tsx | 71 +++ .../components/LeftDrawer/DrawerHeader.tsx | 98 ++++ .../src/components/LeftDrawer/LeftDrawer.tsx | 52 +++ .../components/LeftDrawer/WorkbookList.tsx | 426 ++++++++++++++++++ .../frontend/src/components/ShareButton.tsx | 22 +- .../WelcomeDialog/WelcomeDialog.tsx | 2 +- .../frontend/src/components/WorkbookTitle.tsx | 8 +- .../frontend/src/components/storage.ts | 114 ++++- 14 files changed, 1007 insertions(+), 212 deletions(-) create mode 100644 webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerContent.tsx create mode 100644 webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerFooter.tsx create mode 100644 webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerHeader.tsx create mode 100644 webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/LeftDrawer.tsx create mode 100644 webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/WorkbookList.tsx diff --git a/webapp/app.ironcalc.com/frontend/src/App.tsx b/webapp/app.ironcalc.com/frontend/src/App.tsx index b4361a3..e2fa0e1 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 WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog"; import { get_documentation_model, @@ -10,6 +11,7 @@ import { } from "./components/rpc"; import { createNewModel, + deleteModelByUuid, deleteSelectedModel, isStorageEmpty, loadSelectedModelFromStorage, @@ -27,6 +29,7 @@ function App() { const [model, setModel] = useState(null); const [showWelcomeDialog, setShowWelcomeDialog] = useState(false); const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); useEffect(() => { async function start() { @@ -88,43 +91,71 @@ function App() { } }, 1000); + // 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); + } + }; + // 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. 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={() => { - const createdModel = createNewModel(); - setModel(createdModel); - }} - newModelFromTemplate={() => { - setTemplatesDialogOpen(true); - }} - 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} /> - + + {isDrawerOpen && ( + setIsDrawerOpen(false)} /> + )} + { + 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} + newModelFromTemplate={() => { + setTemplatesDialogOpen(true); + }} + setModel={handleSetModel} + onDelete={handleDeleteModel} + isDrawerOpen={isDrawerOpen} + setIsDrawerOpen={setIsDrawerOpen} + /> + + {showWelcomeDialog && ( { @@ -175,13 +206,44 @@ function App() { } const Wrapper = styled("div")` - margin: 0px; - padding: 0px; + 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.2s; + width: ${({ isDrawerOpen }) => + isDrawerOpen ? "calc(100% - 264px)" : "100%"}; display: flex; flex-direction: column; + position: relative; + + @media (max-width: 440px) { + ${({ isDrawerOpen }) => + isDrawerOpen && + ` + min-width: 440px; + `} + +`; + +const MobileOverlay = styled("div")` position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + z-index: 1; + cursor: pointer; + + @media (min-width: 441px) { + display: none; + } `; 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 15b8f9b..47797e8 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx @@ -1,6 +1,7 @@ import styled from "@emotion/styled"; import type { Model } from "@ironcalc/workbook"; -import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook"; +import { IconButton, Tooltip } from "@mui/material"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { useLayoutEffect, useRef, useState } from "react"; import { FileMenu } from "./FileMenu"; import { HelpMenu } from "./HelpMenu"; @@ -31,6 +32,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); @@ -48,23 +51,45 @@ export function FileBar(properties: { return ( - - - - { - const model = properties.model; - const bytes = model.toBytes(); - const fileName = model.getName(); - await downloadModel(bytes, fileName); + - + > + properties.setIsDrawerOpen(!properties.isDrawerOpen)} + disableRipple + > + {properties.isDrawerOpen ? : } + + + {width > 440 && ( + { + const model = properties.model; + const bytes = model.toBytes(); + const fileName = model.getName(); + await downloadModel(bytes, fileName); + }} + onDelete={properties.onDelete} + /> + )} + {width > 440 && } ` +// cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")}; +const DrawerButton = styled(IconButton)` + margin-left: 8px; + height: 32px; + width: 32px; + padding: 8px; + border-radius: 4px; -const StyledIronCalcIcon = styled(IronCalcIcon)` - width: 36px; - margin-left: 10px; - @media (min-width: 769px) { - display: none; + svg { + stroke-width: 2px; + stroke: #757575; + width: 16px; + height: 16px; + } + &: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; + gap: 2px; align-items: center; border-bottom: 1px solid #e0e0e0; justify-content: space-between; diff --git a/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx b/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx index 19399d4..d41fae9 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { Menu, MenuItem, Modal } from "@mui/material"; -import { Check, FileDown, FileUp, Plus, Table2, Trash2 } from "lucide-react"; +import { FileDown, FileUp, Plus, Table2, Trash2 } from "lucide-react"; import { useRef, useState } from "react"; import DeleteWorkbookDialog from "./DeleteWorkbookDialog"; import UploadFileDialog from "./UploadFileDialog"; @@ -19,40 +19,8 @@ export function FileMenu(props: { 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 ( <> @@ -90,10 +58,8 @@ export function FileMenu(props: { setMenuOpen(false); }} > - - - - New blank workbook + + New blank workbook { @@ -101,10 +67,8 @@ export function FileMenu(props: { setMenuOpen(false); }} > - - - - New from template + + New from template { @@ -112,31 +76,23 @@ export function FileMenu(props: { setMenuOpen(false); }} > - - - - Import + + Import - - - - Download (.xlsx) + + Download (.xlsx) - { setDeleteDialogOpen(true); setMenuOpen(false); }} > - - - - Delete workbook - - - {elements} + + Delete workbook + setDeleteDialogOpen(false)} onConfirm={props.onDelete} - workbookName={selectedUuid ? models[selectedUuid] : ""} + workbookName={selectedUuid ? models[selectedUuid].name : ""} /> ); } -const StyledIcon = styled.div` - display: flex; - align-items: center; - svg { - width: 16px; - height: 100%; - color: #757575; - padding-right: 10px; - } -`; - -const MenuDivider = styled.div` +export const MenuDivider = styled.div` width: 100%; margin: auto; margin-top: 4px; @@ -188,12 +133,7 @@ const MenuDivider = styled.div` border-top: 1px solid #eeeeee; `; -const MenuItemText = styled.div` - color: #000; - font-size: 12px; -`; - -const MenuItemWrapper = styled(MenuItem)` +export const MenuItemWrapper = styled(MenuItem)` display: flex; justify-content: flex-start; font-size: 14px; @@ -203,6 +143,14 @@ const MenuItemWrapper = styled(MenuItem)` border-radius: 4px; padding: 8px; height: 32px; + color: #000; + font-size: 12px; + gap: 8px; + svg { + width: 16px; + height: 100%; + color: #757575; + } `; const FileMenuWrapper = styled.button<{ $isActive: boolean }>` @@ -215,14 +163,20 @@ const FileMenuWrapper = styled.button<{ $isActive: boolean }>` cursor: pointer; background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")}; border: none; - background: none; &:hover { background-color: #f2f2f2; } `; -const CheckIndicator = styled.span` - display: flex; - justify-content: center; - min-width: 26px; +export const DeleteButton = styled(MenuItemWrapper)` + color: #EB5757; + svg { + color: #EB5757; + } + &:hover { + background-color: #EB57571A; + } + &:active { + background-color: #EB57571A; + } `; diff --git a/webapp/app.ironcalc.com/frontend/src/components/HelpMenu.tsx b/webapp/app.ironcalc.com/frontend/src/components/HelpMenu.tsx index 938e0cd..6b2f9e7 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/HelpMenu.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/HelpMenu.tsx @@ -1,7 +1,8 @@ import styled from "@emotion/styled"; -import { Menu, MenuItem } from "@mui/material"; +import { Menu } from "@mui/material"; import { BookOpen, Keyboard } from "lucide-react"; import { useRef, useState } from "react"; +import { MenuItemWrapper } from "./FileMenu"; export function HelpMenu() { const [isMenuOpen, setMenuOpen] = useState(false); @@ -61,10 +62,8 @@ export function HelpMenu() { ); }} > - - - - Documentation + + Documentation { @@ -76,10 +75,8 @@ export function HelpMenu() { ); }} > - - - - Keyboard Shortcuts + + Keyboard Shortcuts @@ -96,37 +93,7 @@ const HelpButton = styled.button<{ $isActive?: boolean }>` cursor: pointer; background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")}; border: none; - background: none; &:hover { background-color: #f2f2f2; } `; - -const MenuItemWrapper = styled(MenuItem)` - display: flex; - align-items: center; - justify-content: flex-start; - font-size: 14px; - width: calc(100% - 8px); - min-width: 172px; - margin: 0px 4px; - border-radius: 4px; - padding: 8px; - height: 32px; -`; - -const StyledIcon = styled.div` - display: flex; - align-items: center; - svg { - width: 16px; - height: 100%; - color: #757575; - padding-right: 10px; - } -`; - -const MenuItemText = styled.div` - color: #000; - font-size: 12px; -`; 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..4d0c1b0 --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/DrawerHeader.tsx @@ -0,0 +1,98 @@ +import styled from "@emotion/styled"; +import { IronCalcIconWhite as IronCalcIcon } from "@ironcalc/workbook"; +import { IconButton, Tooltip } from "@mui/material"; +import { Plus } from "lucide-react"; +import { DialogHeaderLogoWrapper } from "../WelcomeDialog/WelcomeDialog"; + +interface DrawerHeaderProps { + onNewModel: () => void; +} + +function DrawerHeader({ onNewModel }: DrawerHeaderProps) { + return ( + + + + + + IronCalc + + + + + + + + ); +} + +const HeaderContainer = styled("div")` + display: flex; + align-items: center; + padding: 12px 8px 12px 16px; + justify-content: space-between; + max-height: 60px; + min-height: 60px; + box-sizing: border-box; + box-shadow: 0 1px 0 0 #e0e0e0; +`; + +const LogoWrapper = styled("div")` + display: flex; + align-items: center; + gap: 8px; + +`; + +const Title = styled("h1")` + font-size: 14px; + font-weight: 600; +`; + +const Logo = styled(DialogHeaderLogoWrapper)` + transform: none; + margin-bottom: 0px; + padding: 6px; +`; + +const AddButton = styled(IconButton)` + margin-left: 8px; + height: 32px; + width: 32px; + padding: 8px; + border-radius: 4px; + + svg { + stroke-width: 2px; + stroke: #757575; + width: 16px; + height: 16px; + } + &:hover { + background-color: #E0E0E0; + } + &:active { + background-color: #BDBDBD; + } +`; + +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..8f6219a --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/LeftDrawer.tsx @@ -0,0 +1,52 @@ +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..3c5b8bb --- /dev/null +++ b/webapp/app.ironcalc.com/frontend/src/components/LeftDrawer/WorkbookList.tsx @@ -0,0 +1,426 @@ +import styled from "@emotion/styled"; +import { Menu, MenuItem, Modal } from "@mui/material"; +import { + Copy, + EllipsisVertical, + FileDown, + Pin, + PinOff, + Table2, + Trash2, +} from "lucide-react"; +import type React from "react"; +import { useEffect, useState } from "react"; +import DeleteWorkbookDialog from "../DeleteWorkbookDialog"; +import { DeleteButton, MenuDivider, MenuItemWrapper } from "../FileMenu"; +import { downloadModel } from "../rpc"; +import { + duplicateModel, + getModelsMetadata, + getSelectedUuid, + isWorkbookPinned, + selectModelFromStorage, + togglePinWorkbook, +} 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); + } + }; + + const handlePinToggle = (uuid: string) => { + togglePinWorkbook(uuid); + setIntendedSelection(null); + handleMenuClose(); + }; + + const handleDuplicate = (uuid: string) => { + const duplicatedModel = duplicateModel(uuid); + if (duplicatedModel) { + setIntendedSelection(null); + handleMenuClose(); + } + }; + + // Group workbooks by pinned status and creation date + const groupWorkbooks = () => { + const now = Date.now(); + const millisecondsInDay = 24 * 60 * 60 * 1000; + const millisecondsIn30Days = 30 * millisecondsInDay; + + const pinnedModels = []; + const modelsCreatedToday = []; + const modelsCreatedThisMonth = []; + const olderModels = []; + const modelsMetadata = getModelsMetadata(); + + for (const uuid in modelsMetadata) { + const createdAt = modelsMetadata[uuid].createdAt; + const age = now - createdAt; + + if (modelsMetadata[uuid].pinned) { + pinnedModels.push(uuid); + } else 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 { + pinnedModels: sortByNewest(pinnedModels), + modelsCreatedToday: sortByNewest(modelsCreatedToday), + modelsCreatedThisMonth: sortByNewest(modelsCreatedThisMonth), + olderModels: sortByNewest(olderModels), + }; + }; + + const { + pinnedModels, + 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 === "Pinned" && } + {title} + + {uuids.map(renderWorkbookItem)} + + ); + }; + + const models = getModelsMetadata(); + + return ( + <> + {renderSection("Pinned", pinnedModels)} + {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) { + handlePinToggle(selectedWorkbookUuid); + } + }} + disableRipple + > + {selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid) ? ( + + ) : ( + + )} + {selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid) + ? "Unpin" + : "Pin"} + + { + if (selectedWorkbookUuid) { + handleDuplicate(selectedWorkbookUuid); + } + }} + disableRipple + > + + Duplicate + + + { + 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: ${({ isOpen }) => (isOpen ? "24px" : "0px")}; + border-radius: 4px; + color: #333333; + stroke-width: 2px; + background-color: ${({ isOpen }) => (isOpen ? "#E0E0E0" : "none")}; + opacity: ${({ isOpen }) => (isOpen ? "1" : "0")}; + &:hover { + background: #BDBDBD; + 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"}; + + &:hover { + background-color: #e0e0e0; + button { + opacity: 1; + min-width: 24px; + } + } +`; + +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 SectionContainer = styled("div")` + margin-bottom: 16px; + display: flex; + flex-direction: column; + gap: 2px; +`; + +const SectionTitle = styled("div")` + display: flex; + align-items: center; + gap: 4px; + font-weight: 400; + color: #9e9e9e; + margin-bottom: 8px; + padding: 0px 8px; + font-size: 12px; + svg { + width: 12px; + height: 12px; + } +`; + +export default WorkbookList; diff --git a/webapp/app.ironcalc.com/frontend/src/components/ShareButton.tsx b/webapp/app.ironcalc.com/frontend/src/components/ShareButton.tsx index a14fa38..7c89061 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/ShareButton.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/ShareButton.tsx @@ -5,8 +5,8 @@ export function ShareButton(properties: { onClick: () => void }) { const { onClick } = properties; return ( {}}> - - Share + + Share ); } @@ -23,8 +23,24 @@ const Wrapper = styled("div")` display: flex; align-items: center; font-family: "Inter"; - font-size: 14px; + font-size: 12px; &:hover { background: #d68742; } `; + +const ShareIcon = styled(Share2)` + width: 16px; + height: 16px; + margin-right: 10px; + + @media (max-width: 440px) { + margin-right: 0px; + } +`; + +const ShareText = styled.span` + @media (max-width: 440px) { + display: none; + } +`; diff --git a/webapp/app.ironcalc.com/frontend/src/components/WelcomeDialog/WelcomeDialog.tsx b/webapp/app.ironcalc.com/frontend/src/components/WelcomeDialog/WelcomeDialog.tsx index 6ac308e..cbba0e3 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/WelcomeDialog/WelcomeDialog.tsx +++ b/webapp/app.ironcalc.com/frontend/src/components/WelcomeDialog/WelcomeDialog.tsx @@ -106,7 +106,7 @@ const DialogHeaderTitleSubtitle = styled("span")` color: #757575; `; -const DialogHeaderLogoWrapper = styled("div")` +export const DialogHeaderLogoWrapper = styled("div")` display: flex; flex-direction: row; align-items: center; 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 dc5da2b..c9b8a85 100644 --- a/webapp/app.ironcalc.com/frontend/src/components/storage.ts +++ b/webapp/app.ironcalc.com/frontend/src/components/storage.ts @@ -3,7 +3,10 @@ import { base64ToBytes, bytesToBase64 } from "./util"; const MAX_WORKBOOKS = 50; -type ModelsMetadata = Record; +type ModelsMetadata = Record< + string, + { name: string; createdAt: number; pinned?: boolean } +>; export function updateNameSelectedWorkbook(model: Model, newName: string) { const uuid = localStorage.getItem("selected"); @@ -12,7 +15,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 +35,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 +74,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; } @@ -103,7 +129,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)); } @@ -135,3 +161,79 @@ 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(); +} + +export function togglePinWorkbook(uuid: string): void { + const metadata = getModelsMetadata(); + if (metadata[uuid]) { + metadata[uuid].pinned = !metadata[uuid].pinned; + localStorage.setItem("models", JSON.stringify(metadata)); + } +} + +export function isWorkbookPinned(uuid: string): boolean { + const metadata = getModelsMetadata(); + return metadata[uuid]?.pinned || false; +} + +export function duplicateModel(uuid: string): Model | null { + const originalModel = selectModelFromStorage(uuid); + if (!originalModel) return null; + + const duplicatedModel = Model.from_bytes(originalModel.toBytes()); + const models = getModelsMetadata(); + const originalName = models[uuid]?.name || "Workbook"; + const existingNames = Object.values(models).map((m) => m.name); + + // Find next available number + let counter = 1; + let newName = `${originalName} (${counter})`; + while (existingNames.includes(newName)) { + counter++; + newName = `${originalName} (${counter})`; + } + + duplicatedModel.setName(newName); + + const newUuid = crypto.randomUUID(); + localStorage.setItem("selected", newUuid); + localStorage.setItem(newUuid, bytesToBase64(duplicatedModel.toBytes())); + + models[newUuid] = { name: newName, createdAt: Date.now() }; + localStorage.setItem("models", JSON.stringify(models)); + + return duplicatedModel; +}