Compare commits
21 Commits
feature/da
...
right-draw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
443ff6808d | ||
|
|
ed64716f0f | ||
|
|
dd29287c5a | ||
|
|
7841abe2d2 | ||
|
|
49c3d1e03a | ||
|
|
b709041f9d | ||
|
|
b177a33815 | ||
|
|
b506ccf908 | ||
|
|
eb3e92ffd8 | ||
|
|
0b925a4d6a | ||
|
|
6a3e37f4c1 | ||
|
|
2496227344 | ||
|
|
72355a5201 | ||
|
|
81901ec717 | ||
|
|
aa664a95a1 | ||
|
|
c1aa743763 | ||
|
|
6321030ac8 | ||
|
|
c2c5751ee3 | ||
|
|
6c27ae1355 | ||
|
|
7bcd978998 | ||
|
|
3f083d9882 |
@@ -84,7 +84,7 @@ And then use this code in `main.rs`:
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
use ironcalc::{
|
use ironcalc::{
|
||||||
base::{expressions::utils::number_to_column, model::Model},
|
base::{expressions::utils::number_to_column, Model},
|
||||||
export::save_to_xlsx,
|
export::save_to_xlsx,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -99,10 +99,9 @@ const FormulaSymbolButton = styled(StyledButton)`
|
|||||||
|
|
||||||
const Divider = styled("div")`
|
const Divider = styled("div")`
|
||||||
background-color: ${theme.palette.grey["300"]};
|
background-color: ${theme.palette.grey["300"]};
|
||||||
width: 1px;
|
min-width: 1px;
|
||||||
height: 20px;
|
height: 16px;
|
||||||
margin-left: 16px;
|
margin: 0px 16px;
|
||||||
margin-right: 16px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FormulaContainer = styled("div")`
|
const FormulaContainer = styled("div")`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import {
|
|||||||
CLIPBOARD_ID_SESSION_STORAGE_KEY,
|
CLIPBOARD_ID_SESSION_STORAGE_KEY,
|
||||||
getNewClipboardId,
|
getNewClipboardId,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
|
import { TOOLBAR_HEIGHT } from "../constants";
|
||||||
import {
|
import {
|
||||||
type NavigationKey,
|
type NavigationKey,
|
||||||
getCellAddress,
|
getCellAddress,
|
||||||
@@ -41,6 +42,8 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
|
|||||||
// This is needed because `model` or `workbookState` can change without React being aware of it
|
// This is needed because `model` or `workbookState` can change without React being aware of it
|
||||||
const setRedrawId = useState(0)[1];
|
const setRedrawId = useState(0)[1];
|
||||||
|
|
||||||
|
const [isDrawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
const worksheets = model.getWorksheetsProperties();
|
const worksheets = model.getWorksheetsProperties();
|
||||||
const info = worksheets.map(
|
const info = worksheets.map(
|
||||||
({ name, color, sheet_id, state }: WorksheetProperties) => {
|
({ name, color, sheet_id, state }: WorksheetProperties) => {
|
||||||
@@ -692,77 +695,119 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
|
|||||||
worksheets,
|
worksheets,
|
||||||
definedNameList: model.getDefinedNameList(),
|
definedNameList: model.getDefinedNameList(),
|
||||||
}}
|
}}
|
||||||
/>
|
openDrawer={() => {
|
||||||
<FormulaBar
|
setDrawerOpen(true);
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
|
<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
|
<SheetTabBar
|
||||||
sheets={info}
|
sheets={info}
|
||||||
selectedIndex={model.getSelectedSheet()}
|
selectedIndex={model.getSelectedSheet()}
|
||||||
workbookState={workbookState}
|
workbookState={workbookState}
|
||||||
onSheetSelected={(sheet: number): void => {
|
onSheetSelected={(sheet: number): void => {
|
||||||
if (info[sheet].state !== "visible") {
|
if (info[sheet].state !== "visible") {
|
||||||
model.unhideSheet(sheet);
|
model.unhideSheet(sheet);
|
||||||
}
|
}
|
||||||
model.setSelectedSheet(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);
|
setRedrawId((value) => value + 1);
|
||||||
} catch (e) {
|
}}
|
||||||
// TODO: Show a proper modal dialog
|
onAddBlankSheet={(): void => {
|
||||||
alert(`${e}`);
|
model.newSheet();
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSheetRenamed={(name: string): void => {
|
|
||||||
try {
|
|
||||||
model.renameSheet(model.getSelectedSheet(), name);
|
|
||||||
setRedrawId((value) => value + 1);
|
setRedrawId((value) => value + 1);
|
||||||
} catch (e) {
|
}}
|
||||||
// TODO: Show a proper modal dialog
|
onSheetColorChanged={(hex: string): void => {
|
||||||
alert(`${e}`);
|
try {
|
||||||
}
|
model.setSheetColor(model.getSelectedSheet(), hex);
|
||||||
}}
|
setRedrawId((value) => value + 1);
|
||||||
onSheetDeleted={(): void => {
|
} catch (e) {
|
||||||
const selectedSheet = model.getSelectedSheet();
|
// TODO: Show a proper modal dialog
|
||||||
model.deleteSheet(selectedSheet);
|
alert(`${e}`);
|
||||||
setRedrawId((value) => value + 1);
|
}
|
||||||
}}
|
}}
|
||||||
onHideSheet={(): void => {
|
onSheetRenamed={(name: string): void => {
|
||||||
const selectedSheet = model.getSelectedSheet();
|
try {
|
||||||
model.hideSheet(selectedSheet);
|
model.renameSheet(model.getSelectedSheet(), name);
|
||||||
setRedrawId((value) => value + 1);
|
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>
|
</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")`
|
const Container = styled("div")`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ import {
|
|||||||
outlineColor,
|
outlineColor,
|
||||||
} from "../WorksheetCanvas/constants";
|
} from "../WorksheetCanvas/constants";
|
||||||
import WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
|
import WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
|
||||||
import {
|
import { FORMULA_BAR_HEIGHT, NAVIGATION_HEIGHT } from "../constants";
|
||||||
FORMULA_BAR_HEIGHT,
|
|
||||||
NAVIGATION_HEIGHT,
|
|
||||||
TOOLBAR_HEIGHT,
|
|
||||||
} from "../constants";
|
|
||||||
import type { Cell } from "../types";
|
import type { Cell } from "../types";
|
||||||
import type { WorkbookState } from "../workbookState";
|
import type { WorkbookState } from "../workbookState";
|
||||||
import CellContextMenu from "./CellContextMenu";
|
import CellContextMenu from "./CellContextMenu";
|
||||||
@@ -459,7 +455,7 @@ const SheetContainer = styled("div")`
|
|||||||
const Wrapper = styled("div")({
|
const Wrapper = styled("div")({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
overflow: "scroll",
|
overflow: "scroll",
|
||||||
top: TOOLBAR_HEIGHT + FORMULA_BAR_HEIGHT + 1,
|
top: FORMULA_BAR_HEIGHT + 1,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: NAVIGATION_HEIGHT + 1,
|
bottom: NAVIGATION_HEIGHT + 1,
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const TOOLBAR_HEIGHT = 48;
|
export const TOOLBAR_HEIGHT = 40;
|
||||||
export const FORMULA_BAR_HEIGHT = 40;
|
export const FORMULA_BAR_HEIGHT = 40;
|
||||||
export const NAVIGATION_HEIGHT = 40;
|
export const NAVIGATION_HEIGHT = 40;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import InsertRowAboveIcon from "./insert-row-above.svg?react";
|
|||||||
import InsertRowBelow from "./insert-row-below.svg?react";
|
import InsertRowBelow from "./insert-row-below.svg?react";
|
||||||
|
|
||||||
import IronCalcIcon from "./ironcalc_icon.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 IronCalcLogo from "./orange+black.svg?react";
|
||||||
|
|
||||||
import Fx from "./fx.svg?react";
|
import Fx from "./fx.svg?react";
|
||||||
@@ -41,6 +42,7 @@ export {
|
|||||||
InsertRowAboveIcon,
|
InsertRowAboveIcon,
|
||||||
InsertRowBelow,
|
InsertRowBelow,
|
||||||
IronCalcIcon,
|
IronCalcIcon,
|
||||||
|
IronCalcIconWhite,
|
||||||
IronCalcLogo,
|
IronCalcLogo,
|
||||||
Fx,
|
Fx,
|
||||||
};
|
};
|
||||||
|
|||||||
7
webapp/IronCalc/src/icons/ironcalc_icon_white.svg
Normal file
7
webapp/IronCalc/src/icons/ironcalc_icon_white.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import init, { Model } from "@ironcalc/wasm";
|
import init, { Model } from "@ironcalc/wasm";
|
||||||
import IronCalc from "./IronCalc";
|
import IronCalc from "./IronCalc";
|
||||||
import { IronCalcIcon, IronCalcLogo } from "./icons";
|
import { IronCalcIcon, IronCalcIconWhite, IronCalcLogo } from "./icons";
|
||||||
|
|
||||||
export { init, Model, IronCalc, IronCalcIcon, IronCalcLogo };
|
export { init, Model, IronCalc, IronCalcIcon, IronCalcIconWhite, IronCalcLogo };
|
||||||
|
|||||||
@@ -27,13 +27,15 @@
|
|||||||
"vertical_align_top": "Align top",
|
"vertical_align_top": "Align top",
|
||||||
"selected_png": "Export Selected area as PNG",
|
"selected_png": "Export Selected area as PNG",
|
||||||
"wrap_text": "Wrap text",
|
"wrap_text": "Wrap text",
|
||||||
|
"scroll_left": "Scroll left",
|
||||||
|
"scroll_right": "Scroll right",
|
||||||
"format_menu": {
|
"format_menu": {
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
"percentage": "Percentage",
|
"percentage": "Percentage",
|
||||||
"currency_eur": "Euro (EUR)",
|
"currency_eur": "Euro (EUR)",
|
||||||
"currency_usd": "Dollar (USD)",
|
"currency_usd": "Dollar (USD)",
|
||||||
"currency_gbp": "British Pound (GBD)",
|
"currency_gbp": "British Pound (GBP)",
|
||||||
"date_short": "Short date",
|
"date_short": "Short date",
|
||||||
"date_long": "Long date",
|
"date_long": "Long date",
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
|
|||||||
@@ -2,7 +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 {
|
import {
|
||||||
get_documentation_model,
|
get_documentation_model,
|
||||||
get_model,
|
get_model,
|
||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
} from "./components/rpc";
|
} from "./components/rpc";
|
||||||
import {
|
import {
|
||||||
createNewModel,
|
createNewModel,
|
||||||
deleteModelByUuid,
|
|
||||||
deleteSelectedModel,
|
deleteSelectedModel,
|
||||||
// getModelsMetadata,
|
isStorageEmpty,
|
||||||
// getSelectedUuid,
|
loadSelectedModelFromStorage,
|
||||||
loadModelFromStorageOrCreate,
|
|
||||||
saveModelToStorage,
|
saveModelToStorage,
|
||||||
saveSelectedModelInStorage,
|
saveSelectedModelInStorage,
|
||||||
selectModelFromStorage,
|
selectModelFromStorage,
|
||||||
@@ -22,10 +20,13 @@ import {
|
|||||||
|
|
||||||
// From IronCalc
|
// From IronCalc
|
||||||
import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook";
|
import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook";
|
||||||
|
import { Modal } from "@mui/material";
|
||||||
|
import TemplatesDialog from "./components/WelcomeDialog/TemplatesDialog";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [model, setModel] = useState<Model | null>(null);
|
const [model, setModel] = useState<Model | null>(null);
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
|
||||||
|
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function start() {
|
async function start() {
|
||||||
@@ -57,8 +58,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// try to load from local storage
|
// try to load from local storage
|
||||||
const newModel = loadModelFromStorageOrCreate();
|
const newModel = loadSelectedModelFromStorage();
|
||||||
setModel(newModel);
|
if (!newModel) {
|
||||||
|
setShowWelcomeDialog(true);
|
||||||
|
const createdModel = new Model("template", "en", "UTC");
|
||||||
|
setModel(createdModel);
|
||||||
|
} else {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
start();
|
start();
|
||||||
@@ -84,80 +91,97 @@ function App() {
|
|||||||
// 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.
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<AppContainer>
|
<Wrapper>
|
||||||
<LeftDrawer
|
<FileBar
|
||||||
open={isDrawerOpen}
|
model={model}
|
||||||
onClose={() => setIsDrawerOpen(false)}
|
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
|
||||||
newModel={handleNewModel}
|
const blob = await uploadFile(arrayBuffer, fileName);
|
||||||
setModel={handleSetModel}
|
|
||||||
onDelete={handleDeleteModelByUuid}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MainContent isDrawerOpen={isDrawerOpen}>
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
<FileBar
|
const newModel = Model.from_bytes(bytes);
|
||||||
model={model}
|
saveModelToStorage(newModel);
|
||||||
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
|
|
||||||
const blob = await uploadFile(arrayBuffer, fileName);
|
setModel(newModel);
|
||||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
}}
|
||||||
const newModel = Model.from_bytes(bytes);
|
newModel={() => {
|
||||||
saveModelToStorage(newModel);
|
const createdModel = createNewModel();
|
||||||
|
setModel(createdModel);
|
||||||
|
}}
|
||||||
|
newModelFromTemplate={() => {
|
||||||
|
setTemplatesDialogOpen(true);
|
||||||
|
}}
|
||||||
|
setModel={(uuid: string) => {
|
||||||
|
const newModel = selectModelFromStorage(uuid);
|
||||||
|
if (newModel) {
|
||||||
setModel(newModel);
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
const newModel = deleteSelectedModel();
|
||||||
|
if (newModel) {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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);
|
||||||
}}
|
}}
|
||||||
newModel={handleNewModel}
|
|
||||||
setModel={handleSetModel}
|
|
||||||
onDelete={handleDeleteModel}
|
|
||||||
isDrawerOpen={isDrawerOpen}
|
|
||||||
setIsDrawerOpen={setIsDrawerOpen}
|
|
||||||
/>
|
/>
|
||||||
<IronCalc model={model} />
|
)}
|
||||||
</MainContent>
|
<Modal
|
||||||
</AppContainer>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppContainer = styled("div")`
|
const Wrapper = styled("div")`
|
||||||
display: flex;
|
margin: 0px;
|
||||||
|
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.3s ease;
|
|
||||||
width: ${({ isDrawerOpen }) =>
|
|
||||||
isDrawerOpen ? "calc(100% - 264px)" : "100%"};
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: absolute;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Loading = styled("div")`
|
const Loading = styled("div")`
|
||||||
|
|||||||
@@ -12,9 +12,19 @@ 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 (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import type { Model } from "@ironcalc/workbook";
|
import type { Model } from "@ironcalc/workbook";
|
||||||
import { Button, IconButton } from "@mui/material";
|
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
|
||||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { DesktopMenu, MobileMenu } from "./FileMenu";
|
import { FileMenu } from "./FileMenu";
|
||||||
|
import { HelpMenu } from "./HelpMenu";
|
||||||
import { ShareButton } from "./ShareButton";
|
import { ShareButton } from "./ShareButton";
|
||||||
import ShareWorkbookDialog from "./ShareWorkbookDialog";
|
import ShareWorkbookDialog from "./ShareWorkbookDialog";
|
||||||
import { WorkbookTitle } from "./WorkbookTitle";
|
import { WorkbookTitle } from "./WorkbookTitle";
|
||||||
@@ -27,11 +27,10 @@ function useWindowWidth() {
|
|||||||
export function FileBar(properties: {
|
export function FileBar(properties: {
|
||||||
model: Model;
|
model: Model;
|
||||||
newModel: () => void;
|
newModel: () => void;
|
||||||
|
newModelFromTemplate: () => void;
|
||||||
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);
|
||||||
@@ -47,49 +46,25 @@ export function FileBar(properties: {
|
|||||||
}
|
}
|
||||||
}, [width]);
|
}, [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 (
|
return (
|
||||||
<FileBarWrapper>
|
<FileBarWrapper>
|
||||||
<DrawerButton
|
<StyledDesktopLogo />
|
||||||
$isDrawerOpen={properties.isDrawerOpen}
|
<StyledIronCalcIcon />
|
||||||
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
|
<Divider />
|
||||||
disableRipple
|
<FileMenu
|
||||||
title="Toggle sidebar"
|
newModel={properties.newModel}
|
||||||
>
|
newModelFromTemplate={properties.newModelFromTemplate}
|
||||||
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
|
setModel={properties.setModel}
|
||||||
</DrawerButton>
|
onModelUpload={properties.onModelUpload}
|
||||||
<DesktopButtonsWrapper>
|
onDownload={async () => {
|
||||||
<DesktopMenu
|
const model = properties.model;
|
||||||
newModel={properties.newModel}
|
const bytes = model.toBytes();
|
||||||
setModel={properties.setModel}
|
const fileName = model.getName();
|
||||||
onModelUpload={properties.onModelUpload}
|
await downloadModel(bytes, fileName);
|
||||||
onDownload={handleDownload}
|
}}
|
||||||
onDelete={properties.onDelete}
|
onDelete={properties.onDelete}
|
||||||
/>
|
/>
|
||||||
<FileBarButton
|
<HelpMenu />
|
||||||
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>
|
<WorkbookTitleWrapper>
|
||||||
<WorkbookTitle
|
<WorkbookTitle
|
||||||
name={properties.model.getName()}
|
name={properties.model.getName()}
|
||||||
@@ -115,8 +90,12 @@ 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")`
|
const WorkbookTitleWrapper = styled("div")`
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// The "Spacer" component occupies as much space as possible between the menu and the share button
|
// The "Spacer" component occupies as much space as possible between the menu and the share button
|
||||||
@@ -124,79 +103,38 @@ const Spacer = styled("div")`
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>`
|
const StyledDesktopLogo = styled(IronCalcLogo)`
|
||||||
margin-left: 8px;
|
width: 120px;
|
||||||
height: 32px;
|
margin-left: 12px;
|
||||||
width: 32px;
|
@media (max-width: 769px) {
|
||||||
padding: 8px;
|
display: none;
|
||||||
border-radius: 4px;
|
|
||||||
cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")};
|
|
||||||
svg {
|
|
||||||
stroke-width: 2px;
|
|
||||||
stroke: #757575;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
}
|
||||||
&:hover {
|
`;
|
||||||
background-color: #f2f2f2;
|
|
||||||
}
|
const StyledIronCalcIcon = styled(IronCalcIcon)`
|
||||||
&:active {
|
width: 36px;
|
||||||
background-color: #e0e0e0;
|
margin-left: 10px;
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
justify-content: space-between;
|
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")`
|
const DialogContainer = styled("div")`
|
||||||
|
|||||||
@@ -1,209 +1,143 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import { Button, IconButton, Menu, MenuItem, Modal } from "@mui/material";
|
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||||
import {
|
import { Check, FileDown, FileUp, Plus, Table2, Trash2 } from "lucide-react";
|
||||||
ChevronRight,
|
|
||||||
EllipsisVertical,
|
|
||||||
FileDown,
|
|
||||||
FileUp,
|
|
||||||
Plus,
|
|
||||||
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";
|
||||||
|
// import TemplatesDialog from "./WelcomeDialog/TemplatesDialog";
|
||||||
import { getModelsMetadata, getSelectedUuid } from "./storage";
|
import { getModelsMetadata, getSelectedUuid } from "./storage";
|
||||||
|
|
||||||
export function DesktopMenu(props: {
|
|
||||||
newModel: () => void;
|
|
||||||
setModel: (key: string) => void;
|
|
||||||
onDownload: () => void;
|
|
||||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
|
||||||
onDelete: () => void;
|
|
||||||
}) {
|
|
||||||
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
|
|
||||||
const anchorElement = useRef<HTMLButtonElement>(
|
|
||||||
null as unknown as HTMLButtonElement,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FileBarButton
|
|
||||||
onClick={(): void => setFileMenuOpen(!isFileMenuOpen)}
|
|
||||||
ref={anchorElement}
|
|
||||||
disableRipple
|
|
||||||
isOpen={isFileMenuOpen}
|
|
||||||
>
|
|
||||||
File
|
|
||||||
</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}
|
|
||||||
>
|
|
||||||
<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: {
|
export function FileMenu(props: {
|
||||||
newModel: () => void;
|
newModel: () => void;
|
||||||
|
newModelFromTemplate: () => void;
|
||||||
setModel: (key: string) => void;
|
setModel: (key: string) => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
isFileMenuOpen: boolean;
|
|
||||||
setFileMenuOpen: (open: boolean) => void;
|
|
||||||
setMobileMenuOpen: (open: boolean) => void;
|
|
||||||
anchorElement: React.RefObject<HTMLButtonElement>;
|
|
||||||
}) {
|
}) {
|
||||||
|
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||||
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
||||||
|
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 (
|
||||||
<>
|
<>
|
||||||
<StyledMenu
|
<FileMenuWrapper
|
||||||
open={props.isFileMenuOpen}
|
type="button"
|
||||||
onClose={(): void => props.setFileMenuOpen(false)}
|
id="file-menu-button"
|
||||||
anchorEl={props.anchorElement.current}
|
onClick={(): void => setMenuOpen(true)}
|
||||||
anchorOrigin={{
|
ref={anchorElement}
|
||||||
vertical: "bottom",
|
$isActive={isMenuOpen}
|
||||||
horizontal: "left",
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
File
|
||||||
|
</FileMenuWrapper>
|
||||||
|
<Menu
|
||||||
|
open={isMenuOpen}
|
||||||
|
onClose={(): void => setMenuOpen(false)}
|
||||||
|
anchorEl={anchorElement.current}
|
||||||
|
autoFocus={false}
|
||||||
|
disableRestoreFocus={true}
|
||||||
|
sx={{
|
||||||
|
"& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" },
|
||||||
|
"& .MuiList-root": { padding: "0" },
|
||||||
|
transform: "translate(-4px, 4px)",
|
||||||
}}
|
}}
|
||||||
transformOrigin={{
|
slotProps={{
|
||||||
vertical: "top",
|
list: {
|
||||||
horizontal: "left",
|
"aria-labelledby": "file-menu-button",
|
||||||
}}
|
tabIndex: -1,
|
||||||
// To prevent closing parent menu when interacting with submenu
|
},
|
||||||
onMouseLeave={() => {
|
|
||||||
if (!isImportMenuOpen && !isDeleteDialogOpen) {
|
|
||||||
props.setFileMenuOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.newModel();
|
props.newModel();
|
||||||
props.setFileMenuOpen(false);
|
setMenuOpen(false);
|
||||||
props.setMobileMenuOpen(false);
|
|
||||||
}}
|
}}
|
||||||
disableRipple
|
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<StyledIcon>
|
||||||
<Plus />
|
<Plus />
|
||||||
</StyledIcon>
|
</StyledIcon>
|
||||||
<MenuItemText>New</MenuItemText>
|
<MenuItemText>New blank workbook</MenuItemText>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuItemWrapper
|
||||||
|
onClick={() => {
|
||||||
|
props.newModelFromTemplate();
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledIcon>
|
||||||
|
<Table2 />
|
||||||
|
</StyledIcon>
|
||||||
|
<MenuItemText>New from template</MenuItemText>
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setImportMenuOpen(true);
|
setImportMenuOpen(true);
|
||||||
props.setFileMenuOpen(false);
|
setMenuOpen(false);
|
||||||
props.setMobileMenuOpen(false);
|
|
||||||
}}
|
}}
|
||||||
disableRipple
|
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<StyledIcon>
|
||||||
<FileUp />
|
<FileUp />
|
||||||
</StyledIcon>
|
</StyledIcon>
|
||||||
<MenuItemText>Import</MenuItemText>
|
<MenuItemText>Import</MenuItemText>
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuItemWrapper
|
<MenuDivider />
|
||||||
onClick={() => {
|
<MenuItemWrapper onClick={props.onDownload}>
|
||||||
props.onDownload();
|
<StyledIcon>
|
||||||
props.setMobileMenuOpen(false);
|
<FileDown />
|
||||||
}}
|
</StyledIcon>
|
||||||
disableRipple
|
|
||||||
>
|
|
||||||
<StyledFileDown />
|
|
||||||
<MenuItemText>Download (.xlsx)</MenuItemText>
|
<MenuItemText>Download (.xlsx)</MenuItemText>
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuDivider />
|
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
props.setFileMenuOpen(false);
|
setMenuOpen(false);
|
||||||
props.setMobileMenuOpen(false);
|
|
||||||
}}
|
}}
|
||||||
disableRipple
|
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<StyledIcon>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
</StyledIcon>
|
</StyledIcon>
|
||||||
<MenuItemText>Delete workbook</MenuItemText>
|
<MenuItemText>Delete workbook</MenuItemText>
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
</StyledMenu>
|
<MenuDivider />
|
||||||
|
{elements}
|
||||||
|
</Menu>
|
||||||
<Modal
|
<Modal
|
||||||
open={isImportMenuOpen}
|
open={isImportMenuOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -228,7 +162,7 @@ export function FileMenu(props: {
|
|||||||
<DeleteWorkbookDialog
|
<DeleteWorkbookDialog
|
||||||
onClose={() => setDeleteDialogOpen(false)}
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
onConfirm={props.onDelete}
|
onConfirm={props.onDelete}
|
||||||
workbookName={selectedUuid ? models[selectedUuid]?.name || "" : ""}
|
workbookName={selectedUuid ? models[selectedUuid] : ""}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
@@ -246,55 +180,7 @@ const StyledIcon = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MenuButton = styled(IconButton)`
|
const MenuDivider = styled.div`
|
||||||
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%;
|
width: 100%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -305,7 +191,6 @@ const MenuDivider = styled("div")`
|
|||||||
const MenuItemText = styled.div`
|
const MenuItemText = styled.div`
|
||||||
color: #000;
|
color: #000;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
flex-grow: 1;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MenuItemWrapper = styled(MenuItem)`
|
const MenuItemWrapper = styled(MenuItem)`
|
||||||
@@ -318,19 +203,26 @@ const MenuItemWrapper = styled(MenuItem)`
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
min-height: 32px;
|
`;
|
||||||
svg {
|
|
||||||
width: 16px;
|
const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
|
||||||
height: 16px;
|
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;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledMenu = styled(Menu)`
|
const CheckIndicator = styled.span`
|
||||||
.MuiPaper-root {
|
display: flex;
|
||||||
border-radius: 8px;
|
justify-content: center;
|
||||||
padding: 4px 0px;
|
min-width: 26px;
|
||||||
},
|
|
||||||
.MuiList-root {
|
|
||||||
padding: 0;
|
|
||||||
},
|
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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;
|
||||||
@@ -72,10 +72,10 @@ export function WorkbookTitle(properties: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled("div")`
|
const Container = styled("div")`
|
||||||
text-align: left;
|
text-align: center;
|
||||||
padding: 6px 4px;
|
padding: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-family: Inter;
|
font-family: Inter;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ const TitleInput = styled("input")`
|
|||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 1px solid grey;
|
border: 1px solid grey;
|
||||||
}
|
}
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { base64ToBytes, bytesToBase64 } from "./util";
|
|||||||
|
|
||||||
const MAX_WORKBOOKS = 50;
|
const MAX_WORKBOOKS = 50;
|
||||||
|
|
||||||
type ModelsMetadata = Record<string, { name: string; createdAt: number }>;
|
type ModelsMetadata = Record<string, string>;
|
||||||
|
|
||||||
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,11 +12,7 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
|||||||
if (modelsJson) {
|
if (modelsJson) {
|
||||||
try {
|
try {
|
||||||
const models = JSON.parse(modelsJson);
|
const models = JSON.parse(modelsJson);
|
||||||
if (models[uuid]) {
|
models[uuid] = newName;
|
||||||
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");
|
||||||
@@ -32,26 +28,7 @@ export function getModelsMetadata(): ModelsMetadata {
|
|||||||
if (!modelsJson) {
|
if (!modelsJson) {
|
||||||
modelsJson = "{}";
|
modelsJson = "{}";
|
||||||
}
|
}
|
||||||
const models = JSON.parse(modelsJson);
|
return 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
|
||||||
@@ -71,19 +48,19 @@ 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).map((m) => m.name));
|
const name = getNewName(Object.values(models));
|
||||||
|
|
||||||
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, createdAt: Date.now() };
|
models[uuid] = name;
|
||||||
localStorage.setItem("models", JSON.stringify(models));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadModelFromStorageOrCreate(): Model {
|
export function loadSelectedModelFromStorage(): Model | null {
|
||||||
const uuid = localStorage.getItem("selected");
|
const uuid = localStorage.getItem("selected");
|
||||||
if (uuid) {
|
if (uuid) {
|
||||||
// We try to load the selected model
|
// We try to load the selected model
|
||||||
@@ -91,14 +68,22 @@ export function loadModelFromStorageOrCreate(): Model {
|
|||||||
if (modelBytesString) {
|
if (modelBytesString) {
|
||||||
return Model.from_bytes(base64ToBytes(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;
|
|
||||||
}
|
}
|
||||||
// If there was no selected model we create a new one
|
return null;
|
||||||
return createNewModel();
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSelectedModelInStorage(model: Model) {
|
export function saveSelectedModelInStorage(model: Model) {
|
||||||
@@ -118,7 +103,7 @@ export function saveModelToStorage(model: Model) {
|
|||||||
modelsJson = "{}";
|
modelsJson = "{}";
|
||||||
}
|
}
|
||||||
const models = JSON.parse(modelsJson);
|
const models = JSON.parse(modelsJson);
|
||||||
models[uuid] = { name: model.getName(), createdAt: Date.now() };
|
models[uuid] = model.getName();
|
||||||
localStorage.setItem("models", JSON.stringify(models));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,37 +135,3 @@ 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();
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user