Compare commits

..

1 Commits

Author SHA1 Message Date
Daniel
4b0dbc598f update: add leftbar to app 2025-09-30 19:46:57 +02:00
25 changed files with 1519 additions and 1413 deletions

View File

@@ -84,7 +84,7 @@ And then use this code in `main.rs`:
```rust
use ironcalc::{
base::{expressions::utils::number_to_column, Model},
base::{expressions::utils::number_to_column, model::Model},
export::save_to_xlsx,
};

View File

@@ -99,9 +99,10 @@ const FormulaSymbolButton = styled(StyledButton)`
const Divider = styled("div")`
background-color: ${theme.palette.grey["300"]};
min-width: 1px;
height: 16px;
margin: 0px 16px;
width: 1px;
height: 20px;
margin-left: 16px;
margin-right: 16px;
`;
const FormulaContainer = styled("div")`

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@ import {
CLIPBOARD_ID_SESSION_STORAGE_KEY,
getNewClipboardId,
} from "../clipboard";
import { TOOLBAR_HEIGHT } from "../constants";
import {
type NavigationKey,
getCellAddress,
@@ -42,8 +41,6 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
// This is needed because `model` or `workbookState` can change without React being aware of it
const setRedrawId = useState(0)[1];
const [isDrawerOpen, setDrawerOpen] = useState(false);
const worksheets = model.getWorksheetsProperties();
const info = worksheets.map(
({ name, color, sheet_id, state }: WorksheetProperties) => {
@@ -695,119 +692,77 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
worksheets,
definedNameList: model.getDefinedNameList(),
}}
openDrawer={() => {
setDrawerOpen(true);
/>
<FormulaBar
cellAddress={cellAddress()}
formulaValue={formulaValue()}
onChange={() => {
setRedrawId((id) => id + 1);
focusWorkbook();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1);
}}
model={model}
workbookState={workbookState}
/>
<Worksheet
model={model}
workbookState={workbookState}
refresh={(): void => {
setRedrawId((id) => id + 1);
}}
ref={worksheetRef}
/>
<SheetTabBar
sheets={info}
selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}
onSheetSelected={(sheet: number): void => {
if (info[sheet].state !== "visible") {
model.unhideSheet(sheet);
}
model.setSelectedSheet(sheet);
setRedrawId((value) => value + 1);
}}
onAddBlankSheet={(): void => {
model.newSheet();
setRedrawId((value) => value + 1);
}}
onSheetColorChanged={(hex: string): void => {
try {
model.setSheetColor(model.getSelectedSheet(), hex);
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetRenamed={(name: string): void => {
try {
model.renameSheet(model.getSelectedSheet(), name);
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetDeleted={(): void => {
const selectedSheet = model.getSelectedSheet();
model.deleteSheet(selectedSheet);
setRedrawId((value) => value + 1);
}}
onHideSheet={(): void => {
const selectedSheet = model.getSelectedSheet();
model.hideSheet(selectedSheet);
setRedrawId((value) => value + 1);
}}
/>
<WorksheetAreaLeft $drawerWidth={isDrawerOpen ? DRAWER_WIDTH : 0}>
<FormulaBar
cellAddress={cellAddress()}
formulaValue={formulaValue()}
onChange={() => {
setRedrawId((id) => id + 1);
focusWorkbook();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1);
}}
model={model}
workbookState={workbookState}
/>
<Worksheet
model={model}
workbookState={workbookState}
refresh={(): void => {
setRedrawId((id) => id + 1);
}}
ref={worksheetRef}
/>
<SheetTabBar
sheets={info}
selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}
onSheetSelected={(sheet: number): void => {
if (info[sheet].state !== "visible") {
model.unhideSheet(sheet);
}
model.setSelectedSheet(sheet);
setRedrawId((value) => value + 1);
}}
onAddBlankSheet={(): void => {
model.newSheet();
setRedrawId((value) => value + 1);
}}
onSheetColorChanged={(hex: string): void => {
try {
model.setSheetColor(model.getSelectedSheet(), hex);
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetRenamed={(name: string): void => {
try {
model.renameSheet(model.getSelectedSheet(), name);
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetDeleted={(): void => {
const selectedSheet = model.getSelectedSheet();
model.deleteSheet(selectedSheet);
setRedrawId((value) => value + 1);
}}
onHideSheet={(): void => {
const selectedSheet = model.getSelectedSheet();
model.hideSheet(selectedSheet);
setRedrawId((value) => value + 1);
}}
/>
</WorksheetAreaLeft>
<WorksheetAreaRight $drawerWidth={isDrawerOpen ? DRAWER_WIDTH : 0}>
<span
onClick={() => setDrawerOpen(false)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setDrawerOpen(false);
}
}}
aria-label="Close drawer"
>
x
</span>
</WorksheetAreaRight>
</Container>
);
};
const DRAWER_WIDTH = 300;
type WorksheetAreaLeftProps = { $drawerWidth: number };
const WorksheetAreaLeft = styled("div")<WorksheetAreaLeftProps>(
({ $drawerWidth }) => ({
position: "absolute",
top: `${TOOLBAR_HEIGHT + 1}px`,
width: `calc(100% - ${$drawerWidth}px)`,
height: `calc(100% - ${TOOLBAR_HEIGHT + 1}px)`,
}),
);
const WorksheetAreaRight = styled("div")<WorksheetAreaLeftProps>(
({ $drawerWidth }) => ({
position: "absolute",
overflow: "hidden",
backgroundColor: "red",
right: 0,
top: `${TOOLBAR_HEIGHT + 1}px`,
bottom: 0,
width: `${$drawerWidth}px`,
}),
);
const Container = styled("div")`
display: flex;
flex-direction: column;

