update: add leftbar to app
This commit is contained in:
@@ -2,6 +2,7 @@ import "./App.css";
|
||||
import styled from "@emotion/styled";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileBar } from "./components/FileBar";
|
||||
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
|
||||
import {
|
||||
get_documentation_model,
|
||||
get_model,
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
} from "./components/rpc";
|
||||
import {
|
||||
createNewModel,
|
||||
deleteModelByUuid,
|
||||
deleteSelectedModel,
|
||||
// getModelsMetadata,
|
||||
// getSelectedUuid,
|
||||
loadModelFromStorageOrCreate,
|
||||
saveModelToStorage,
|
||||
saveSelectedModelInStorage,
|
||||
@@ -21,6 +25,7 @@ import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook";
|
||||
|
||||
function App() {
|
||||
const [model, setModel] = useState<Model | null>(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function start() {
|
||||
@@ -79,48 +84,80 @@ function App() {
|
||||
// We could use context for model, but the problem is that it should initialized to null.
|
||||
// Passing the property down makes sure it is always defined.
|
||||
|
||||
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);
|
||||
|
||||
// Handlers for model changes that also update our models state
|
||||
const handleNewModel = () => {
|
||||
const newModel = createNewModel();
|
||||
setModel(newModel);
|
||||
}}
|
||||
newModel={() => {
|
||||
setModel(createNewModel());
|
||||
}}
|
||||
setModel={(uuid: string) => {
|
||||
};
|
||||
|
||||
const handleSetModel = (uuid: string) => {
|
||||
const newModel = selectModelFromStorage(uuid);
|
||||
if (newModel) {
|
||||
setModel(newModel);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
};
|
||||
|
||||
const handleDeleteModel = () => {
|
||||
const newModel = deleteSelectedModel();
|
||||
if (newModel) {
|
||||
setModel(newModel);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModelByUuid = (uuid: string) => {
|
||||
const newModel = deleteModelByUuid(uuid);
|
||||
if (newModel) {
|
||||
setModel(newModel);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<LeftDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
newModel={handleNewModel}
|
||||
setModel={handleSetModel}
|
||||
onDelete={handleDeleteModelByUuid}
|
||||
/>
|
||||
|
||||
<MainContent isDrawerOpen={isDrawerOpen}>
|
||||
<FileBar
|
||||
model={model}
|
||||
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
|
||||
const blob = await uploadFile(arrayBuffer, fileName);
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const newModel = Model.from_bytes(bytes);
|
||||
saveModelToStorage(newModel);
|
||||
setModel(newModel);
|
||||
}}
|
||||
newModel={handleNewModel}
|
||||
setModel={handleSetModel}
|
||||
onDelete={handleDeleteModel}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
setIsDrawerOpen={setIsDrawerOpen}
|
||||
/>
|
||||
<IronCalc model={model} />
|
||||
</Wrapper>
|
||||
</MainContent>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
const AppContainer = styled("div")`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const MainContent = styled("div")<{ isDrawerOpen: boolean }>`
|
||||
margin-left: ${({ isDrawerOpen }) => (isDrawerOpen ? "0px" : "-264px")};
|
||||
transition: margin-left 0.3s ease;
|
||||
width: ${({ isDrawerOpen }) =>
|
||||
isDrawerOpen ? "calc(100% - 264px)" : "100%"};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const Loading = styled("div")`
|
||||
|
||||
@@ -12,19 +12,9 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "blur(2px)";
|
||||
}
|
||||
if (deleteButtonRef.current) {
|
||||
deleteButtonRef.current.focus();
|
||||
}
|
||||
return () => {
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "none";
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import styled from "@emotion/styled";
|
||||
import type { Model } from "@ironcalc/workbook";
|
||||
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
|
||||
import { Button, IconButton } from "@mui/material";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { FileMenu } from "./FileMenu";
|
||||
import { HelpMenu } from "./HelpMenu";
|
||||
import { DesktopMenu, MobileMenu } from "./FileMenu";
|
||||
import { ShareButton } from "./ShareButton";
|
||||
import ShareWorkbookDialog from "./ShareWorkbookDialog";
|
||||
import { WorkbookTitle } from "./WorkbookTitle";
|
||||
@@ -30,6 +30,8 @@ export function FileBar(properties: {
|
||||
setModel: (key: string) => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
isDrawerOpen: boolean;
|
||||
setIsDrawerOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const spacerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,24 +47,49 @@ export function FileBar(properties: {
|
||||
}
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<FileBarWrapper>
|
||||
<StyledDesktopLogo />
|
||||
<StyledIronCalcIcon />
|
||||
<Divider />
|
||||
<FileMenu
|
||||
newModel={properties.newModel}
|
||||
setModel={properties.setModel}
|
||||
onModelUpload={properties.onModelUpload}
|
||||
onDownload={async () => {
|
||||
// Common handler functions for both menu types
|
||||
const handleDownload = async () => {
|
||||
const model = properties.model;
|
||||
const bytes = model.toBytes();
|
||||
const fileName = model.getName();
|
||||
await downloadModel(bytes, fileName);
|
||||
}}
|
||||
};
|
||||
|
||||
return (
|
||||
<FileBarWrapper>
|
||||
<DrawerButton
|
||||
$isDrawerOpen={properties.isDrawerOpen}
|
||||
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
|
||||
disableRipple
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||
</DrawerButton>
|
||||
<DesktopButtonsWrapper>
|
||||
<DesktopMenu
|
||||
newModel={properties.newModel}
|
||||
setModel={properties.setModel}
|
||||
onModelUpload={properties.onModelUpload}
|
||||
onDownload={handleDownload}
|
||||
onDelete={properties.onDelete}
|
||||
/>
|
||||
<HelpMenu />
|
||||
<FileBarButton
|
||||
disableRipple
|
||||
onClick={() => window.open("https://docs.ironcalc.com", "_blank")}
|
||||
>
|
||||
Help
|
||||
</FileBarButton>
|
||||
</DesktopButtonsWrapper>
|
||||
<MobileButtonsWrapper>
|
||||
<MobileMenu
|
||||
newModel={properties.newModel}
|
||||
setModel={properties.setModel}
|
||||
onModelUpload={properties.onModelUpload}
|
||||
onDownload={handleDownload}
|
||||
onDelete={properties.onDelete}
|
||||
/>
|
||||
</MobileButtonsWrapper>
|
||||
<Spacer ref={spacerRef} />
|
||||
<WorkbookTitleWrapper>
|
||||
<WorkbookTitle
|
||||
name={properties.model.getName()}
|
||||
@@ -88,12 +115,8 @@ export function FileBar(properties: {
|
||||
);
|
||||
}
|
||||
|
||||
// We want the workbook title to be exactly an the center of the page,
|
||||
// so we need an absolute position
|
||||
const WorkbookTitleWrapper = styled("div")`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
// The "Spacer" component occupies as much space as possible between the menu and the share button
|
||||
@@ -101,38 +124,79 @@ const Spacer = styled("div")`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledDesktopLogo = styled(IronCalcLogo)`
|
||||
width: 120px;
|
||||
margin-left: 12px;
|
||||
@media (max-width: 769px) {
|
||||
display: none;
|
||||
const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>`
|
||||
margin-left: 8px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")};
|
||||
svg {
|
||||
stroke-width: 2px;
|
||||
stroke: #757575;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIronCalcIcon = styled(IronCalcIcon)`
|
||||
width: 36px;
|
||||
margin-left: 10px;
|
||||
@media (min-width: 769px) {
|
||||
display: none;
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
&:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Divider = styled("div")`
|
||||
margin: 0px 8px 0px 16px;
|
||||
height: 12px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
`;
|
||||
|
||||
// The container must be relative positioned so we can position the title absolutely
|
||||
const FileBarWrapper = styled("div")`
|
||||
position: relative;
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const DesktopButtonsWrapper = styled("div")`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
@media (max-width: 600px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileButtonsWrapper = styled("div")`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@media (min-width: 601px) {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
const FileBarButton = styled(Button)`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
width: auto;
|
||||
padding: 4px 8px;
|
||||
font-weight: 400;
|
||||
min-width: 0px;
|
||||
text-transform: capitalize;
|
||||
color: #333333;
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
&:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
`;
|
||||
|
||||
const DialogContainer = styled("div")`
|
||||
|
||||
@@ -1,93 +1,165 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||
import { Check, FileDown, FileUp, Plus, Trash2 } from "lucide-react";
|
||||
import { Button, IconButton, Menu, MenuItem, Modal } from "@mui/material";
|
||||
import {
|
||||
ChevronRight,
|
||||
EllipsisVertical,
|
||||
FileDown,
|
||||
FileUp,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
|
||||
import UploadFileDialog from "./UploadFileDialog";
|
||||
import { getModelsMetadata, getSelectedUuid } from "./storage";
|
||||
|
||||
export function DesktopMenu(props: {
|
||||
newModel: () => void;
|
||||
setModel: (key: string) => void;
|
||||
onDownload: () => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
|
||||
const anchorElement = useRef<HTMLButtonElement>(
|
||||
null as unknown as HTMLButtonElement,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileBarButton
|
||||
onClick={(): void => setFileMenuOpen(!isFileMenuOpen)}
|
||||
ref={anchorElement}
|
||||
disableRipple
|
||||
isOpen={isFileMenuOpen}
|
||||
>
|
||||
File
|
||||
</FileBarButton>
|
||||
<FileMenu
|
||||
newModel={props.newModel}
|
||||
setModel={props.setModel}
|
||||
onDownload={props.onDownload}
|
||||
onModelUpload={props.onModelUpload}
|
||||
onDelete={props.onDelete}
|
||||
isFileMenuOpen={isFileMenuOpen}
|
||||
setFileMenuOpen={setFileMenuOpen}
|
||||
setMobileMenuOpen={() => {}}
|
||||
anchorElement={anchorElement}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileMenu(props: {
|
||||
newModel: () => void;
|
||||
setModel: (key: string) => void;
|
||||
onDownload: () => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [isMobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
|
||||
const anchorElement = useRef<HTMLButtonElement>(
|
||||
null as unknown as HTMLButtonElement,
|
||||
);
|
||||
const [fileMenuAnchorEl, setFileMenuAnchorEl] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
onClick={(): void => setMobileMenuOpen(true)}
|
||||
ref={anchorElement}
|
||||
disableRipple
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</MenuButton>
|
||||
<StyledMenu
|
||||
open={isMobileMenuOpen}
|
||||
onClose={(): void => setMobileMenuOpen(false)}
|
||||
anchorEl={anchorElement.current}
|
||||
>
|
||||
<MenuItemWrapper
|
||||
onClick={(event) => {
|
||||
setFileMenuOpen(true);
|
||||
setFileMenuAnchorEl(event.currentTarget);
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<MenuItemText>File</MenuItemText>
|
||||
<ChevronRight />
|
||||
</MenuItemWrapper>
|
||||
<MenuDivider />
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
window.open("https://docs.ironcalc.com", "_blank");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<MenuItemText>Help</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
</StyledMenu>
|
||||
<FileMenu
|
||||
newModel={props.newModel}
|
||||
setModel={props.setModel}
|
||||
onDownload={props.onDownload}
|
||||
onModelUpload={props.onModelUpload}
|
||||
onDelete={props.onDelete}
|
||||
isFileMenuOpen={isFileMenuOpen}
|
||||
setFileMenuOpen={setFileMenuOpen}
|
||||
setMobileMenuOpen={setMobileMenuOpen}
|
||||
anchorElement={anchorElement}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileMenu(props: {
|
||||
newModel: () => void;
|
||||
setModel: (key: string) => void;
|
||||
onDownload: () => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
isFileMenuOpen: boolean;
|
||||
setFileMenuOpen: (open: boolean) => void;
|
||||
setMobileMenuOpen: (open: boolean) => void;
|
||||
anchorElement: React.RefObject<HTMLButtonElement>;
|
||||
}) {
|
||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
||||
const anchorElement = useRef<HTMLButtonElement>(null);
|
||||
const models = getModelsMetadata();
|
||||
const uuids = Object.keys(models);
|
||||
const selectedUuid = getSelectedUuid();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const elements = [];
|
||||
for (const uuid of uuids) {
|
||||
elements.push(
|
||||
<MenuItemWrapper
|
||||
key={uuid}
|
||||
onClick={() => {
|
||||
props.setModel(uuid);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIndicator>
|
||||
{uuid === selectedUuid ? (
|
||||
<StyledIcon>
|
||||
<Check />
|
||||
</StyledIcon>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</CheckIndicator>
|
||||
<MenuItemText
|
||||
style={{
|
||||
maxWidth: "240px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{models[uuid]}
|
||||
</MenuItemText>
|
||||
</MenuItemWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileMenuWrapper
|
||||
type="button"
|
||||
id="file-menu-button"
|
||||
onClick={(): void => setMenuOpen(true)}
|
||||
ref={anchorElement}
|
||||
$isActive={isMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
File
|
||||
</FileMenuWrapper>
|
||||
<Menu
|
||||
open={isMenuOpen}
|
||||
onClose={(): void => setMenuOpen(false)}
|
||||
anchorEl={anchorElement.current}
|
||||
autoFocus={false}
|
||||
disableRestoreFocus={true}
|
||||
sx={{
|
||||
"& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" },
|
||||
"& .MuiList-root": { padding: "0" },
|
||||
transform: "translate(-4px, 4px)",
|
||||
<StyledMenu
|
||||
open={props.isFileMenuOpen}
|
||||
onClose={(): void => props.setFileMenuOpen(false)}
|
||||
anchorEl={props.anchorElement.current}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
slotProps={{
|
||||
list: {
|
||||
"aria-labelledby": "file-menu-button",
|
||||
tabIndex: -1,
|
||||
},
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
// To prevent closing parent menu when interacting with submenu
|
||||
onMouseLeave={() => {
|
||||
if (!isImportMenuOpen && !isDeleteDialogOpen) {
|
||||
props.setFileMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
props.newModel();
|
||||
setMenuOpen(false);
|
||||
props.setFileMenuOpen(false);
|
||||
props.setMobileMenuOpen(false);
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<StyledIcon>
|
||||
<Plus />
|
||||
@@ -97,34 +169,41 @@ export function FileMenu(props: {
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
setImportMenuOpen(true);
|
||||
setMenuOpen(false);
|
||||
props.setFileMenuOpen(false);
|
||||
props.setMobileMenuOpen(false);
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<StyledIcon>
|
||||
<FileUp />
|
||||
</StyledIcon>
|
||||
<MenuItemText>Import</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper onClick={props.onDownload}>
|
||||
<StyledIcon>
|
||||
<FileDown />
|
||||
</StyledIcon>
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
props.onDownload();
|
||||
props.setMobileMenuOpen(false);
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<StyledFileDown />
|
||||
<MenuItemText>Download (.xlsx)</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuDivider />
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setMenuOpen(false);
|
||||
props.setFileMenuOpen(false);
|
||||
props.setMobileMenuOpen(false);
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<StyledIcon>
|
||||
<Trash2 />
|
||||
</StyledIcon>
|
||||
<MenuItemText>Delete workbook</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuDivider />
|
||||
{elements}
|
||||
</Menu>
|
||||
</StyledMenu>
|
||||
<Modal
|
||||
open={isImportMenuOpen}
|
||||
onClose={() => {
|
||||
@@ -149,7 +228,7 @@ export function FileMenu(props: {
|
||||
<DeleteWorkbookDialog
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={props.onDelete}
|
||||
workbookName={selectedUuid ? models[selectedUuid] : ""}
|
||||
workbookName={selectedUuid ? models[selectedUuid]?.name || "" : ""}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
@@ -167,7 +246,55 @@ const StyledIcon = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const MenuDivider = styled.div`
|
||||
const MenuButton = styled(IconButton)`
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
svg {
|
||||
stroke-width: 2px;
|
||||
stroke: #757575;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
&:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
`;
|
||||
|
||||
const FileBarButton = styled(Button)<{ isOpen: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
width: auto;
|
||||
padding: 4px 8px;
|
||||
font-weight: 400;
|
||||
min-width: 0px;
|
||||
text-transform: capitalize;
|
||||
color: #333333;
|
||||
background-color: ${({ isOpen }) => (isOpen ? "#f2f2f2" : "none")};
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
&:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledFileDown = styled(FileDown)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
|
||||
const MenuDivider = styled("div")`
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
margin-top: 4px;
|
||||
@@ -178,6 +305,7 @@ const MenuDivider = styled.div`
|
||||
const MenuItemText = styled.div`
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const MenuItemWrapper = styled(MenuItem)`
|
||||
@@ -190,26 +318,19 @@ const MenuItemWrapper = styled(MenuItem)`
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-family: Inter;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
|
||||
border: none;
|
||||
background: none;
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
min-height: 32px;
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckIndicator = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
const StyledMenu = styled(Menu)`
|
||||
.MuiPaper-root {
|
||||
border-radius: 8px;
|
||||
padding: 4px 0px;
|
||||
},
|
||||
.MuiList-root {
|
||||
padding: 0;
|
||||
},
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import styled from "@emotion/styled";
|
||||
import WorkbookList from "./WorkbookList";
|
||||
|
||||
interface DrawerContentProps {
|
||||
setModel: (key: string) => void;
|
||||
onDelete: (uuid: string) => void;
|
||||
}
|
||||
|
||||
function DrawerContent(props: DrawerContentProps) {
|
||||
const { setModel, onDelete } = props;
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<WorkbookList setModel={setModel} onDelete={onDelete} />
|
||||
</ContentContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const ContentContainer = styled("div")`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 12px;
|
||||
height: 100%;
|
||||
overflow: scroll;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
export default DrawerContent;
|
||||
@@ -0,0 +1,71 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { BookOpen } from "lucide-react";
|
||||
|
||||
function DrawerFooter() {
|
||||
return (
|
||||
<StyledDrawerFooter>
|
||||
<FooterLink
|
||||
href="https://docs.ironcalc.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<OpenBookIcon>
|
||||
<BookOpen />
|
||||
</OpenBookIcon>
|
||||
<FooterLinkText>Documentation</FooterLinkText>
|
||||
</FooterLink>
|
||||
</StyledDrawerFooter>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDrawerFooter = styled("div")`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
justify-content: space-between;
|
||||
max-height: 60px;
|
||||
height: 60px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const FooterLink = styled("a")`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
min-width: 172px;
|
||||
border-radius: 8px;
|
||||
padding: 8px 4px 8px 8px;
|
||||
transition: gap 0.5s;
|
||||
background-color: transparent;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const OpenBookIcon = styled("div")`
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
stroke: #9e9e9e;
|
||||
}
|
||||
`;
|
||||
|
||||
const FooterLinkText = styled("div")`
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export default DrawerFooter;
|
||||
@@ -0,0 +1,61 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { IronCalcLogo } from "@ironcalc/workbook";
|
||||
import { IconButton } from "@mui/material";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface DrawerHeaderProps {
|
||||
onNewModel: () => void;
|
||||
}
|
||||
|
||||
function DrawerHeader({ onNewModel }: DrawerHeaderProps) {
|
||||
return (
|
||||
<HeaderContainer>
|
||||
<StyledDesktopLogo />
|
||||
<AddButton onClick={onNewModel} title="New workbook">
|
||||
<PlusIcon />
|
||||
</AddButton>
|
||||
</HeaderContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const HeaderContainer = styled("div")`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 8px 12px 16px;
|
||||
justify-content: space-between;
|
||||
max-height: 60px;
|
||||
min-height: 60px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const StyledDesktopLogo = styled(IronCalcLogo)`
|
||||
width: 120px;
|
||||
height: 28px;
|
||||
`;
|
||||
|
||||
const AddButton = styled(IconButton)`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
color: #333333;
|
||||
stroke-width: 2px;
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlusIcon = styled(Plus)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
`;
|
||||
|
||||
export default DrawerHeader;
|
||||
@@ -0,0 +1,51 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Drawer } from "@mui/material";
|
||||
import DrawerContent from "./DrawerContent";
|
||||
import DrawerFooter from "./DrawerFooter";
|
||||
import DrawerHeader from "./DrawerHeader";
|
||||
|
||||
interface LeftDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
newModel: () => void;
|
||||
setModel: (key: string) => void;
|
||||
onDelete: (uuid: string) => void;
|
||||
}
|
||||
|
||||
function LeftDrawer({
|
||||
open,
|
||||
onClose,
|
||||
newModel,
|
||||
setModel,
|
||||
onDelete,
|
||||
}: LeftDrawerProps) {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
variant="persistent"
|
||||
anchor="left"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<DrawerHeader onNewModel={newModel} />
|
||||
<DrawerContent setModel={setModel} onDelete={onDelete} />
|
||||
|
||||
<DrawerFooter />
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const DrawerWrapper = styled(Drawer)`
|
||||
width: 264px;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
font-family: "Inter", sans-serif;
|
||||
|
||||
.MuiDrawer-paper {
|
||||
width: 264px;
|
||||
background-color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default LeftDrawer;
|
||||
@@ -0,0 +1,379 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||
import {
|
||||
EllipsisVertical,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import DeleteWorkbookDialog from "../DeleteWorkbookDialog";
|
||||
import { downloadModel } from "../rpc";
|
||||
import {
|
||||
getModelsMetadata,
|
||||
getSelectedUuid,
|
||||
selectModelFromStorage,
|
||||
} from "../storage";
|
||||
|
||||
interface WorkbookListProps {
|
||||
setModel: (key: string) => void;
|
||||
onDelete: (uuid: string) => void;
|
||||
}
|
||||
|
||||
function WorkbookList({ setModel, onDelete }: WorkbookListProps) {
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [selectedWorkbookUuid, setSelectedWorkbookUuid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [workbookToDelete, setWorkbookToDelete] = useState<string | null>(null);
|
||||
const [intendedSelection, setIntendedSelection] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedUuid = getSelectedUuid();
|
||||
|
||||
// Clear intended selection when selectedUuid changes from outside
|
||||
useEffect(() => {
|
||||
if (intendedSelection && selectedUuid === intendedSelection) {
|
||||
setIntendedSelection(null);
|
||||
}
|
||||
}, [selectedUuid, intendedSelection]);
|
||||
|
||||
const handleMenuOpen = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
uuid: string,
|
||||
) => {
|
||||
console.log("Menu open", uuid);
|
||||
event.stopPropagation();
|
||||
setSelectedWorkbookUuid(uuid);
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
setIntendedSelection(uuid);
|
||||
setModel(uuid);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
console.log(
|
||||
"Menu closing, selectedWorkbookUuid:",
|
||||
selectedWorkbookUuid,
|
||||
"intendedSelection:",
|
||||
intendedSelection,
|
||||
);
|
||||
setMenuAnchorEl(null);
|
||||
// If we have an intended selection, make sure it's still selected
|
||||
if (intendedSelection && intendedSelection !== selectedUuid) {
|
||||
console.log("Re-selecting intended workbook:", intendedSelection);
|
||||
setModel(intendedSelection);
|
||||
}
|
||||
// Don't reset selectedWorkbookUuid here - we want to keep track of which workbook was selected
|
||||
// The selectedWorkbookUuid will be used for download/delete operations
|
||||
};
|
||||
|
||||
const handleDeleteClick = (uuid: string) => {
|
||||
console.log("Delete workbook:", uuid);
|
||||
setWorkbookToDelete(uuid);
|
||||
setIsDeleteDialogOpen(true);
|
||||
setIntendedSelection(null);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (workbookToDelete) {
|
||||
onDelete(workbookToDelete);
|
||||
setWorkbookToDelete(null);
|
||||
}
|
||||
setIsDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setWorkbookToDelete(null);
|
||||
setIsDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDownload = async (uuid: string) => {
|
||||
try {
|
||||
const model = selectModelFromStorage(uuid);
|
||||
if (model) {
|
||||
const bytes = model.toBytes();
|
||||
const fileName = model.getName();
|
||||
await downloadModel(bytes, fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to download workbook:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Group workbooks by creation date
|
||||
const groupWorkbooks = () => {
|
||||
const now = Date.now();
|
||||
const millisecondsInDay = 24 * 60 * 60 * 1000;
|
||||
const millisecondsIn30Days = 30 * millisecondsInDay;
|
||||
|
||||
const modelsCreatedToday = [];
|
||||
const modelsCreatedThisMonth = [];
|
||||
const olderModels = [];
|
||||
const modelsMetadata = getModelsMetadata();
|
||||
|
||||
for (const uuid in modelsMetadata) {
|
||||
const createdAt = modelsMetadata[uuid].createdAt;
|
||||
const age = now - createdAt;
|
||||
|
||||
if (age < millisecondsInDay) {
|
||||
modelsCreatedToday.push(uuid);
|
||||
} else if (age < millisecondsIn30Days) {
|
||||
modelsCreatedThisMonth.push(uuid);
|
||||
} else {
|
||||
olderModels.push(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each group by creation timestamp (newest first)
|
||||
const sortByNewest = (uuids: string[]) =>
|
||||
uuids.sort(
|
||||
(a, b) => modelsMetadata[b].createdAt - modelsMetadata[a].createdAt,
|
||||
);
|
||||
|
||||
return {
|
||||
modelsCreatedToday: sortByNewest(modelsCreatedToday),
|
||||
modelsCreatedThisMonth: sortByNewest(modelsCreatedThisMonth),
|
||||
olderModels: sortByNewest(olderModels),
|
||||
};
|
||||
};
|
||||
|
||||
const { modelsCreatedToday, modelsCreatedThisMonth, olderModels } =
|
||||
groupWorkbooks();
|
||||
|
||||
const renderWorkbookItem = (uuid: string) => {
|
||||
const isMenuOpen = menuAnchorEl !== null && selectedWorkbookUuid === uuid;
|
||||
const isAnyMenuOpen = menuAnchorEl !== null;
|
||||
const models = getModelsMetadata();
|
||||
return (
|
||||
<WorkbookListItem
|
||||
key={uuid}
|
||||
onClick={() => {
|
||||
// Prevent clicking on list items when any menu is open
|
||||
if (isAnyMenuOpen) {
|
||||
return;
|
||||
}
|
||||
setModel(uuid);
|
||||
}}
|
||||
selected={uuid === selectedUuid}
|
||||
disableRipple
|
||||
style={{ pointerEvents: isAnyMenuOpen ? "none" : "auto" }}
|
||||
>
|
||||
<StorageIndicator>
|
||||
<FileSpreadsheet />
|
||||
</StorageIndicator>
|
||||
<WorkbookListText>{models[uuid].name}</WorkbookListText>
|
||||
<EllipsisButton
|
||||
onClick={(e) => handleMenuOpen(e, uuid)}
|
||||
isOpen={isMenuOpen}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</EllipsisButton>
|
||||
</WorkbookListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSection = (title: string, uuids: string[]) => {
|
||||
if (uuids.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionContainer key={title}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{uuids.map(renderWorkbookItem)}
|
||||
</SectionContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const models = getModelsMetadata();
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderSection("Today", modelsCreatedToday)}
|
||||
{renderSection("Last 30 Days", modelsCreatedThisMonth)}
|
||||
{renderSection("Older", olderModels)}
|
||||
|
||||
<StyledMenu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={Boolean(menuAnchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
MenuListProps={{
|
||||
dense: true,
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
console.log(
|
||||
"Download clicked, selectedWorkbookUuid:",
|
||||
selectedWorkbookUuid,
|
||||
);
|
||||
if (selectedWorkbookUuid) {
|
||||
handleDownload(selectedWorkbookUuid);
|
||||
}
|
||||
setIntendedSelection(null);
|
||||
handleMenuClose();
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<FileDown />
|
||||
Download (.xlsx)
|
||||
</MenuItemWrapper>
|
||||
<MenuDivider />
|
||||
<MenuItemWrapper
|
||||
selected={false}
|
||||
onClick={() => {
|
||||
if (selectedWorkbookUuid) {
|
||||
handleDeleteClick(selectedWorkbookUuid);
|
||||
}
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete workbook
|
||||
</MenuItemWrapper>
|
||||
</StyledMenu>
|
||||
|
||||
<Modal
|
||||
open={isDeleteDialogOpen}
|
||||
onClose={handleDeleteCancel}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DeleteWorkbookDialog
|
||||
onClose={handleDeleteCancel}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
workbookName={workbookToDelete ? models[workbookToDelete].name : ""}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StorageIndicator = styled("div")`
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
stroke: #9e9e9e;
|
||||
}
|
||||
`;
|
||||
|
||||
const EllipsisButton = styled("button")<{ isOpen: boolean }>`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
color: #333333;
|
||||
stroke-width: 2px;
|
||||
background-color: ${({ isOpen }) => (isOpen ? "#E0E0E0" : "none")};
|
||||
opacity: ${({ isOpen }) => (isOpen ? "1" : "0.5")};
|
||||
transition: opacity 0.3s, background-color 0.3s;
|
||||
&:hover {
|
||||
background: none;
|
||||
opacity: 1;
|
||||
}
|
||||
&:active {
|
||||
background: #bdbdbd;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const WorkbookListItem = styled(MenuItem)<{ selected: boolean }>`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
min-width: 172px;
|
||||
border-radius: 8px;
|
||||
padding: 8px 4px 8px 8px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
transition: gap 0.5s;
|
||||
background-color: ${({ selected }) =>
|
||||
selected ? "#e0e0e0 !important" : "transparent"};
|
||||
|
||||
/* Prevent hover effects when menu is open */
|
||||
&:hover {
|
||||
background-color: ${({ selected }) =>
|
||||
selected ? "#e0e0e0 !important" : "transparent"};
|
||||
}
|
||||
`;
|
||||
|
||||
const WorkbookListText = styled("div")`
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const StyledMenu = styled(Menu)`
|
||||
.MuiPaper-root {
|
||||
border-radius: 8px;
|
||||
padding: 4px 0px;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.01);
|
||||
},
|
||||
.MuiList-root {
|
||||
padding: 0;
|
||||
},
|
||||
`;
|
||||
|
||||
const MenuDivider = styled("div")`
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-top: 1px solid #eeeeee;
|
||||
`;
|
||||
|
||||
const MenuItemWrapper = styled(MenuItem)`
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
font-size: 12px;
|
||||
width: calc(100% - 8px);
|
||||
min-width: 140px;
|
||||
margin: 0px 4px;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 32px;
|
||||
gap: 8px;
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionContainer = styled("div")`
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled("div")`
|
||||
font-weight: 600;
|
||||
color: #9e9e9e;
|
||||
margin-bottom: 8px;
|
||||
padding: 0px 8px;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
export default WorkbookList;
|
||||
@@ -72,10 +72,10 @@ export function WorkbookTitle(properties: {
|
||||
}
|
||||
|
||||
const Container = styled("div")`
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
padding: 6px 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
font-family: Inter;
|
||||
`;
|
||||
|
||||
@@ -108,7 +108,7 @@ const TitleInput = styled("input")`
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
&:focus {
|
||||
border: 1px solid grey;
|
||||
outline: 1px solid grey;
|
||||
}
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { base64ToBytes, bytesToBase64 } from "./util";
|
||||
|
||||
const MAX_WORKBOOKS = 50;
|
||||
|
||||
type ModelsMetadata = Record<string, string>;
|
||||
type ModelsMetadata = Record<string, { name: string; createdAt: number }>;
|
||||
|
||||
export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
||||
const uuid = localStorage.getItem("selected");
|
||||
@@ -12,7 +12,11 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
||||
if (modelsJson) {
|
||||
try {
|
||||
const models = JSON.parse(modelsJson);
|
||||
models[uuid] = newName;
|
||||
if (models[uuid]) {
|
||||
models[uuid].name = newName;
|
||||
} else {
|
||||
models[uuid] = { name: newName, createdAt: Date.now() };
|
||||
}
|
||||
localStorage.setItem("models", JSON.stringify(models));
|
||||
} catch (e) {
|
||||
console.warn("Failed saving new name");
|
||||
@@ -28,7 +32,26 @@ export function getModelsMetadata(): ModelsMetadata {
|
||||
if (!modelsJson) {
|
||||
modelsJson = "{}";
|
||||
}
|
||||
return JSON.parse(modelsJson);
|
||||
const models = JSON.parse(modelsJson);
|
||||
|
||||
// Migrate old format to new format
|
||||
const migratedModels: ModelsMetadata = {};
|
||||
for (const [uuid, value] of Object.entries(models)) {
|
||||
if (typeof value === "string") {
|
||||
// Old format: just the name string
|
||||
migratedModels[uuid] = { name: value, createdAt: Date.now() };
|
||||
} else if (typeof value === "object" && value !== null && "name" in value) {
|
||||
// New format: object with name and createdAt
|
||||
migratedModels[uuid] = value as { name: string; createdAt: number };
|
||||
}
|
||||
}
|
||||
|
||||
// Save migrated data back to localStorage
|
||||
if (JSON.stringify(models) !== JSON.stringify(migratedModels)) {
|
||||
localStorage.setItem("models", JSON.stringify(migratedModels));
|
||||
}
|
||||
|
||||
return migratedModels;
|
||||
}
|
||||
|
||||
// Pick a different name Workbook{N} where N = 1, 2, 3
|
||||
@@ -48,14 +71,14 @@ function getNewName(existingNames: string[]): string {
|
||||
|
||||
export function createNewModel(): Model {
|
||||
const models = getModelsMetadata();
|
||||
const name = getNewName(Object.values(models));
|
||||
const name = getNewName(Object.values(models).map((m) => m.name));
|
||||
|
||||
const model = new Model(name, "en", "UTC");
|
||||
const uuid = crypto.randomUUID();
|
||||
localStorage.setItem("selected", uuid);
|
||||
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
|
||||
|
||||
models[uuid] = name;
|
||||
models[uuid] = { name, createdAt: Date.now() };
|
||||
localStorage.setItem("models", JSON.stringify(models));
|
||||
return model;
|
||||
}
|
||||
@@ -95,7 +118,7 @@ export function saveModelToStorage(model: Model) {
|
||||
modelsJson = "{}";
|
||||
}
|
||||
const models = JSON.parse(modelsJson);
|
||||
models[uuid] = model.getName();
|
||||
models[uuid] = { name: model.getName(), createdAt: Date.now() };
|
||||
localStorage.setItem("models", JSON.stringify(models));
|
||||
}
|
||||
|
||||
@@ -127,3 +150,37 @@ export function deleteSelectedModel(): Model | null {
|
||||
}
|
||||
return selectModelFromStorage(uuids[0]);
|
||||
}
|
||||
|
||||
export function deleteModelByUuid(uuid: string): Model | null {
|
||||
localStorage.removeItem(uuid);
|
||||
const metadata = getModelsMetadata();
|
||||
delete metadata[uuid];
|
||||
localStorage.setItem("models", JSON.stringify(metadata));
|
||||
|
||||
// If this was the selected model, we need to select a different one
|
||||
const selectedUuid = localStorage.getItem("selected");
|
||||
if (selectedUuid === uuid) {
|
||||
const uuids = Object.keys(metadata);
|
||||
if (uuids.length === 0) {
|
||||
return createNewModel();
|
||||
}
|
||||
// Find the newest workbook by creation timestamp
|
||||
const newestUuid = uuids.reduce((newest, current) => {
|
||||
const newestTime = metadata[newest]?.createdAt || 0;
|
||||
const currentTime = metadata[current]?.createdAt || 0;
|
||||
return currentTime > newestTime ? current : newest;
|
||||
});
|
||||
return selectModelFromStorage(newestUuid);
|
||||
}
|
||||
|
||||
// If it wasn't the selected model, return the currently selected model
|
||||
if (selectedUuid) {
|
||||
const modelBytesString = localStorage.getItem(selectedUuid);
|
||||
if (modelBytesString) {
|
||||
return Model.from_bytes(base64ToBytes(modelBytesString));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to creating a new model if no valid selected model
|
||||
return createNewModel();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user