Files
IronCalc/webapp/app.ironcalc.com/frontend/src/App.tsx
2025-11-13 19:49:04 +01:00

269 lines
8.0 KiB
TypeScript

import "./App.css";
import styled from "@emotion/styled";
// From IronCalc
import { IronCalc, IronCalcIcon, init, Model } from "@ironcalc/workbook";
import { Modal } from "@mui/material";
import { useEffect, useState } from "react";
import { FileBar } from "./components/FileBar";
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
import {
get_documentation_model,
get_model,
uploadFile,
} from "./components/rpc";
import {
createModelWithSafeTimezone,
createNewModel,
deleteModelByUuid,
deleteSelectedModel,
isStorageEmpty,
loadSelectedModelFromStorage,
saveModelToStorage,
saveSelectedModelInStorage,
selectModelFromStorage,
} from "./components/storage";
import TemplatesDialog from "./components/WelcomeDialog/TemplatesDialog";
import WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog";
function App() {
const [model, setModel] = useState<Model | null>(null);
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [localStorageId, setLocalStorageId] = useState<number>(1);
useEffect(() => {
async function start() {
await init();
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const modelHash = urlParams.get("model");
const exampleFilename = urlParams.get("example");
// If there is a model name ?model=modelHash we try to load it
// if there is not, or the loading failed we load an empty model
if (modelHash) {
// Get a remote model
try {
const model_bytes = await get_model(modelHash);
const importedModel = Model.from_bytes(model_bytes);
localStorage.removeItem("selected");
setModel(importedModel);
} catch (e) {
alert("Model not found, or failed to load");
}
} else if (exampleFilename) {
try {
const model_bytes = await get_documentation_model(exampleFilename);
const importedModel = Model.from_bytes(model_bytes);
localStorage.removeItem("selected");
setModel(importedModel);
} catch (e) {
alert("Example file not found, or failed to load");
}
} else {
// try to load from local storage
const newModel = loadSelectedModelFromStorage();
if (!newModel) {
setShowWelcomeDialog(true);
const createdModel = createModelWithSafeTimezone("template");
setModel(createdModel);
} else {
setModel(newModel);
}
}
}
start();
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: localStorageId needed to detect name changes (model mutates internally)
useEffect(() => {
if (model) {
const workbookName = model.getName();
document.title = workbookName ? `${workbookName} - IronCalc` : "IronCalc";
} else {
document.title = "IronCalc";
}
}, [model, localStorageId]);
if (!model) {
return (
<Loading>
<IronCalcIcon style={{ width: 24, height: 24, marginBottom: 16 }} />
<div>Loading IronCalc</div>
</Loading>
);
}
// We try to save the model every second
setInterval(() => {
const queue = model.flushSendQueue();
if (queue.length !== 1) {
saveSelectedModelInStorage(model);
}
}, 1000);
// Handlers for model changes that also update our models state
const handleNewModel = () => {
const newModel = createNewModel();
setModel(newModel);
};
const handleSetModel = (uuid: string) => {
const newModel = selectModelFromStorage(uuid);
if (newModel) {
setModel(newModel);
}
};
const handleDeleteModel = () => {
const newModel = deleteSelectedModel();
if (newModel) {
setModel(newModel);
}
};
const handleDeleteModelByUuid = (uuid: string) => {
const newModel = deleteModelByUuid(uuid);
if (newModel) {
setModel(newModel);
}
};
// We could use context for model, but the problem is that it should initialized to null.
// Passing the property down makes sure it is always defined.
return (
<Wrapper>
<LeftDrawer
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
newModel={handleNewModel}
setModel={handleSetModel}
onDelete={handleDeleteModelByUuid}
localStorageId={localStorageId}
/>
<MainContent isDrawerOpen={isDrawerOpen}>
{isDrawerOpen && (
<MobileOverlay onClick={() => setIsDrawerOpen(false)} />
)}
<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}
newModelFromTemplate={() => {
setTemplatesDialogOpen(true);
}}
setModel={handleSetModel}
onDelete={handleDeleteModel}
isDrawerOpen={isDrawerOpen}
setIsDrawerOpen={setIsDrawerOpen}
setLocalStorageId={setLocalStorageId}
/>
<IronCalc model={model} />
</MainContent>
{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);
}}
/>
)}
<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>
);
}
const Wrapper = styled("div")`
display: flex;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
`;
const DRAWER_WIDTH = 264;
const MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE = 440;
const MainContent = styled("div")<{ isDrawerOpen: boolean }>`
margin-left: ${({ isDrawerOpen }) => (isDrawerOpen ? "0px" : `-${DRAWER_WIDTH}px`)};
width: ${({ isDrawerOpen }) => (isDrawerOpen ? `calc(100% - ${DRAWER_WIDTH}px)` : "100%")};
display: flex;
flex-direction: column;
position: relative;
@media (max-width: ${MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE}px) {
${({ isDrawerOpen }) => isDrawerOpen && `min-width: ${MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE}px;`}
}
`;
const MobileOverlay = styled("div")`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
z-index: 1;
cursor: pointer;
@media (min-width: ${MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE + 1}px) {
display: none;
}
`;
const Loading = styled("div")`
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: "Inter";
font-size: 14px;
`;
export default App;