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
This commit is contained in:
Daniel González-Albo
2025-10-19 10:20:31 +02:00
committed by GitHub
parent dd4467f95d
commit f2da24326b
14 changed files with 1007 additions and 212 deletions

View File

@@ -2,6 +2,7 @@ import "./App.css";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FileBar } from "./components/FileBar"; import { FileBar } from "./components/FileBar";
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
import WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog"; import WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog";
import { import {
get_documentation_model, get_documentation_model,
@@ -10,6 +11,7 @@ import {
} from "./components/rpc"; } from "./components/rpc";
import { import {
createNewModel, createNewModel,
deleteModelByUuid,
deleteSelectedModel, deleteSelectedModel,
isStorageEmpty, isStorageEmpty,
loadSelectedModelFromStorage, loadSelectedModelFromStorage,
@@ -27,6 +29,7 @@ function App() {
const [model, setModel] = useState<Model | null>(null); const [model, setModel] = useState<Model | null>(null);
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false); const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false); const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
useEffect(() => { useEffect(() => {
async function start() { async function start() {
@@ -88,11 +91,49 @@ function App() {
} }
}, 1000); }, 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. // 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. // Passing the property down makes sure it is always defined.
return ( return (
<Wrapper> <Wrapper>
<LeftDrawer
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
newModel={handleNewModel}
setModel={handleSetModel}
onDelete={handleDeleteModelByUuid}
/>
<MainContent isDrawerOpen={isDrawerOpen}>
{isDrawerOpen && (
<MobileOverlay onClick={() => setIsDrawerOpen(false)} />
)}
<FileBar <FileBar
model={model} model={model}
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => { onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
@@ -104,27 +145,17 @@ function App() {
setModel(newModel); setModel(newModel);
}} }}
newModel={() => { newModel={handleNewModel}
const createdModel = createNewModel();
setModel(createdModel);
}}
newModelFromTemplate={() => { newModelFromTemplate={() => {
setTemplatesDialogOpen(true); setTemplatesDialogOpen(true);
}} }}
setModel={(uuid: string) => { setModel={handleSetModel}
const newModel = selectModelFromStorage(uuid); onDelete={handleDeleteModel}
if (newModel) { isDrawerOpen={isDrawerOpen}
setModel(newModel); setIsDrawerOpen={setIsDrawerOpen}
}
}}
onDelete={() => {
const newModel = deleteSelectedModel();
if (newModel) {
setModel(newModel);
}
}}
/> />
<IronCalc model={model} /> <IronCalc model={model} />
</MainContent>
{showWelcomeDialog && ( {showWelcomeDialog && (
<WelcomeDialog <WelcomeDialog
onClose={() => { onClose={() => {
@@ -175,13 +206,44 @@ function App() {
} }
const Wrapper = styled("div")` const Wrapper = styled("div")`
margin: 0px; display: flex;
padding: 0px;
width: 100%; width: 100%;
height: 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; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
@media (max-width: 440px) {
${({ isDrawerOpen }) =>
isDrawerOpen &&
`
min-width: 440px;
`}
`;
const MobileOverlay = styled("div")`
position: absolute; 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")` const Loading = styled("div")`

View File

@@ -12,19 +12,9 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
const deleteButtonRef = useRef<HTMLButtonElement>(null); const deleteButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "blur(2px)";
}
if (deleteButtonRef.current) { if (deleteButtonRef.current) {
deleteButtonRef.current.focus(); deleteButtonRef.current.focus();
} }
return () => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "none";
}
};
}, []); }, []);
return ( return (

View File

@@ -1,6 +1,7 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import type { Model } from "@ironcalc/workbook"; 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 { useLayoutEffect, useRef, useState } from "react";
import { FileMenu } from "./FileMenu"; import { FileMenu } from "./FileMenu";
import { HelpMenu } from "./HelpMenu"; import { HelpMenu } from "./HelpMenu";
@@ -31,6 +32,8 @@ export function FileBar(properties: {
setModel: (key: string) => void; setModel: (key: string) => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>; onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void; onDelete: () => void;
isDrawerOpen: boolean;
setIsDrawerOpen: (open: boolean) => void;
}) { }) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const spacerRef = useRef<HTMLDivElement>(null); const spacerRef = useRef<HTMLDivElement>(null);
@@ -48,9 +51,30 @@ export function FileBar(properties: {
return ( return (
<FileBarWrapper> <FileBarWrapper>
<StyledDesktopLogo /> <Tooltip
<StyledIronCalcIcon /> title="Toggle sidebar"
<Divider /> slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<DrawerButton
// $isDrawerOpen={properties.isDrawerOpen}
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
disableRipple
>
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
</DrawerButton>
</Tooltip>
{width > 440 && (
<FileMenu <FileMenu
newModel={properties.newModel} newModel={properties.newModel}
newModelFromTemplate={properties.newModelFromTemplate} newModelFromTemplate={properties.newModelFromTemplate}
@@ -64,7 +88,8 @@ export function FileBar(properties: {
}} }}
onDelete={properties.onDelete} onDelete={properties.onDelete}
/> />
<HelpMenu /> )}
{width > 440 && <HelpMenu />}
<WorkbookTitleWrapper> <WorkbookTitleWrapper>
<WorkbookTitle <WorkbookTitle
name={properties.model.getName()} name={properties.model.getName()}
@@ -103,35 +128,38 @@ const Spacer = styled("div")`
flex-grow: 1; flex-grow: 1;
`; `;
const StyledDesktopLogo = styled(IronCalcLogo)` // const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>`
width: 120px; // cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")};
margin-left: 12px; const DrawerButton = styled(IconButton)`
@media (max-width: 769px) { margin-left: 8px;
display: none; height: 32px;
} width: 32px;
`; padding: 8px;
border-radius: 4px;
const StyledIronCalcIcon = styled(IronCalcIcon)` svg {
width: 36px; stroke-width: 2px;
margin-left: 10px; stroke: #757575;
@media (min-width: 769px) { width: 16px;
display: none; 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 // The container must be relative positioned so we can position the title absolutely
const FileBarWrapper = styled("div")` const FileBarWrapper = styled("div")`
position: relative; position: relative;
height: 60px; height: 60px;
min-height: 60px;
width: 100%; width: 100%;
background: #fff; background: #fff;
display: flex; display: flex;
gap: 2px;
align-items: center; align-items: center;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
justify-content: space-between; justify-content: space-between;

View File

@@ -1,6 +1,6 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Menu, MenuItem, Modal } from "@mui/material"; 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 { useRef, useState } from "react";
import DeleteWorkbookDialog from "./DeleteWorkbookDialog"; import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
import UploadFileDialog from "./UploadFileDialog"; import UploadFileDialog from "./UploadFileDialog";
@@ -19,40 +19,8 @@ export function FileMenu(props: {
const [isImportMenuOpen, setImportMenuOpen] = useState(false); const [isImportMenuOpen, setImportMenuOpen] = useState(false);
const anchorElement = useRef<HTMLButtonElement>(null); const anchorElement = useRef<HTMLButtonElement>(null);
const models = getModelsMetadata(); const models = getModelsMetadata();
const uuids = Object.keys(models);
const selectedUuid = getSelectedUuid(); const selectedUuid = getSelectedUuid();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const elements = [];
for (const uuid of uuids) {
elements.push(
<MenuItemWrapper
key={uuid}
onClick={() => {
props.setModel(uuid);
setMenuOpen(false);
}}
>
<CheckIndicator>
{uuid === selectedUuid ? (
<StyledIcon>
<Check />
</StyledIcon>
) : (
""
)}
</CheckIndicator>
<MenuItemText
style={{
maxWidth: "240px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{models[uuid]}
</MenuItemText>
</MenuItemWrapper>,
);
}
return ( return (
<> <>
@@ -90,10 +58,8 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<Plus /> <Plus />
</StyledIcon> New blank workbook
<MenuItemText>New blank workbook</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -101,10 +67,8 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<Table2 /> <Table2 />
</StyledIcon> New from template
<MenuItemText>New from template</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -112,31 +76,23 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<FileUp /> <FileUp />
</StyledIcon> Import
<MenuItemText>Import</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuDivider /> <MenuDivider />
<MenuItemWrapper onClick={props.onDownload}> <MenuItemWrapper onClick={props.onDownload}>
<StyledIcon>
<FileDown /> <FileDown />
</StyledIcon> Download (.xlsx)
<MenuItemText>Download (.xlsx)</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <DeleteButton
onClick={() => { onClick={() => {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<Trash2 /> <Trash2 />
</StyledIcon> Delete workbook
<MenuItemText>Delete workbook</MenuItemText> </DeleteButton>
</MenuItemWrapper>
<MenuDivider />
{elements}
</Menu> </Menu>
<Modal <Modal
open={isImportMenuOpen} open={isImportMenuOpen}
@@ -162,25 +118,14 @@ export function FileMenu(props: {
<DeleteWorkbookDialog <DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)} onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete} onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid] : ""} workbookName={selectedUuid ? models[selectedUuid].name : ""}
/> />
</Modal> </Modal>
</> </>
); );
} }
const StyledIcon = styled.div` export const MenuDivider = styled.div`
display: flex;
align-items: center;
svg {
width: 16px;
height: 100%;
color: #757575;
padding-right: 10px;
}
`;
const MenuDivider = styled.div`
width: 100%; width: 100%;
margin: auto; margin: auto;
margin-top: 4px; margin-top: 4px;
@@ -188,12 +133,7 @@ const MenuDivider = styled.div`
border-top: 1px solid #eeeeee; border-top: 1px solid #eeeeee;
`; `;
const MenuItemText = styled.div` export const MenuItemWrapper = styled(MenuItem)`
color: #000;
font-size: 12px;
`;
const MenuItemWrapper = styled(MenuItem)`
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
font-size: 14px; font-size: 14px;
@@ -203,6 +143,14 @@ const MenuItemWrapper = styled(MenuItem)`
border-radius: 4px; border-radius: 4px;
padding: 8px; padding: 8px;
height: 32px; height: 32px;
color: #000;
font-size: 12px;
gap: 8px;
svg {
width: 16px;
height: 100%;
color: #757575;
}
`; `;
const FileMenuWrapper = styled.button<{ $isActive: boolean }>` const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
@@ -215,14 +163,20 @@ const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
cursor: pointer; cursor: pointer;
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")}; background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
border: none; border: none;
background: none;
&:hover { &:hover {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
`; `;
const CheckIndicator = styled.span` export const DeleteButton = styled(MenuItemWrapper)`
display: flex; color: #EB5757;
justify-content: center; svg {
min-width: 26px; color: #EB5757;
}
&:hover {
background-color: #EB57571A;
}
&:active {
background-color: #EB57571A;
}
`; `;

View File

@@ -1,7 +1,8 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Menu, MenuItem } from "@mui/material"; import { Menu } from "@mui/material";
import { BookOpen, Keyboard } from "lucide-react"; import { BookOpen, Keyboard } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { MenuItemWrapper } from "./FileMenu";
export function HelpMenu() { export function HelpMenu() {
const [isMenuOpen, setMenuOpen] = useState(false); const [isMenuOpen, setMenuOpen] = useState(false);
@@ -61,10 +62,8 @@ export function HelpMenu() {
); );
}} }}
> >
<StyledIcon>
<BookOpen /> <BookOpen />
</StyledIcon> Documentation
<MenuItemText>Documentation</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -76,10 +75,8 @@ export function HelpMenu() {
); );
}} }}
> >
<StyledIcon>
<Keyboard /> <Keyboard />
</StyledIcon> Keyboard Shortcuts
<MenuItemText>Keyboard Shortcuts</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
</Menu> </Menu>
</div> </div>
@@ -96,37 +93,7 @@ const HelpButton = styled.button<{ $isActive?: boolean }>`
cursor: pointer; cursor: pointer;
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")}; background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
border: none; border: none;
background: none;
&:hover { &:hover {
background-color: #f2f2f2; 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;
`;

View File

@@ -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 (
<ContentContainer>
<WorkbookList setModel={setModel} onDelete={onDelete} />
</ContentContainer>
);
}
const ContentContainer = styled("div")`
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 12px;
height: 100%;
overflow: scroll;
font-size: 12px;
`;
export default DrawerContent;

