From 46feee9badb7805cb12bccd630b7ed1aa1a1d832 Mon Sep 17 00:00:00 2001 From: southclaws Date: Sun, 14 Jan 2024 13:39:27 +0000 Subject: [PATCH] cluster creation and editing --- web/package.json | 3 +- web/panda.config.ts | 4 +- .../(dashboard)/directory/[...slug]/page.tsx | 30 ++-- .../app/(dashboard)/directory/new/page.tsx | 5 + .../ContentComposer/ContentComposer.tsx | 8 +- .../ContentComposer/useContentComposer.ts | 12 +- .../components => }/FileDrop/FileDrop.tsx | 0 .../components => }/FileDrop/useFileDrop.ts | 10 +- .../components => }/FileDrop/utils.ts | 0 .../directory/datagraph/Breadcrumbs.tsx | 63 ++++++-- .../datagraph/ClusterCard/ClusterCard.tsx | 2 +- .../components/directory/datagraph/utils.ts | 21 +++ .../components/directory/links/LinkCard.tsx | 2 +- .../directory/links/LinkCardList.tsx | 2 +- .../directory/members/MemberList.tsx | 2 +- .../feed/common/PostRef/PostRefList.tsx | 3 +- web/src/components/feed/link/LinkPost.tsx | 2 +- web/src/components/site/Action/Edit.tsx | 7 +- web/src/components/site/Action/Save.tsx | 10 +- .../{feed/common/PostRef => site}/Empty.tsx | 8 +- web/src/components/site/EmptyBox.tsx | 21 +++ .../components/TitleInput/TitleInput.tsx | 18 +-- .../ClusterCreateScreen.tsx | 20 +++ .../useClusterCreateScreen.ts | 38 +++++ .../datagraph/ClusterScreen/ClusterScreen.tsx | 146 +++++++++++++++--- .../datagraph/ClusterScreen/ContentInput.tsx | 37 +++++ .../datagraph/ClusterScreen/TitleInput.tsx | 36 +++++ .../ClusterScreen/useClusterScreen.ts | 77 +++++++-- .../ClusterViewerScreen.tsx | 15 ++ .../useClusterViewerScreen.ts | 50 ++++++ .../datagraph/DatagraphIndexScreen.tsx | 2 +- .../datagraph/ItemScreen/ItemScreen.tsx | 2 +- .../directory/datagraph/useDirectoryPath.tsx | 17 ++ .../Admonition/admonition.recipe.ts | 4 +- web/src/theme/components/Admonition/index.tsx | 8 +- .../theme/components/HeadingInput/index.tsx | 95 ++++++++++++ .../theme/components/HeadingInput/recipe.ts | 35 +++++ web/src/theme/components/TitleInput/index.tsx | 39 ----- .../TitleInput/titleInput.recipe.ts | 34 ---- web/styled-system/recipes/heading-input.d.ts | 28 ++++ web/styled-system/recipes/heading-input.mjs | 19 +++ web/styled-system/recipes/index.d.ts | 2 +- web/styled-system/recipes/index.mjs | 2 +- web/yarn.lock | 10 +- 44 files changed, 763 insertions(+), 186 deletions(-) create mode 100644 web/src/app/(dashboard)/directory/new/page.tsx rename web/src/components/content/{ContentComposer/components => }/FileDrop/FileDrop.tsx (100%) rename web/src/components/content/{ContentComposer/components => }/FileDrop/useFileDrop.ts (89%) rename web/src/components/content/{ContentComposer/components => }/FileDrop/utils.ts (100%) create mode 100644 web/src/components/directory/datagraph/utils.ts rename web/src/components/{feed/common/PostRef => site}/Empty.tsx (59%) create mode 100644 web/src/components/site/EmptyBox.tsx create mode 100644 web/src/screens/directory/datagraph/ClusterCreateScreen/ClusterCreateScreen.tsx create mode 100644 web/src/screens/directory/datagraph/ClusterCreateScreen/useClusterCreateScreen.ts create mode 100644 web/src/screens/directory/datagraph/ClusterScreen/ContentInput.tsx create mode 100644 web/src/screens/directory/datagraph/ClusterScreen/TitleInput.tsx create mode 100644 web/src/screens/directory/datagraph/ClusterViewerScreen/ClusterViewerScreen.tsx create mode 100644 web/src/screens/directory/datagraph/ClusterViewerScreen/useClusterViewerScreen.ts create mode 100644 web/src/theme/components/HeadingInput/index.tsx create mode 100644 web/src/theme/components/HeadingInput/recipe.ts delete mode 100644 web/src/theme/components/TitleInput/index.tsx delete mode 100644 web/src/theme/components/TitleInput/titleInput.recipe.ts create mode 100644 web/styled-system/recipes/heading-input.d.ts create mode 100644 web/styled-system/recipes/heading-input.mjs diff --git a/web/package.json b/web/package.json index fd6169f74..842e54a75 100644 --- a/web/package.json +++ b/web/package.json @@ -28,6 +28,7 @@ "polished": "^4.2.2", "react": "^18.2.0", "react-avatar-editor": "^13.0.0", + "react-contenteditable": "^3.3.7", "react-dom": "^18.2.0", "react-hook-form": "^7.46.1", "remark-parse": "^10.0.2", @@ -64,4 +65,4 @@ "minimumChangeThreshold": 0, "showDetails": true } -} \ No newline at end of file +} diff --git a/web/panda.config.ts b/web/panda.config.ts index ec53a840a..1b115ff96 100644 --- a/web/panda.config.ts +++ b/web/panda.config.ts @@ -10,13 +10,13 @@ import { admonition } from "src/theme/components/Admonition/admonition.recipe"; import { button } from "src/theme/components/Button/button.recipe"; import { checkbox } from "src/theme/components/Checkbox/checkbox.recipe"; import { heading } from "src/theme/components/Heading/heading.recipe"; +import { headingInput } from "src/theme/components/HeadingInput/recipe"; import { input } from "src/theme/components/Input/input.recipe"; import { link } from "src/theme/components/Link/link.recipe"; import { menu } from "src/theme/components/Menu/menu.recipe"; import { popover } from "src/theme/components/Popover/popover.recipe"; import { skeleton } from "src/theme/components/Skeleton/skeleton.recipe"; import { tabs } from "src/theme/components/Tabs/tabs.recipe"; -import { titleInput } from "src/theme/components/TitleInput/titleInput.recipe"; // TODO: Dark mode = 40% const L = "80%"; @@ -125,7 +125,7 @@ export default defineConfig({ recipes: { admonition: admonition, input: input, - titleInput: titleInput, + headingInput: headingInput, heading: heading, button: button, link: link, diff --git a/web/src/app/(dashboard)/directory/[...slug]/page.tsx b/web/src/app/(dashboard)/directory/[...slug]/page.tsx index f32ef1e3e..0bbaa52f3 100644 --- a/web/src/app/(dashboard)/directory/[...slug]/page.tsx +++ b/web/src/app/(dashboard)/directory/[...slug]/page.tsx @@ -1,12 +1,13 @@ -import { last } from "lodash"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { ClusterGetOKResponse, ItemGetOKResponse, } from "src/api/openapi/schemas"; import { server } from "src/api/server"; -import { ClusterScreen } from "src/screens/directory/datagraph/ClusterScreen/ClusterScreen"; +import { getTargetSlug } from "src/components/directory/datagraph/utils"; +import { ClusterCreateScreen } from "src/screens/directory/datagraph/ClusterCreateScreen/ClusterCreateScreen"; +import { ClusterViewerScreen } from "src/screens/directory/datagraph/ClusterViewerScreen/ClusterViewerScreen"; import { ItemScreen } from "src/screens/directory/datagraph/ItemScreen/ItemScreen"; import { Params, @@ -20,26 +21,35 @@ type Props = { export default async function Page(props: Props) { const { slug } = ParamsSchema.parse(props.params); - const top = last(slug) ?? ""; + const [targetSlug, fallback, isNew] = getTargetSlug(slug); // TODO: here we're firing two requests to the server, one for a cluster and // one for the item at the same slug. We should probably have a single request // that returns either or a 404. We're also not handling other errors either. const [cluster, item] = await Promise.all([ - server({ url: `/v1/clusters/${top}` }).catch(() => { - // ignore any errors - }), - server({ url: `/v1/items/${top}` }).catch(() => { + server({ url: `/v1/clusters/${targetSlug}` }).catch( + () => { + // ignore any errors + }, + ), + server({ url: `/v1/items/${targetSlug}` }).catch(() => { // ignore any errors }), ]); if (cluster) { - return ; + if (isNew) { + return ; + } + + return ; } if (item) { - return ; + if (isNew) { + redirect(`/directory/${fallback}`); + } + return ; } notFound(); diff --git a/web/src/app/(dashboard)/directory/new/page.tsx b/web/src/app/(dashboard)/directory/new/page.tsx new file mode 100644 index 000000000..aeda45347 --- /dev/null +++ b/web/src/app/(dashboard)/directory/new/page.tsx @@ -0,0 +1,5 @@ +import { ClusterCreateScreen } from "src/screens/directory/datagraph/ClusterCreateScreen/ClusterCreateScreen"; + +export default async function Page() { + return ; +} diff --git a/web/src/components/content/ContentComposer/ContentComposer.tsx b/web/src/components/content/ContentComposer/ContentComposer.tsx index d000da5e5..f8a22583b 100644 --- a/web/src/components/content/ContentComposer/ContentComposer.tsx +++ b/web/src/components/content/ContentComposer/ContentComposer.tsx @@ -1,9 +1,10 @@ import { PropsWithChildren, useCallback } from "react"; import { Editable, Slate } from "slate-react"; +import { FileDrop } from "../FileDrop/FileDrop"; + import { Box } from "@/styled-system/jsx"; -import { FileDrop } from "./components/FileDrop/FileDrop"; import { Element } from "./render/Element"; import { Leaf } from "./render/Leaf"; import { Props, useContentComposer } from "./useContentComposer"; @@ -13,7 +14,8 @@ export function ContentComposer({ children, ...props }: PropsWithChildren) { - const { editor, initialValue, onChange } = useContentComposer(props); + const { editor, initialValue, onChange, handleAssetUpload } = + useContentComposer(props); const renderLeaf = useCallback((props: any) => , []); const renderElement = useCallback((props: any) => , []); @@ -29,7 +31,7 @@ export function ContentComposer({ {children} - + ) { diff --git a/web/src/components/content/ContentComposer/components/FileDrop/utils.ts b/web/src/components/content/FileDrop/utils.ts similarity index 100% rename from web/src/components/content/ContentComposer/components/FileDrop/utils.ts rename to web/src/components/content/FileDrop/utils.ts diff --git a/web/src/components/directory/datagraph/Breadcrumbs.tsx b/web/src/components/directory/datagraph/Breadcrumbs.tsx index c2f35adda..918313990 100644 --- a/web/src/components/directory/datagraph/Breadcrumbs.tsx +++ b/web/src/components/directory/datagraph/Breadcrumbs.tsx @@ -1,35 +1,80 @@ -import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { ChevronRightIcon, PlusCircleIcon } from "@heroicons/react/24/outline"; +import { pull } from "lodash"; +import { FormEventHandler, ForwardedRef, Fragment, forwardRef } from "react"; import { DirectoryPath, joinDirectoryPath, } from "src/screens/directory/datagraph/useDirectoryPath"; +import { Input } from "src/theme/components/Input"; import { Link } from "src/theme/components/Link"; import { HStack } from "@/styled-system/jsx"; type Props = { directoryPath: DirectoryPath; + create: "hide" | "show" | "edit"; + value?: string; + defaultValue?: string; + onChange?: FormEventHandler; }; -export function Breadcrumbs(props: Props) { +export const _Breadcrumbs = ( + { directoryPath, create, value, defaultValue, onChange, ...rest }: Props, + ref: ForwardedRef, +) => { + const isEditing = create == "edit" && onChange !== undefined; + const editingPath = isEditing ? directoryPath.slice(0, -1) : directoryPath; + const paths = pull(editingPath, "new"); + const jointNew = joinDirectoryPath(directoryPath, "new"); + return ( - - + + Directory - {props.directoryPath.map((p) => ( - <> + {paths.map((p) => ( + {p} - + ))} + {create == "show" && ( + <> + + + Create + + + )} + {isEditing && ( + <> + + + + )} ); -} +}; + +export const Breadcrumbs = forwardRef(_Breadcrumbs); diff --git a/web/src/components/directory/datagraph/ClusterCard/ClusterCard.tsx b/web/src/components/directory/datagraph/ClusterCard/ClusterCard.tsx index a13f48e14..1ff370e34 100644 --- a/web/src/components/directory/datagraph/ClusterCard/ClusterCard.tsx +++ b/web/src/components/directory/datagraph/ClusterCard/ClusterCard.tsx @@ -1,5 +1,5 @@ import { Cluster } from "src/api/openapi/schemas"; -import { Empty } from "src/components/feed/common/PostRef/Empty"; +import { Empty } from "src/components/site/Empty"; import { DirectoryPath, joinDirectoryPath, diff --git a/web/src/components/directory/datagraph/utils.ts b/web/src/components/directory/datagraph/utils.ts new file mode 100644 index 000000000..7fe3eac6f --- /dev/null +++ b/web/src/components/directory/datagraph/utils.ts @@ -0,0 +1,21 @@ +import { last, nth, takeWhile } from "lodash"; + +export function getTargetSlug(slug: string[]): [string, string, boolean] { + const top = last(slug); + + const isNew = top === "new"; + + const target = isNew + ? // If the tail item is "new" then walk back until we find an actual slug. + last(takeWhile(slug, (s) => s !== "new")) + : // Otherwise, it's whatever the last item is. + top; + + // The fallback is for when the target is actually an item. Items cannot have + // children, so we can assume the parent is the left-most slug of the target, + // which is the third index from the end of the slug array. This is an edge + // case though as the only way to hit this would be to manually add "/new". + const fallback = slug.length > 2 ? nth(slug, -3) : ""; + + return [target ?? "", fallback ?? "", isNew]; +} diff --git a/web/src/components/directory/links/LinkCard.tsx b/web/src/components/directory/links/LinkCard.tsx index 4f0552409..8a5db6e7a 100644 --- a/web/src/components/directory/links/LinkCard.tsx +++ b/web/src/components/directory/links/LinkCard.tsx @@ -1,7 +1,7 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid"; import { Link as LinkSchema } from "src/api/openapi/schemas"; -import { Empty } from "src/components/feed/common/PostRef/Empty"; +import { Empty } from "src/components/site/Empty"; import { Link } from "src/theme/components/Link"; import { diff --git a/web/src/components/directory/links/LinkCardList.tsx b/web/src/components/directory/links/LinkCardList.tsx index 879391083..bdfd58c86 100644 --- a/web/src/components/directory/links/LinkCardList.tsx +++ b/web/src/components/directory/links/LinkCardList.tsx @@ -1,6 +1,6 @@ import { LinkListResult } from "src/api/openapi/schemas"; import { LinkCard } from "src/components/directory/links/LinkCard"; -import { Empty } from "src/components/feed/common/PostRef/Empty"; +import { Empty } from "src/components/site/Empty"; import { styled } from "@/styled-system/jsx"; diff --git a/web/src/components/directory/members/MemberList.tsx b/web/src/components/directory/members/MemberList.tsx index dc8f75470..33e3b3d51 100644 --- a/web/src/components/directory/members/MemberList.tsx +++ b/web/src/components/directory/members/MemberList.tsx @@ -1,5 +1,5 @@ import { PublicProfileList } from "src/api/openapi/schemas"; -import { Empty } from "src/components/feed/common/PostRef/Empty"; +import { Empty } from "src/components/site/Empty"; import { styled } from "@/styled-system/jsx"; diff --git a/web/src/components/feed/common/PostRef/PostRefList.tsx b/web/src/components/feed/common/PostRef/PostRefList.tsx index 565c762b1..3a57fe84a 100644 --- a/web/src/components/feed/common/PostRef/PostRefList.tsx +++ b/web/src/components/feed/common/PostRef/PostRefList.tsx @@ -1,8 +1,9 @@ import { PostProps, ThreadReference } from "src/api/openapi/schemas"; +import { Empty } from "../../../site/Empty"; + import { styled } from "@/styled-system/jsx"; -import { Empty } from "./Empty"; import { PostRef } from "./PostRef"; type Either = PostProps | ThreadReference; diff --git a/web/src/components/feed/link/LinkPost.tsx b/web/src/components/feed/link/LinkPost.tsx index 715812475..55518ecc2 100644 --- a/web/src/components/feed/link/LinkPost.tsx +++ b/web/src/components/feed/link/LinkPost.tsx @@ -2,8 +2,8 @@ import { Link as LinkSchema, ThreadReference } from "src/api/openapi/schemas"; import { Anchor } from "src/components/site/Anchor"; import { Heading1 } from "src/theme/components/Heading/Index"; +import { Empty } from "../../site/Empty"; import { FeedItemByline } from "../common/FeedItemByline/FeedItemByline"; -import { Empty } from "../common/PostRef/Empty"; import { Box, Flex, VStack, styled } from "@/styled-system/jsx"; import { Card } from "@/styled-system/patterns"; diff --git a/web/src/components/site/Action/Edit.tsx b/web/src/components/site/Action/Edit.tsx index e0e209583..a699643f2 100644 --- a/web/src/components/site/Action/Edit.tsx +++ b/web/src/components/site/Action/Edit.tsx @@ -1,11 +1,16 @@ import { PencilIcon } from "@heroicons/react/24/outline"; +import { PropsWithChildren } from "react"; import { Button, ButtonProps } from "src/theme/components/Button"; -export function EditAction(props: ButtonProps) { +export function EditAction({ + children, + ...props +}: PropsWithChildren) { return ( ); } diff --git a/web/src/components/site/Action/Save.tsx b/web/src/components/site/Action/Save.tsx index 6a70d420e..784dff991 100644 --- a/web/src/components/site/Action/Save.tsx +++ b/web/src/components/site/Action/Save.tsx @@ -1,11 +1,15 @@ import { CloudArrowUpIcon } from "@heroicons/react/24/outline"; +import { PropsWithChildren } from "react"; import { Button, ButtonProps } from "src/theme/components/Button"; -export function SaveAction(props: ButtonProps) { +export function SaveAction({ + children, + ...props +}: PropsWithChildren) { return ( - ); } diff --git a/web/src/components/feed/common/PostRef/Empty.tsx b/web/src/components/site/Empty.tsx similarity index 59% rename from web/src/components/feed/common/PostRef/Empty.tsx rename to web/src/components/site/Empty.tsx index ab7c4ba3a..70a7e1ffe 100644 --- a/web/src/components/feed/common/PostRef/Empty.tsx +++ b/web/src/components/site/Empty.tsx @@ -6,11 +6,9 @@ import { HStack, styled } from "@/styled-system/jsx"; export function Empty({ children }: PropsWithChildren) { return ( - - - - {children} - + + + {children} ); } diff --git a/web/src/components/site/EmptyBox.tsx b/web/src/components/site/EmptyBox.tsx new file mode 100644 index 000000000..ad26f019c --- /dev/null +++ b/web/src/components/site/EmptyBox.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren } from "react"; + +import { Center } from "@/styled-system/jsx"; + +import { Empty } from "./Empty"; + +export function EmptyBox({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/web/src/screens/compose/components/TitleInput/TitleInput.tsx b/web/src/screens/compose/components/TitleInput/TitleInput.tsx index c8c65681e..7d9b7bbd7 100644 --- a/web/src/screens/compose/components/TitleInput/TitleInput.tsx +++ b/web/src/screens/compose/components/TitleInput/TitleInput.tsx @@ -1,9 +1,8 @@ -import { FormEvent } from "react"; import { Controller } from "react-hook-form"; import { FormControl } from "src/theme/components/FormControl"; import { FormErrorText } from "src/theme/components/FormErrorText"; -import { TitleInput as TitleInputComponent } from "src/theme/components/TitleInput"; +import { HeadingInput } from "src/theme/components/HeadingInput"; import { useTitleInput } from "./useTitleInput"; @@ -15,21 +14,14 @@ export function TitleInput() { { - function onInput(e: FormEvent) { - // NOTE: not sure which event type to use here... - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onChange((e.target as any).textContent); - } - return ( - - {formState.defaultValues?.["title"]} - + /> ); }} control={control} diff --git a/web/src/screens/directory/datagraph/ClusterCreateScreen/ClusterCreateScreen.tsx b/web/src/screens/directory/datagraph/ClusterCreateScreen/ClusterCreateScreen.tsx new file mode 100644 index 000000000..f44ad4cd6 --- /dev/null +++ b/web/src/screens/directory/datagraph/ClusterCreateScreen/ClusterCreateScreen.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { ClusterScreen } from "../ClusterScreen/ClusterScreen"; + +import { useClusterCreateScreen } from "./useClusterCreateScreen"; + +export function ClusterCreateScreen() { + const { + handlers: { handleCreate }, + initial, + } = useClusterCreateScreen(); + + return ( + + ); +} diff --git a/web/src/screens/directory/datagraph/ClusterCreateScreen/useClusterCreateScreen.ts b/web/src/screens/directory/datagraph/ClusterCreateScreen/useClusterCreateScreen.ts new file mode 100644 index 000000000..97de0e356 --- /dev/null +++ b/web/src/screens/directory/datagraph/ClusterCreateScreen/useClusterCreateScreen.ts @@ -0,0 +1,38 @@ +import { useRouter } from "next/navigation"; + +import { clusterCreate } from "src/api/openapi/clusters"; +import { ClusterInitialProps, ClusterWithItems } from "src/api/openapi/schemas"; +import { useSession } from "src/auth"; + +export function useClusterCreateScreen() { + const account = useSession(); + const router = useRouter(); + + if (!account) { + router.push("/login"); + } + + const initial: ClusterWithItems = { + id: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + name: "", + slug: "", + description: "", + owner: account!, + properties: {}, + items: [], + clusters: [], + }; + + async function handleCreate(cluster: ClusterInitialProps) { + await clusterCreate(cluster); + } + + return { + initial, + handlers: { + handleCreate, + }, + }; +} diff --git a/web/src/screens/directory/datagraph/ClusterScreen/ClusterScreen.tsx b/web/src/screens/directory/datagraph/ClusterScreen/ClusterScreen.tsx index ec2c6e7c5..bef30ea7d 100644 --- a/web/src/screens/directory/datagraph/ClusterScreen/ClusterScreen.tsx +++ b/web/src/screens/directory/datagraph/ClusterScreen/ClusterScreen.tsx @@ -1,47 +1,143 @@ "use client"; +import { isEmpty } from "lodash"; +import { FormProvider } from "react-hook-form"; + import { ContentViewer } from "src/components/content/ContentViewer/ContentViewer"; +import { FileDrop } from "src/components/content/FileDrop/FileDrop"; import { Breadcrumbs } from "src/components/directory/datagraph/Breadcrumbs"; import { ClusterList } from "src/components/directory/datagraph/ClusterList"; -import { DatagraphHeader } from "src/components/directory/datagraph/Header"; import { ItemGrid } from "src/components/directory/datagraph/ItemGrid"; -import { Empty } from "src/components/feed/common/PostRef/Empty"; -import { Unready } from "src/components/site/Unready"; +import { EditAction } from "src/components/site/Action/Edit"; +import { SaveAction } from "src/components/site/Action/Save"; +import { Empty } from "src/components/site/Empty"; +import { EmptyBox } from "src/components/site/EmptyBox"; +import { Admonition } from "src/theme/components/Admonition"; +import { Heading1 } from "src/theme/components/Heading/Index"; +import { Input } from "src/theme/components/Input"; -import { VStack } from "@/styled-system/jsx"; +import { HStack, VStack, styled } from "@/styled-system/jsx"; +import { ContentInput } from "./ContentInput"; +import { TitleInput } from "./TitleInput"; import { Props, useClusterScreen } from "./useClusterScreen"; export function ClusterScreen(props: Props) { - const { ready, data, directoryPath, error } = useClusterScreen(props); - - if (!ready) return ; + const { + form, + handlers: { handleSave, handleEditMode, handleAssetUpload }, + directoryPath, + editing, + cluster, + isAllowedToEdit, + } = useClusterScreen(props); return ( - - + + + {Object.values(form.formState.errors).map((error, i) => ( +