View File

@@ -18,7 +18,11 @@ import {
outlineColor,
} from "../WorksheetCanvas/constants";
import WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import { FORMULA_BAR_HEIGHT, NAVIGATION_HEIGHT } from "../constants";
import {
FORMULA_BAR_HEIGHT,
NAVIGATION_HEIGHT,
TOOLBAR_HEIGHT,
} from "../constants";
import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState";
import CellContextMenu from "./CellContextMenu";
@@ -455,7 +459,7 @@ const SheetContainer = styled("div")`
const Wrapper = styled("div")({
position: "absolute",
overflow: "scroll",
top: FORMULA_BAR_HEIGHT + 1,
top: TOOLBAR_HEIGHT + FORMULA_BAR_HEIGHT + 1,
left: 0,
right: 0,
bottom: NAVIGATION_HEIGHT + 1,

View File

@@ -1,3 +1,3 @@
export const TOOLBAR_HEIGHT = 40;
export const TOOLBAR_HEIGHT = 48;
export const FORMULA_BAR_HEIGHT = 40;
export const NAVIGATION_HEIGHT = 40;

View File

@@ -18,7 +18,6 @@ import InsertRowAboveIcon from "./insert-row-above.svg?react";
import InsertRowBelow from "./insert-row-below.svg?react";
import IronCalcIcon from "./ironcalc_icon.svg?react";
import IronCalcIconWhite from "./ironcalc_icon_white.svg?react";
import IronCalcLogo from "./orange+black.svg?react";
import Fx from "./fx.svg?react";
@@ -42,7 +41,6 @@ export {
InsertRowAboveIcon,
InsertRowBelow,
IronCalcIcon,
IronCalcIconWhite,
IronCalcLogo,
Fx,
};

View File

@@ -1,7 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.8" d="M9.95898 8.08594C9.60893 8.35318 9.27389 8.64313 8.95898 8.95801C7.09126 10.8257 6.042 13.3586 6.04199 16H6.04102V7.91406C6.39142 7.64662 6.72781 7.35715 7.04297 7.04199C8.90157 5.18307 9.9492 2.6648 9.95898 0.0371094V8.08594Z" fill="white"/>
<path opacity="0.8" d="M6.04102 7.91406C4.31493 9.23162 2.19571 9.95898 0 9.95898V6.04102C1.60208 6.04102 3.13861 5.40429 4.27148 4.27148C5.40436 3.13861 6.04101 1.60213 6.04102 0L6.04102 7.91406Z" fill="white"/>
<path opacity="0.8" d="M9.95947 8.08594C11.6856 6.76838 13.8048 6.04102 16.0005 6.04102V9.95898C14.3984 9.95898 12.8619 10.5957 11.729 11.7285C10.5961 12.8614 9.95948 14.3979 9.95947 16L9.95947 8.08594Z" fill="white"/>
<path d="M9.95898 0C9.95898 2.64126 8.90957 5.17429 7.04199 7.04199C6.727 7.35698 6.39119 7.64674 6.04102 7.91406L6.04102 0H9.95898Z" fill="white"/>
<path d="M6.04102 16C6.04102 13.3587 7.09042 10.8257 8.95801 8.95801C9.273 8.64302 9.60881 8.35326 9.95898 8.08594V16H6.04102Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,5 @@
import init, { Model } from "@ironcalc/wasm";
import IronCalc from "./IronCalc";
import { IronCalcIcon, IronCalcIconWhite, IronCalcLogo } from "./icons";
import { IronCalcIcon, IronCalcLogo } from "./icons";
export { init, Model, IronCalc, IronCalcIcon, IronCalcIconWhite, IronCalcLogo };
export { init, Model, IronCalc, IronCalcIcon, IronCalcLogo };

View File

@@ -27,15 +27,13 @@
"vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG",
"wrap_text": "Wrap text",
"scroll_left": "Scroll left",
"scroll_right": "Scroll right",
"format_menu": {
"auto": "Auto",
"number": "Number",
"percentage": "Percentage",
"currency_eur": "Euro (EUR)",
"currency_usd": "Dollar (USD)",
"currency_gbp": "British Pound (GBP)",
"currency_gbp": "British Pound (GBD)",
"date_short": "Short date",
"date_long": "Long date",
"custom": "Custom",

View File

@@ -2,7 +2,7 @@ import "./App.css";
import styled from "@emotion/styled";
import { useEffect, useState } from "react";
import { FileBar } from "./components/FileBar";
import WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog";
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
import {
get_documentation_model,
get_model,
@@ -10,9 +10,11 @@ import {
} from "./components/rpc";
import {
createNewModel,
deleteModelByUuid,
deleteSelectedModel,
isStorageEmpty,
loadSelectedModelFromStorage,
// getModelsMetadata,
// getSelectedUuid,
loadModelFromStorageOrCreate,
saveModelToStorage,
saveSelectedModelInStorage,
selectModelFromStorage,
@@ -20,13 +22,10 @@ import {
// From IronCalc
import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook";
import { Modal } from "@mui/material";
import TemplatesDialog from "./components/WelcomeDialog/TemplatesDialog";
function App() {
const [model, setModel] = useState<Model | null>(null);
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
useEffect(() => {
async function start() {
@@ -58,14 +57,8 @@ function App() {
}
} else {
// try to load from local storage
const newModel = loadSelectedModelFromStorage();
if (!newModel) {
setShowWelcomeDialog(true);
const createdModel = new Model("template", "en", "UTC");
setModel(createdModel);
} else {
setModel(newModel);
}
const newModel = loadModelFromStorageOrCreate();
setModel(newModel);
}
}
start();
@@ -91,97 +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 (
<Wrapper>
<FileBar
model={model}
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
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);
}
}}
<AppContainer>
<LeftDrawer
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
newModel={handleNewModel}
setModel={handleSetModel}
onDelete={handleDeleteModelByUuid}
/>
<IronCalc model={model} />
{showWelcomeDialog && (
<WelcomeDialog
onClose={() => {
if (isStorageEmpty()) {
const createdModel = createNewModel();
setModel(createdModel);
}
setShowWelcomeDialog(false);
}}
onSelectTemplate={async (templateId) => {
switch (templateId) {
case "blank": {
const createdModel = createNewModel();
setModel(createdModel);
break;
}
default: {
const model_bytes = await get_documentation_model(templateId);
const importedModel = Model.from_bytes(model_bytes);
saveModelToStorage(importedModel);
setModel(importedModel);
break;
}
}
setShowWelcomeDialog(false);
<MainContent isDrawerOpen={isDrawerOpen}>
<FileBar
model={model}
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
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}
/>
)}
<Modal
open={isTemplatesDialogOpen}
onClose={() => setTemplatesDialogOpen(false)}
aria-labelledby="templates-dialog-title"
aria-describedby="templates-dialog-description"
>
<TemplatesDialog
onClose={() => setTemplatesDialogOpen(false)}
onSelectTemplate={async (fileName) => {
const model_bytes = await get_documentation_model(fileName);
const importedModel = Model.from_bytes(model_bytes);
saveModelToStorage(importedModel);
setModel(importedModel);
setTemplatesDialogOpen(false);
}}
/>
</Modal>
</Wrapper>
<IronCalc model={model} />
</MainContent>
</AppContainer>
);
}
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")`

