Skip to content

Instantly share code, notes, and snippets.

@pablomikel
Created January 3, 2024 17:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pablomikel/f6bf79d0af53ec76ce7a1718fe4c0e6e to your computer and use it in GitHub Desktop.
Save pablomikel/f6bf79d0af53ec76ce7a1718fe4c0e6e to your computer and use it in GitHub Desktop.
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { RedirectToSignIn, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import * as Dialog from "@radix-ui/react-dialog";
import isEqual from "deep-eql";
import { AnimatePresence, motion, useAnimate } from "framer-motion";
import { useHotkeys } from "react-hotkeys-hook";
import {
TbBold,
TbCheck,
TbChevronUp,
TbClipboard,
TbClipboardCheck,
TbClipboardX,
TbCloudUpload,
TbFilePlus,
TbFiles,
TbItalic,
TbLoader,
TbPhotoPlus,
TbTrash,
TbTrashX,
TbX,
} from "react-icons/tb";
import styled from "styled-components";
import { useSessionStorage } from "usehooks-ts";
import { allExtensions } from "@poplar/tiptap";
import type { Editor } from "@poplar/tiptap/core";
import type { JSONContent } from "@poplar/tiptap/react";
import { BubbleMenu, EditorContent, useEditor } from "@poplar/tiptap/react";
import Button, { ButtonStyled } from "~/components/Button";
import { FolderSelector } from "~/components/FolderSelector";
import Skeleton from "~/components/Skeleton";
import { StyledInput } from "~/components/Tabs";
import Toolbar from "~/components/Toolbar";
import Version from "~/components/Version";
import { theme } from "~/styles/theme";
import { api } from "~/utils/api";
import extractIdFromParam from "~/utils/extractIdFromParam";
import jsonContentIsValid from "~/utils/jsonContentIsValid";
import { useUploadThing } from "~/utils/uploadthing";
import useDebounce from "~/utils/useDebounce";
import { Main } from "../..";
export const EditorContainer = styled.div`
border: 1px solid ${theme.colors.stroke.actionable};
border-bottom: none;
border-radius: 6px 6px 0px 0px;
width: calc(100% - 32px);
max-width: 800px;
position: relative;
min-height: fill-available;
display: grid;
justify-self: center;
margin: 0px 16px 0 16px;
overflow: hidden;
box-sizing: border-box;
`;
export const SkeletonContainer = styled.div`
position: absolute;
inset: 0;
align-content: start;
z-index: 10;
pointer-events: none;
display: grid;
justify-content: stretch;
align-content: stretch;
${Skeleton} {
min-height: 100%;
min-width: 100%;
}
`;
export const Container = styled.div`
grid-template-rows: 1fr;
min-height: fill-available;
display: grid;
grid-auto-flow: row;
justify-content: stretch;
position: relative;
`;
const SaveStatus = styled.div`
display: grid;
grid-auto-flow: column;
padding: 0 8px;
gap: 8px;
font-size: 16px;
color: ${theme.colors.text.base};
justify-self: stretch;
align-items: center;
justify-items: end;
span {
font-size: 14px;
justify-self: start;
}
`;
export const VersionContainer = styled.div`
backdrop-filter: saturate(180%) blur(8px);
:before {
content: "";
position: absolute;
inset: 0;
background-color: ${theme.colors.bg.subdued};
z-index: -1;
opacity: 0.5;
}
`;
const ClipBoardButton = ({ editor }: { editor: Editor }) => {
const [copiedState, setCopiedState] = useState<
"idle" | "success" | "failure"
>("idle");
useEffect(() => {
if (copiedState === "idle") {
return;
}
const timeout = setTimeout(() => {
setCopiedState("idle");
}, 1000);
return () => {
clearTimeout(timeout);
};
}, [copiedState]);
return (
<Button
onClick={() => {
const { from, to, empty } = editor.state.selection;
if (empty) {
return null;
}
const text = editor.state.doc.textBetween(from, to, " ");
navigator.clipboard
.writeText(text)
.then(() => {
setCopiedState("success");
})
.catch(() => {
setCopiedState("failure");
});
}}
>
{copiedState === "idle" ? (
<TbClipboard />
) : copiedState === "success" ? (
<TbClipboardCheck />
) : (
<TbClipboardX />
)}
</Button>
);
};
const EditDocumentPage = () => {
const router = useRouter();
const { id } = router.query;
const [versionCountRef, animate] = useAnimate<HTMLSpanElement>();
const auth = useAuth();
const { isSignedIn } = auth;
const context = api.useContext();
const [saveStatus, setSaveStatus] = useState<
"saved" | "saving" | "error" | ""
>("");
const documentId = extractIdFromParam(id);
const [slug, setSlug] = useState<string>("");
const [slugActive, setSlugActive] = useState<boolean>(false);
const document = api.document.getDocumentById.useQuery(
{ id: documentId ?? "" },
{
enabled: router.isReady && !!documentId && isSignedIn,
onSuccess: (data) => {
if (!data) {
return;
}
setSlug(data.slug ?? "");
},
},
);
const updateDocument = api.document.updateDocument.useMutation({
onSuccess: (res) => {
setSaveStatus("saved");
context.document.getDocumentById.setData({ id: res.id }, () => {
return res;
});
setTimeout(() => {
setSaveStatus("");
}, 1000);
},
});
const newVersion = api.document.createVersion.useMutation({
onSuccess: (data) => {
void animate(
versionCountRef.current,
{
scale: [1.3, 1],
},
{ duration: 0.3 },
);
if (!documentId) {
return;
}
const oldDocumentById = context.document.getDocumentById.getData({
id: documentId,
});
if (!oldDocumentById) {
return;
}
context.document.getDocumentById.setData({ id: documentId }, () => {
return {
...oldDocumentById,
versions: data.versions,
};
});
},
});
const deleteDocument = api.document.deleteDocumentById.useMutation({
onSuccess: async (data) => {
if (!documentId) {
return;
}
/*
const oldDocumentsByCurrentUser =
context.document.getDocumentsByCurrentUser.getData();
context.document.getDocumentsByCurrentUser.setData(undefined, () => {
return assign(oldDocumentsByCurrentUser, data);
});
*/
await router.push("/files").then(() => {
context.document.getDocumentById.setData(
{ id: documentId },
(oldData) => {
if (!oldData) {
return;
}
return {
...oldData,
...data,
};
},
);
});
},
});
const restoreDocument = api.document.restoreDocumentById.useMutation({
onSuccess: (data) => {
if (!documentId) {
return;
}
/*
const oldData = context.document.getDocumentsByCurrentUser.getData();
context.document.getDocumentsByCurrentUser.setData(undefined, () => {
return oldData?.filter((d) => d.id !== documentId);
});
*/
context.document.getDocumentById.setData({ id: documentId }, () => {
return data;
});
},
});
const [showVersions, setShowVersions] = useState(false);
const updateSlug = api.document.updateDocumentSlug.useMutation({
onMutate: () => {
setSaveStatus("saving");
},
onSuccess: (res) => {
if (!documentId) {
return;
}
context.document.getDocumentById.setData({ id: documentId }, (data) => {
if (!data) {
return;
}
return {
...data,
slug: res,
};
});
setSaveStatus("saved");
setTimeout(() => {
setSaveStatus("");
}, 1000);
},
});
const debouncedUpdateSlug = useDebounce(async () => {
if (!documentId) {
return null;
}
await updateSlug.mutateAsync({
documentId: documentId,
slug: slug,
});
}, 400);
const newAutoSave = useDebounce(async () => {
const content = editor?.getJSON();
if (!content) {
return;
}
if (!documentId) {
return;
}
if (!document.data) {
return;
}
if (document.data?.deleted) {
return;
}
const serverContent = document.data?.versions?.[0]?.content;
const isValid = jsonContentIsValid(content, allExtensions);
const isChanged = !isEqual(content, serverContent);
if (documentId && isValid && isChanged) {
// if (serverContent) {
// console.log("DIFF", detailedDiff(serverContent, content));
// }
await updateDocument
.mutateAsync({ id: documentId, content: content })
.then(async () => {
await context.document.getDocumentById.invalidate({ id: documentId });
});
} else {
setSaveStatus("saved");
setTimeout(() => {
setSaveStatus("");
}, 1000);
}
}, 400);
const [editorContent, setEditorContent] = useState<JSONContent | null>(null);
const [init, setInit] = useState(false);
const documentContent = document.data?.versions?.[0]?.content;
const editor = useEditor(
{
extensions: allExtensions,
editable: document.data?.deleted ? false : document.data ? true : false,
content: editorContent,
onUpdate: () => {
setSaveStatus("saving");
newAutoSave();
},
},
[editorContent, document.data?.deleted],
);
useEffect(() => {
if (documentContent && !editorContent && !init) {
setEditorContent(documentContent);
}
}, [documentContent, editorContent, editor, init]);
useEffect(() => {
if (document.data) {
setInit(true);
}
}, [document.data]);
/* const flowers = api.user.getPreferences.useQuery(undefined, {
staleTime: Infinity,
refetchOnWindowFocus: false,
enabled: isSignedIn,
}).data?.flowers; */
const [menuOpen, setMenuOpen] = useSessionStorage("editorMenu", false);
useHotkeys("meta+j", () => setMenuOpen(!menuOpen), {
enableOnContentEditable: true,
});
/* useEffect(() => {
router.events.on('routeChangeStart', () => {
const content = editor?.getJSON();
if (!content) {
return;
}
if (!documentId) {
return;
}
const isValid = jsonContentIsValid(content, extensions);
if (!isValid) {
return;
}
updateDocument.mutate({ id: documentId, content: content });
});
}, [documentId, editor, router.events, updateDocument]);
useBeforeunload(() => {
const content = editor?.getJSON();
if (!content) {
return;
}
if (!documentId) {
return;
}
const isValid = jsonContentIsValid(content, extensions);
if (!isValid) {
return;
}
updateDocument.mutate({ id: documentId, content: content });
}); */
const uploader = useUploadThing("imageUploader", {
onClientUploadComplete: (res) => {
res?.map((r) => {
editor?.chain().focus().setImage({ src: r.url }).run();
});
},
});
const { startUpload, isUploading } = uploader;
return (
<>
<SignedOut>
<RedirectToSignIn />
</SignedOut>
<SignedIn>
{!document.isLoading && !document.data ? (
<Main>
<p>No data</p>
</Main>
) : document.isError ? (
<Main>
<p>Something went wrong</p>
<p>{document.error.message}</p>
</Main>
) : (
<>
<ButtonsContainer
as={motion.div}
initial={false}
animate={
menuOpen
? {
background: theme.colors.bg.base.toString(),
boxShadow: theme.shadow.subtle.toString(),
borderColor: theme.colors.stroke.default.toString(),
backdropFilter: "saturate(180%) blur(8px)",
}
: {
background: "transparent",
boxShadow: "none",
borderColor: "transparent",
backdropFilter: "none",
}
}
>
<ButtonsContainerBg
initial={false}
animate={
menuOpen
? {
opacity: 0.85,
}
: {
opacity: 0,
}
}
/>
<ButtonsSubContainer
$menuOpen={menuOpen}
initial={false}
as={motion.div}
animate={
menuOpen
? {
height: "auto",
marginTop: 16,
marginBottom: 12,
opacity: 1,
}
: { height: 0, marginTop: 12, marginBottom: 0, opacity: 0 }
}
>
{document.data?.versions[0]?.active ? (
document.data.slug ? (
<p
style={{
margin: 0,
padding: "4px 8px",
borderRadius: 4,
fontSize: 12,
boxShadow: theme.shadow.subtle.toString(),
background: theme.colors.avatar.bg.toString(),
color: theme.colors.avatar.text.toString(),
border: `1px solid ${theme.colors.avatar.border.toString()}`,
justifySelf: "start",
}}
>
Published
</p>
) : (
<p
style={{
margin: 0,
padding: "4px 8px",
borderRadius: 4,
fontSize: 12,
boxShadow: theme.shadow.subtle.toString(),
background:
theme.colors.button.danger.default.bg.toString(),
color:
theme.colors.button.danger.default.text.toString(),
border: `1px solid ${theme.colors.button.danger.default.border.toString()}`,
justifySelf: "start",
}}
>
Needs slug
</p>
)
) : (
<p
style={{
margin: 0,
padding: "4px 8px",
borderRadius: 4,
fontSize: 12,
boxShadow: theme.shadow.subtle.toString(),
background: theme.colors.bg.subdued.toString(),
color: theme.colors.text.subdued.toString(),
border: `1px solid ${theme.colors.stroke.default.toString()}`,
justifySelf: "start",
}}
>
Not published
</p>
)}
<AnimatePresence mode="popLayout">
{document.data?.deleted ? null : saveStatus === "saving" ? (
<SaveStatus
key={"saving"}
as={motion.div}
animate={{ opacity: [0.5, 0.5, 1, 0.5, 0.5] }}
transition={{
duration: 2.5,
repeat: Infinity,
repeatType: "loop",
}}
>
<span>Saving...</span> <TbCloudUpload />
</SaveStatus>
) : (
<SaveStatus
key={"saved"}
as={motion.div}
initial={{ scale: 1 }}
animate={{
opacity: saveStatus === "saved" ? 1 : 0.5,
scale: saveStatus === "saved" ? 1.01 : 1,
}}
transition={{
duration: 0.2,
}}
>
<span>Saved</span> <TbCheck />
</SaveStatus>
)}
</AnimatePresence>
<Button
style={{ gridTemplateColumns: "auto 1fr auto" }}
onClick={() => {
if (documentId) {
context.document.getDocumentById.setData(
{ id: documentId },
(data) => {
const content = editor?.getJSON();
if (!data || !content) {
return;
}
const newVersions = data.versions.map(
(version, i) => {
if (i === 0) {
return {
...version,
content: content,
};
}
return version;
},
);
return {
...data,
versions: newVersions,
};
},
);
setShowVersions(true);
}
}}
disabled={
!(
document.data?.versions &&
document.data.versions.length > 0
)
}
>
<TbFiles />
<span>
<span
style={{
position: "relative",
}}
>
<span>
{document?.data?.versions.length &&
document.data.versions.length > 1 ? (
<span
style={{
display: "inline-block",
fontVariantNumeric: "normal",
}}
ref={versionCountRef}
>
{(document.data.versions.length - 1).toString()}
</span>
) : (
"No"
)}
</span>
</span>{" "}
snapshot
{document.data?.versions.length === 0 ||
(document.data?.versions.length &&
document.data?.versions.length === 2)
? ""
: "s"}
</span>
{document.data?.versions.find(
(version) => version.active,
) && (
<div
style={{
width: 8,
height: 8,
borderRadius: 4,
background: theme.colors.avatar.border.toString(),
justifySelf: "end",
}}
/>
)}
</Button>
{document.data?.deleted ? null : (
<>
<Button
onClick={() => {
documentId && newVersion.mutate({ id: documentId });
}}
disabled={
isEqual(
editor?.getJSON(),
document.data?.versions?.[1]?.content,
) ||
!documentId ||
saveStatus === "saving" ||
!document.data?.versions?.[0]?.content
}
loading={newVersion.isLoading}
>
<TbFilePlus /> <span>Snapshot</span>
</Button>
</>
)}
{documentId && document.data && (
<FolderSelector
defaultValue={document.data.folderId ?? "no-folder"}
documentId={documentId}
/>
)}
<div style={{ position: "relative" }}>
<StyledInput
style={{
position: "absolute",
inset: 0,
zIndex: 2,
pointerEvents: "none",
opacity: slugActive ? 0 : 1,
}}
placeholder="Slug..."
value={encodeURI(slug)}
/>
<StyledInput
style={{ position: "relative", zIndex: 1 }}
value={slug}
onBlur={() => setSlugActive(false)}
onFocus={() => setSlugActive(true)}
onChange={(e) => {
setSlug(e.target.value);
debouncedUpdateSlug();
}}
/>
</div>
<div
style={{
position: "relative",
justifySelf: "stretch",
display: "grid",
}}
>
<ButtonStyled style={{ justifyContent: "start" }}>
{isUploading ? (
<>
<Spinning>
<TbLoader />
</Spinning>
Uploading...
</>
) : (
<>
<TbPhotoPlus />
Add image
</>
)}
</ButtonStyled>
<input
style={{
position: "absolute",
inset: 0,
opacity: 0,
width: "100%",
height: "100%",
cursor: "pointer",
}}
type="file"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
await startUpload([file]);
}}
/>
</div>
{document.data?.deleted ? (
<Button
onClick={() =>
documentId && restoreDocument.mutate({ id: documentId })
}
>
<TbTrashX /> <span>Restore</span>
</Button>
) : (
<Button
variant="danger"
onClick={() =>
documentId && deleteDocument.mutate({ id: documentId })
}
>
<TbTrash /> <span>Delete</span>
</Button>
)}
</ButtonsSubContainer>
<Button onClick={() => setMenuOpen(!menuOpen)}>
<TbChevronUp
style={{
width: 14,
height: 14,
rotate: menuOpen ? "-180deg" : "0deg",
transition: "rotate 0.2s ease-in-out",
}}
/>
</Button>
</ButtonsContainer>
<Container>
<EditorContainer
// as={motion.div}
// initial={{ y: 4 }}
// animate={{ y: 0 }}
>
<>
{document.isLoading && (
<SkeletonContainer>
<Skeleton />
</SkeletonContainer>
)}
</>
{editor && (
<StyledBubbleMenu
editor={editor}
tippyOptions={{ duration: 100 }}
shouldShow={(props) =>
(props.editor.isActive("image") ? false : true) &&
!props.editor.state.selection.empty
}
>
<Button
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
>
<TbBold />
</Button>
<Button
onClick={() =>
editor.chain().focus().toggleItalic().run()
}
active={editor.isActive("italic")}
>
<TbItalic />
</Button>
<ClipBoardButton editor={editor} />
</StyledBubbleMenu>
)}
{/*
{editor && (
<StyledFloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
>
<Button onClick={addImage}>
<TbPhotoPlus />
</Button>
</StyledFloatingMenu>
)}
*/}
<EditorContent
editor={editor}
style={{
display: "flex",
minHeight: "fill-available",
}}
/>
</EditorContainer>
<Dialog.Root open={showVersions} onOpenChange={setShowVersions}>
<Dialog.Portal>
<Dialog.Overlay style={{ position: "absolute", inset: 0 }} />
<Dialog.Content
asChild
onOpenAutoFocus={(event) => event.preventDefault()}
>
<VersionContainer
style={{
display: "grid",
gridTemplateRows: "1fr",
position: "fixed",
inset: 0,
alignContent: "stretch",
alignItems: "stretch",
overflow: "hidden",
zIndex: 100000,
}}
>
<Toolbar
showBorder={false}
slot={{
left: (
<Button onClick={() => setShowVersions(false)}>
<TbX />
<span>Close</span>
</Button>
),
}}
/>
<div
style={{
display: "grid",
gridAutoFlow: "column",
overflowX: "auto",
alignContent: "stretch",
paddingLeft:
"clamp(48px, calc(50vw - 332px), calc(50vw - 332px))",
paddingRight:
"clamp(48px, calc(50vw - 332px), calc(50vw - 332px))",
scrollSnapType: "x mandatory",
scrollPaddingLeft:
"clamp(48px, calc(50vw - 332px), calc(50vw - 332px))",
paddingTop: 90,
}}
>
<AnimatePresence>
{document.data?.versions.map((version, i) => {
return (
<Version
key={version.id}
version={version}
i={i}
documentId={documentId}
/>
);
})}
</AnimatePresence>
</div>
</VersionContainer>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/* {!!flowers && (
<div
style={{
position: "fixed",
bottom: "0",
left: "0",
right: "0",
display: "flex",
justifyContent: "center",
alignContent: "end",
zIndex: -1,
}}
>
<Flowers
numOfFlowers={
(
editor?.storage.characterCount as CharacterCountStorage
)?.words() ?? 0
}
salt={documentId ?? ""}
/>
</div>
)} */}
</Container>
</>
)}
</SignedIn>
</>
);
};
export default EditDocumentPage;
const StyledBubbleMenu = styled(BubbleMenu)`
display: grid;
grid-auto-flow: column;
gap: 8px;
button {
font-size: 16px;
padding: 4px 4px;
border-radius: 4px;
outline-width: 2px;
height: unset;
}
`;
/* const StyledFloatingMenu = styled(FloatingMenu)`
button {
font-size: 16px;
padding: 4px 4px;
border-radius: 4px;
outline-width: 2px;
height: unset;
}
`; */
export const ButtonsContainer = styled.div`
display: grid;
grid-auto-flow: row;
justify-content: end;
justify-items: end;
align-items: center;
justify-self: center;
position: fixed;
bottom: 24px;
right: 24px;
z-index: 100;
padding: 12px;
padding-top: 0;
box-shadow: ${theme.shadow.subtle};
border-radius: 12px;
border: 1px solid ${theme.colors.stroke.default};
background: transparent;
backdrop-filter: saturate(180%) blur(8px);
overflow: hidden;
box-sizing: border-box;
`;
export const ButtonsContainerBg = styled(motion.div)`
position: absolute;
inset: 0;
background-color: ${theme.colors.bg.base};
opacity: 0.85;
z-index: 0;
`;
export const ButtonsSubContainer = styled.div<{ $menuOpen: boolean }>`
display: grid;
grid-auto-flow: row;
justify-content: stretch;
justify-items: stretch;
align-items: end;
align-content: end;
gap: 16px;
box-sizing: border-box;
pointer-events: ${(props) => (props.$menuOpen ? "all" : "none")};
position: relative;
z-index: 1;
button {
justify-content: start;
}
`;
const Spinning = styled.div`
display: flex;
@-webkit-keyframes rotating /* Safari and Chrome */ {
from {
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes rotating {
from {
-ms-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
animation: rotating 2s linear infinite;
`;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment