Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: essay
> ./src/DocExplorer/components/Sidebar.tsx
Lenses
(coming soon!)
import { AutomergeUrl, isValidAutomergeUrl } from "@automerge/automerge-repo";
import React, { useEffect, useRef, useState } from "react";
import { ChevronsLeft, FileQuestionIcon, FolderInput } from "lucide-react";
import { Tree, NodeRendererProps } from "react-arborist";
import { FillFlexParent } from "./FillFlexParent";
import { AccountPicker } from "./AccountPicker";
import {
DocLink,
DocLinkWithFolderPath,
FolderDoc,
FolderDocWithChildren,
} from "@/folders/datatype";
import { DatatypeId, datatypes } from "../../datatypes";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { useRepo } from "@automerge/automerge-repo-react-hooks";
import { structuredClone } from "@tldraw/tldraw";
import { FolderDocWithMetadata } from "@/folders/useFolderDocWithChildren";
import { capitalize } from "lodash";
const Node = (props: NodeRendererProps<DocLinkWithFolderPath>) => {
const { node, style, dragHandle } = props;
const Icon = datatypes[node.data.type]?.icon ?? FileQuestionIcon;
return (
<div
style={style}
ref={dragHandle}
className={`cursor-pointer text-sm py-1 w-full truncate ${
node.isSelected
? " bg-gray-300 hover:bg-gray-300 text-gray-900"
: "text-gray-600 hover:bg-gray-200"
}`}
onDoubleClick={() => node.edit()}
>
<Icon
size={14}
className={`${
node.isSelected ? "text-gray-800" : "text-gray-500"
} inline-block align-top mt-[3px] ml-2 mx-2`}
/>
{!node.isEditing && (
<span>
{datatypes[node.data.type]
? node.data.name
: `Unknown type: ${node.data.type}`}
</span>
)}
{node.isEditing && <Edit {...props} />}
</div>
);
};
const Edit = ({ node }: NodeRendererProps<DocLink>) => {
const input = useRef<any>();
useEffect(() => {
input.current?.focus();
input.current?.select();
}, []);
return (
<input
ref={input}
defaultValue={node.data.name}
onBlur={() => node.reset()}
onKeyDown={(e) => {
if (e.key === "Escape") node.reset();
if (e.key === "Enter") node.submit(input.current?.value || "");
}}
></input>
);
};
type SidebarProps = {
rootFolderDoc: FolderDocWithMetadata;
selectedDocLink: DocLinkWithFolderPath | null;
selectDocLink: (docLink: DocLinkWithFolderPath | null) => void;
hideSidebar: () => void;
addNewDocument: (doc: { type: DatatypeId }) => void;
};
const prepareDataForTree = (
folderDoc: FolderDocWithChildren,
folderPath: AutomergeUrl[]
) => {
if (!folderDoc) {
return [];
}
return folderDoc.docs.map((docLink) => ({
...docLink,
folderPath,
children:
docLink.type === "folder" && docLink.folderContents
? prepareDataForTree(docLink.folderContents, [
...folderPath,
docLink.url,
])
: undefined,
}));
};
const idAccessor = (item: DocLinkWithFolderPath) => {
return JSON.stringify({
url: item.url,
folderPath: item.folderPath,
});
};
export const Sidebar: React.FC<SidebarProps> = ({
selectedDocLink,
selectDocLink,
hideSidebar,
addNewDocument,
rootFolderDoc,
}) => {
const repo = useRepo();
const {
doc: rootFolderDocWithChildren,
status,
rootFolderUrl,
flatDocLinks,
} = rootFolderDoc;
// state related to open popover
const [openNewDocPopoverVisible, setOpenNewDocPopoverVisible] =
useState(false);
const [openUrlInput, setOpenUrlInput] = useState("");
const automergeUrlMatch = openUrlInput
.replace(/%3A/g, ":")
.match(/(automerge:[a-zA-Z0-9]*)/);
const automergeUrlToOpen =
automergeUrlMatch &&
automergeUrlMatch[1] &&
isValidAutomergeUrl(automergeUrlMatch[1])
? automergeUrlMatch[1]
: null;
// Show a loading spinner until we've recursively loaded all folder contents
if (status === "loading") {
return (
<div className="flex items-center justify-center h-screen">
<p className="text-gray-400 text-sm">Loading...</p>
</div>
);
}
const onMove = ({ parentNode, index: dragTargetIndex, dragNodes }) => {
for (const dragNode of dragNodes) {
const currentParentUrl =
dragNode.parent.level < 0
? rootFolderUrl
: (dragNode.parent.data.url as AutomergeUrl);
const currentParentHandle = repo.find<FolderDoc>(currentParentUrl);
const dragItemIndex = currentParentHandle
.docSync()
.docs.findIndex((item) => item.url === dragNode.data.url);
const newParentUrl =
!parentNode || parentNode.level < 0
? rootFolderUrl
: (parentNode.data.url as AutomergeUrl);
const newParentHandle = repo.find<FolderDoc>(newParentUrl);
if (dragItemIndex === undefined) {
return;
}
// If we're dragging later within the same folder, we need to account for
// the fact that the array will be shorter after we remove the original element
const adjustedTargetIndex =
currentParentUrl === newParentUrl && dragItemIndex < dragTargetIndex
? dragTargetIndex - 1
: dragTargetIndex;
let removedItem;
currentParentHandle.change((d) => {
const spliceResult = d.docs.splice(dragItemIndex, 1);
removedItem = structuredClone({ ...spliceResult[0] });
});
newParentHandle.change((d) => {
d.docs.splice(adjustedTargetIndex, 0, removedItem);
});
}
};
const dataForTree = prepareDataForTree(rootFolderDocWithChildren, [
rootFolderUrl,
]);
const treeSelection = selectedDocLink ? idAccessor(selectedDocLink) : null;
return (
<div className="flex flex-col h-screen">
<div className="h-10 py-2 px-4 font-semibold text-gray-500 text-sm flex">
<div className="mw-40 mt-[3px]">My Documents</div>
<div className="ml-auto">
<div
className="text-gray-400 hover:bg-gray-300 hover:text-gray-500 cursor-pointer transition-all p-1 m-[-4px] mr-[-8px] rounded-sm"
onClick={hideSidebar}
>
<ChevronsLeft />
</div>
</div>
</div>
<div className="py-2 border-b border-gray-200">
{Object.entries(datatypes).map(([id, docType]) => (
<div key={docType.id}>
{" "}
<div
className="py-1 px-2 text-sm text-gray-600 cursor-pointer hover:bg-gray-200 "
onClick={() => addNewDocument({ type: id as DatatypeId })}
>
<docType.icon
size={14}
className="inline-block font-bold mr-2 align-top mt-[2px]"
/>
New {docType.name}
</div>
</div>
))}
<div
className="py-1 px-2 text-sm text-gray-600 cursor-pointer hover:bg-gray-200 "
onClick={() => setOpenNewDocPopoverVisible(true)}
>
{/* todo: extract a component for this */}
<Popover
open={openNewDocPopoverVisible}
onOpenChange={setOpenNewDocPopoverVisible}
>
<PopoverTrigger>
<FolderInput
size={14}
className="inline-block font-bold mr-2 align-top mt-[2px]"
/>
Open document
</PopoverTrigger>
<PopoverContent className="w-96 h-20" side="right">
<Input
value={openUrlInput}
placeholder="automerge:<url>"
onChange={(e) => setOpenUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && automergeUrlToOpen) {
// openDocFromUrl(automergeUrlToOpen); // TODO FIX THIS
setOpenUrlInput("");
setOpenNewDocPopoverVisible(false);
}
}}
className={`outline-none ${
automergeUrlToOpen
? "bg-green-100"
: openUrlInput.length > 0
? "bg-red-100"
: ""
}`}
/>
<div className="text-xs text-gray-500 text-right mt-1">
{automergeUrlToOpen && <> {"\u23CE"} Enter to open </>}
{openUrlInput.length > 0 &&
!automergeUrlToOpen &&
"Not a valid Automerge URL"}
</div>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex-grow overflow-auto">
<FillFlexParent>
{({ width, height }) => {
return (
<Tree
data={dataForTree}
width={width}
height={height}
rowHeight={28}
selection={treeSelection}
idAccessor={idAccessor}
onSelect={(selections) => {
if (!selections || selections.length === 0) {
return false;
}
const newlySelectedDocLink = selections[0].data;
if (isValidAutomergeUrl(newlySelectedDocLink.url)) {
selectDocLink(newlySelectedDocLink);
}
}}
// For now, don't allow deleting w/ backspace key in the sidebar—
// it's too unsafe without undo.
// onDelete={({ ids }) => {
// for (const id of ids) {
// deleteFromAccountDocList(id as AutomergeUrl);
// }
// }}
onMove={onMove}
onRename={({ node, name }) => {
const docLink = flatDocLinks.find(
(doc) => doc.url === node.data.url
);
const datatype = datatypes[docLink.type];
if (!datatype.setTitle) {
alert(
`${capitalize(
datatype.name
)} documents can only be renamed in the main editor, not the sidebar.`
);
return;
}
if (!docLink) {
return;
}
const parentHandle = repo.find<FolderDoc>(
docLink.folderPath[docLink.folderPath.length - 1]
);
parentHandle.change((d) => {
const doc = d.docs.find((doc) => doc.url === docLink.url);
if (doc) {
doc.name = name;
}
});
}}
>
{Node}
</Tree>
);
}}
</FillFlexParent>
</div>
<div className="h-12 border-t border-gray-300 py-1 px-2 bg-gray-200">
<AccountPicker showName />
</div>
</div>
);
};