{error.message}

+ ))} +
+ + + + + {isAllowedToEdit && ( + + {editing ? ( + Save + ) : ( + Edit + )} + + )} + + + + + {cluster.image_url ? ( + + ) : ( + + add images + + )} + + + + + {editing ? ( + + ) : ( + {cluster.name || "(untitled)"} + )} + - + {/* TODO: Links and link editing for clusters + {cluster.link && ( + + + {cluster.link?.url} + + + )} */} - {props.cluster.content && ( - - + {editing ? ( + + ) : ( + {cluster.description} + )} + - )} - - {data.clusters.length === 0 && data.items.length === 0 && ( - Nothing inside + {editing ? ( + + ) : ( + )} - {data.clusters.length > 0 && ( - - )} + + {cluster.clusters.length === 0 && cluster.items.length === 0 && ( + Nothing inside + )} - {data.items.length > 0 && ( - - )} - - + {cluster && (cluster.clusters.length ?? 0) > 0 && ( + + )} + + {cluster && cluster.items.length > 0 && ( + + )} + + +
); } diff --git a/web/src/screens/directory/datagraph/ClusterScreen/ContentInput.tsx b/web/src/screens/directory/datagraph/ClusterScreen/ContentInput.tsx new file mode 100644 index 000000000..e33ec245c --- /dev/null +++ b/web/src/screens/directory/datagraph/ClusterScreen/ContentInput.tsx @@ -0,0 +1,37 @@ +import { PropsWithChildren } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { Asset } from "src/api/openapi/schemas"; +import { ContentComposer } from "src/components/content/ContentComposer/ContentComposer"; +import { FormControl } from "src/theme/components/FormControl"; + +import { Form } from "./useClusterScreen"; + +type Props = { + onAssetUpload: (asset: Asset) => void; +}; + +export function ContentInput({ + children, + onAssetUpload, +}: PropsWithChildren) { + const { control } = useFormContext
(); + + return ( + + ( + + {children} + + )} + control={control} + name="content" + /> + + ); +} diff --git a/web/src/screens/directory/datagraph/ClusterScreen/TitleInput.tsx b/web/src/screens/directory/datagraph/ClusterScreen/TitleInput.tsx new file mode 100644 index 000000000..40ebbb737 --- /dev/null +++ b/web/src/screens/directory/datagraph/ClusterScreen/TitleInput.tsx @@ -0,0 +1,36 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl } from "src/theme/components/FormControl"; +import { FormErrorText } from "src/theme/components/FormErrorText"; +import { HeadingInput } from "src/theme/components/HeadingInput"; + +import { Form } from "./useClusterScreen"; + +export function TitleInput() { + const { control, formState } = useFormContext(); + + const fieldError = formState.errors?.["name"]; + + return ( + + { + return ( + + ); + }} + control={control} + name="name" + /> + + {fieldError?.message?.toString()} + + ); +} diff --git a/web/src/screens/directory/datagraph/ClusterScreen/useClusterScreen.ts b/web/src/screens/directory/datagraph/ClusterScreen/useClusterScreen.ts index dbf3ca090..5e4c46dfa 100644 --- a/web/src/screens/directory/datagraph/ClusterScreen/useClusterScreen.ts +++ b/web/src/screens/directory/datagraph/ClusterScreen/useClusterScreen.ts @@ -1,33 +1,76 @@ -import { useClusterGet } from "src/api/openapi/clusters"; -import { ClusterWithItems } from "src/api/openapi/schemas"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { ClusterInitialProps, ClusterWithItems } from "src/api/openapi/schemas"; +import { useSession } from "src/auth"; import { useDirectoryPath } from "../useDirectoryPath"; +export const FormSchema = z.object({ + name: z.string().min(1, "Please enter a name."), + slug: z.string().min(1, "Please enter a slug."), + description: z.string().min(1, "Please enter a short description."), + content: z.string().optional(), +}); +export type Form = z.infer; + export type Props = { - slug: string; cluster: ClusterWithItems; + initialEditingState?: boolean; + editable?: boolean; + onSave: (c: ClusterInitialProps) => Promise; }; -export function useClusterScreen(props: Props) { - const { data, mutate, error } = useClusterGet(props.slug, { - swr: { - fallbackData: props.cluster, - }, +export function useClusterScreen({ + cluster, + initialEditingState = false, + editable = true, + onSave, +}: Props) { + const directoryPath = useDirectoryPath(); + const account = useSession(); + const [editing, setEditing] = useState(initialEditingState); + + const isAllowedToEdit = + editable && Boolean(account?.id === cluster.owner.id || account?.admin); + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: cluster, }); - const directoryPath = useDirectoryPath(); + function handleEditMode() { + if (editing) return; + if (!isAllowedToEdit) return; - if (!data) { - return { - ready: false as const, - error, - }; + setEditing(true); + } + + function handleSave(payload: ClusterInitialProps) { + if (!editing) return; + + setEditing(false); + onSave(payload); + } + + function handleAssetUpload() { + if (!editing) return; + + onSave(form.getValues()); } return { - ready: true as const, - data, + form, + handlers: { + handleEditMode, + handleSave, + handleAssetUpload, + }, directoryPath, - mutate, + editing, + cluster, + isAllowedToEdit, }; } diff --git a/web/src/screens/directory/datagraph/ClusterViewerScreen/ClusterViewerScreen.tsx b/web/src/screens/directory/datagraph/ClusterViewerScreen/ClusterViewerScreen.tsx new file mode 100644 index 000000000..db8261cc2 --- /dev/null +++ b/web/src/screens/directory/datagraph/ClusterViewerScreen/ClusterViewerScreen.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Unready } from "src/components/site/Unready"; + +import { ClusterScreen } from "../ClusterScreen/ClusterScreen"; + +import { Props, useClusterViewerScreen } from "./useClusterViewerScreen"; + +export function ClusterViewerScreen(props: Props) { + const { ready, data, handlers, error } = useClusterViewerScreen(props); + + if (!ready) return ; + + return ; +} diff --git a/web/src/screens/directory/datagraph/ClusterViewerScreen/useClusterViewerScreen.ts b/web/src/screens/directory/datagraph/ClusterViewerScreen/useClusterViewerScreen.ts new file mode 100644 index 000000000..ede3bc47a --- /dev/null +++ b/web/src/screens/directory/datagraph/ClusterViewerScreen/useClusterViewerScreen.ts @@ -0,0 +1,50 @@ +import { useRouter } from "next/navigation"; + +import { clusterUpdate, useClusterGet } from "src/api/openapi/clusters"; +import { ClusterInitialProps, ClusterWithItems } from "src/api/openapi/schemas"; + +import { replaceDirectoryPath, useDirectoryPath } from "../useDirectoryPath"; + +export type Props = { + slug: string; + cluster: ClusterWithItems; +}; + +export function useClusterViewerScreen(props: Props) { + const router = useRouter(); + const { data, mutate, error } = useClusterGet(props.slug, { + swr: { + fallbackData: props.cluster, + }, + }); + + const directoryPath = useDirectoryPath(); + + if (!data) { + return { + ready: false as const, + error, + }; + } + + const { slug } = data; + + async function handleSave(cluster: ClusterInitialProps) { + await clusterUpdate(slug, cluster); + await mutate(); + + // Handle slug changes properly by redirecting to the new path. + if (cluster.slug !== slug) { + const newPath = replaceDirectoryPath(directoryPath, slug, cluster.slug); + router.push(newPath); + } + } + + return { + ready: true as const, + data, + handlers: { handleSave }, + directoryPath, + mutate, + }; +} diff --git a/web/src/screens/directory/datagraph/DatagraphIndexScreen.tsx b/web/src/screens/directory/datagraph/DatagraphIndexScreen.tsx index 679966667..c45fedf28 100644 --- a/web/src/screens/directory/datagraph/DatagraphIndexScreen.tsx +++ b/web/src/screens/directory/datagraph/DatagraphIndexScreen.tsx @@ -3,7 +3,7 @@ import { ClusterList } from "src/components/directory/datagraph/ClusterList"; import { ItemGrid } from "src/components/directory/datagraph/ItemGrid"; import { LinkCardList } from "src/components/directory/links/LinkCardList"; -import { Empty } from "src/components/feed/common/PostRef/Empty"; +import { Empty } from "src/components/site/Empty"; import { Unready } from "src/components/site/Unready"; import { Heading1, Heading2 } from "src/theme/components/Heading/Index"; diff --git a/web/src/screens/directory/datagraph/ItemScreen/ItemScreen.tsx b/web/src/screens/directory/datagraph/ItemScreen/ItemScreen.tsx index 06e4be02e..fc3672659 100644 --- a/web/src/screens/directory/datagraph/ItemScreen/ItemScreen.tsx +++ b/web/src/screens/directory/datagraph/ItemScreen/ItemScreen.tsx @@ -4,7 +4,7 @@ import { ContentViewer } from "src/components/content/ContentViewer/ContentViewe import { Breadcrumbs } from "src/components/directory/datagraph/Breadcrumbs"; import { ClusterList } from "src/components/directory/datagraph/ClusterList"; import { DatagraphHeader } from "src/components/directory/datagraph/Header"; -import { Empty } from "src/components/feed/common/PostRef/Empty"; +import { Empty } from "src/components/site/Empty"; import { Unready } from "src/components/site/Unready"; import { Heading2 } from "src/theme/components/Heading/Index"; diff --git a/web/src/screens/directory/datagraph/useDirectoryPath.tsx b/web/src/screens/directory/datagraph/useDirectoryPath.tsx index 4445dae42..e39664461 100644 --- a/web/src/screens/directory/datagraph/useDirectoryPath.tsx +++ b/web/src/screens/directory/datagraph/useDirectoryPath.tsx @@ -51,3 +51,20 @@ export function joinDirectoryPath(onto: DirectoryPath, end: string): string { return list.join("/"); } + +/** + * replaceDirectoryPath is for when the slug of a datagraph node changes. It's + * similar to joinDirectoryPath but it'll replace the old slug with the new one. + */ +export function replaceDirectoryPath( + onto: DirectoryPath, + oldSlug: string, + newSlug: string, +): string { + const inPath = indexOf(onto, oldSlug); + + const list = + inPath === -1 ? [...onto, newSlug] : [...onto.slice(0, inPath), newSlug]; + + return list.join("/"); +} diff --git a/web/src/theme/components/Admonition/admonition.recipe.ts b/web/src/theme/components/Admonition/admonition.recipe.ts index 0ba2cb306..1d8f9b8db 100644 --- a/web/src/theme/components/Admonition/admonition.recipe.ts +++ b/web/src/theme/components/Admonition/admonition.recipe.ts @@ -29,8 +29,8 @@ export const admonition = defineRecipe({ color: "accent.text.500", }, failure: { - backgroundColor: "rose.600", - color: "white", + backgroundColor: "bg.destructive", + color: "fg.default", }, }, }, diff --git a/web/src/theme/components/Admonition/index.tsx b/web/src/theme/components/Admonition/index.tsx index 31478f1d5..228dea96f 100644 --- a/web/src/theme/components/Admonition/index.tsx +++ b/web/src/theme/components/Admonition/index.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from "react"; import { Button } from "../Button"; +import { css } from "@/styled-system/css"; import { VStack, styled } from "@/styled-system/jsx"; import { AdmonitionVariantProps, admonition } from "@/styled-system/recipes"; @@ -22,7 +23,12 @@ export function Admonition(props: PropsWithChildren) { const [admonitionVariantProps] = admonition.splitVariantProps(props); return ( - + <_Admonition {...admonitionVariantProps}> {props.title && ( diff --git a/web/src/theme/components/HeadingInput/index.tsx b/web/src/theme/components/HeadingInput/index.tsx new file mode 100644 index 000000000..b377b73dc --- /dev/null +++ b/web/src/theme/components/HeadingInput/index.tsx @@ -0,0 +1,95 @@ +import { ark } from "@ark-ui/react"; +import { + ClipboardEvent, + type ComponentPropsWithoutRef, + FormEvent, + ForwardedRef, + KeyboardEvent, + forwardRef, + useCallback, +} from "react"; +import { + type HeadingInputVariantProps, + type HeadingVariantProps, + headingInput, +} from "styled-system/recipes"; + +import { cx } from "@/styled-system/css"; +import { styled } from "@/styled-system/jsx"; +import { heading } from "@/styled-system/recipes"; + +type CustomProps = { + onValueChange: (s: string) => void; +}; + +export type HeadingInputProps = HeadingInputVariantProps & + HeadingVariantProps & + ComponentPropsWithoutRef & + CustomProps; + +function HeadingInputWithRef( + props: HeadingInputProps, + ref: ForwardedRef, +) { + const { onValueChange, defaultValue, ...rest } = props; + const [recipeProps, componentProps] = headingInput.splitVariantProps(rest); + + const [headingProps] = heading.splitVariantProps(rest); + + const handleInput = useCallback( + (e: FormEvent) => { + const text = (e.target as any).textContent; + onValueChange(text); + }, + [onValueChange], + ); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.code === "Enter") { + e.preventDefault(); + e.stopPropagation(); + } + }, []); + + const handlePaste = useCallback((e: ClipboardEvent) => { + e.preventDefault(); + + const text = e.clipboardData.getData("text/plain"); + + const stripped = text.replace(/(\r\n|\n|\r)/gm, " "); + + document.execCommand("insertText", false, stripped); + }, []); + + return ( + + {defaultValue} + + ); +} + +const HeadingInput = forwardRef(HeadingInputWithRef); + +export { HeadingInput }; diff --git a/web/src/theme/components/HeadingInput/recipe.ts b/web/src/theme/components/HeadingInput/recipe.ts new file mode 100644 index 000000000..20e46791d --- /dev/null +++ b/web/src/theme/components/HeadingInput/recipe.ts @@ -0,0 +1,35 @@ +import { defineRecipe } from "@pandacss/dev"; + +export const headingInput = defineRecipe({ + className: "headingInput", + base: { + display: "inline-block", + width: "full", + fontSize: "3xl", + overflowWrap: "break-word", + wordBreak: "break-word", + fontWeight: "semibold", + cursor: "text", + borderBottomColor: "border.default", + borderBottomWidth: "1px", + borderBottomStyle: "solid", + _focus: { + outline: "none", + borderBottomColor: "accent.500", + borderBottomWidth: "1px", + borderBottomStyle: "solid", + }, + _empty: { + _before: { + content: "attr(placeholder)", + opacity: 0.3, + color: "fg.default", + }, + }, + _invalid: { + borderBottomColor: "fg.destructive", + borderBottomWidth: "1px", + borderBottomStyle: "solid", + }, + }, +}); diff --git a/web/src/theme/components/TitleInput/index.tsx b/web/src/theme/components/TitleInput/index.tsx deleted file mode 100644 index d9f7e4362..000000000 --- a/web/src/theme/components/TitleInput/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ark } from "@ark-ui/react"; -import { - type ComponentPropsWithoutRef, - ForwardedRef, - type PropsWithChildren, - forwardRef, -} from "react"; -import { type TitleInputVariantProps, titleInput } from "styled-system/recipes"; - -import { cx } from "@/styled-system/css"; -import { styled } from "@/styled-system/jsx"; - -export type TitleInputProps = TitleInputVariantProps & - ComponentPropsWithoutRef; - -function _TitleInput( - props: PropsWithChildren, - ref: ForwardedRef, -) { - const { children, ...rest } = props; - const [recipeProps, componentProps] = titleInput.splitVariantProps(rest); - - return ( - - {children} - - ); -} - -const TitleInput = forwardRef(_TitleInput); - -export { TitleInput }; diff --git a/web/src/theme/components/TitleInput/titleInput.recipe.ts b/web/src/theme/components/TitleInput/titleInput.recipe.ts deleted file mode 100644 index 51883a3f7..000000000 --- a/web/src/theme/components/TitleInput/titleInput.recipe.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineRecipe } from "@pandacss/dev"; - -export const titleInput = defineRecipe({ - className: "titleInput", - base: { - display: "inline-block", - contentEditable: true, - // - // NOTE: We're doing a bit of a hack here in order to make this - // field look nice and behave like the Substack title editor. - // - // More info: - // - // https://medium.com/programming-essentials/good-to-know-about-the-state-management-of-a-contenteditable-element-in-react-adb4f933df12 - // - suppressContentEditableWarning: true, - width: "full", - fontSize: "3xl", - overflowWrap: "break-word", - wordBreak: "break-word", - fontWeight: "semibold", - cursor: "text", - _focus: { - outline: "none", - }, - _empty: { - _before: { - content: "attr(placeholder)", - opacity: 0.3, - color: "fg.default", - }, - }, - }, -}); diff --git a/web/styled-system/recipes/heading-input.d.ts b/web/styled-system/recipes/heading-input.d.ts new file mode 100644 index 000000000..2484726e6 --- /dev/null +++ b/web/styled-system/recipes/heading-input.d.ts @@ -0,0 +1,28 @@ +/* eslint-disable */ +import type { ConditionalValue } from '../types/index'; +import type { Pretty } from '../types/helpers'; +import type { DistributiveOmit } from '../types/system-types'; + +interface HeadingInputVariant { + +} + +type HeadingInputVariantMap = { + [key in keyof HeadingInputVariant]: Array +} + +export type HeadingInputVariantProps = { + [key in keyof HeadingInputVariant]?: ConditionalValue +} + +export interface HeadingInputRecipe { + __type: HeadingInputVariantProps + (props?: HeadingInputVariantProps): string + raw: (props?: HeadingInputVariantProps) => HeadingInputVariantProps + variantMap: HeadingInputVariantMap + variantKeys: Array + splitVariantProps(props: Props): [HeadingInputVariantProps, Pretty>] +} + + +export declare const headingInput: HeadingInputRecipe \ No newline at end of file diff --git a/web/styled-system/recipes/heading-input.mjs b/web/styled-system/recipes/heading-input.mjs new file mode 100644 index 000000000..41a268ffc --- /dev/null +++ b/web/styled-system/recipes/heading-input.mjs @@ -0,0 +1,19 @@ +import { splitProps } from '../helpers.mjs'; +import { createRecipe } from './create-recipe.mjs'; + +const headingInputFn = /* @__PURE__ */ createRecipe('headingInput', {}, []) + +const headingInputVariantMap = {} + +const headingInputVariantKeys = Object.keys(headingInputVariantMap) + +export const headingInput = /* @__PURE__ */ Object.assign(headingInputFn, { + __recipe__: true, + __name__: 'headingInput', + raw: (props) => props, + variantKeys: headingInputVariantKeys, + variantMap: headingInputVariantMap, + splitVariantProps(props) { + return splitProps(props, headingInputVariantKeys) + }, +}) \ No newline at end of file diff --git a/web/styled-system/recipes/index.d.ts b/web/styled-system/recipes/index.d.ts index cf87b1332..38859d35b 100644 --- a/web/styled-system/recipes/index.d.ts +++ b/web/styled-system/recipes/index.d.ts @@ -1,7 +1,7 @@ /* eslint-disable */ export * from './admonition'; export * from './input'; -export * from './title-input'; +export * from './heading-input'; export * from './heading'; export * from './button'; export * from './link'; diff --git a/web/styled-system/recipes/index.mjs b/web/styled-system/recipes/index.mjs index 019c6f19e..055b9fa35 100644 --- a/web/styled-system/recipes/index.mjs +++ b/web/styled-system/recipes/index.mjs @@ -1,6 +1,6 @@ export * from './admonition.mjs'; export * from './input.mjs'; -export * from './title-input.mjs'; +export * from './heading-input.mjs'; export * from './heading.mjs'; export * from './button.mjs'; export * from './link.mjs'; diff --git a/web/yarn.lock b/web/yarn.lock index 65296829b..c70de258c 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -6740,7 +6740,7 @@ prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.7.1, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -6778,6 +6778,14 @@ react-avatar-editor@^13.0.0: "@babel/runtime" "^7.12.5" prop-types "^15.7.2" +react-contenteditable@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/react-contenteditable/-/react-contenteditable-3.3.7.tgz#18dd1f281841ba2c2b306e2d28278bc31b7929ed" + integrity sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ== + dependencies: + fast-deep-equal "^3.1.3" + prop-types "^15.7.1" + react-dom@18.2.0, react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"