View File

@@ -12,19 +12,9 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
const deleteButtonRef = useRef<HTMLButtonElement>(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 (

View File

@@ -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";
@@ -27,10 +27,11 @@ function useWindowWidth() {
export function FileBar(properties: {
model: Model;
newModel: () => void;
newModelFromTemplate: () => void;
setModel: (key: string) => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
isDrawerOpen: boolean;
setIsDrawerOpen: (open: boolean) => void;
}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const spacerRef = useRef<HTMLDivElement>(null);
@@ -46,25 +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 (
<FileBarWrapper>
<StyledDesktopLogo />
<StyledIronCalcIcon />
<Divider />
<FileMenu
newModel={properties.newModel}
newModelFromTemplate={properties.newModelFromTemplate}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={async () => {
const model = properties.model;
const bytes = model.toBytes();
const fileName = model.getName();
await downloadModel(bytes, fileName);
}}
onDelete={properties.onDelete}
/>
<HelpMenu />
<DrawerButton
$isDrawerOpen={properties.isDrawerOpen}
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
disableRipple
title="Toggle sidebar"
>
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
</DrawerButton>
<DesktopButtonsWrapper>
<DesktopMenu
newModel={properties.newModel}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={handleDownload}
onDelete={properties.onDelete}
/>
<FileBarButton
disableRipple
onClick={() => window.open("https://docs.ironcalc.com", "_blank")}
>
Help
</FileBarButton>
</DesktopButtonsWrapper>
<MobileButtonsWrapper>
<MobileMenu
newModel={properties.newModel}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={handleDownload}
onDelete={properties.onDelete}
/>
</MobileButtonsWrapper>
<Spacer ref={spacerRef} />
<WorkbookTitleWrapper>
<WorkbookTitle
name={properties.model.getName()}
@@ -90,12 +115,8 @@ export function FileBar(properties: {
);
}
// We want the workbook title to be exactly an the center of the page,
// so we need an absolute position
const WorkbookTitleWrapper = styled("div")`
position: absolute;
left: 50%;
transform: translateX(-50%);
position: relative;
`;
// The "Spacer" component occupies as much space as possible between the menu and the share button
@@ -103,38 +124,79 @@ const Spacer = styled("div")`
flex-grow: 1;
`;
const StyledDesktopLogo = styled(IronCalcLogo)`
width: 120px;
margin-left: 12px;
@media (max-width: 769px) {
display: none;
const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>`
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")`

View File

@@ -1,143 +1,209 @@
import styled from "@emotion/styled";
import { Menu, MenuItem, Modal } from "@mui/material";
import { Check, FileDown, FileUp, Plus, Table2, 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 TemplatesDialog from "./WelcomeDialog/TemplatesDialog";
import { getModelsMetadata, getSelectedUuid } from "./storage";
export function FileMenu(props: {
export function DesktopMenu(props: {
newModel: () => void;
newModelFromTemplate: () => void;
setModel: (key: string) => void;
onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
}) {
const [isMenuOpen, setMenuOpen] = useState(false);
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
const anchorElement = useRef<HTMLButtonElement>(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(
<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>,
);
}
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
const anchorElement = useRef<HTMLButtonElement>(
null as unknown as HTMLButtonElement,
);
return (
<>
<FileMenuWrapper
type="button"
id="file-menu-button"
onClick={(): void => setMenuOpen(true)}
<FileBarButton
onClick={(): void => setFileMenuOpen(!isFileMenuOpen)}
ref={anchorElement}
$isActive={isMenuOpen}
aria-haspopup="true"
disableRipple
isOpen={isFileMenuOpen}
>
File
</FileMenuWrapper>
<Menu
open={isMenuOpen}
onClose={(): void => setMenuOpen(false)}
</FileBarButton>
<FileMenu
newModel={props.newModel}
setModel={props.setModel}
onDownload={props.onDownload}
onModelUpload={props.onModelUpload}
onDelete={props.onDelete}
isFileMenuOpen={isFileMenuOpen}
setFileMenuOpen={setFileMenuOpen}
setMobileMenuOpen={() => {}}
anchorElement={anchorElement}
/>
</>
);
}
export function MobileMenu(props: {
newModel: () => void;
setModel: (key: string) => void;
onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
}) {
const [isMobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
const anchorElement = useRef<HTMLButtonElement>(
null as unknown as HTMLButtonElement,
);
const [fileMenuAnchorEl, setFileMenuAnchorEl] = useState<HTMLElement | null>(
null,
);
return (
<>
<MenuButton
onClick={(): void => setMobileMenuOpen(true)}
ref={anchorElement}
disableRipple
>
<EllipsisVertical />
</MenuButton>
<StyledMenu
open={isMobileMenuOpen}
onClose={(): void => setMobileMenuOpen(false)}
anchorEl={anchorElement.current}
autoFocus={false}
disableRestoreFocus={true}
sx={{
"& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" },
"& .MuiList-root": { padding: "0" },
transform: "translate(-4px, 4px)",
>
<MenuItemWrapper
onClick={(event) => {
setFileMenuOpen(true);
setFileMenuAnchorEl(event.currentTarget);
}}
disableRipple
>
<MenuItemText>File</MenuItemText>
<ChevronRight />
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper
onClick={() => {
window.open("https://docs.ironcalc.com", "_blank");
setMobileMenuOpen(false);
}}
disableRipple
>
<MenuItemText>Help</MenuItemText>
</MenuItemWrapper>
</StyledMenu>
<FileMenu
newModel={props.newModel}
setModel={props.setModel}
onDownload={props.onDownload}
onModelUpload={props.onModelUpload}
onDelete={props.onDelete}
isFileMenuOpen={isFileMenuOpen}
setFileMenuOpen={setFileMenuOpen}
setMobileMenuOpen={setMobileMenuOpen}
anchorElement={anchorElement}
/>
</>
);
}
export function FileMenu(props: {
newModel: () => void;
setModel: (key: string) => void;
onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
isFileMenuOpen: boolean;
setFileMenuOpen: (open: boolean) => void;
setMobileMenuOpen: (open: boolean) => void;
anchorElement: React.RefObject<HTMLButtonElement>;
}) {
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
const models = getModelsMetadata();
const selectedUuid = getSelectedUuid();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
return (
<>
<StyledMenu
open={props.isFileMenuOpen}
onClose={(): void => 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);
}
}}
>
<MenuItemWrapper
onClick={() => {
props.newModel();
setMenuOpen(false);
props.setFileMenuOpen(false);
props.setMobileMenuOpen(false);
}}
disableRipple
>
<StyledIcon>
<Plus />
</StyledIcon>
<MenuItemText>New blank workbook</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
props.newModelFromTemplate();
setMenuOpen(false);
}}
>
<StyledIcon>
<Table2 />
</StyledIcon>
<MenuItemText>New from template</MenuItemText>
<MenuItemText>New</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
setImportMenuOpen(true);
setMenuOpen(false);
props.setFileMenuOpen(false);
props.setMobileMenuOpen(false);
}}
disableRipple
>
<StyledIcon>
<FileUp />
</StyledIcon>
<MenuItemText>Import</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper onClick={props.onDownload}>
<StyledIcon>
<FileDown />
</StyledIcon>
<MenuItemWrapper
onClick={() => {
props.onDownload();
props.setMobileMenuOpen(false);
}}
disableRipple
>
<StyledFileDown />
<MenuItemText>Download (.xlsx)</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper
onClick={() => {
setDeleteDialogOpen(true);
setMenuOpen(false);
props.setFileMenuOpen(false);
props.setMobileMenuOpen(false);
}}
disableRipple
>
<StyledIcon>
<Trash2 />
</StyledIcon>
<MenuItemText>Delete workbook</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
{elements}
</Menu>
</StyledMenu>
<Modal
open={isImportMenuOpen}
onClose={() => {
@@ -162,7 +228,7 @@ export function FileMenu(props: {
<DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid] : ""}
workbookName={selectedUuid ? models[selectedUuid]?.name || "" : ""}
/>
</Modal>
</>
@@ -180,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;
@@ -191,6 +305,7 @@ const MenuDivider = styled.div`
const MenuItemText = styled.div`
color: #000;
font-size: 12px;
flex-grow: 1;
`;
const MenuItemWrapper = styled(MenuItem)`
@@ -203,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;
},
`;

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,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 (
<HeaderContainer>
<StyledDesktopLogo />
<AddButton onClick={onNewModel} title="New workbook">
<PlusIcon />
</AddButton>
</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;
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;

View File

@@ -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 (
<DrawerWrapper
variant="persistent"
anchor="left"
open={open}
onClose={onClose}
>
<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,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 | 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);
}
};
// 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 (
<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>
<FileSpreadsheet />
</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}</SectionTitle>
{uuids.map(renderWorkbookItem)}
</SectionContainer>
);
};
const models = getModelsMetadata();
return (
<>
{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>
<MenuDivider />
<MenuItemWrapper
selected={false}
onClick={() => {
if (selectedWorkbookUuid) {
handleDeleteClick(selectedWorkbookUuid);
}
}}
disableRipple
>
<Trash2 size={16} />
Delete workbook
</MenuItemWrapper>
</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: 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;

View File

@@ -1,79 +0,0 @@
import { Dialog, styled } from "@mui/material";
import { X } from "lucide-react";
import { useState } from "react";
import TemplatesList, {
Cross,
DialogContent,
DialogFooter,
DialogFooterButton,
} from "./TemplatesList";
function TemplatesDialog(properties: {
onClose: () => void;
onSelectTemplate: (templateId: string) => void;
}) {
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const handleClose = () => {
properties.onClose();
};
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
};
return (
<DialogWrapper open={true} onClose={() => {}}>
<DialogTemplateHeader>
<span style={{ flexGrow: 2, marginLeft: 12 }}>Choose a template</span>
<Cross
style={{ marginRight: 12 }}
onClick={handleClose}
title="Close Dialog"
tabIndex={0}
onKeyDown={(event) => event.key === "Enter" && properties.onClose()}
>
<X />
</Cross>
</DialogTemplateHeader>
<DialogContent>
<TemplatesList
selectedTemplate={selectedTemplate}
handleTemplateSelect={handleTemplateSelect}
/>
</DialogContent>
<DialogFooter>
<DialogFooterButton
onClick={() => properties.onSelectTemplate(selectedTemplate)}
>
Create workbook
</DialogFooterButton>
</DialogFooter>
</DialogWrapper>
);
}
export const DialogWrapper = styled(Dialog)`
font-family: Inter;
.MuiDialog-paper {
width: 440px;
border-radius: 12px;
margin: 16px;
border: 1px solid #e0e0e0;
}
.MuiBackdrop-root {
background-color: rgba(0, 0, 0, 0.4);
}
`;
const DialogTemplateHeader = styled("div")`
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
height: 44px;
font-size: 14px;
font-weight: 500;
font-family: Inter;
`;
export default TemplatesDialog;

View File

@@ -1,103 +0,0 @@
import { Dialog, styled } from "@mui/material";
import { House, TicketsPlane } from "lucide-react";
import TemplatesListItem from "./TemplatesListItem";
function TemplatesList(props: {
selectedTemplate: string;
handleTemplateSelect: (templateId: string) => void;
}) {
const { selectedTemplate, handleTemplateSelect } = props;
return (
<TemplatesListWrapper>
<TemplatesListItem
title="Mortgage calculator"
description="Estimate payments, interest, and overall cost."
icon={<House />}
iconColor="#2F80ED"
active={selectedTemplate === "mortgage_calculator"}
onClick={() => handleTemplateSelect("mortgage_calculator")}
/>
<TemplatesListItem
title="Travel expenses tracker"
description="Track trip costs and stay on budget."
icon={<TicketsPlane />}
iconColor="#EB5757"
active={selectedTemplate === "travel_expenses_tracker"}
onClick={() => handleTemplateSelect("travel_expenses_tracker")}
/>
</TemplatesListWrapper>
);
}
export const DialogWrapper = styled(Dialog)`
font-family: Inter;
.MuiDialog-paper {
width: 440px;
border-radius: 12px;
margin: 16px;
border: 1px solid #e0e0e0;
}
.MuiBackdrop-root {
background-color: rgba(0, 0, 0, 0.4);
}
`;
export const Cross = styled("div")`
&:hover {
background-color: #f5f5f5;
}
display: flex;
border-radius: 4px;
min-height: 24px;
min-width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
`;
export const DialogContent = styled("div")`
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
max-height: 300px;
overflow: hidden;
overflow-y: auto;
`;
export const TemplatesListWrapper = styled("div")`
display: flex;
flex-direction: column;
gap: 10px;
`;
export const DialogFooter = styled("div")`
border-top: 1px solid #e0e0e0;
padding: 16px;
`;
export const DialogFooterButton = styled("button")`
background-color: #f2994a;
border: none;
color: #fff;
padding: 12px;
border-radius: 4px;
cursor: pointer;
width: 100%;
font-size: 12px;
font-family: Inter;
&:hover {
background-color: #d68742;
}
&:active {
background-color: #d68742;
}
`;
// export default TemplatesDialog;
export default TemplatesList;

View File

@@ -1,107 +0,0 @@
import { styled } from "@mui/material";
import type { ReactNode } from "react";
interface TemplatesListItemProps {
title: string;
description: string;
icon: ReactNode;
iconColor: string;
active: boolean;
onClick: () => void;
}
function TemplatesListItem({
title,
description,
icon,
iconColor,
active,
onClick,
}: TemplatesListItemProps) {
return (
<ListItemWrapper active={active} iconColor={iconColor} onClick={onClick}>
<StyledIcon iconColor={iconColor}>{icon}</StyledIcon>
<TemplatesListItemTitle>
<Title>{title}</Title>
<Subtitle>{description}</Subtitle>
</TemplatesListItemTitle>
<RadioButton active={active}>
<RadioButtonDot />
</RadioButton>
</ListItemWrapper>
);
}
const ListItemWrapper = styled("div")<{ active?: boolean; iconColor?: string }>`
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: #424242;
border: 1px solid ${(props) => (props.active ? props.iconColor || "#424242" : "rgba(224, 224, 224, 0.60)")};
background-color: #FFFFFF;
padding: 16px;
border-radius: 8px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
cursor: pointer;
outline: ${(props) => (props.active ? `4px solid ${props.iconColor || "#424242"}24` : "none")};
transition: border 0.1s ease-in-out;
user-select: none;
&:hover {
border: 1px solid ${(props) => props.iconColor};
transition: border 0.1s ease-in-out;
}
`;
const TemplatesListItemTitle = styled("div")`
display: flex;
flex-direction: column;
color: #424242;
width: 100%;
gap: 2px;
`;
const Title = styled("div")`
font-weight: 600;
color: #424242;
line-height: 16px;
`;
const Subtitle = styled("div")`
color: #757575;
`;
const StyledIcon = styled("div")<{ iconColor?: string }>`
display: flex;
align-items: center;
margin-top: -1px;
svg {
width: 18px;
height: 100%;
color: ${(props) => props.iconColor || "#424242"};
}
`;
const RadioButton = styled("div")<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 16px;
min-width: 16px;
height: 16px;
border-radius: 16px;
margin-top: -4px;
margin-right: -4px;
background-color: ${(props) => (props.active ? "#F2994A" : "#FFFFFF")};
border: ${(props) => (props.active ? "none" : "1px solid #E0E0E0")};
`;
const RadioButtonDot = styled("div")`
width: 6px;
height: 6px;
border-radius: 6px;
background-color: #FFF;
`;
export default TemplatesListItem;

View File

@@ -1,136 +0,0 @@
import { IronCalcIconWhite as IronCalcIcon } from "@ironcalc/workbook";
import { styled } from "@mui/material";
import { Table, X } from "lucide-react";
import { useState } from "react";
import TemplatesListItem from "./TemplatesListItem";
import TemplatesList, {
Cross,
DialogContent,
DialogFooter,
DialogFooterButton,
DialogWrapper,
TemplatesListWrapper,
} from "./TemplatesList";
function WelcomeDialog(properties: {
onClose: () => void;
onSelectTemplate: (templateId: string) => void;
}) {
const [selectedTemplate, setSelectedTemplate] = useState<string>("blank");
const handleClose = () => {
properties.onClose();
};
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
};
return (
<DialogWrapper open={true} onClose={() => {}}>
<DialogWelcomeHeader>
<DialogHeaderTitleWrapper>
<DialogHeaderLogoWrapper>
<IronCalcIcon />
</DialogHeaderLogoWrapper>
<DialogHeaderTitle>Welcome to IronCalc</DialogHeaderTitle>
<DialogHeaderTitleSubtitle>
Start with a blank workbook or a ready-made template.
</DialogHeaderTitleSubtitle>
</DialogHeaderTitleWrapper>
<Cross
onClick={handleClose}
title="Close Dialog"
tabIndex={0}
onKeyDown={(event) => event.key === "Enter" && properties.onClose()}
>
<X />
</Cross>
</DialogWelcomeHeader>
<DialogContent>
<ListTitle>New</ListTitle>
<TemplatesListWrapper>
<TemplatesListItem
title="Blank workbook"
description="Create from scratch or upload your own file."
icon={<Table />}
iconColor="#F2994A"
active={selectedTemplate === "blank"}
onClick={() => handleTemplateSelect("blank")}
/>
</TemplatesListWrapper>
<ListTitle>Templates</ListTitle>
<TemplatesList
selectedTemplate={selectedTemplate}
handleTemplateSelect={handleTemplateSelect}
/>
</DialogContent>
<DialogFooter>
<DialogFooterButton
onClick={() => properties.onSelectTemplate(selectedTemplate)}
>
Create workbook
</DialogFooterButton>
</DialogFooter>
</DialogWrapper>
);
}
const DialogWelcomeHeader = styled("div")`
display: flex;
flex-direction: row;
align-items: flex-start;
border-bottom: 1px solid #e0e0e0;
padding: 16px;
font-family: Inter;
`;
const DialogHeaderTitleWrapper = styled("span")`
display: flex;
flex-direction: column;
align-items: flex-start;
font-size: 14px;
font-weight: 500;
padding: 4px 0px;
gap: 4px;
width: 100%;
`;
const DialogHeaderTitle = styled("span")`
font-weight: 700;
`;
const DialogHeaderTitleSubtitle = styled("span")`
font-size: 12px;
color: #757575;
`;
const DialogHeaderLogoWrapper = styled("div")`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
max-width: 20px;
max-height: 20px;
background-color: #f2994a;
padding: 10px;
margin-bottom: 12px;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transform: rotate(-8deg);
user-select: none;
svg {
width: 18px;
height: 18px;
}
`;
const ListTitle = styled("div")`
font-size: 12px;
font-weight: 600;
color: #424242;
`;
export default WelcomeDialog;

View File

@@ -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;

View File

@@ -3,7 +3,7 @@ import { base64ToBytes, bytesToBase64 } from "./util";
const MAX_WORKBOOKS = 50;
type ModelsMetadata = Record<string, string>;
type ModelsMetadata = Record<string, { name: string; createdAt: number }>;
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,19 +71,19 @@ 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;
}
export function loadSelectedModelFromStorage(): Model | null {
export function loadModelFromStorageOrCreate(): Model {
const uuid = localStorage.getItem("selected");
if (uuid) {
// We try to load the selected model
@@ -68,22 +91,14 @@ export function loadSelectedModelFromStorage(): Model | null {
if (modelBytesString) {
return Model.from_bytes(base64ToBytes(modelBytesString));
}
// If it doesn't exist we create one at that uuid
const newModel = new Model("Workbook1", "en", "UTC");
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(newModel.toBytes()));
return newModel;
}
return null;
}
// check if storage is empty
export function isStorageEmpty(): boolean {
const modelsJson = localStorage.getItem("models");
if (!modelsJson) {
return true;
}
try {
const models = JSON.parse(modelsJson);
return Object.keys(models).length === 0;
} catch (e) {
return true;
}
// If there was no selected model we create a new one
return createNewModel();
}
export function saveSelectedModelInStorage(model: Model) {
@@ -103,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));
}
@@ -135,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();
}