UPDATE: split the webapp in a widget and the app itself
This splits the webapp in: * IronCalc (the widget to be published on npmjs) * The frontend for our "service" * Adds "dummy code" for the backend using sqlite
This commit is contained in:
committed by
Nicolás Hatcher Andrés
parent
378f8351d3
commit
8215cfc9fb
7
webapp/app.ironcalc.com/Caddyfile
Normal file
7
webapp/app.ironcalc.com/Caddyfile
Normal file
@@ -0,0 +1,7 @@
|
||||
:2080
|
||||
|
||||
# rocket API
|
||||
reverse_proxy /api/* 127.0.0.1:8000
|
||||
|
||||
# everything else is the frontend
|
||||
reverse_proxy :5173
|
||||
44
webapp/app.ironcalc.com/README.md
Normal file
44
webapp/app.ironcalc.com/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# IronCalc service application
|
||||
|
||||
This directory contains the code (frontend and backend) to run the code deployed at https://app.ironcalc.com
|
||||
|
||||
## Development build:
|
||||
|
||||
1. Run in this folder `caddy run` (that just just a proxy for the front end and backend).
|
||||
You will need to leave it running all the time.
|
||||
2. In the server folder run `cargo run`
|
||||
3. In the frontend folder `npm install` and `npm run dev`
|
||||
|
||||
That's it if you point your browser to localhost:2080 you should see the app.
|
||||
|
||||
Note that step three involves alo building thw wasm bindings and the widget
|
||||
|
||||
## Deployment
|
||||
|
||||
The development environment is very close to a deployment environment.
|
||||
|
||||
### Build the server binary:
|
||||
|
||||
In the server directory run:
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
You will find a single binary in target/release/ironcalc_server
|
||||
|
||||
### Build the frontend files
|
||||
|
||||
In the frontend folder:
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
That will create a bunch of files that you should copy to your server
|
||||
|
||||
## TODO
|
||||
|
||||
Deployment details, brotli, logs, stats, Postgres, systemctl files, ...
|
||||
|
||||
24
webapp/app.ironcalc.com/frontend/.gitignore
vendored
Normal file
24
webapp/app.ironcalc.com/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist/*
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
17
webapp/app.ironcalc.com/frontend/README.md
Normal file
17
webapp/app.ironcalc.com/frontend/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# IronCalc application
|
||||
|
||||
This is the front end deployed at https:://app.ironcalc.com
|
||||
|
||||
|
||||
To build for production:
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
A development build:
|
||||
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
19
webapp/app.ironcalc.com/frontend/biome.json
Normal file
19
webapp/app.ironcalc.com/frontend/biome.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"organizeImports": { "enabled": true },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"correctness": { "noUnusedImports": "error" }
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
webapp/app.ironcalc.com/frontend/index.html
Normal file
16
webapp/app.ironcalc.com/frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ironcalc.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- <meta name="theme-color" content="#1bb566"> -->
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2994A" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
|
||||
<title>IronCalc Spreadsheet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2834
webapp/app.ironcalc.com/frontend/package-lock.json
generated
Normal file
2834
webapp/app.ironcalc.com/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
webapp/app.ironcalc.com/frontend/package.json
Normal file
32
webapp/app.ironcalc.com/frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "biome check ./src",
|
||||
"check-write": "biome check --write ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@ironcalc/ironcalc": "file:../../IronCalc",
|
||||
"@mui/material": "^6.3.1",
|
||||
"lucide": "^0.469.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.8.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
}
|
||||
10
webapp/app.ironcalc.com/frontend/src/App.css
Normal file
10
webapp/app.ironcalc.com/frontend/src/App.css
Normal file
@@ -0,0 +1,10 @@
|
||||
#root {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
margin: 0px;
|
||||
border: none;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
136
webapp/app.ironcalc.com/frontend/src/App.tsx
Normal file
136
webapp/app.ironcalc.com/frontend/src/App.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import "./App.css";
|
||||
import styled from "@emotion/styled";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileBar } from "./components/FileBar";
|
||||
import {
|
||||
get_documentation_model,
|
||||
get_model,
|
||||
uploadFile,
|
||||
} from "./components/rpc";
|
||||
import {
|
||||
createNewModel,
|
||||
deleteSelectedModel,
|
||||
loadModelFromStorageOrCreate,
|
||||
saveModelToStorage,
|
||||
saveSelectedModelInStorage,
|
||||
selectModelFromStorage,
|
||||
} from "./components/storage";
|
||||
|
||||
// From IronCalc
|
||||
import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/ironcalc";
|
||||
|
||||
function App() {
|
||||
const [model, setModel] = useState<Model | null>(null);
|
||||
|
||||
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 = loadModelFromStorageOrCreate();
|
||||
setModel(newModel);
|
||||
}
|
||||
}
|
||||
start();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
// 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>
|
||||
<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={() => {
|
||||
setModel(createNewModel());
|
||||
}}
|
||||
setModel={(uuid: string) => {
|
||||
const newModel = selectModelFromStorage(uuid);
|
||||
if (newModel) {
|
||||
setModel(newModel);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
const newModel = deleteSelectedModel();
|
||||
if (newModel) {
|
||||
setModel(newModel);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IronCalc model={model} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,185 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface DeleteWorkbookDialogProperties {
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
workbookName: string;
|
||||
}
|
||||
|
||||
function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "blur(2px)";
|
||||
}
|
||||
if (deleteButtonRef.current) {
|
||||
deleteButtonRef.current.focus();
|
||||
}
|
||||
return () => {
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "none";
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === "Escape") {
|
||||
properties.onClose();
|
||||
}
|
||||
}}
|
||||
role="dialog"
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<IconWrapper>
|
||||
<Trash2 />
|
||||
</IconWrapper>
|
||||
<ContentWrapper>
|
||||
<Title>Are you sure?</Title>
|
||||
<Body>
|
||||
The workbook <strong>'{properties.workbookName}'</strong> will be
|
||||
permanently deleted. This action cannot be undone.
|
||||
</Body>
|
||||
<ButtonGroup>
|
||||
<DeleteButton
|
||||
onClick={() => {
|
||||
properties.onConfirm();
|
||||
properties.onClose();
|
||||
}}
|
||||
ref={deleteButtonRef}
|
||||
>
|
||||
Yes, delete workbook
|
||||
</DeleteButton>
|
||||
<CancelButton onClick={properties.onClose}>Cancel</CancelButton>
|
||||
</ButtonGroup>
|
||||
</ContentWrapper>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
DeleteWorkbookDialog.displayName = "DeleteWorkbookDialog";
|
||||
|
||||
// some colors taken from the IronCalc palette
|
||||
const COMMON_WHITE = "#FFF";
|
||||
const COMMON_BLACK = "#272525";
|
||||
|
||||
const ERROR_MAIN = "#EB5757";
|
||||
const ERROR_DARK = "#CB4C4C";
|
||||
|
||||
const GREY_200 = "#EEEEEE";
|
||||
const GREY_300 = "#E0E0E0";
|
||||
const GREY_700 = "#616161";
|
||||
const GREY_900 = "#333333";
|
||||
|
||||
const PRIMARY_MAIN = "#F2994A";
|
||||
const PRIMARY_DARK = "#D68742";
|
||||
|
||||
const DialogWrapper = styled.div`
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0px 1px 3px 0px ${COMMON_BLACK}1A;
|
||||
width: 280px;
|
||||
max-width: calc(100% - 40px);
|
||||
z-index: 50;
|
||||
font-family: "Inter", sans-serif;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
background-color: ${ERROR_MAIN}1A;
|
||||
margin: 12px auto 0 auto;
|
||||
color: ${ERROR_MAIN};
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: inherit;
|
||||
color: ${GREY_900};
|
||||
`;
|
||||
|
||||
const Body = styled.p`
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: ${GREY_900};
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
cursor: pointer;
|
||||
color: ${COMMON_WHITE};
|
||||
background-color: ${PRIMARY_MAIN};
|
||||
padding: 0px 10px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
transition: background-color 150ms;
|
||||
&:hover {
|
||||
background-color: ${PRIMARY_DARK};
|
||||
}
|
||||
`;
|
||||
|
||||
const DeleteButton = styled(Button)`
|
||||
background-color: ${ERROR_MAIN};
|
||||
color: ${COMMON_WHITE};
|
||||
&:hover {
|
||||
background-color: ${ERROR_DARK};
|
||||
}
|
||||
`;
|
||||
|
||||
const CancelButton = styled(Button)`
|
||||
background-color: ${GREY_200};
|
||||
color: ${GREY_700};
|
||||
&:hover {
|
||||
background-color: ${GREY_300};
|
||||
}
|
||||
`;
|
||||
|
||||
export default DeleteWorkbookDialog;
|
||||
143
webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx
Normal file
143
webapp/app.ironcalc.com/frontend/src/components/FileBar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import styled from "@emotion/styled";
|
||||
import type { Model } from "@ironcalc/ironcalc";
|
||||
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/ironcalc";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
// import { IronCalcIcon, IronCalcLogo } from "./../icons";
|
||||
import { FileMenu } from "./FileMenu";
|
||||
import { ShareButton } from "./ShareButton";
|
||||
import { WorkbookTitle } from "./WorkbookTitle";
|
||||
import { downloadModel, shareModel } from "./rpc";
|
||||
import { updateNameSelectedWorkbook } from "./storage";
|
||||
|
||||
export function FileBar(properties: {
|
||||
model: Model;
|
||||
newModel: () => void;
|
||||
setModel: (key: string) => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||
const [toast, setToast] = useState(false);
|
||||
return (
|
||||
<FileBarWrapper>
|
||||
<StyledDesktopLogo />
|
||||
<StyledIronCalcIcon />
|
||||
<Divider />
|
||||
<FileMenu
|
||||
newModel={properties.newModel}
|
||||
setModel={properties.setModel}
|
||||
onModelUpload={properties.onModelUpload}
|
||||
onDownload={async () => {
|
||||
const model = properties.model;
|
||||
const bytes = model.toBytes();
|
||||
const fileName = model.getName();
|
||||
await downloadModel(bytes, fileName);
|
||||
}}
|
||||
onDelete={properties.onDelete}
|
||||
/>
|
||||
<HelpButton
|
||||
onClick={() => window.open("https://docs.ironcalc.com", "_blank")}
|
||||
>
|
||||
Help
|
||||
</HelpButton>
|
||||
<WorkbookTitle
|
||||
name={properties.model.getName()}
|
||||
onNameChange={(name) => {
|
||||
properties.model.setName(name);
|
||||
updateNameSelectedWorkbook(properties.model, name);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="text"
|
||||
style={{ position: "absolute", left: -9999, top: -9999 }}
|
||||
/>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
{toast ? (
|
||||
<Toast>
|
||||
<CircleCheck style={{ width: 12 }} />
|
||||
<span
|
||||
style={{ marginLeft: 8, marginRight: 12, fontFamily: "Inter" }}
|
||||
>
|
||||
URL copied to clipboard
|
||||
</span>
|
||||
</Toast>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<ShareButton
|
||||
onClick={async () => {
|
||||
const model = properties.model;
|
||||
const bytes = model.toBytes();
|
||||
const fileName = model.getName();
|
||||
const hash = await shareModel(bytes, fileName);
|
||||
const value = `${location.origin}/?model=${hash}`;
|
||||
if (hiddenInputRef.current) {
|
||||
hiddenInputRef.current.value = value;
|
||||
hiddenInputRef.current.select();
|
||||
document.execCommand("copy");
|
||||
setToast(true);
|
||||
setTimeout(() => setToast(false), 5000);
|
||||
}
|
||||
console.log(value);
|
||||
}}
|
||||
/>
|
||||
</FileBarWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDesktopLogo = styled(IronCalcLogo)`
|
||||
width: 120px;
|
||||
margin-left: 12px;
|
||||
@media (max-width: 769px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIronCalcIcon = styled(IronCalcIcon)`
|
||||
width: 36px;
|
||||
margin-left: 10px;
|
||||
@media (min-width: 769px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const HelpButton = styled("div")`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-family: Inter;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
`;
|
||||
|
||||
const Toast = styled("div")`
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: #9e9e9e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Divider = styled("div")`
|
||||
margin: 0px 8px 0px 16px;
|
||||
height: 12px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
`;
|
||||
|
||||
const FileBarWrapper = styled("div")`
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
217
webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx
Normal file
217
webapp/app.ironcalc.com/frontend/src/components/FileMenu.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||
import { Check, FileDown, FileUp, Plus, Trash2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
|
||||
import UploadFileDialog from "./UploadFileDialog";
|
||||
import { getModelsMetadata, getSelectedUuid } from "./storage";
|
||||
|
||||
export function FileMenu(props: {
|
||||
newModel: () => void;
|
||||
setModel: (key: string) => void;
|
||||
onDownload: () => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
||||
const anchorElement = useRef<HTMLDivElement>(null);
|
||||
const models = getModelsMetadata();
|
||||
const uuids = Object.keys(models);
|
||||
const selectedUuid = getSelectedUuid();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const elements = [];
|
||||
for (const uuid of uuids) {
|
||||
elements.push(
|
||||
<MenuItemWrapper
|
||||
key={uuid}
|
||||
onClick={() => {
|
||||
props.setModel(uuid);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIndicator>
|
||||
{uuid === selectedUuid ? <StyledCheck /> : ""}
|
||||
</CheckIndicator>
|
||||
<MenuItemText
|
||||
style={{
|
||||
maxWidth: "240px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{models[uuid]}
|
||||
</MenuItemText>
|
||||
</MenuItemWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileMenuWrapper
|
||||
onClick={(): void => setMenuOpen(true)}
|
||||
ref={anchorElement}
|
||||
>
|
||||
File
|
||||
</FileMenuWrapper>
|
||||
<Menu
|
||||
open={isMenuOpen}
|
||||
onClose={(): void => setMenuOpen(false)}
|
||||
anchorEl={anchorElement.current}
|
||||
sx={{
|
||||
"& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" },
|
||||
"& .MuiList-root": { padding: "0" },
|
||||
}}
|
||||
|
||||
// anchorOrigin={properties.anchorOrigin}
|
||||
>
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
props.newModel();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<StyledPlus />
|
||||
<MenuItemText>New</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
setImportMenuOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<StyledFileUp />
|
||||
<MenuItemText>Import</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper>
|
||||
<StyledFileDown />
|
||||
<MenuItemText onClick={props.onDownload}>
|
||||
Download (.xlsx)
|
||||
</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<StyledTrash />
|
||||
<MenuItemText>Delete workbook</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuDivider />
|
||||
{elements}
|
||||
</Menu>
|
||||
<Modal
|
||||
open={isImportMenuOpen}
|
||||
onClose={() => {
|
||||
setImportMenuOpen(false);
|
||||
}}
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description"
|
||||
>
|
||||
<>
|
||||
<UploadFileDialog
|
||||
onClose={() => {
|
||||
setImportMenuOpen(false);
|
||||
}}
|
||||
onModelUpload={props.onModelUpload}
|
||||
/>
|
||||
</>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isDeleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<>
|
||||
<DeleteWorkbookDialog
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={props.onDelete}
|
||||
workbookName={selectedUuid ? models[selectedUuid] : ""}
|
||||
/>
|
||||
</>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledPlus = styled(Plus)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
const StyledFileDown = styled(FileDown)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
const StyledFileUp = styled(FileUp)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
const StyledTrash = styled(Trash2)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
const StyledCheck = styled(Check)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
const MenuDivider = styled("div")`
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-top: 1px solid #eeeeee;
|
||||
`;
|
||||
|
||||
const MenuItemText = styled("div")`
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const MenuItemWrapper = styled(MenuItem)`
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
font-size: 14px;
|
||||
width: calc(100% - 8px);
|
||||
min-width: 172px;
|
||||
margin: 0px 4px;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
const FileMenuWrapper = styled("div")`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-family: Inter;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckIndicator = styled("span")`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Share2 } from "lucide-react";
|
||||
|
||||
export function ShareButton(properties: { onClick: () => void }) {
|
||||
const { onClick } = properties;
|
||||
return (
|
||||
<Wrapper onClick={onClick} onKeyDown={() => {}}>
|
||||
<Share2 style={{ width: "16px", height: "16px", marginRight: "10px" }} />
|
||||
<span>Share</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
background: #f2994a;
|
||||
padding: 0px 10px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: "Inter";
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: #d68742;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Dialog, styled } from "@mui/material";
|
||||
import { BookOpen, FileUp, X } from "lucide-react";
|
||||
import { type DragEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
function UploadFileDialog(properties: {
|
||||
onClose: () => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const crossRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { onModelUpload } = properties;
|
||||
|
||||
useEffect(() => {
|
||||
if (crossRef.current) {
|
||||
crossRef.current.focus();
|
||||
}
|
||||
return () => {
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "none";
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
properties.onClose();
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setHover(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
setHover(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setHover(false);
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const dt = event.dataTransfer;
|
||||
const items = dt.items;
|
||||
|
||||
if (items) {
|
||||
// Use DataTransferItemList to access the file(s)
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
// If dropped items aren't files, skip them
|
||||
if (items[i].kind === "file") {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const files = dt.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (file: File) => {
|
||||
setMessage(`Uploading ${file.name}...`);
|
||||
|
||||
// Read the file as ArrayBuffer
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
await onModelUpload(reader.result as ArrayBuffer, file.name);
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
console.log("error", e);
|
||||
setMessage(`${e}`);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={true}
|
||||
tabIndex={0}
|
||||
role="dialog"
|
||||
onClose={handleClose}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UploadTitle>
|
||||
<span style={{ flexGrow: 2, marginLeft: 12 }}>
|
||||
Import an .xlsx file
|
||||
</span>
|
||||
<Cross
|
||||
style={{ marginRight: 12 }}
|
||||
onClick={handleClose}
|
||||
title="Close Dialog"
|
||||
ref={crossRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => event.key === "Enter" && properties.onClose()}
|
||||
>
|
||||
<X />
|
||||
</Cross>
|
||||
</UploadTitle>
|
||||
{message === "" ? (
|
||||
<DropZone
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragExit={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{!hover ? (
|
||||
<>
|
||||
<div style={{ flexGrow: 2 }} />
|
||||
<div>
|
||||
<FileUp
|
||||
style={{
|
||||
width: 16,
|
||||
color: "#EFAA6D",
|
||||
backgroundColor: "#F2994A1A",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<span style={{ color: "#333333" }}>
|
||||
Drag and drop a file here or{" "}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
const files = event.target.files;
|
||||
if (files) {
|
||||
for (const file of files) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DocLink
|
||||
onClick={() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
click to browse
|
||||
</DocLink>
|
||||
</div>
|
||||
<div style={{ flexGrow: 2 }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ flexGrow: 2 }} />
|
||||
<div>Drop file here</div>
|
||||
<div style={{ flexGrow: 2 }} />
|
||||
</>
|
||||
)}
|
||||
</DropZone>
|
||||
) : (
|
||||
<DropZone>
|
||||
<>
|
||||
<div style={{ flexGrow: 2 }} />
|
||||
<div>{message}</div>
|
||||
<div style={{ flexGrow: 2 }} />
|
||||
</>
|
||||
</DropZone>
|
||||
)}
|
||||
|
||||
<UploadFooter>
|
||||
<BookOpen />
|
||||
<UploadFooterLink
|
||||
href="https://docs.ironcalc.com/web-application/importing-files.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more about importing files into IronCalc
|
||||
</UploadFooterLink>
|
||||
</UploadFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const DialogWrapper = styled(Dialog)`
|
||||
.MuiDialog-paper {
|
||||
width: 460px;
|
||||
}
|
||||
.MuiBackdrop-root {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const Cross = styled("div")`
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const DocLink = styled("span")`
|
||||
color: #f2994a;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const UploadTitle = styled("div")`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: Inter;
|
||||
`;
|
||||
|
||||
const UploadFooter = styled("div")`
|
||||
height: 44px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
color: #757575;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: Inter;
|
||||
gap: 8px;
|
||||
padding: 0px 12px;
|
||||
svg {
|
||||
max-width: 16px;
|
||||
`;
|
||||
|
||||
const UploadFooterLink = styled("a")`
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const DropZone = styled("div")`
|
||||
flex-grow: 2;
|
||||
border-radius: 10px;
|
||||
height: 160px;
|
||||
text-align: center;
|
||||
margin: 12px;
|
||||
color: #aaa;
|
||||
font-family: Inter;
|
||||
cursor: pointer;
|
||||
background-color: #faebd7;
|
||||
border: 1px dashed #efaa6d;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(242, 153, 74, 0.08) 0%,
|
||||
rgba(242, 153, 74, 0) 100%
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
vertical-align: center;
|
||||
gap: 16px;
|
||||
transition: 0.2s ease-in-out;
|
||||
&:hover {
|
||||
border: 1px dashed #f2994a;
|
||||
transition: 0.2s ease-in-out;
|
||||
gap: 8px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(242, 153, 74, 0.12) 0%,
|
||||
rgba(242, 153, 74, 0) 100%
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
export default UploadFileDialog;
|
||||
@@ -0,0 +1,104 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function WorkbookTitle(props: {
|
||||
name: string;
|
||||
onNameChange: (name: string) => void;
|
||||
}) {
|
||||
const [width, setWidth] = useState(0);
|
||||
const [value, setValue] = useState(props.name);
|
||||
const mirrorDivRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setValue(event.target.value);
|
||||
if (mirrorDivRef.current) {
|
||||
setWidth(mirrorDivRef.current.scrollWidth);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mirrorDivRef.current) {
|
||||
setWidth(mirrorDivRef.current.scrollWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(props.name);
|
||||
}, [props.name]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
textAlign: "center",
|
||||
transform: "translateX(-50%)",
|
||||
// height: "60px",
|
||||
// lineHeight: "60px",
|
||||
padding: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "700",
|
||||
fontFamily: "Inter",
|
||||
width,
|
||||
}}
|
||||
>
|
||||
<TitleWrapper
|
||||
value={value}
|
||||
rows={1}
|
||||
onChange={handleChange}
|
||||
onBlur={(event) => {
|
||||
props.onNameChange(event.target.value);
|
||||
}}
|
||||
style={{ width: width }}
|
||||
spellCheck="false"
|
||||
>
|
||||
{value}
|
||||
</TitleWrapper>
|
||||
<div
|
||||
ref={mirrorDivRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-9999px",
|
||||
left: "-9999px",
|
||||
whiteSpace: "pre-wrap",
|
||||
textWrap: "nowrap",
|
||||
visibility: "hidden",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
padding: "inherit",
|
||||
border: "inherit",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TitleWrapper = styled("textarea")`
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 4px;
|
||||
padding: inherit;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
resize: none;
|
||||
text-wrap: nowrap;
|
||||
border: none;
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
&:focus {
|
||||
border: 1px solid grey;
|
||||
}
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
78
webapp/app.ironcalc.com/frontend/src/components/rpc.ts
Normal file
78
webapp/app.ironcalc.com/frontend/src/components/rpc.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export async function uploadFile(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
fileName: string,
|
||||
): Promise<Blob> {
|
||||
// Fetch request to upload the file
|
||||
const response = await fetch(`/api/upload/${fileName}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
body: arrayBuffer,
|
||||
});
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function get_model(modelHash: string): Promise<Uint8Array> {
|
||||
return new Uint8Array(
|
||||
await (await fetch(`/api/model/${modelHash}`)).arrayBuffer(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function get_documentation_model(
|
||||
filename: string,
|
||||
): Promise<Uint8Array> {
|
||||
return new Uint8Array(
|
||||
await (await fetch(`/models/${filename}.ic`)).arrayBuffer(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadModel(bytes: Uint8Array, fileName: string) {
|
||||
const response = await fetch("/api/download", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
body: bytes,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a link element and trigger a download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
|
||||
// Use the same filename or change as needed
|
||||
a.download = `${fileName}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Clean up
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
}
|
||||
|
||||
export async function shareModel(
|
||||
bytes: Uint8Array,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
const response = await fetch("/api/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
body: bytes,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
129
webapp/app.ironcalc.com/frontend/src/components/storage.ts
Normal file
129
webapp/app.ironcalc.com/frontend/src/components/storage.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Model } from "@ironcalc/ironcalc";
|
||||
import { base64ToBytes, bytesToBase64 } from "./util";
|
||||
|
||||
const MAX_WORKBOOKS = 50;
|
||||
|
||||
type ModelsMetadata = Record<string, string>;
|
||||
|
||||
export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
||||
const uuid = localStorage.getItem("selected");
|
||||
if (uuid) {
|
||||
const modelsJson = localStorage.getItem("models");
|
||||
if (modelsJson) {
|
||||
try {
|
||||
const models = JSON.parse(modelsJson);
|
||||
models[uuid] = newName;
|
||||
localStorage.setItem("models", JSON.stringify(models));
|
||||
} catch (e) {
|
||||
console.warn("Failed saving new name");
|
||||
}
|
||||
}
|
||||
const modeBytes = model.toBytes();
|
||||
localStorage.setItem(uuid, bytesToBase64(modeBytes));
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelsMetadata(): ModelsMetadata {
|
||||
let modelsJson = localStorage.getItem("models");
|
||||
if (!modelsJson) {
|
||||
modelsJson = "{}";
|
||||
}
|
||||
return JSON.parse(modelsJson);
|
||||
}
|
||||
|
||||
// Pick a different name Workbook{N} where N = 1, 2, 3
|
||||
function getNewName(existingNames: string[]): string {
|
||||
const baseName = "Workbook";
|
||||
let index = 1;
|
||||
while (index < MAX_WORKBOOKS) {
|
||||
const name = `${baseName}${index}`;
|
||||
index += 1;
|
||||
if (!existingNames.includes(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
// FIXME: Too many workbooks?
|
||||
return "Workbook-Infinity";
|
||||
}
|
||||
|
||||
export function createNewModel(): Model {
|
||||
const models = getModelsMetadata();
|
||||
const name = getNewName(Object.values(models));
|
||||
|
||||
const model = new Model(name, "en", "UTC");
|
||||
const uuid = crypto.randomUUID();
|
||||
localStorage.setItem("selected", uuid);
|
||||
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
|
||||
|
||||
models[uuid] = name;
|
||||
localStorage.setItem("models", JSON.stringify(models));
|
||||
return model;
|
||||
}
|
||||
|
||||
export function loadModelFromStorageOrCreate(): Model {
|
||||
const uuid = localStorage.getItem("selected");
|
||||
if (uuid) {
|
||||
// We try to load the selected model
|
||||
const modelBytesString = localStorage.getItem(uuid);
|
||||
if (modelBytesString) {
|
||||
return Model.from_bytes(base64ToBytes(modelBytesString));
|
||||
}
|
||||
// If it doesn't exist we create one at that uuid
|
||||
const newModel = new Model("Workbook1", "en", "UTC");
|
||||
localStorage.setItem("selected", uuid);
|
||||
localStorage.setItem(uuid, bytesToBase64(newModel.toBytes()));
|
||||
return newModel;
|
||||
}
|
||||
// If there was no selected model we create a new one
|
||||
return createNewModel();
|
||||
}
|
||||
|
||||
export function saveSelectedModelInStorage(model: Model) {
|
||||
const uuid = localStorage.getItem("selected");
|
||||
if (uuid) {
|
||||
const modeBytes = model.toBytes();
|
||||
localStorage.setItem(uuid, bytesToBase64(modeBytes));
|
||||
}
|
||||
}
|
||||
|
||||
export function saveModelToStorage(model: Model) {
|
||||
const uuid = crypto.randomUUID();
|
||||
localStorage.setItem("selected", uuid);
|
||||
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
|
||||
let modelsJson = localStorage.getItem("models");
|
||||
if (!modelsJson) {
|
||||
modelsJson = "{}";
|
||||
}
|
||||
const models = JSON.parse(modelsJson);
|
||||
models[uuid] = model.getName();
|
||||
localStorage.setItem("models", JSON.stringify(models));
|
||||
}
|
||||
|
||||
export function selectModelFromStorage(uuid: string): Model | null {
|
||||
localStorage.setItem("selected", uuid);
|
||||
const modelBytesString = localStorage.getItem(uuid);
|
||||
if (modelBytesString) {
|
||||
return Model.from_bytes(base64ToBytes(modelBytesString));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSelectedUuid(): string | null {
|
||||
return localStorage.getItem("selected");
|
||||
}
|
||||
|
||||
export function deleteSelectedModel(): Model | null {
|
||||
const uuid = localStorage.getItem("selected");
|
||||
if (!uuid) {
|
||||
return null;
|
||||
}
|
||||
localStorage.removeItem(uuid);
|
||||
const metadata = getModelsMetadata();
|
||||
delete metadata[uuid];
|
||||
localStorage.setItem("models", JSON.stringify(metadata));
|
||||
const uuids = Object.keys(metadata);
|
||||
if (uuids.length === 0) {
|
||||
return createNewModel();
|
||||
}
|
||||
return selectModelFromStorage(uuids[0]);
|
||||
}
|
||||
18
webapp/app.ironcalc.com/frontend/src/components/util.ts
Normal file
18
webapp/app.ironcalc.com/frontend/src/components/util.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function base64ToBytes(base64: string): Uint8Array {
|
||||
// const binString = atob(base64);
|
||||
// return Uint8Array.from(binString, (m) => m.codePointAt(0));
|
||||
|
||||
return new Uint8Array(
|
||||
atob(base64)
|
||||
.split("")
|
||||
.map((c) => c.charCodeAt(0)),
|
||||
);
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
const binString = Array.from(bytes, (byte) =>
|
||||
String.fromCodePoint(byte),
|
||||
).join("");
|
||||
// btoa(String.fromCharCode(...bytes));
|
||||
return btoa(binString);
|
||||
}
|
||||
16
webapp/app.ironcalc.com/frontend/src/fonts.css
Normal file
16
webapp/app.ironcalc.com/frontend/src/fonts.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* inter-regular - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("fonts/inter-v13-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-600 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url("fonts/inter-v13-latin-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
5
webapp/app.ironcalc.com/frontend/src/index.css
Normal file
5
webapp/app.ironcalc.com/frontend/src/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
body {
|
||||
inset: 0px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
12
webapp/app.ironcalc.com/frontend/src/main.tsx
Normal file
12
webapp/app.ironcalc.com/frontend/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import "./fonts.css";
|
||||
|
||||
// biome-ignore lint: we know the 'root' element exists.
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
1
webapp/app.ironcalc.com/frontend/src/vite-env.d.ts
vendored
Normal file
1
webapp/app.ironcalc.com/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
webapp/app.ironcalc.com/frontend/tsconfig.app.json
Normal file
26
webapp/app.ironcalc.com/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
webapp/app.ironcalc.com/frontend/tsconfig.json
Normal file
7
webapp/app.ironcalc.com/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
webapp/app.ironcalc.com/frontend/tsconfig.node.json
Normal file
24
webapp/app.ironcalc.com/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
webapp/app.ironcalc.com/frontend/vite.config.ts
Normal file
14
webapp/app.ironcalc.com/frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), svgr()],
|
||||
server: {
|
||||
fs: {
|
||||
// Allow serving files from one level up to the project root
|
||||
allow: ['../../../'],
|
||||
},
|
||||
},
|
||||
})
|
||||
1
webapp/app.ironcalc.com/server/.gitignore
vendored
Normal file
1
webapp/app.ironcalc.com/server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/*
|
||||
2705
webapp/app.ironcalc.com/server/Cargo.lock
generated
Normal file
2705
webapp/app.ironcalc.com/server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
webapp/app.ironcalc.com/server/Cargo.toml
Normal file
14
webapp/app.ironcalc.com/server/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ironcalc_server"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rocket = "0.5"
|
||||
rand = "0.8"
|
||||
ironcalc = { path = "../../../xlsx/"}
|
||||
|
||||
[dependencies.rocket_db_pools]
|
||||
version = "0.2.0"
|
||||
features = ["sqlx_sqlite"]
|
||||
|
||||
7
webapp/app.ironcalc.com/server/README.md
Normal file
7
webapp/app.ironcalc.com/server/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# IronCalc AppServer
|
||||
|
||||
This is the Application server deployed at https://app.ironcalc.com
|
||||
|
||||
It is a simple Rocket server. It is assumed to run alongside a file-server
|
||||
|
||||
All /api/ RPCs will go to this server
|
||||
2
webapp/app.ironcalc.com/server/Rocket.toml
Normal file
2
webapp/app.ironcalc.com/server/Rocket.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[default.databases.ironcalc]
|
||||
url = "ironcalc.sqlite"
|
||||
1
webapp/app.ironcalc.com/server/init_db.sql
Normal file
1
webapp/app.ironcalc.com/server/init_db.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE TABLE models (hash TEXT, bytes BLOB);
|
||||
BIN
webapp/app.ironcalc.com/server/ironcalc.sqlite
Normal file
BIN
webapp/app.ironcalc.com/server/ironcalc.sqlite
Normal file
Binary file not shown.
48
webapp/app.ironcalc.com/server/src/database.rs
Normal file
48
webapp/app.ironcalc.com/server/src/database.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::io;
|
||||
|
||||
use rocket_db_pools::Connection;
|
||||
|
||||
use rocket_db_pools::{sqlx, Database};
|
||||
|
||||
#[derive(Database)]
|
||||
#[database("ironcalc")]
|
||||
pub struct IronCalcDB(sqlx::SqlitePool);
|
||||
|
||||
pub async fn get_model_list_from_db(mut db: Connection<IronCalcDB>) -> Result<Vec<String>, io::Error> {
|
||||
let row: Vec<(String, )> = sqlx::query_as("SELECT * FROM models")
|
||||
.fetch_all(&mut **db)
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(row.into_iter().map(|s| s.0).collect())
|
||||
}
|
||||
|
||||
pub async fn add_model(
|
||||
mut db: Connection<IronCalcDB>,
|
||||
hash: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), io::Error> {
|
||||
sqlx::query("INSERT INTO models (hash, bytes) VALUES (?, ?)")
|
||||
.bind(hash)
|
||||
.bind(bytes)
|
||||
.execute(&mut **db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Failed to save to the database: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn select_model(
|
||||
mut db: Connection<IronCalcDB>,
|
||||
hash: &str,
|
||||
) -> Result<Vec<u8>, io::Error> {
|
||||
let row: (Vec<u8>,) = sqlx::query_as("SELECT bytes FROM models WHERE hash = ?")
|
||||
.bind(hash)
|
||||
.fetch_one(&mut **db)
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(row.0)
|
||||
}
|
||||
38
webapp/app.ironcalc.com/server/src/id.rs
Normal file
38
webapp/app.ironcalc.com/server/src/id.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use rand::{rngs::StdRng, Rng, SeedableRng};
|
||||
const CHARS: [char; 64] = [
|
||||
'_', '!', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g',
|
||||
'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
|
||||
'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
|
||||
];
|
||||
|
||||
fn random(size: usize) -> Vec<u8> {
|
||||
let mut rng = StdRng::from_entropy();
|
||||
let mut result: Vec<u8> = vec![0; size];
|
||||
|
||||
rng.fill(&mut result[..]);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn new_id() -> String {
|
||||
let size = 15;
|
||||
let mask = CHARS.len() - 1;
|
||||
let step: usize = 5;
|
||||
let mut id = String::new();
|
||||
|
||||
loop {
|
||||
let bytes = random(step);
|
||||
|
||||
for &byte in &bytes {
|
||||
let byte = byte as usize & mask;
|
||||
|
||||
id.push(CHARS[byte]);
|
||||
|
||||
if id.len() >= size + 2 {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
id.push('-');
|
||||
}
|
||||
}
|
||||
133
webapp/app.ironcalc.com/server/src/main.rs
Normal file
133
webapp/app.ironcalc.com/server/src/main.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
mod database;
|
||||
mod id;
|
||||
|
||||
use std::io::{self, BufWriter, Cursor, Write};
|
||||
|
||||
use database::{add_model, get_model_list_from_db, select_model, IronCalcDB};
|
||||
use ironcalc::base::Model as IModel;
|
||||
use ironcalc::export::save_xlsx_to_writer;
|
||||
use ironcalc::import::load_from_xlsx_bytes;
|
||||
use rocket::data::{Data, ToByteUnit};
|
||||
use rocket::http::{ContentType, Header};
|
||||
use rocket::response::Responder;
|
||||
|
||||
const MAX_SIZE_MB: u8 = 20;
|
||||
|
||||
use rocket_db_pools::{Connection, Database};
|
||||
|
||||
#[derive(Responder)]
|
||||
struct FileResponder {
|
||||
inner: Vec<u8>,
|
||||
content_type: ContentType,
|
||||
disposition: Header<'static>,
|
||||
}
|
||||
|
||||
/// Return an xlsx version of the app.
|
||||
#[post("/api/download", data = "<data>")]
|
||||
async fn download(data: Data<'_>) -> io::Result<FileResponder> {
|
||||
println!("Download xlsx");
|
||||
|
||||
let bytes = data
|
||||
.open(MAX_SIZE_MB.megabytes())
|
||||
.into_bytes()
|
||||
.await
|
||||
.unwrap();
|
||||
if !bytes.is_complete() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"The file was not fully uploaded",
|
||||
));
|
||||
};
|
||||
|
||||
let model = IModel::from_bytes(&bytes).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("Error creating model, '{e}'"))
|
||||
})?;
|
||||
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
{
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
let mut writer = BufWriter::new(cursor);
|
||||
save_xlsx_to_writer(&model, &mut writer).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("Error saving model: '{e}'"))
|
||||
})?;
|
||||
writer.flush().unwrap();
|
||||
}
|
||||
|
||||
let content_type = ContentType::new(
|
||||
"application",
|
||||
"vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
|
||||
let disposition = Header::new(
|
||||
"Content-Disposition".to_string(),
|
||||
"attachment; filename=\"data.xlsx\"".to_string(),
|
||||
);
|
||||
|
||||
println!("Download: success. ");
|
||||
|
||||
Ok(FileResponder {
|
||||
inner: buffer,
|
||||
content_type,
|
||||
disposition,
|
||||
})
|
||||
}
|
||||
|
||||
/// Saves the model on a file called
|
||||
#[post("/api/share", data = "<data>")]
|
||||
async fn share(db: Connection<IronCalcDB>, data: Data<'_>) -> io::Result<String> {
|
||||
println!("start share");
|
||||
let hash = id::new_id();
|
||||
let bytes = data.open(MAX_SIZE_MB.megabytes()).into_bytes().await?;
|
||||
if !bytes.is_complete() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"file was not fully uploaded",
|
||||
));
|
||||
}
|
||||
add_model(db, &hash, &bytes).await?;
|
||||
println!("done share: '{}'", hash);
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
#[get("/api/model/<hash>")]
|
||||
async fn get_model(db: Connection<IronCalcDB>, hash: &str) -> io::Result<Vec<u8>> {
|
||||
let bytes = select_model(db, hash).await.unwrap();
|
||||
println!("Select model: '{}'", hash);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[get("/api/list")]
|
||||
async fn get_model_list(db: Connection<IronCalcDB>) -> io::Result<String> {
|
||||
let model_list = get_model_list_from_db(db).await.unwrap();
|
||||
println!("Model list: '{:?}'", model_list);
|
||||
Ok(model_list.join(","))
|
||||
}
|
||||
|
||||
#[post("/api/upload/<name>", data = "<data>")]
|
||||
async fn upload(data: Data<'_>, name: &str) -> io::Result<Vec<u8>> {
|
||||
println!("start upload");
|
||||
let bytes = data.open(MAX_SIZE_MB.megabytes()).into_bytes().await?;
|
||||
if !bytes.is_complete() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"file was not fully uploaded",
|
||||
));
|
||||
}
|
||||
let workbook = load_from_xlsx_bytes(&bytes, name.trim_end_matches(".xlsx"), "en", "UTC")
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("Error loading model: '{e}'")))?;
|
||||
let model = IModel::from_workbook(workbook).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("Error creating model: '{e}'"))
|
||||
})?;
|
||||
println!("end upload");
|
||||
Ok(model.to_bytes())
|
||||
}
|
||||
|
||||
#[launch]
|
||||
fn rocket() -> _ {
|
||||
rocket::build()
|
||||
.attach(IronCalcDB::init())
|
||||
.mount("/", routes![upload, download, share, get_model, get_model_list])
|
||||
}
|
||||
Reference in New Issue
Block a user