View File

@@ -0,0 +1,71 @@
import styled from "@emotion/styled";
import { BookOpen } from "lucide-react";
function DrawerFooter() {
return (
<StyledDrawerFooter>
<FooterLink
href="https://docs.ironcalc.com/"
target="_blank"
rel="noopener noreferrer"
>
<OpenBookIcon>
<BookOpen />
</OpenBookIcon>
<FooterLinkText>Documentation</FooterLinkText>
</FooterLink>
</StyledDrawerFooter>
);
}
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;

View File

@@ -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 (
<HeaderContainer>
<LogoWrapper>
<Logo>
<IronCalcIcon />
</Logo>
<Title>IronCalc</Title>
</LogoWrapper>
<Tooltip
title="New workbook"
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<AddButton onClick={onNewModel}>
<PlusIcon />
</AddButton>
</Tooltip>
</HeaderContainer>
);
}
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;

View File

@@ -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 (
<DrawerWrapper
variant="persistent"
anchor="left"
open={open}
onClose={onClose}
transitionDuration={200}
>
<DrawerHeader onNewModel={newModel} />
<DrawerContent setModel={setModel} onDelete={onDelete} />
<DrawerFooter />
</DrawerWrapper>
);
}
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;

View File

@@ -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 | HTMLElement>(null);
const [selectedWorkbookUuid, setSelectedWorkbookUuid] = useState<
string | null
>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [workbookToDelete, setWorkbookToDelete] = useState<string | null>(null);
const [intendedSelection, setIntendedSelection] = useState<string | null>(
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<HTMLButtonElement>,
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 (
<WorkbookListItem
key={uuid}
onClick={() => {
// Prevent clicking on list items when any menu is open
if (isAnyMenuOpen) {
return;
}
setModel(uuid);
}}
selected={uuid === selectedUuid}
disableRipple
style={{ pointerEvents: isAnyMenuOpen ? "none" : "auto" }}
>
<StorageIndicator>
<Table2 />
</StorageIndicator>
<WorkbookListText>{models[uuid].name}</WorkbookListText>
<EllipsisButton
onClick={(e) => handleMenuOpen(e, uuid)}
isOpen={isMenuOpen}
onMouseDown={(e) => e.stopPropagation()}
style={{ pointerEvents: "auto" }}
>
<EllipsisVertical />
</EllipsisButton>
</WorkbookListItem>
);
};
const renderSection = (title: string, uuids: string[]) => {
if (uuids.length === 0) return null;
return (
<SectionContainer key={title}>
<SectionTitle>
{title === "Pinned" && <Pin />}
{title}
</SectionTitle>
{uuids.map(renderWorkbookItem)}
</SectionContainer>
);
};
const models = getModelsMetadata();
return (
<>
{renderSection("Pinned", pinnedModels)}
{renderSection("Today", modelsCreatedToday)}
{renderSection("Last 30 Days", modelsCreatedThisMonth)}
{renderSection("Older", olderModels)}
<StyledMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
MenuListProps={{
dense: true,
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItemWrapper
onClick={() => {
console.log(
"Download clicked, selectedWorkbookUuid:",
selectedWorkbookUuid,
);
if (selectedWorkbookUuid) {
handleDownload(selectedWorkbookUuid);
}
setIntendedSelection(null);
handleMenuClose();
}}
disableRipple
>
<FileDown />
Download (.xlsx)
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
if (selectedWorkbookUuid) {
handlePinToggle(selectedWorkbookUuid);
}
}}
disableRipple
>
{selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid) ? (
<PinOff />
) : (
<Pin />
)}
{selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid)
? "Unpin"
: "Pin"}
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
if (selectedWorkbookUuid) {
handleDuplicate(selectedWorkbookUuid);
}
}}
disableRipple
>
<Copy />
Duplicate
</MenuItemWrapper>
<MenuDivider />
<DeleteButton
selected={false}
onClick={() => {
if (selectedWorkbookUuid) {
handleDeleteClick(selectedWorkbookUuid);
}
}}
disableRipple
>
<Trash2 size={16} />
Delete workbook
</DeleteButton>
</StyledMenu>
<Modal
open={isDeleteDialogOpen}
onClose={handleDeleteCancel}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DeleteWorkbookDialog
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
workbookName={workbookToDelete ? models[workbookToDelete].name : ""}
/>
</Modal>
</>
);
}
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;

