update: Add a left drawer to improve workbook management (#453)
* update: add leftbar to app * style: a few cosmetic changes * update: allow pinning workbooks * style: show ellipsis button only on hover * update: add basic responsiveness * style: use active state when file and help menus are open * style: increase transition time * update: allow duplication of workbooks * chore: standardize menus
This commit is contained in:
committed by
GitHub
parent
dd4467f95d
commit
f2da24326b
@@ -2,6 +2,7 @@ import "./App.css";
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FileBar } from "./components/FileBar";
|
import { FileBar } from "./components/FileBar";
|
||||||
|
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
|
||||||
import WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog";
|
import WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog";
|
||||||
import {
|
import {
|
||||||
get_documentation_model,
|
get_documentation_model,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
} from "./components/rpc";
|
} from "./components/rpc";
|
||||||
import {
|
import {
|
||||||
createNewModel,
|
createNewModel,
|
||||||
|
deleteModelByUuid,
|
||||||
deleteSelectedModel,
|
deleteSelectedModel,
|
||||||
isStorageEmpty,
|
isStorageEmpty,
|
||||||
loadSelectedModelFromStorage,
|
loadSelectedModelFromStorage,
|
||||||
@@ -27,6 +29,7 @@ function App() {
|
|||||||
const [model, setModel] = useState<Model | null>(null);
|
const [model, setModel] = useState<Model | null>(null);
|
||||||
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
|
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
|
||||||
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false);
|
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false);
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function start() {
|
async function start() {
|
||||||
@@ -88,43 +91,71 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
// Handlers for model changes that also update our models state
|
||||||
|
const handleNewModel = () => {
|
||||||
|
const newModel = createNewModel();
|
||||||
|
setModel(newModel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetModel = (uuid: string) => {
|
||||||
|
const newModel = selectModelFromStorage(uuid);
|
||||||
|
if (newModel) {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteModel = () => {
|
||||||
|
const newModel = deleteSelectedModel();
|
||||||
|
if (newModel) {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteModelByUuid = (uuid: string) => {
|
||||||
|
const newModel = deleteModelByUuid(uuid);
|
||||||
|
if (newModel) {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// We could use context for model, but the problem is that it should initialized to null.
|
// We could use context for model, but the problem is that it should initialized to null.
|
||||||
// Passing the property down makes sure it is always defined.
|
// Passing the property down makes sure it is always defined.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<FileBar
|
<LeftDrawer
|
||||||
model={model}
|
open={isDrawerOpen}
|
||||||
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
|
onClose={() => setIsDrawerOpen(false)}
|
||||||
const blob = await uploadFile(arrayBuffer, fileName);
|
newModel={handleNewModel}
|
||||||
|
setModel={handleSetModel}
|
||||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
onDelete={handleDeleteModelByUuid}
|
||||||
const newModel = Model.from_bytes(bytes);
|
|
||||||
saveModelToStorage(newModel);
|
|
||||||
|
|
||||||
setModel(newModel);
|
|
||||||
}}
|
|
||||||
newModel={() => {
|
|
||||||
const createdModel = createNewModel();
|
|
||||||
setModel(createdModel);
|
|
||||||
}}
|
|
||||||
newModelFromTemplate={() => {
|
|
||||||
setTemplatesDialogOpen(true);
|
|
||||||
}}
|
|
||||||
setModel={(uuid: string) => {
|
|
||||||
const newModel = selectModelFromStorage(uuid);
|
|
||||||
if (newModel) {
|
|
||||||
setModel(newModel);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
const newModel = deleteSelectedModel();
|
|
||||||
if (newModel) {
|
|
||||||
setModel(newModel);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<IronCalc model={model} />
|
<MainContent isDrawerOpen={isDrawerOpen}>
|
||||||
|
{isDrawerOpen && (
|
||||||
|
<MobileOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||||
|
)}
|
||||||
|
<FileBar
|
||||||
|
model={model}
|
||||||
|
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
|
||||||
|
const blob = await uploadFile(arrayBuffer, fileName);
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
const newModel = Model.from_bytes(bytes);
|
||||||
|
saveModelToStorage(newModel);
|
||||||
|
|
||||||
|
setModel(newModel);
|
||||||
|
}}
|
||||||
|
newModel={handleNewModel}
|
||||||
|
newModelFromTemplate={() => {
|
||||||
|
setTemplatesDialogOpen(true);
|
||||||
|
}}
|
||||||
|
setModel={handleSetModel}
|
||||||
|
onDelete={handleDeleteModel}
|
||||||
|
isDrawerOpen={isDrawerOpen}
|
||||||
|
setIsDrawerOpen={setIsDrawerOpen}
|
||||||
|
/>
|
||||||
|
<IronCalc model={model} />
|
||||||
|
</MainContent>
|
||||||
{showWelcomeDialog && (
|
{showWelcomeDialog && (
|
||||||
<WelcomeDialog
|
<WelcomeDialog
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -175,13 +206,44 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Wrapper = styled("div")`
|
const Wrapper = styled("div")`
|
||||||
margin: 0px;
|
display: flex;
|
||||||
padding: 0px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MainContent = styled("div")<{ isDrawerOpen: boolean }>`
|
||||||
|
margin-left: ${({ isDrawerOpen }) => (isDrawerOpen ? "0px" : "-264px")};
|
||||||
|
transition: margin-left 0.2s;
|
||||||
|
width: ${({ isDrawerOpen }) =>
|
||||||
|
isDrawerOpen ? "calc(100% - 264px)" : "100%"};
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@media (max-width: 440px) {
|
||||||
|
${({ isDrawerOpen }) =>
|
||||||
|
isDrawerOpen &&
|
||||||
|
`
|
||||||
|
min-width: 440px;
|
||||||
|
`}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MobileOverlay = styled("div")`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
z-index: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media (min-width: 441px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Loading = styled("div")`
|
const Loading = styled("div")`
|
||||||
|
|||||||
@@ -12,19 +12,9 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
|
|||||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.getElementById("root");
|
|
||||||
if (root) {
|
|
||||||
root.style.filter = "blur(2px)";
|
|
||||||
}
|
|
||||||
if (deleteButtonRef.current) {
|
if (deleteButtonRef.current) {
|
||||||
deleteButtonRef.current.focus();
|
deleteButtonRef.current.focus();
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
const root = document.getElementById("root");
|
|
||||||
if (root) {
|
|
||||||
root.style.filter = "none";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import type { Model } from "@ironcalc/workbook";
|
import type { Model } from "@ironcalc/workbook";
|
||||||
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
|
import { IconButton, Tooltip } from "@mui/material";
|
||||||
|
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { FileMenu } from "./FileMenu";
|
import { FileMenu } from "./FileMenu";
|
||||||
import { HelpMenu } from "./HelpMenu";
|
import { HelpMenu } from "./HelpMenu";
|
||||||
@@ -31,6 +32,8 @@ export function FileBar(properties: {
|
|||||||
setModel: (key: string) => void;
|
setModel: (key: string) => void;
|
||||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
isDrawerOpen: boolean;
|
||||||
|
setIsDrawerOpen: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const spacerRef = useRef<HTMLDivElement>(null);
|
const spacerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -48,23 +51,45 @@ export function FileBar(properties: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FileBarWrapper>
|
<FileBarWrapper>
|
||||||
<StyledDesktopLogo />
|
<Tooltip
|
||||||
<StyledIronCalcIcon />
|
title="Toggle sidebar"
|
||||||
<Divider />
|
slotProps={{
|
||||||
<FileMenu
|
popper: {
|
||||||
newModel={properties.newModel}
|
modifiers: [
|
||||||
newModelFromTemplate={properties.newModelFromTemplate}
|
{
|
||||||
setModel={properties.setModel}
|
name: "offset",
|
||||||
onModelUpload={properties.onModelUpload}
|
options: {
|
||||||
onDownload={async () => {
|
offset: [0, -8],
|
||||||
const model = properties.model;
|
},
|
||||||
const bytes = model.toBytes();
|
},
|
||||||
const fileName = model.getName();
|
],
|
||||||
await downloadModel(bytes, fileName);
|
},
|
||||||
}}
|
}}
|
||||||
onDelete={properties.onDelete}
|
>
|
||||||
/>
|
<DrawerButton
|
||||||
<HelpMenu />
|
// $isDrawerOpen={properties.isDrawerOpen}
|
||||||
|
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||||
|
</DrawerButton>
|
||||||
|
</Tooltip>
|
||||||
|
{width > 440 && (
|
||||||
|
<FileMenu
|
||||||
|
newModel={properties.newModel}
|
||||||
|
newModelFromTemplate={properties.newModelFromTemplate}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{width > 440 && <HelpMenu />}
|
||||||
<WorkbookTitleWrapper>
|
<WorkbookTitleWrapper>
|
||||||
<WorkbookTitle
|
<WorkbookTitle
|
||||||
name={properties.model.getName()}
|
name={properties.model.getName()}
|
||||||
@@ -103,35 +128,38 @@ const Spacer = styled("div")`
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledDesktopLogo = styled(IronCalcLogo)`
|
// const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>`
|
||||||
width: 120px;
|
// cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")};
|
||||||
margin-left: 12px;
|
const DrawerButton = styled(IconButton)`
|
||||||
@media (max-width: 769px) {
|
margin-left: 8px;
|
||||||
display: none;
|
height: 32px;
|
||||||
}
|
width: 32px;
|
||||||
`;
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
const StyledIronCalcIcon = styled(IronCalcIcon)`
|
svg {
|
||||||
width: 36px;
|
stroke-width: 2px;
|
||||||
margin-left: 10px;
|
stroke: #757575;
|
||||||
@media (min-width: 769px) {
|
width: 16px;
|
||||||
display: none;
|
height: 16px;
|
||||||
|
}
|
||||||
|
&: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
|
// The container must be relative positioned so we can position the title absolutely
|
||||||
const FileBarWrapper = styled("div")`
|
const FileBarWrapper = styled("div")`
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
min-height: 60px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import { Menu, MenuItem, Modal } from "@mui/material";
|
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||||
import { Check, FileDown, FileUp, Plus, Table2, Trash2 } from "lucide-react";
|
import { FileDown, FileUp, Plus, Table2, Trash2 } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
|
import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
|
||||||
import UploadFileDialog from "./UploadFileDialog";
|
import UploadFileDialog from "./UploadFileDialog";
|
||||||
@@ -19,40 +19,8 @@ export function FileMenu(props: {
|
|||||||
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
||||||
const anchorElement = useRef<HTMLButtonElement>(null);
|
const anchorElement = useRef<HTMLButtonElement>(null);
|
||||||
const models = getModelsMetadata();
|
const models = getModelsMetadata();
|
||||||
const uuids = Object.keys(models);
|
|
||||||
const selectedUuid = getSelectedUuid();
|
const selectedUuid = getSelectedUuid();
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const elements = [];
|
|
||||||
for (const uuid of uuids) {
|
|
||||||
elements.push(
|
|
||||||
<MenuItemWrapper
|
|
||||||
key={uuid}
|
|
||||||
onClick={() => {
|
|
||||||
props.setModel(uuid);
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckIndicator>
|
|
||||||
{uuid === selectedUuid ? (
|
|
||||||
<StyledIcon>
|
|
||||||
<Check />
|
|
||||||
</StyledIcon>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</CheckIndicator>
|
|
||||||
<MenuItemText
|
|
||||||
style={{
|
|
||||||
maxWidth: "240px",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{models[uuid]}
|
|
||||||
</MenuItemText>
|
|
||||||
</MenuItemWrapper>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -90,10 +58,8 @@ export function FileMenu(props: {
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<Plus />
|
||||||
<Plus />
|
New blank workbook
|
||||||
</StyledIcon>
|
|
||||||
<MenuItemText>New blank workbook</MenuItemText>
|
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -101,10 +67,8 @@ export function FileMenu(props: {
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<Table2 />
|
||||||
<Table2 />
|
New from template
|
||||||
</StyledIcon>
|
|
||||||
<MenuItemText>New from template</MenuItemText>
|
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -112,31 +76,23 @@ export function FileMenu(props: {
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<FileUp />
|
||||||
<FileUp />
|
Import
|
||||||
</StyledIcon>
|
|
||||||
<MenuItemText>Import</MenuItemText>
|
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
<MenuItemWrapper onClick={props.onDownload}>
|
<MenuItemWrapper onClick={props.onDownload}>
|
||||||
<StyledIcon>
|
<FileDown />
|
||||||
<FileDown />
|
Download (.xlsx)
|
||||||
</StyledIcon>
|
|
||||||
<MenuItemText>Download (.xlsx)</MenuItemText>
|
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuItemWrapper
|
<DeleteButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<Trash2 />
|
||||||
<Trash2 />
|
Delete workbook
|
||||||
</StyledIcon>
|
</DeleteButton>
|
||||||
<MenuItemText>Delete workbook</MenuItemText>
|
|
||||||
</MenuItemWrapper>
|
|
||||||
<MenuDivider />
|
|
||||||
{elements}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
<Modal
|
<Modal
|
||||||
open={isImportMenuOpen}
|
open={isImportMenuOpen}
|
||||||
@@ -162,25 +118,14 @@ export function FileMenu(props: {
|
|||||||
<DeleteWorkbookDialog
|
<DeleteWorkbookDialog
|
||||||
onClose={() => setDeleteDialogOpen(false)}
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
onConfirm={props.onDelete}
|
onConfirm={props.onDelete}
|
||||||
workbookName={selectedUuid ? models[selectedUuid] : ""}
|
workbookName={selectedUuid ? models[selectedUuid].name : ""}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledIcon = styled.div`
|
export const MenuDivider = styled.div`
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 100%;
|
|
||||||
color: #757575;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MenuDivider = styled.div`
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -188,12 +133,7 @@ const MenuDivider = styled.div`
|
|||||||
border-top: 1px solid #eeeeee;
|
border-top: 1px solid #eeeeee;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MenuItemText = styled.div`
|
export const MenuItemWrapper = styled(MenuItem)`
|
||||||
color: #000;
|
|
||||||
font-size: 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MenuItemWrapper = styled(MenuItem)`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -203,6 +143,14 @@ const MenuItemWrapper = styled(MenuItem)`
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 100%;
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
|
const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
|
||||||
@@ -215,14 +163,20 @@ const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
|
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CheckIndicator = styled.span`
|
export const DeleteButton = styled(MenuItemWrapper)`
|
||||||
display: flex;
|
color: #EB5757;
|
||||||
justify-content: center;
|
svg {
|
||||||
min-width: 26px;
|
color: #EB5757;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: #EB57571A;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: #EB57571A;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import { Menu, MenuItem } from "@mui/material";
|
import { Menu } from "@mui/material";
|
||||||
import { BookOpen, Keyboard } from "lucide-react";
|
import { BookOpen, Keyboard } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { MenuItemWrapper } from "./FileMenu";
|
||||||
|
|
||||||
export function HelpMenu() {
|
export function HelpMenu() {
|
||||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -61,10 +62,8 @@ export function HelpMenu() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<BookOpen />
|
||||||
<BookOpen />
|
Documentation
|
||||||
</StyledIcon>
|
|
||||||
<MenuItemText>Documentation</MenuItemText>
|
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -76,10 +75,8 @@ export function HelpMenu() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<Keyboard />
|
||||||
<Keyboard />
|
Keyboard Shortcuts
|
||||||
</StyledIcon>
|
|
||||||
<MenuItemText>Keyboard Shortcuts</MenuItemText>
|
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,37 +93,7 @@ const HelpButton = styled.button<{ $isActive?: boolean }>`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
|
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MenuItemWrapper = styled(MenuItem)`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
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 StyledIcon = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 100%;
|
|
||||||
color: #757575;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MenuItemText = styled.div`
|
|
||||||
color: #000;
|
|
||||||
font-size: 12px;
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -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,98 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import { IronCalcIconWhite as IronCalcIcon } from "@ironcalc/workbook";
|
||||||
|
import { IconButton, Tooltip } from "@mui/material";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { DialogHeaderLogoWrapper } from "../WelcomeDialog/WelcomeDialog";
|
||||||
|
|
||||||
|
interface DrawerHeaderProps {
|
||||||
|
onNewModel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ onNewModel }: DrawerHeaderProps) {
|
||||||
|
return (
|
||||||
|
<HeaderContainer>
|
||||||
|
<LogoWrapper>
|
||||||
|
<Logo>
|
||||||
|
<IronCalcIcon />
|
||||||
|
</Logo>
|
||||||
|
<Title>IronCalc</Title>
|
||||||
|
</LogoWrapper>
|
||||||
|
<Tooltip
|
||||||
|
title="New workbook"
|
||||||
|
slotProps={{
|
||||||
|
popper: {
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "offset",
|
||||||
|
options: {
|
||||||
|
offset: [0, -8],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddButton onClick={onNewModel}>
|
||||||
|
<PlusIcon />
|
||||||
|
</AddButton>
|
||||||
|
</Tooltip>
|
||||||
|
</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;
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 1px 0 0 #e0e0e0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LogoWrapper = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled("h1")`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Logo = styled(DialogHeaderLogoWrapper)`
|
||||||
|
transform: none;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
padding: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AddButton = styled(IconButton)`
|
||||||
|
margin-left: 8px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke: #757575;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: #E0E0E0;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: #BDBDBD;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlusIcon = styled(Plus)`
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default DrawerHeader;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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}
|
||||||
|
transitionDuration={200}
|
||||||
|
>
|
||||||
|
<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,426 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
EllipsisVertical,
|
||||||
|
FileDown,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
|
Table2,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import DeleteWorkbookDialog from "../DeleteWorkbookDialog";
|
||||||
|
import { DeleteButton, MenuDivider, MenuItemWrapper } from "../FileMenu";
|
||||||
|
import { downloadModel } from "../rpc";
|
||||||
|
import {
|
||||||
|
duplicateModel,
|
||||||
|
getModelsMetadata,
|
||||||
|
getSelectedUuid,
|
||||||
|
isWorkbookPinned,
|
||||||
|
selectModelFromStorage,
|
||||||
|
togglePinWorkbook,
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinToggle = (uuid: string) => {
|
||||||
|
togglePinWorkbook(uuid);
|
||||||
|
setIntendedSelection(null);
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = (uuid: string) => {
|
||||||
|
const duplicatedModel = duplicateModel(uuid);
|
||||||
|
if (duplicatedModel) {
|
||||||
|
setIntendedSelection(null);
|
||||||
|
handleMenuClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group workbooks by pinned status and creation date
|
||||||
|
const groupWorkbooks = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const millisecondsInDay = 24 * 60 * 60 * 1000;
|
||||||
|
const millisecondsIn30Days = 30 * millisecondsInDay;
|
||||||
|
|
||||||
|
const pinnedModels = [];
|
||||||
|
const modelsCreatedToday = [];
|
||||||
|
const modelsCreatedThisMonth = [];
|
||||||
|
const olderModels = [];
|
||||||
|
const modelsMetadata = getModelsMetadata();
|
||||||
|
|
||||||
|
for (const uuid in modelsMetadata) {
|
||||||
|
const createdAt = modelsMetadata[uuid].createdAt;
|
||||||
|
const age = now - createdAt;
|
||||||
|
|
||||||
|
if (modelsMetadata[uuid].pinned) {
|
||||||
|
pinnedModels.push(uuid);
|
||||||
|
} else 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 {
|
||||||
|
pinnedModels: sortByNewest(pinnedModels),
|
||||||
|
modelsCreatedToday: sortByNewest(modelsCreatedToday),
|
||||||
|
modelsCreatedThisMonth: sortByNewest(modelsCreatedThisMonth),
|
||||||
|
olderModels: sortByNewest(olderModels),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
pinnedModels,
|
||||||
|
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>
|
||||||
|
<Table2 />
|
||||||
|
</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 === "Pinned" && <Pin />}
|
||||||
|
{title}
|
||||||
|
</SectionTitle>
|
||||||
|
{uuids.map(renderWorkbookItem)}
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const models = getModelsMetadata();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderSection("Pinned", pinnedModels)}
|
||||||
|
{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>
|
||||||
|
<MenuItemWrapper
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedWorkbookUuid) {
|
||||||
|
handlePinToggle(selectedWorkbookUuid);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
{selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid) ? (
|
||||||
|
<PinOff />
|
||||||
|
) : (
|
||||||
|
<Pin />
|
||||||
|
)}
|
||||||
|
{selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid)
|
||||||
|
? "Unpin"
|
||||||
|
: "Pin"}
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuItemWrapper
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedWorkbookUuid) {
|
||||||
|
handleDuplicate(selectedWorkbookUuid);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
Duplicate
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuDivider />
|
||||||
|
<DeleteButton
|
||||||
|
selected={false}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedWorkbookUuid) {
|
||||||
|
handleDeleteClick(selectedWorkbookUuid);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Delete workbook
|
||||||
|
</DeleteButton>
|
||||||
|
</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: ${({ isOpen }) => (isOpen ? "24px" : "0px")};
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #333333;
|
||||||
|
stroke-width: 2px;
|
||||||
|
background-color: ${({ isOpen }) => (isOpen ? "#E0E0E0" : "none")};
|
||||||
|
opacity: ${({ isOpen }) => (isOpen ? "1" : "0")};
|
||||||
|
&:hover {
|
||||||
|
background: #BDBDBD;
|
||||||
|
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"};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
button {
|
||||||
|
opacity: 1;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 SectionContainer = styled("div")`
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #9e9e9e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default WorkbookList;
|
||||||
@@ -5,8 +5,8 @@ export function ShareButton(properties: { onClick: () => void }) {
|
|||||||
const { onClick } = properties;
|
const { onClick } = properties;
|
||||||
return (
|
return (
|
||||||
<Wrapper onClick={onClick} onKeyDown={() => {}}>
|
<Wrapper onClick={onClick} onKeyDown={() => {}}>
|
||||||
<Share2 style={{ width: "16px", height: "16px", marginRight: "10px" }} />
|
<ShareIcon />
|
||||||
<span>Share</span>
|
<ShareText>Share</ShareText>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -23,8 +23,24 @@ const Wrapper = styled("div")`
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #d68742;
|
background: #d68742;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const ShareIcon = styled(Share2)`
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
@media (max-width: 440px) {
|
||||||
|
margin-right: 0px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ShareText = styled.span`
|
||||||
|
@media (max-width: 440px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ const DialogHeaderTitleSubtitle = styled("span")`
|
|||||||
color: #757575;
|
color: #757575;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DialogHeaderLogoWrapper = styled("div")`
|
export const DialogHeaderLogoWrapper = styled("div")`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export function WorkbookTitle(properties: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled("div")`
|
const Container = styled("div")`
|
||||||
text-align: center;
|
text-align: left;
|
||||||
padding: 8px;
|
padding: 6px 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-family: Inter;
|
font-family: Inter;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ const TitleInput = styled("input")`
|
|||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
&:focus {
|
&:focus {
|
||||||
border: 1px solid grey;
|
outline: 1px solid grey;
|
||||||
}
|
}
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { base64ToBytes, bytesToBase64 } from "./util";
|
|||||||
|
|
||||||
const MAX_WORKBOOKS = 50;
|
const MAX_WORKBOOKS = 50;
|
||||||
|
|
||||||
type ModelsMetadata = Record<string, string>;
|
type ModelsMetadata = Record<
|
||||||
|
string,
|
||||||
|
{ name: string; createdAt: number; pinned?: boolean }
|
||||||
|
>;
|
||||||
|
|
||||||
export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
||||||
const uuid = localStorage.getItem("selected");
|
const uuid = localStorage.getItem("selected");
|
||||||
@@ -12,7 +15,11 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
|||||||
if (modelsJson) {
|
if (modelsJson) {
|
||||||
try {
|
try {
|
||||||
const models = JSON.parse(modelsJson);
|
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));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed saving new name");
|
console.warn("Failed saving new name");
|
||||||
@@ -28,7 +35,26 @@ export function getModelsMetadata(): ModelsMetadata {
|
|||||||
if (!modelsJson) {
|
if (!modelsJson) {
|
||||||
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
|
// Pick a different name Workbook{N} where N = 1, 2, 3
|
||||||
@@ -48,14 +74,14 @@ function getNewName(existingNames: string[]): string {
|
|||||||
|
|
||||||
export function createNewModel(): Model {
|
export function createNewModel(): Model {
|
||||||
const models = getModelsMetadata();
|
const models = getModelsMetadata();
|
||||||
const name = getNewName(Object.values(models));
|
const name = getNewName(Object.values(models).map((m) => m.name));
|
||||||
|
|
||||||
const model = new Model(name, "en", "UTC");
|
const model = new Model(name, "en", "UTC");
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
localStorage.setItem("selected", uuid);
|
localStorage.setItem("selected", uuid);
|
||||||
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
|
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
|
||||||
|
|
||||||
models[uuid] = name;
|
models[uuid] = { name, createdAt: Date.now() };
|
||||||
localStorage.setItem("models", JSON.stringify(models));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
@@ -103,7 +129,7 @@ export function saveModelToStorage(model: Model) {
|
|||||||
modelsJson = "{}";
|
modelsJson = "{}";
|
||||||
}
|
}
|
||||||
const models = JSON.parse(modelsJson);
|
const models = JSON.parse(modelsJson);
|
||||||
models[uuid] = model.getName();
|
models[uuid] = { name: model.getName(), createdAt: Date.now() };
|
||||||
localStorage.setItem("models", JSON.stringify(models));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,3 +161,79 @@ export function deleteSelectedModel(): Model | null {
|
|||||||
}
|
}
|
||||||
return selectModelFromStorage(uuids[0]);
|
return selectModelFromStorage(uuids[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteModelByUuid(uuid: string): Model | null {
|
||||||
|
localStorage.removeItem(uuid);
|
||||||
|
const metadata = getModelsMetadata();
|
||||||
|
delete metadata[uuid];
|
||||||
|
localStorage.setItem("models", JSON.stringify(metadata));
|
||||||
|
|
||||||
|
// If this was the selected model, we need to select a different one
|
||||||
|
const selectedUuid = localStorage.getItem("selected");
|
||||||
|
if (selectedUuid === uuid) {
|
||||||
|
const uuids = Object.keys(metadata);
|
||||||
|
if (uuids.length === 0) {
|
||||||
|
return createNewModel();
|
||||||
|
}
|
||||||
|
// Find the newest workbook by creation timestamp
|
||||||
|
const newestUuid = uuids.reduce((newest, current) => {
|
||||||
|
const newestTime = metadata[newest]?.createdAt || 0;
|
||||||
|
const currentTime = metadata[current]?.createdAt || 0;
|
||||||
|
return currentTime > newestTime ? current : newest;
|
||||||
|
});
|
||||||
|
return selectModelFromStorage(newestUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it wasn't the selected model, return the currently selected model
|
||||||
|
if (selectedUuid) {
|
||||||
|
const modelBytesString = localStorage.getItem(selectedUuid);
|
||||||
|
if (modelBytesString) {
|
||||||
|
return Model.from_bytes(base64ToBytes(modelBytesString));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to creating a new model if no valid selected model
|
||||||
|
return createNewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePinWorkbook(uuid: string): void {
|
||||||
|
const metadata = getModelsMetadata();
|
||||||
|
if (metadata[uuid]) {
|
||||||
|
metadata[uuid].pinned = !metadata[uuid].pinned;
|
||||||
|
localStorage.setItem("models", JSON.stringify(metadata));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkbookPinned(uuid: string): boolean {
|
||||||
|
const metadata = getModelsMetadata();
|
||||||
|
return metadata[uuid]?.pinned || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function duplicateModel(uuid: string): Model | null {
|
||||||
|
const originalModel = selectModelFromStorage(uuid);
|
||||||
|
if (!originalModel) return null;
|
||||||
|
|
||||||
|
const duplicatedModel = Model.from_bytes(originalModel.toBytes());
|
||||||
|
const models = getModelsMetadata();
|
||||||
|
const originalName = models[uuid]?.name || "Workbook";
|
||||||
|
const existingNames = Object.values(models).map((m) => m.name);
|
||||||
|
|
||||||
|
// Find next available number
|
||||||
|
let counter = 1;
|
||||||
|
let newName = `${originalName} (${counter})`;
|
||||||
|
while (existingNames.includes(newName)) {
|
||||||
|
counter++;
|
||||||
|
newName = `${originalName} (${counter})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicatedModel.setName(newName);
|
||||||
|
|
||||||
|
const newUuid = crypto.randomUUID();
|
||||||
|
localStorage.setItem("selected", newUuid);
|
||||||
|
localStorage.setItem(newUuid, bytesToBase64(duplicatedModel.toBytes()));
|
||||||
|
|
||||||
|
models[newUuid] = { name: newName, createdAt: Date.now() };
|
||||||
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
|
|
||||||
|
return duplicatedModel;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user