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:
Nicolás Hatcher
2025-01-07 18:17:06 +01:00
committed by Nicolás Hatcher Andrés
parent 378f8351d3
commit 8215cfc9fb
121 changed files with 7997 additions and 1347 deletions

View 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?

View 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
```

View File

@@ -0,0 +1,19 @@
{
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"correctness": { "noUnusedImports": "error" }
}
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2
},
"css": {
"formatter": {
"enabled": true
}
}
}

View 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>

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,10 @@
#root {
position: absolute;
inset: 0px;
margin: 0px;
border: none;
}
html,
body {
overscroll-behavior: none;
}

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

View File

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

View 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;
`;

View 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;
`;

View File

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

View File

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

View File

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

View 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();
}

View 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]);
}

View 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);
}

View 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+ */
}

View File

@@ -0,0 +1,5 @@
body {
inset: 0px;
margin: 0;
padding: 0;
}

View 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>,
);

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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: ['../../../'],
},
},
})