View File

@@ -5,8 +5,8 @@ export function ShareButton(properties: { onClick: () => void }) {
const { onClick } = properties; const { onClick } = properties;
return ( return (
<Wrapper onClick={onClick} onKeyDown={() => {}}> <Wrapper onClick={onClick} onKeyDown={() => {}}>
<Share2 style={{ width: "16px", height: "16px", marginRight: "10px" }} /> <ShareIcon />
<span>Share</span> <ShareText>Share</ShareText>
</Wrapper> </Wrapper>
); );
} }
@@ -23,8 +23,24 @@ const Wrapper = styled("div")`
display: flex; display: flex;
align-items: center; align-items: center;
font-family: "Inter"; font-family: "Inter";
font-size: 14px; font-size: 12px;
&:hover { &:hover {
background: #d68742; 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;
}
`;

View File

@@ -106,7 +106,7 @@ const DialogHeaderTitleSubtitle = styled("span")`
color: #757575; color: #757575;
`; `;
const DialogHeaderLogoWrapper = styled("div")` export const DialogHeaderLogoWrapper = styled("div")`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;

View File

@@ -72,10 +72,10 @@ export function WorkbookTitle(properties: {
} }
const Container = styled("div")` const Container = styled("div")`
text-align: center; text-align: left;
padding: 8px; padding: 6px 4px;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 600;
font-family: Inter; font-family: Inter;
`; `;
@@ -108,7 +108,7 @@ const TitleInput = styled("input")`
background-color: #f2f2f2; background-color: #f2f2f2;
} }
&:focus { &:focus {
border: 1px solid grey; outline: 1px solid grey;
} }
font-weight: inherit; font-weight: inherit;
font-family: inherit; font-family: inherit;

View File

@@ -3,7 +3,10 @@ import { base64ToBytes, bytesToBase64 } from "./util";
const MAX_WORKBOOKS = 50; const MAX_WORKBOOKS = 50;
type ModelsMetadata = Record<string, string>; type ModelsMetadata = Record<
string,
{ name: string; createdAt: number; pinned?: boolean }
>;
export function updateNameSelectedWorkbook(model: Model, newName: string) { export function updateNameSelectedWorkbook(model: Model, newName: string) {
const uuid = localStorage.getItem("selected"); const uuid = localStorage.getItem("selected");
@@ -12,7 +15,11 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) {
if (modelsJson) { if (modelsJson) {
try { try {
const models = JSON.parse(modelsJson); 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)); localStorage.setItem("models", JSON.stringify(models));
} catch (e) { } catch (e) {
console.warn("Failed saving new name"); console.warn("Failed saving new name");
@@ -28,7 +35,26 @@ export function getModelsMetadata(): ModelsMetadata {
if (!modelsJson) { if (!modelsJson) {
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 // Pick a different name Workbook{N} where N = 1, 2, 3
@@ -48,14 +74,14 @@ function getNewName(existingNames: string[]): string {
export function createNewModel(): Model { export function createNewModel(): Model {
const models = getModelsMetadata(); 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 model = new Model(name, "en", "UTC");
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
localStorage.setItem("selected", uuid); localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(model.toBytes())); localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
models[uuid] = name; models[uuid] = { name, createdAt: Date.now() };
localStorage.setItem("models", JSON.stringify(models)); localStorage.setItem("models", JSON.stringify(models));
return model; return model;
} }
@@ -103,7 +129,7 @@ export function saveModelToStorage(model: Model) {
modelsJson = "{}"; modelsJson = "{}";
} }
const models = JSON.parse(modelsJson); const models = JSON.parse(modelsJson);
models[uuid] = model.getName(); models[uuid] = { name: model.getName(), createdAt: Date.now() };
localStorage.setItem("models", JSON.stringify(models)); localStorage.setItem("models", JSON.stringify(models));
} }
@@ -135,3 +161,79 @@ export function deleteSelectedModel(): Model | null {
} }
return selectModelFromStorage(uuids[0]); 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;
}