From dfdda54eba8e96bd84fb0ea31fa1409042f2ef0b Mon Sep 17 00:00:00 2001 From: PrettyGoodName <31628630+IdkGoodName@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:52:12 +0300 Subject: [PATCH 1/7] feat: profile posts --- packages/components/BrandLogo.tsx | 2 +- packages/components/Group.tsx | 8 ++ packages/components/index.ts | 3 +- packages/components/theme/dark.ts | 2 + packages/components/theme/light.ts | 1 + packages/components/types/index.ts | 1 + packages/components/types/typography.ts | 1 + sites/app.campground.gg/api/RESTClient.ts | 29 +++- .../src/components/Datestamp.tsx | 2 +- .../src/components/UserDisplay.tsx | 27 +++- .../components/markdown/MarkdownEditor.tsx | 37 +++++ .../components/markdown/MarkdownWrapper.tsx | 7 + .../src/components/markdown/PostInput.tsx | 45 ++++++ .../app.campground.gg/src/example/profile.ts | 132 ------------------ .../src/layout/GlobalNavbar.tsx | 21 +-- sites/app.campground.gg/src/layout/Home.tsx | 17 --- .../src/layout/PageSidebar.tsx | 17 +++ .../src/layout/PageSidebarItem.tsx | 31 ++++ .../src/layout/profile/ProfileFeed.tsx | 18 +-- .../src/layout/profile/ProfileFeedPost.tsx | 66 +++++---- .../src/layout/profile/ProfilePostView.tsx | 20 +-- .../src/layout/profile/ProfileView.tsx | 8 +- .../app.campground.gg/src/middleware/login.ts | 4 +- .../src/routes/_home._index/HomeActivity.tsx | 28 ++++ .../src/routes/_home._index/route.tsx | 23 +++ .../src/routes/_home/HomeSidebar.tsx | 43 ++++++ .../routes/{_index.tsx => _home/route.tsx} | 21 +-- .../src/routes/camps._index.tsx | 4 +- .../src/routes/profile.$id.tsx | 19 ++- .../routes/profile.($id).posts.$postId.tsx | 29 ++-- .../src/routes/profile._index.tsx | 11 +- .../app.campground.gg/src/routes/profile.tsx | 7 +- sites/app.campground.gg/types/user.ts | 24 ++-- 33 files changed, 430 insertions(+), 278 deletions(-) create mode 100644 packages/components/Group.tsx create mode 100644 sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx create mode 100644 sites/app.campground.gg/src/components/markdown/PostInput.tsx delete mode 100644 sites/app.campground.gg/src/example/profile.ts delete mode 100644 sites/app.campground.gg/src/layout/Home.tsx create mode 100644 sites/app.campground.gg/src/layout/PageSidebar.tsx create mode 100644 sites/app.campground.gg/src/layout/PageSidebarItem.tsx create mode 100644 sites/app.campground.gg/src/routes/_home._index/HomeActivity.tsx create mode 100644 sites/app.campground.gg/src/routes/_home._index/route.tsx create mode 100644 sites/app.campground.gg/src/routes/_home/HomeSidebar.tsx rename sites/app.campground.gg/src/routes/{_index.tsx => _home/route.tsx} (53%) diff --git a/packages/components/BrandLogo.tsx b/packages/components/BrandLogo.tsx index b58a6c9..4018092 100644 --- a/packages/components/BrandLogo.tsx +++ b/packages/components/BrandLogo.tsx @@ -1,5 +1,5 @@ import { Stack, Box, Typography, styled } from "@mui/joy"; -import { DefaultTypographySystem } from "@mui/joy/styles/types"; +import type { DefaultTypographySystem } from "@mui/joy/styles/types"; import SvgLogo from "./svg/SvgLogo"; import SvgUse from "./svg/SvgUse"; diff --git a/packages/components/Group.tsx b/packages/components/Group.tsx new file mode 100644 index 0000000..1abf48f --- /dev/null +++ b/packages/components/Group.tsx @@ -0,0 +1,8 @@ +import { Stack, styled } from "@mui/joy"; + +const Group = styled(Stack, { + name: "CampgroundGroup" +})(() => ({ + flexDirection: "row", +})); +export default Group; \ No newline at end of file diff --git a/packages/components/index.ts b/packages/components/index.ts index 1dba4d5..2643372 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -3,6 +3,7 @@ import theme from "./theme"; import SvgDefs from "./svg/SvgDefs"; import SvgUse from "./svg/SvgUse"; import BrandLogo from "./BrandLogo"; +import Group from "./Group"; import "./types"; -export { theme, PrimaryButton, BrandLogo, SvgDefs, SvgUse }; \ No newline at end of file +export { theme, PrimaryButton, BrandLogo, SvgDefs, SvgUse, Group }; \ No newline at end of file diff --git a/packages/components/theme/dark.ts b/packages/components/theme/dark.ts index 6dc5285..59f3cd7 100644 --- a/packages/components/theme/dark.ts +++ b/packages/components/theme/dark.ts @@ -27,7 +27,9 @@ const darkColorScheme: ColorSystemOptions = { primary: shades[50], secondary: shades[100], tertiary: shades[200], + quartary: shades[400], icon: shades[300], + code: "#fe603f", "code-keyword": "#fe603f", "code-string": "#ff538b", "code-number": "#c998f9", diff --git a/packages/components/theme/light.ts b/packages/components/theme/light.ts index a8e75fb..ad81f3f 100644 --- a/packages/components/theme/light.ts +++ b/packages/components/theme/light.ts @@ -27,6 +27,7 @@ const lightColorScheme: ColorSystemOptions = { primary: shades[50], secondary: shades[100], tertiary: shades[200], + quartary: shades[400], icon: shades[300], "code-keyword": "#fe603f", "code-string": "#ff538b", diff --git a/packages/components/types/index.ts b/packages/components/types/index.ts index 79aabe7..1300b89 100644 --- a/packages/components/types/index.ts +++ b/packages/components/types/index.ts @@ -19,6 +19,7 @@ declare module "@mui/joy/styles/types/colorSystem" { } // Add new text colours interface PaletteTextOverrides { + quartary: true; code: true; ["code-keyword"]: true; ["code-string"]: true; diff --git a/packages/components/types/typography.ts b/packages/components/types/typography.ts index e5e0df8..3ff0bcd 100644 --- a/packages/components/types/typography.ts +++ b/packages/components/types/typography.ts @@ -10,6 +10,7 @@ declare module "@mui/joy/styles/types/typography" { // Add new text levels interface TypographySystemOverrides { code: true; + quartary: true; ["code-keyword"]: true; ["code-string"]: true; ["code-number"]: true; diff --git a/sites/app.campground.gg/api/RESTClient.ts b/sites/app.campground.gg/api/RESTClient.ts index e834c9a..61768bb 100644 --- a/sites/app.campground.gg/api/RESTClient.ts +++ b/sites/app.campground.gg/api/RESTClient.ts @@ -1,6 +1,6 @@ import { defaultXrpcPrefix, defaultAppApiUrl, defaultBackendDomain } from "api.config"; import type { RestResponseError, RestResponseOkWithContent, RestResponseWithContent } from "./RESTResponse"; -import type { User } from "types/user"; +import type { User, UserPostBasic, UserPostDetailed } from "types/user"; import type { RESTRefreshLogin } from "./RESTErrorHandler"; import type { SessionAuthRefresh } from "~/session/types"; @@ -152,4 +152,31 @@ export default class RESTClient { }, }); } + + fetchPosts(actor: string) { + return this.get<{ posts: UserPostBasic[] }>({ + route: `gg.campground.profile.getPosts`, + queries: { + uri: `at://${actor}/gg.campground.profile.post`, + }, + }); + } + + fetchPostReplies(actor: string, post_tid: string) { + return this.get<{ posts: UserPostBasic[] }>({ + route: `gg.campground.profile.getPosts`, + queries: { + uri: `at://${actor}/gg.campground.profile.post/${post_tid}`, + }, + }); + } + + fetchPost(actor: string, post_tid: string) { + return this.get({ + route: `gg.campground.profile.getPost`, + queries: { + uri: `at://${actor}/gg.campground.profile.post/${post_tid}`, + }, + }); + } } \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/Datestamp.tsx b/sites/app.campground.gg/src/components/Datestamp.tsx index 63cef8b..ea9e2d0 100644 --- a/sites/app.campground.gg/src/components/Datestamp.tsx +++ b/sites/app.campground.gg/src/components/Datestamp.tsx @@ -19,7 +19,7 @@ const DatestampText = styled(Typography, { })); export default function Datestamp({ noAgo, displayDate, date }: Props) { - const isInvalid = Number.isNaN(date.getSeconds()); + const isInvalid = !date || Number.isNaN(date.getSeconds()); if (isInvalid) return ( diff --git a/sites/app.campground.gg/src/components/UserDisplay.tsx b/sites/app.campground.gg/src/components/UserDisplay.tsx index 5a9c0ac..ce1058b 100644 --- a/sites/app.campground.gg/src/components/UserDisplay.tsx +++ b/sites/app.campground.gg/src/components/UserDisplay.tsx @@ -1,16 +1,26 @@ -import { Dropdown, Link, Menu, MenuButton, Modal, ModalDialog, Stack, Tooltip, Typography } from "@mui/joy"; +import { Dropdown, Menu, MenuButton, Stack, Typography } from "@mui/joy"; import UserAvatar from "./UserAvatar"; -import React from "react"; import type { User } from "types/user"; import UserProfileCard from "./UserProfileCard"; +type Size = "sm" | "md" | "lg"; + type Props = { user: User; color?: string; - size?: "sm" | "md" | "lg"; + size?: Size; + avatarSize?: Size; + showHandle?: boolean; + alignItems?: "center" | "start" | "end"; +}; + +const sizeToGap: Record = { + sm: 1, + md: 1.5, + lg: 2, }; -export default function UserDisplay({ color, user, size }: Props) { +export default function UserDisplay({ color, user, size, avatarSize, alignItems, showHandle }: Props) { // const [openModal, setOpenModal] = React.useState(false); const actualSize = size ?? "md"; @@ -19,11 +29,16 @@ export default function UserDisplay({ color, user, size }: Props) { <> - - + + ({ color: color ?? theme.vars.palette.neutral[100], })}> {user.displayName} + { + showHandle && <> + @{user.handle.split("/")[2]} + + } {/* ({ color: color ?? theme.vars.palette.neutral[100], textDecorationColor: color ?? theme.vars.palette.neutral[100] })}> */} diff --git a/sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx b/sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx new file mode 100644 index 0000000..417a958 --- /dev/null +++ b/sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx @@ -0,0 +1,37 @@ +import { styled } from "@mui/joy"; +import type { SxProps } from "@mui/joy/styles/types"; +import { EditorContent, type Editor } from "@tiptap/react" +import MarkdownWrapper from "./MarkdownWrapper"; + +type Props = { + editor: Editor; + sx?: SxProps; +}; + +const StyledEditor = styled(EditorContent, { + name: "MarkdownEditor", + slot: "editor", +})(() => ({ + "> .tiptap:focus": { + outline: "none", + }, + "> .tiptap > *:first-child": { + marginTop: 0, + }, + "> .tiptap > *:last-child": { + marginBottom: 0, + }, +})); + +const StyledWrapper = styled(MarkdownWrapper, { + name: "MarkdownEditorWrapper", + slot: "root", +})(() => ({})); + +export default function MarkdownEditor(props: Props) { + return ( + + + + ) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx index 6d46b07..6ed83c4 100644 --- a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx +++ b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx @@ -26,6 +26,13 @@ const MarkdownWrapper = styled(Box, { bottom: 0, } }, + "code": { + backgroundColor: theme.vars.palette.background.body, + color: theme.vars.palette.text.code, + padding: `2px 4px`, + borderRadius: theme.vars.radius.sm, + fontFamily: theme.vars.fontFamily.code, + }, "table": { border: `solid 1px ${theme.vars.palette.neutral[500]}`, borderSpacing: 0, diff --git a/sites/app.campground.gg/src/components/markdown/PostInput.tsx b/sites/app.campground.gg/src/components/markdown/PostInput.tsx new file mode 100644 index 0000000..6dc81ee --- /dev/null +++ b/sites/app.campground.gg/src/components/markdown/PostInput.tsx @@ -0,0 +1,45 @@ +import { Card, CardContent, Link, Typography } from "@mui/joy"; +import type { SxProps } from "@mui/joy/styles/types"; +import { useEditor } from "@tiptap/react"; +import { StarterKit } from "@tiptap/starter-kit"; +import { useState } from "react"; +import type { User } from "types/user"; +import UserAvatar from "../UserAvatar"; +import MarkdownEditor from "./MarkdownEditor"; +import { Group } from "components"; + +type Props = { + user: User; + content?: string; + placeholder?: string; + sx?: SxProps; +}; + +export default function PostInput({ user, content, placeholder, sx }: Props) { + const editor = useEditor({ + extensions: [StarterKit], + content, + }); + + editor.commands.focus(); + + const [open, setOpen] = useState(false); + + return ( + + + {open + ? + + ({ flex: 1, color: theme.vars.palette.text.secondary })} /> + + : setOpen(!open)} gap={1.5} color="neutral" startDecorator={}> + + {placeholder ?? "What are you thinking?"} + + + } + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/example/profile.ts b/sites/app.campground.gg/src/example/profile.ts deleted file mode 100644 index ffc296b..0000000 --- a/sites/app.campground.gg/src/example/profile.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { UserPostComment } from "types/user"; - -export type Post = { - id: string; - title: string; - content: string; - tags: string[]; - comments: number; - createdAt: Date; -}; - -export const examplePosts: Post[] = [ - { - id: "abcdABCD", - title: "Example post", - content: "Hey, this is an example post.", - tags: ["example", "post", "test"], - comments: 2, - createdAt: new Date(Date.now() - 30000), - }, - { - id: "efghEFGH", - title: "Another post", - content: "Hey, this is another example post that you might see. This one tests Markdown: `a`, **b**, *c*", - tags: ["markdown", "example", "post"], - comments: 0, - createdAt: new Date(Date.now() - 720000) - }, - { - id: "ijklIJKL", - title: "Hey hey", - content: -`I made a severe lapse in judgement. Damn. - -# Example - -- **bold** -- *italic* -- \`inline code\` -- ~~Strike through~~ - -# To-do -- [ ] Markdown -- [x] Markdown - -# HTML Examples - -
a
-a - -# Tables - -| | a | b | -|---|----|----| -| a | aa | ab | -| b | ba | bb | - -| | a | b | c | d | e | -|---|----|----|--------------------|-------------------|-----------------------------------------------------------| -| a | aa | ab | aaaaaaaaaaaaaaaaaa | aaaaaaaaaaaaaaaaa | aaaaaaaaaa \`aaaaaaaaaaaaaaa\`aaaaaaaaaaaaaaaaaaaaaaaaaaa | -| b | ba | bb | | | | - -> Example quote -> \`\`\` -> Hello -> aaaa -> \`\`\` - -\`\`\`ts -Another example codeblock -Like here -const a = "b"; -/* -comment here -*/ -type Props = { - a: string[]; -}; -const b = \`a: \${{ - "a": 2 -}} <- a\`; - -export default class Example extends React.Component { - - render() { - return ( - {this.props.a} - ); - } -} -\`\`\` - -\`\`\`js another {"example": "here"} -aaaa -bbb -\`\`\` - -\`\`\`js {"start": 4, "fileName": "example.ts", "languageName": "Example name", "highlight": [2]} -With proper meta now -Notice the starting line is 4 -And this one is marked -And this one is not -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -\`\`\` - -\`\`\`diff -+ example -- example -! example -!!! example -@ example -@@@ example -\`\`\` -`, - tags: ["markdown", "example", "post"], - comments: 0, - createdAt: new Date(Date.now() - 8900000) - }, -]; - -export const exampleComments: Omit[] = [ - { - id: "12345678", - content: "This is a comment.", - createdAt: new Date(Date.now() - 20000), - }, - { - id: "87654321", - content: "This is another comment.", - createdAt: new Date(Date.now() - 10000), - } -]; \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/GlobalNavbar.tsx b/sites/app.campground.gg/src/layout/GlobalNavbar.tsx index fb3119e..2eedfd6 100644 --- a/sites/app.campground.gg/src/layout/GlobalNavbar.tsx +++ b/sites/app.campground.gg/src/layout/GlobalNavbar.tsx @@ -4,6 +4,7 @@ import { GlobalNavbarItem } from "./GlobalNavbarItem"; import NavbarCamp from "~/components/NavbarCamp"; import GlobalNavProfile from "./GlobalNavProfile"; import type { Session, SessionAuthUser } from "~/session/types"; +import { Link } from "react-router"; type Props = { page: string | null; @@ -20,16 +21,18 @@ export default class GlobalNavbar extends React.Component { - - - - - - + + + + + + + + - - {/* + {/* diff --git a/sites/app.campground.gg/src/layout/Home.tsx b/sites/app.campground.gg/src/layout/Home.tsx deleted file mode 100644 index 9283eab..0000000 --- a/sites/app.campground.gg/src/layout/Home.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Stack, Typography } from "@mui/joy"; -import React from "react"; - -type Props = { -}; - -export default class Home extends React.Component { - render(): React.ReactNode { - return ( - - - - - - ); - } -} \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/PageSidebar.tsx b/sites/app.campground.gg/src/layout/PageSidebar.tsx new file mode 100644 index 0000000..1f665ae --- /dev/null +++ b/sites/app.campground.gg/src/layout/PageSidebar.tsx @@ -0,0 +1,17 @@ +import { List, Stack, styled } from "@mui/joy"; +import { ReactNode } from "react"; + +const PageSidebar = styled(Stack, { + name: "PageSidebar" +})(() => ({ + padding: "20px 20px", + width: 300, +})); +export function PageSidebarList({ children }: { children: ReactNode[] | ReactNode }) { + return ( + + {children} + + ); +} +export default PageSidebar; \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/PageSidebarItem.tsx b/sites/app.campground.gg/src/layout/PageSidebarItem.tsx new file mode 100644 index 0000000..92a0f0d --- /dev/null +++ b/sites/app.campground.gg/src/layout/PageSidebarItem.tsx @@ -0,0 +1,31 @@ +import { ListItem, ListItemButton, ListItemContent, ListItemDecorator, type ColorPaletteProp } from "@mui/joy"; +import React, { ReactNode } from "react"; + +type Props = { + href: string; + active?: boolean; + children: ReactNode[] | ReactNode; + icon: ReactNode; + color?: ColorPaletteProp; +}; + +export default class PageSidebarItem extends React.Component { + constructor(props: Props) { + super(props); + } + render() { + const { children, active, href, icon, color } = this.props; + return ( + + ({ borderRadius: theme.vars.radius.lg })}> + + {icon} + + + {children} + + + + ); + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx index 7e9892a..da3b1a9 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx @@ -1,27 +1,29 @@ import { Box, Stack, Typography } from "@mui/joy"; import React from "react"; import ProfileFeedPost from "./ProfileFeedPost"; -import { examplePosts } from "~/example/profile"; -import type { User } from "types/user"; +import type { User, UserPostBasic } from "types/user"; import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; +import PostInput from "~/components/markdown/PostInput"; type Props = { user: User; + isSelf: boolean; + posts: UserPostBasic[] | undefined; }; export default class ProfileFeed extends React.Component { render(): React.ReactNode { - const { user } = this.props; - const posts = examplePosts.map((x) => ({ ...x, author: user, profileUser: user })) + const { user, posts, isSelf } = this.props; return ( Feed + {isSelf && } - {posts.map((x) => + {posts?.map((x) => )} @@ -30,6 +32,6 @@ export default class ProfileFeed extends React.Component { This user has no more posts to be found! Come back later! - ) + ); } } \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx index 04ca171..d20b8e6 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx @@ -1,58 +1,56 @@ -import { Card, CardContent, Chip, Stack, Typography } from "@mui/joy"; +import { Card, CardContent, CardOverflow, Chip, Stack, Typography } from "@mui/joy"; import React from "react"; import UserDisplay from "~/components/UserDisplay"; -import { IconMessage, IconShare } from "@tabler/icons-react"; +import { IconCornerUpRightDouble, IconMessage, IconMoodPlus } from "@tabler/icons-react"; import Datestamp from "~/components/Datestamp"; -import type { UserPost } from "types/user"; +import type { EitherUserPost, UserPost } from "types/user"; import Link from "~/components/Link"; import MarkdownWrapper from "~/components/markdown/MarkdownWrapper"; import { LargeContentMarkdown } from "~/components/markdown/Markdown"; type Props = { post: UserPost; - linkTitle?: boolean; + showCommentsLink?: boolean; }; export default class ProfileFeedPost extends React.Component { render(): React.ReactNode { - const { linkTitle } = this.props; - const { id, title, content, createdAt, comments, tags, author, profileUser } = this.props.post; - const titleNode = ( - {title} - ); + const { showCommentsLink } = this.props; + const { uri, content, createdAt, replies, replyCount, tags, author } = this.props.post as EitherUserPost; + const postTid = uri.split("/")[4]; return ( - - - - {linkTitle - ? {titleNode} - : titleNode - } - - {tags.map((tag, i) => {tag})} - - - + + + + + {/* {ms(Date.now() - createdAt, { long: true })} ago */} + + + + + + ({ mt: -4.5, color: theme.vars.palette.text.secondary })}> {content} {/* {content} */} - - - - {/* {ms(Date.now() - createdAt, { long: true })} ago */} - + + {showCommentsLink && }> + {replyCount ?? replies.length}{" "} + } + {showCommentsLink && }> + {replyCount ?? replies.length}{" "} + } + {showCommentsLink && }> + {" "} + } - - }> - {comments}{" "} - Comments - - }> - Share - + + + {tags.map((tag, i) => {tag})} + diff --git a/sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx b/sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx index 610bc51..7105eec 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx @@ -1,9 +1,8 @@ import { Box, Stack, Typography } from "@mui/joy"; import React from "react"; -import type { User, UserPost, UserPostComment } from "types/user"; +import type { EitherUserPost, User, UserPost } from "types/user"; import ProfileLayout from "./ProfileLayout"; import ProfileFeedPost from "./ProfileFeedPost"; -import ProfileFeedComment from "./ProfileFeedComment"; import { IconArrowNarrowLeft } from "@tabler/icons-react"; import Link from "~/components/Link"; import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; @@ -11,12 +10,12 @@ import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlacehold type Props = { user: User; post: UserPost; - comments: UserPostComment[]; }; export default class ProfilePostView extends React.Component { render(): React.ReactNode { - const { user, post, comments } = this.props; + const { user, post } = this.props; + const { replyCount, replies } = post as EitherUserPost; return ( @@ -30,15 +29,16 @@ export default class ProfilePostView extends React.Component { - Comments ({post.comments}) + Comments ({replyCount ?? replies.length}) { - comments.length - ? comments.map((x) => ( - ( + )) : There are no comments. diff --git a/sites/app.campground.gg/src/layout/profile/ProfileView.tsx b/sites/app.campground.gg/src/layout/profile/ProfileView.tsx index ede2371..fedcafc 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileView.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileView.tsx @@ -2,17 +2,19 @@ import { Box, Stack } from "@mui/joy"; import React from "react"; import ProfileFeed from "./ProfileFeed"; import ProfileAbout from "./ProfileAbout"; -import type { User } from "types/user"; +import type { User, UserPostBasic } from "types/user"; import ProfileLayout from "./ProfileLayout"; import ProfileGames from "./ProfileGames"; type Props = { user: User; + posts: UserPostBasic[] | undefined; + isSelf: boolean; }; export default class ProfileView extends React.Component { render(): React.ReactNode { - const { user } = this.props; + const { user, posts, isSelf } = this.props; return ( @@ -21,7 +23,7 @@ export default class ProfileView extends React.Component { - + diff --git a/sites/app.campground.gg/src/middleware/login.ts b/sites/app.campground.gg/src/middleware/login.ts index bbf6f79..d5d6ee3 100644 --- a/sites/app.campground.gg/src/middleware/login.ts +++ b/sites/app.campground.gg/src/middleware/login.ts @@ -2,7 +2,7 @@ import { redirect } from "react-router"; export function loginPageRejectionMiddleware() { const auth = window.localStorage.getItem("auth"); - console.log("Rejectable login: ", auth); + try { if (auth) { const json = JSON.parse(auth); @@ -19,7 +19,7 @@ export function loginPageRejectionMiddleware() { export function loginRequiredMiddleware() { const auth = window.localStorage.getItem("auth"); - console.log("Logged in: ", auth); + try { if (auth) { const json = JSON.parse(auth); diff --git a/sites/app.campground.gg/src/routes/_home._index/HomeActivity.tsx b/sites/app.campground.gg/src/routes/_home._index/HomeActivity.tsx new file mode 100644 index 0000000..4a944d7 --- /dev/null +++ b/sites/app.campground.gg/src/routes/_home._index/HomeActivity.tsx @@ -0,0 +1,28 @@ +import { Avatar, Card, CardContent, List, ListItem, ListItemContent, ListItemDecorator, Typography } from "@mui/joy"; +import { IconConfettiFilled } from "@tabler/icons-react"; + +type Props = { +}; + +export default function HomeActivity(props: Props) { + return ( + ({ width: "100%", backgroundColor: theme.vars.palette.background.body })}> + + Recent activity + + + + + + + + + Welcome to Campground! + Look around and discover camps, campers and more! + + + + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/_home._index/route.tsx b/sites/app.campground.gg/src/routes/_home._index/route.tsx new file mode 100644 index 0000000..c1e5c87 --- /dev/null +++ b/sites/app.campground.gg/src/routes/_home._index/route.tsx @@ -0,0 +1,23 @@ +import type { Route } from "./+types/route"; +import { loginRequiredMiddleware } from "~/middleware/login"; +import { Box, Typography } from "@mui/joy"; +import HomeActivity from "./HomeActivity"; + +export function meta(routes: Route.MetaArgs) { + return [ + { title: "Campground — Home" }, + { name: "description", content: "Gather around the fire, friends" }, + ]; +} + +export const clientMiddleware: Route.ClientMiddlewareFunction[] = [ + loginRequiredMiddleware +]; + +export default function Index(...args: unknown[]) { + return ( + + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/_home/HomeSidebar.tsx b/sites/app.campground.gg/src/routes/_home/HomeSidebar.tsx new file mode 100644 index 0000000..73d9e69 --- /dev/null +++ b/sites/app.campground.gg/src/routes/_home/HomeSidebar.tsx @@ -0,0 +1,43 @@ +import { type ColorPaletteProp, Stack, Typography } from "@mui/joy"; +import React from "react"; +import PageSidebar, { PageSidebarList } from "../../layout/PageSidebar"; +import { IconFlame, IconFriends } from "@tabler/icons-react"; +import PageSidebarItem from "../../layout/PageSidebarItem"; + +type Props = { + page: string; +}; + +const pages = [ + { + href: "/", + icon: , + text: "Feed", + color: "primary" as ColorPaletteProp + }, + { + href: "/friends", + icon: , + text: "Friends", + }, +]; + +export default class HomeSidebar extends React.Component { + render(): React.ReactNode { + const { page: active } = this.props; + return ( + + + Campground + + {pages.map((page) => + + {page.text} + + )} + + + + ); + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/_index.tsx b/sites/app.campground.gg/src/routes/_home/route.tsx similarity index 53% rename from sites/app.campground.gg/src/routes/_index.tsx rename to sites/app.campground.gg/src/routes/_home/route.tsx index eb59dd3..c86c061 100644 --- a/sites/app.campground.gg/src/routes/_index.tsx +++ b/sites/app.campground.gg/src/routes/_home/route.tsx @@ -1,12 +1,14 @@ -import type { Route } from "./+types/_index"; -import Home from "../layout/Home"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/route"; +import HomeSidebar from "./HomeSidebar"; import GlobalLayout from "~/layout/GlobalLayout"; import { loginRequiredMiddleware } from "~/middleware/login"; import { useSession } from "~/session"; +import { Group } from "components"; -export function meta(routes: Route.MetaArgs) { +export function meta(_routes: Route.MetaArgs) { return [ - { title: "Campground — Camp" }, + { title: "Campground — Home" }, { name: "description", content: "Gather around the fire, friends" }, ]; } @@ -15,13 +17,16 @@ export const clientMiddleware: Route.ClientMiddlewareFunction[] = [ loginRequiredMiddleware ]; -export default function Index(...args: unknown[]) { - console.log("Got args", args); + +export default function Index() { const session = useSession(); - console.log("Got session", session); + return ( - + + + + ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/camps._index.tsx b/sites/app.campground.gg/src/routes/camps._index.tsx index fff4246..1f74d34 100644 --- a/sites/app.campground.gg/src/routes/camps._index.tsx +++ b/sites/app.campground.gg/src/routes/camps._index.tsx @@ -1,4 +1,4 @@ -import Home from "~/layout/Home"; +import HomeSidebar from "~/routes/_home/HomeSidebar"; import type { Route } from "./+types/camps._index"; export function meta(routes: Route.MetaArgs) { @@ -9,5 +9,5 @@ export function meta(routes: Route.MetaArgs) { } export default function Index(...args: unknown[]) { - return ; + return ; } \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile.$id.tsx b/sites/app.campground.gg/src/routes/profile.$id.tsx index 453a29d..7e4c242 100644 --- a/sites/app.campground.gg/src/routes/profile.$id.tsx +++ b/sites/app.campground.gg/src/routes/profile.$id.tsx @@ -5,10 +5,10 @@ import { authMiddleware } from "~/middleware/auth"; import { loginRequiredMiddleware } from "~/middleware/login"; import { sessionRouterContext } from "~/session"; -export function meta(_routes: Route.MetaArgs) { +export function meta({ loaderData }: Route.MetaArgs) { return [ - { title: "Campground — Camp" }, - { name: "description", content: "Gather around the fire, friends" }, + { title: `Campground — ${loaderData.ok ? loaderData.user!.displayName : `Profile`}` }, + { name: "description", content: loaderData.ok ? loaderData.user!.tagline : "Gather around the fire, friends" }, ]; } @@ -28,7 +28,8 @@ export async function clientLoader({ context, params: { id } }: Route.ClientLoad }; const userRequest = await session.restClient.fetchProfile(id); - console.log(userRequest); + const postsRequest = await session.restClient.fetchPosts(id); + console.log(userRequest, postsRequest); const { errorDescription, errorHeader, content, ok, status } = userRequest; @@ -38,15 +39,19 @@ export async function clientLoader({ context, params: { id } }: Route.ClientLoad errorHeader, errorDescription, ok, - user: content + user: content, + isSelf: session.auth.authenticated && session.auth.user.did === content?.did, + posts: postsRequest.content?.posts!, }; } clientLoader.hydrate = true as const; -export default function Index({ loaderData: { status, ok, user, errorHeader, errorDescription } }: Route.ComponentProps) { +export default function Index({ loaderData: { status, ok, isSelf, user, posts, errorHeader, errorDescription } }: Route.ComponentProps) { + + return ( ok - ? + ? : status === 404 ? There is no such user with that DID. Have you entered the wrong DID? diff --git a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx index 24eeb0c..be5e3e9 100644 --- a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx +++ b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx @@ -1,16 +1,15 @@ import type { Route } from "./+types/profile.($id).posts.$postId"; import { Typography } from "@mui/joy"; import { redirect } from "react-router"; -import { exampleComments, examplePosts } from "~/example/profile"; import ProfilePostView from "~/layout/profile/ProfilePostView"; import { authMiddleware } from "~/middleware/auth"; import { loginRequiredMiddleware } from "~/middleware/login"; import { sessionRouterContext } from "~/session"; -export function meta(_routes: Route.MetaArgs) { +export function meta({ loaderData }: Route.MetaArgs) { return [ - { title: "Campground — Camp" }, - { name: "description", content: "Gather around the fire, friends" }, + { title: `Campground — ${loaderData.ok ? loaderData.user!.displayName : `Profile`}` }, + { name: "description", content: loaderData.ok ? loaderData.user!.tagline : "Gather around the fire, friends" }, ]; } @@ -33,12 +32,11 @@ export async function clientLoader({ context, params: { id, postId } }: Route.Cl status: 401, }; - const userRequest = await session.restClient.fetchProfile(id); - console.log(userRequest); + // const userRequest = await session.restClient.fetchProfile(id); + const postRequest = await session.restClient.fetchPost(id, postId); + console.log(postRequest); - const post = examplePosts.find((x) => x.id === postId); - - const { errorDescription, errorHeader, content, ok, status } = userRequest; + const { errorDescription, errorHeader, content, ok, status } = postRequest; return { id, @@ -46,17 +44,16 @@ export async function clientLoader({ context, params: { id, postId } }: Route.Cl errorHeader, errorDescription, ok, - user: content, - post, - comments: exampleComments + user: content?.author, + post: content, }; } clientLoader.hydrate = true as const; -export default function ProfilePosts_Id({ loaderData: { error, user, post, comments } }: Route.ComponentProps) { +export default function ProfilePosts_Id({ loaderData: { ok, errorHeader, errorDescription, status, user, post } }: Route.ComponentProps) { return ( - error == null - ? ({ author: user!, ...x }))} /> - : Error {error} + ok + ? + : Error {status} ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile._index.tsx b/sites/app.campground.gg/src/routes/profile._index.tsx index 66d83b7..517bba0 100644 --- a/sites/app.campground.gg/src/routes/profile._index.tsx +++ b/sites/app.campground.gg/src/routes/profile._index.tsx @@ -1,12 +1,15 @@ import { redirect } from "react-router"; import type { Route } from "./+types/profile._index"; -import Home from "~/layout/Home"; +import HomeSidebar from "~/routes/_home/HomeSidebar"; +import { sessionRouterContext } from "~/session"; -export async function clientLoader({}: Route.ClientLoaderArgs) { - throw redirect("/"); +export async function clientLoader({ context }: Route.ClientLoaderArgs) { + const session = context.get(sessionRouterContext); + + throw redirect(session.auth.authenticated ? `/profile/${session.auth.user.did}` : `/`); } clientLoader.hydrate = true as const; export default function Index() { - return ; + return ; } \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile.tsx b/sites/app.campground.gg/src/routes/profile.tsx index db58fe9..7aebc35 100644 --- a/sites/app.campground.gg/src/routes/profile.tsx +++ b/sites/app.campground.gg/src/routes/profile.tsx @@ -1,13 +1,10 @@ -import type { Route } from "./+types/profile"; import { Outlet } from "react-router"; import GlobalLayout from "~/layout/GlobalLayout"; import { useSession } from "~/session"; -export function meta(routes: Route.MetaArgs) { - console.log("profiles", routes); - +export function meta() { return [ - { title: "Campground — Camp" }, + { title: "Campground — Profile" }, { name: "description", content: "Gather around the fire, friends" }, ]; } diff --git a/sites/app.campground.gg/types/user.ts b/sites/app.campground.gg/types/user.ts index 014f161..fd5ecf9 100644 --- a/sites/app.campground.gg/types/user.ts +++ b/sites/app.campground.gg/types/user.ts @@ -13,18 +13,20 @@ export interface User { createdAt: string; } export interface UserPost { - id: string; - title: string; + uri: string; content: string; tags: string[]; - comments: number; - createdAt: Date; + createdAt: string; + indexedAt: string | null; + updatedAt: string | null; author: User; - profileUser: User; }; -export interface UserPostComment { - id: string; - content: string; - createdAt: Date; - author: User; -}; \ No newline at end of file +export interface UserPostBasic extends UserPost { + replyCount: number; +}; +export interface UserPostDetailed extends UserPost { + replies: UserPostBasic[]; +}; +export interface EitherUserPost extends UserPostBasic, UserPostDetailed { + +} \ No newline at end of file From bd1ed359105218cd16994c2479d279c68623677d Mon Sep 17 00:00:00 2001 From: PrettyGoodName <31628630+IdkGoodName@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:29:46 +0200 Subject: [PATCH 2/7] feat: slate editor --- sites/app.campground.gg/package-lock.json | 912 +++--------------- sites/app.campground.gg/package.json | 6 +- .../src/components/editor/BlockNodeInsert.tsx | 27 + .../src/components/editor/BlockNodeToggle.tsx | 26 + .../src/components/editor/BlockTextEditor.tsx | 89 ++ .../components/editor/CampgroundEditor.tsx | 128 +++ .../editor/CodeBlockEditorHeader.tsx | 45 + .../src/components/editor/CodeNodeToggle.tsx | 27 + .../src/components/editor/EditorElement.tsx | 90 ++ .../src/components/editor/EditorLeaf.tsx | 35 + .../components/editor/InlineNodeToggle.tsx | 26 + .../src/components/editor/ListNodeToggle.tsx | 27 + .../src/components/editor/MarkNodeToggle.tsx | 27 + .../{markdown => editor}/PostInput.tsx | 15 +- .../components/editor/RichEditorToolbar.tsx | 69 ++ .../src/components/editor/block-decorate.tsx | 52 + .../components/editor/codeEditorContext.tsx | 13 + .../src/components/editor/editor.ts | 120 +++ .../src/components/editor/keyboard-logic.ts | 49 + .../src/components/editor/utils.ts | 16 + .../src/components/editor/withCgMarkdown.tsx | 126 +++ .../src/components/markdown/CodeBlock.tsx | 42 +- .../components/markdown/MarkdownEditor.tsx | 37 - .../components/markdown/MarkdownWrapper.tsx | 10 + .../src/layout/profile/ProfileFeed.tsx | 2 +- .../src/layout/profile/ProfileFeedPost.tsx | 2 - 26 files changed, 1172 insertions(+), 846 deletions(-) create mode 100644 sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx create mode 100644 sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx create mode 100644 sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx create mode 100644 sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx create mode 100644 sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx create mode 100644 sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx create mode 100644 sites/app.campground.gg/src/components/editor/EditorElement.tsx create mode 100644 sites/app.campground.gg/src/components/editor/EditorLeaf.tsx create mode 100644 sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx create mode 100644 sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx create mode 100644 sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx rename sites/app.campground.gg/src/components/{markdown => editor}/PostInput.tsx (71%) create mode 100644 sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx create mode 100644 sites/app.campground.gg/src/components/editor/block-decorate.tsx create mode 100644 sites/app.campground.gg/src/components/editor/codeEditorContext.tsx create mode 100644 sites/app.campground.gg/src/components/editor/editor.ts create mode 100644 sites/app.campground.gg/src/components/editor/keyboard-logic.ts create mode 100644 sites/app.campground.gg/src/components/editor/utils.ts create mode 100644 sites/app.campground.gg/src/components/editor/withCgMarkdown.tsx delete mode 100644 sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx diff --git a/sites/app.campground.gg/package-lock.json b/sites/app.campground.gg/package-lock.json index 04b70a2..b071b72 100644 --- a/sites/app.campground.gg/package-lock.json +++ b/sites/app.campground.gg/package-lock.json @@ -18,9 +18,6 @@ "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tabler/icons-react": "^3.35.0", - "@tiptap/pm": "^3.5.0", - "@tiptap/react": "^3.5.0", - "@tiptap/starter-kit": "^3.5.0", "components": "file:../../packages/components", "highlight.js": "^11.11.1", "isbot": "^5", @@ -32,6 +29,9 @@ "react-router": "^7.7.1", "react-router-dom": "^7.9.3", "remark-gfm": "^4.0.1", + "slate": "^0.118.1", + "slate-history": "^0.113.1", + "slate-react": "^0.118.2", "vite-plugin-static-copy": "^2.3.0" }, "devDependencies": { @@ -1250,7 +1250,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -1479,6 +1478,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, "node_modules/@mantine/hooks": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.1.tgz", @@ -2108,12 +2113,6 @@ "react-router": "7.9.1" } }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -2433,444 +2432,6 @@ "react": ">= 16" } }, - "node_modules/@tiptap/core": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.5.0.tgz", - "integrity": "sha512-CClel+XKlLgSLtXYjzJA0CEtkUFwFb2EI2LwaWkgIWSwhumXMkXe/TSELayKpLjjzE6T/DyXbbNVrEHEBZ9R0A==", - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-blockquote": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.5.0.tgz", - "integrity": "sha512-jRwH2Ovf7ks5ErRLapDDoLDg2Dx4YI8txXqWkRhTzsZI40/XGpAzfkFpvRTzNMA/88nShOF0Iy7iY5P6IKwTYw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-bold": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.5.0.tgz", - "integrity": "sha512-va5Y8jM4QQ2j8xgTfFDzMjFfKOtW8BenDVoT7IJ/QzG7GUmes4y2AJNN2fbMvnEumEVw1wFmek/eo/NhQ0nPAQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-bubble-menu": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.5.0.tgz", - "integrity": "sha512-0vHLYvERHyBLfcKTvCujj+S+DBrrYYZoZ1xtYdBnr31UAkcv4ezaJq650slj4M2FvwGuUXSsPkntFFWcHGftVw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-bullet-list": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.5.0.tgz", - "integrity": "sha512-c7KKjSqol68G+9NBATCzVWDKdeiBkSe/xKr1RlXIeOLfVv4I9cYHfq0luv52WpDP8SiebhC+/znyrK/GgkGl7g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extension-list": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-code": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.5.0.tgz", - "integrity": "sha512-UBjibp1LuLKze7kF6M02feeJyk7PLJTDqg5uGRxMyiWdHkEMcMV50u3nB1mKxj4Hr9fnve0Onoq4PoA2spSOag==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-code-block": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.5.0.tgz", - "integrity": "sha512-E1qSgmcbHvo+Hp3T+9jBRk6WG8lX7avZwBzedx224lW8rdADWeT/6TaEob0LU0N5GFlVXq7Cv37r5qzvtfR/vQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-document": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.5.0.tgz", - "integrity": "sha512-WKqw9FyrAFrlnWJJRTg2SQBuMWTvFEFe/D20eMb6Dk8DDwWtkMuWYqqRfEatolW0NDpbCyiZJftRBHzdGI4SCg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-dropcursor": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.5.0.tgz", - "integrity": "sha512-tL7T4mpJORZmhPl/QEyZY3s0uhGtwu7fEaTpkfzR/UPYYPfxw5KJNwkKpAkIWgQb4p8WDFbkGnZkwDxUt4z3Uw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extensions": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-floating-menu": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.5.0.tgz", - "integrity": "sha512-GGND1UhfMP+Io69G5KEWp+V+4srcAhcZDQ7tBfDVXJNMtXiut9cxgWzprn4t0LFRBOM48ZjWNtA6OWpAez2Y4Q==", - "license": "MIT", - "optional": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-gapcursor": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.5.0.tgz", - "integrity": "sha512-woyMV8wpC6UVhBp/NolDjiJi1dwn9LEXZBEAGW1b7+boJEozGsSu/XLC3tNf2l3dAGpuqsqv/ZRadur9ZlpZvQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extensions": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-hard-break": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.5.0.tgz", - "integrity": "sha512-6QXW05ooXQirgJDhHlnIgsGvNMFksK/u/xJ7ltpSTzOe9sw9WyuvHFELsannzM0N+WSiZFKSzvt+yFVom/Z/9g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-heading": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.5.0.tgz", - "integrity": "sha512-PnvE9H4A7LmqLAj2wbiNtvYL0YKM3XJRzMySpYWtrTa7mgRUawxl4xiqI2X3KgAT3oJuAANR94X+THVUE5vLYw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-horizontal-rule": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.5.0.tgz", - "integrity": "sha512-egZPoFglqYqSld7JfkO4ovFqX+UXI2A7Cu/znf1r71XPujF1YK8uHmiXPHpz5lE/4PuAtSWRYGHIhgZQzTUR+g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-italic": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.5.0.tgz", - "integrity": "sha512-Csp3AAWeuou70Bil1oX1a8Vms9IBWmIp3ZxmqvmpgsS9WSL2XUBG6srhq0ujNlKEHEcN6ebRU7kymHqkyG9tog==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-link": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.5.0.tgz", - "integrity": "sha512-71mtmSYfsfl0Pk1yTAImh/4kl8awjwl3Oip/EXZxkpFVayOLk7HfHZxeoLzgctJUtx+xowLDWaYLd1I2gGdAqQ==", - "license": "MIT", - "dependencies": { - "linkifyjs": "^4.3.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-list": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.5.0.tgz", - "integrity": "sha512-q/eoWX4z0Xdj+vurEkMR8L6m1kJTGz/Oo0sTeSuSFDFqbJuJmz0x8xmsI1HD1uD2LeTF/NusBXBlbhEwnmvicA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-list-item": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.5.0.tgz", - "integrity": "sha512-+Pn2o4H9AfZs4lcAJpAHix2G1hkTCx0NaduEM1EV6IY9o2PhT/wklZaqs/sZnKhGgrsEDWixorEixBDh+p007A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extension-list": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-list-keymap": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.5.0.tgz", - "integrity": "sha512-etVIUVK8p7Zc8SIVlHXNMmgkYFr3DwYSmr1OFFpel0QYOHcCj7E7PqrYT4yyf2xnDeM+Gif/fedPg5qboXwQcQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extension-list": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-ordered-list": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.5.0.tgz", - "integrity": "sha512-dHC34yCCDlKTNqZWByXQCwIiLg/gyXG1rJWInTj93gEAUv6Lv37Hg/uEGJs54Jhv+bUeyQNpyyRsveaNaAl0BA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extension-list": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-paragraph": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.5.0.tgz", - "integrity": "sha512-U43CWElQbezfw4f6yFrwCjCOiUSKXA5YI3P/D+0nv81zEpJO0smVVapFOaIhkwwzVv7kzJetQ/NbTjc8Kz3+qA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-strike": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.5.0.tgz", - "integrity": "sha512-I4XmXPuCgIQ93Hfn39S8n/EZhiVHSHzU/7awRqQM3cx/kiByc/CiZ86c7opkQozXAIxSycR1IMhS/WVsxQggJw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-text": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.5.0.tgz", - "integrity": "sha512-DalMr70Tc2T6P2SEdnrTr2z+7ifYeTNcaDoaWEs8Ojj5U3cNH/pPTI+ebT0TaG+Q4hSdwuPKnrxVbVNnmD+o5A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extension-underline": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.5.0.tgz", - "integrity": "sha512-ZbeVF+TXy7hsJEYvpMBns6OO3J321SRsXXkzmbcaDku7pKOTcfJoLXwM6HyGQUQUe307wKSBijmi3jxrJJYiHA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0" - } - }, - "node_modules/@tiptap/extensions": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.5.0.tgz", - "integrity": "sha512-6LKkOXLgXC5z4XYkijCyZtY+989treWbjBiuoK7aLK5bi74ltO9C70GZnjMa12v6qKtuxxifeKp5vxGpiqHH7Q==", - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0" - } - }, - "node_modules/@tiptap/pm": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.5.0.tgz", - "integrity": "sha512-OU9BkOxgDeKE/F9BbVZP9TG59+OqEcW1oT5ie6IuimmEW6iibcJEyXAEaVf3Wgpvwnnk5TkpXE8jyMcgGTiLig==", - "license": "MIT", - "peer": true, - "dependencies": { - "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.24.1", - "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.5.0", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.38.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - } - }, - "node_modules/@tiptap/react": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.5.0.tgz", - "integrity": "sha512-eS21l9JuwIIFeSfZTQdRm7tjlpBd15de+0+Issn2ku+P85Cy++uy+temmjNSpBxEeWERnKwmhRpP8iyZuC2tVA==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "fast-deep-equal": "^3.1.3", - "use-sync-external-store": "^1.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "optionalDependencies": { - "@tiptap/extension-bubble-menu": "^3.5.0", - "@tiptap/extension-floating-menu": "^3.5.0" - }, - "peerDependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/pm": "^3.5.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tiptap/starter-kit": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.5.0.tgz", - "integrity": "sha512-upe/FBGTYjTavm7FTkDf7o/bgE7DpRC6NyKIg6ZRb+s+L6Sr0YmvpC0/DmmMnJFTn3AxGyz2WjWVGCteplj3cw==", - "license": "MIT", - "dependencies": { - "@tiptap/core": "^3.5.0", - "@tiptap/extension-blockquote": "^3.5.0", - "@tiptap/extension-bold": "^3.5.0", - "@tiptap/extension-bullet-list": "^3.5.0", - "@tiptap/extension-code": "^3.5.0", - "@tiptap/extension-code-block": "^3.5.0", - "@tiptap/extension-document": "^3.5.0", - "@tiptap/extension-dropcursor": "^3.5.0", - "@tiptap/extension-gapcursor": "^3.5.0", - "@tiptap/extension-hard-break": "^3.5.0", - "@tiptap/extension-heading": "^3.5.0", - "@tiptap/extension-horizontal-rule": "^3.5.0", - "@tiptap/extension-italic": "^3.5.0", - "@tiptap/extension-link": "^3.5.0", - "@tiptap/extension-list": "^3.5.0", - "@tiptap/extension-list-item": "^3.5.0", - "@tiptap/extension-list-keymap": "^3.5.0", - "@tiptap/extension-ordered-list": "^3.5.0", - "@tiptap/extension-paragraph": "^3.5.0", - "@tiptap/extension-strike": "^3.5.0", - "@tiptap/extension-text": "^3.5.0", - "@tiptap/extension-underline": "^3.5.0", - "@tiptap/extensions": "^3.5.0", - "@tiptap/pm": "^3.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2968,22 +2529,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2993,12 +2538,6 @@ "@types/unist": "*" } }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3031,8 +2570,8 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3043,12 +2582,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", @@ -3461,6 +2994,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -3907,6 +3441,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3981,12 +3521,6 @@ "node": ">= 6" } }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4105,6 +3639,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4152,18 +3699,6 @@ "node": ">= 0.8" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -4574,6 +4109,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5127,6 +4663,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5293,6 +4839,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==", + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5314,6 +4866,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isbot": { "version": "5.1.31", "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.31.tgz", @@ -5456,21 +5017,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5531,23 +5077,6 @@ "yallist": "^3.0.2" } }, - "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5849,12 +5378,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6803,12 +6326,6 @@ "node": ">= 0.8.0" } }, - "node_modules/orderedmap": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", - "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", - "license": "MIT" - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7117,204 +6634,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/prosemirror-changeset": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", - "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", - "license": "MIT", - "dependencies": { - "prosemirror-transform": "^1.0.0" - } - }, - "node_modules/prosemirror-collab": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", - "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0" - } - }, - "node_modules/prosemirror-commands": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", - "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.0.0", - "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.10.2" - } - }, - "node_modules/prosemirror-dropcursor": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", - "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.1.0", - "prosemirror-view": "^1.1.0" - } - }, - "node_modules/prosemirror-gapcursor": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz", - "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", - "license": "MIT", - "dependencies": { - "prosemirror-keymap": "^1.0.0", - "prosemirror-model": "^1.0.0", - "prosemirror-state": "^1.0.0", - "prosemirror-view": "^1.0.0" - } - }, - "node_modules/prosemirror-history": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", - "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.2.2", - "prosemirror-transform": "^1.0.0", - "prosemirror-view": "^1.31.0", - "rope-sequence": "^1.3.0" - } - }, - "node_modules/prosemirror-inputrules": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz", - "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.0.0" - } - }, - "node_modules/prosemirror-keymap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", - "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0", - "w3c-keyname": "^2.2.0" - } - }, - "node_modules/prosemirror-markdown": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", - "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14.0.0", - "markdown-it": "^14.0.0", - "prosemirror-model": "^1.25.0" - } - }, - "node_modules/prosemirror-menu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", - "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", - "license": "MIT", - "dependencies": { - "crelt": "^1.0.0", - "prosemirror-commands": "^1.0.0", - "prosemirror-history": "^1.0.0", - "prosemirror-state": "^1.0.0" - } - }, - "node_modules/prosemirror-model": { - "version": "1.25.3", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz", - "integrity": "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==", - "license": "MIT", - "peer": true, - "dependencies": { - "orderedmap": "^2.0.0" - } - }, - "node_modules/prosemirror-schema-basic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", - "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.25.0" - } - }, - "node_modules/prosemirror-schema-list": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", - "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.0.0", - "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.7.3" - } - }, - "node_modules/prosemirror-state": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", - "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "prosemirror-model": "^1.0.0", - "prosemirror-transform": "^1.0.0", - "prosemirror-view": "^1.27.0" - } - }, - "node_modules/prosemirror-tables": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz", - "integrity": "sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==", - "license": "MIT", - "dependencies": { - "prosemirror-keymap": "^1.2.2", - "prosemirror-model": "^1.25.0", - "prosemirror-state": "^1.4.3", - "prosemirror-transform": "^1.10.3", - "prosemirror-view": "^1.39.1" - } - }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", - "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" - } - }, - "node_modules/prosemirror-transform": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", - "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.21.0" - } - }, - "node_modules/prosemirror-view": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.1.tgz", - "integrity": "sha512-cViIhlt1/T5bQMINrmXh43JZcdIgdW1YkOABmIuH5gSt3/HiCZHsLN9d5GvsgzrXn2+zZ8il0kkghisusm7tSA==", - "license": "MIT", - "peer": true, - "dependencies": { - "prosemirror-model": "^1.20.0", - "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.1.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7338,15 +6657,6 @@ "node": ">=6" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -7726,12 +7036,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rope-sequence": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", - "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7787,6 +7091,15 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -7976,6 +7289,68 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slate": { + "version": "0.118.1", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.118.1.tgz", + "integrity": "sha512-6H1DNgnSwAFhq/pIgf+tLvjNzH912M5XrKKhP9Frmbds2zFXdSJ6L/uFNyVKxQIkPzGWPD0m+wdDfmEuGFH5Tg==", + "license": "MIT", + "peer": true, + "dependencies": { + "immer": "^10.0.3", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-dom": { + "version": "0.118.1", + "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.118.1.tgz", + "integrity": "sha512-D6J0DF9qdJrXnRDVhYZfHzzpVxzqKRKFfS0Wcin2q0UC+OnQZ0lbCGJobatVbisOlbSe7dYFHBp9OZ6v1lEcbQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "slate": ">=0.99.0" + } + }, + "node_modules/slate-history": { + "version": "0.113.1", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz", + "integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react": { + "version": "0.118.2", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.118.2.tgz", + "integrity": "sha512-D7eQVZGgiqV36mooozu8sNWuCkzJqcHQWERQn9FxqmugnbEOKaPBj5OX1x5WGAVexfrxAT5dTAHUaRb0lGqFDw==", + "license": "MIT", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "slate": ">=0.114.0", + "slate-dom": ">=0.116.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -8236,6 +7611,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8428,12 +7815,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -8579,15 +7960,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8887,12 +8259,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/sites/app.campground.gg/package.json b/sites/app.campground.gg/package.json index 1995ba6..b22c419 100644 --- a/sites/app.campground.gg/package.json +++ b/sites/app.campground.gg/package.json @@ -20,9 +20,6 @@ "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tabler/icons-react": "^3.35.0", - "@tiptap/pm": "^3.5.0", - "@tiptap/react": "^3.5.0", - "@tiptap/starter-kit": "^3.5.0", "components": "file:../../packages/components", "highlight.js": "^11.11.1", "isbot": "^5", @@ -34,6 +31,9 @@ "react-router": "^7.7.1", "react-router-dom": "^7.9.3", "remark-gfm": "^4.0.1", + "slate": "^0.118.1", + "slate-history": "^0.113.1", + "slate-react": "^0.118.2", "vite-plugin-static-copy": "^2.3.0" }, "devDependencies": { diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx new file mode 100644 index 0000000..646aa59 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx @@ -0,0 +1,27 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditorBlockElementType } from "./editor"; + +type Props = { + format: RichEditorBlockElementType; + children: ReactNode[] | ReactNode; +}; + +export default function BlockNodeInsert({ children, format: formatting }: Props) { + const editor = useSlate(); + + const addFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + + editor.insertNode({ + type: formatting, + children: [], + }); + }; + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx new file mode 100644 index 0000000..8fb6cdc --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx @@ -0,0 +1,26 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditorBlockElementType } from "./editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: RichEditorBlockElementType; + children: ReactNode[] | ReactNode; +}; + +export default function BlockNodeToggle({ children, format: formatting }: Props) { + const editor = useSlate(); + + const active = CampgroundEditor.isNodeFormatted(editor, formatting); + + const toggleFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + CampgroundEditor.toggleBlockFormatting(editor, formatting); + }; + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx new file mode 100644 index 0000000..6bc14ff --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { createEditor } from "slate"; +import { Editable, Slate, withReact } from "slate-react"; +import MarkdownWrapper from "../markdown/MarkdownWrapper"; +import type { SxProps } from "@mui/joy/styles/types"; +import { Divider, styled } from "@mui/joy"; +import { withHistory } from "slate-history"; +import type { RichEditor } from "./editor"; +import EditorLeaf from "./EditorLeaf"; +import EditorElement from "./EditorElement"; +import RichEditorToolbar, { RichEditorToolbarBlockFormatting, RichEditorToolbarInlineFormatting } from "./RichEditorToolbar"; +import withCgMarkdown from "./withCgMarkdown"; +import useBlockDecorate from "./block-decorate"; +import { editorKeyboardLogic } from "./keyboard-logic"; + +type Props = { + sx: SxProps; +}; + +const StyledEditor = styled(Editable, { + name: "MarkdownEditor", + slot: "editor", +})(() => ({ + ":focus": { + outline: "none", + }, + "> .tiptap > *:first-child": { + marginTop: 0, + }, + "> .tiptap > *:last-child": { + marginBottom: 0, + }, +})); + +const StyledContainer = styled(MarkdownWrapper, { + name: "MarkdownEditorContainer", + slot: "root", +})(({ theme }) => ({ + border: `solid 1px ${theme.vars.palette.neutral[600]}`, + borderRadius: theme.vars.radius.md, + position: "relative", + overflow: "hidden", +})); +const StyledWrapper = styled(MarkdownWrapper, { + name: "MarkdownEditorWrapper", + slot: "editor", +})(() => ({ + padding: `6px 12px`, + position: "relative", + overflow: "auto", + width: "100%", + height: "100%", +})); + +export default function BlockTextEditor({ sx }: Props) { + const [editor] = useState(() => withCgMarkdown(withHistory(withReact(createEditor()))) as RichEditor); + const blockDecorate = useBlockDecorate(); + + return ( + + console.log("VVVV", v)}> + + + + + + + + { + const logic = editorKeyboardLogic[event.key]; + + if (!logic) + return; + + event.preventDefault(); + logic(editor, event); + }} + /> + + + + ) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx new file mode 100644 index 0000000..59287e3 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx @@ -0,0 +1,128 @@ +import { Editor, Element, Text, Transforms } from "slate"; +import { RichEditorInlineElementType, type RichEditor, type RichEditorAnyElement, type RichEditorAnyElementType, type RichEditorBlockElementType, type RichEditorItemElementType, type RichEditorTextFormatting } from "./editor"; + +export default class CampgroundEditor { + static isNodeFormatted(editor: RichEditor, type: RichEditorAnyElementType) { + const { selection } = editor; + + // Can't detect nodes; out of focus of editor + if (!selection) + return false; + + const [match] = Array.from( + Editor.nodes(editor, { + at: Editor.unhangRange(editor, selection), + match: n => { + if (!Editor.isEditor(n) && Element.isElement(n)) + return n.type === type; + + return false; + } + }) + ); + + // Returned at least one element, which means it is formatted + return Boolean(match); + } + static isTextFormatted(editor: RichEditor, type: keyof RichEditorTextFormatting) { + const marks = Editor.marks(editor); + return (marks?.[type] as boolean | null) ?? false; + } + static toggleBlockFormatting(editor: RichEditor, type: RichEditorBlockElementType) { + const active = this.isNodeFormatted(editor, type); + + // Simple type change + Transforms.setNodes(editor, { + type: active ? `paragraph` : type, + }); + } + static toggleInlineFormatting(editor: RichEditor, type: RichEditorInlineElementType) { + const active = this.isNodeFormatted(editor, type); + + if (active) + return Transforms.unwrapNodes(editor, { + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + (RichEditorInlineElementType as readonly string[]).includes(n.type) + }); + + Transforms.wrapNodes( + editor, + { + type: "inline-quote", + children: [] + }, + { + match: (n) => + !Editor.isEditor(n) && + ((Element.isElement(n) && (RichEditorInlineElementType as readonly string[]).includes(n.type)) || Text.isText(n)) + } + ); + } + static toggleListFormatting(editor: RichEditor, type: RichEditorBlockElementType, itemType: RichEditorItemElementType) { + const active = this.isNodeFormatted(editor, type); + + if (active) + Transforms.unwrapNodes(editor, { + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n), + split: true, + }); + + // Simple type change + Transforms.setNodes( + editor, + { + type: active ? `paragraph` : itemType, + }, + ); + + if (!active) + Transforms.wrapNodes( + editor, + { + type, + children: [], + }, + ); + } + static toggleCodeFormatting(editor: RichEditor, type: RichEditorBlockElementType, itemType: RichEditorItemElementType) { + const active = this.isNodeFormatted(editor, type); + + // Simple type change + Transforms.setNodes( + editor, + { + type: active ? `paragraph` : itemType, + }, + { + match: n => Element.isElement(n) && n.type === "paragraph", + split: true, + } + ); + + if (active) { } + else + Transforms.wrapNodes( + editor, + { + type, + children: [], + language: "js" + } as unknown as RichEditorAnyElement, + { + match: n => Element.isElement(n) && n.type === itemType + } + ); + } + static toggleTextFormatting(editor: RichEditor, type: keyof RichEditorTextFormatting) { + const active = this.isTextFormatted(editor, type); + + if (active) + Editor.removeMark(editor, type); + else + Editor.addMark(editor, type, true); + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx new file mode 100644 index 0000000..54c4910 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx @@ -0,0 +1,45 @@ +import { Option, Select } from "@mui/joy"; +import type { RichEditorCodeBlock } from "./editor"; +import hljs from "highlight.js"; +import { useSlateStatic } from "slate-react"; +import { Element, Transforms } from "slate"; +import { Group } from "components"; + +type Props = { + element: RichEditorCodeBlock; +}; + +export default function CodeBlockEditorHeader({ element }: Props) { + const editor = useSlateStatic(); + + const onValueSelected = (_: unknown, language: string | null) => { + Transforms.setNodes( + editor, + { + language, + }, + { + match: m => + Element.isElement(m) && m === element, + } + ); + }; + + const languages = + hljs + .listLanguages() + .map((x) => [x, hljs.getLanguage(x)?.name ?? x]); + + return ( + + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx new file mode 100644 index 0000000..383085a --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx @@ -0,0 +1,27 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditorBlockElementType, RichEditorItemElementType } from "./editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: RichEditorBlockElementType; + itemFormat: RichEditorItemElementType; + children: ReactNode[] | ReactNode; +}; + +export default function CodeNodeToggle({ children, format: formatting, itemFormat }: Props) { + const editor = useSlate(); + + const active = CampgroundEditor.isNodeFormatted(editor, itemFormat); + + const toggleFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + CampgroundEditor.toggleCodeFormatting(editor, formatting, itemFormat); + }; + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/EditorElement.tsx b/sites/app.campground.gg/src/components/editor/EditorElement.tsx new file mode 100644 index 0000000..bcdc67b --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/EditorElement.tsx @@ -0,0 +1,90 @@ +import { type RenderElementProps } from "slate-react"; +import type { RichEditorAnyElementType, RichEditorCodeBlock, RichEditorCodeLine } from "./editor"; +import { ReactNode } from "react"; +import { CodeContainer, CodeGrid, CodeHeader, CodeLine, CodeLineNumber, CodePre } from "../markdown/CodeBlock"; +import CodeBlockEditorHeader from "./CodeBlockEditorHeader"; +import { CodeEditorContextProvider, useCodeEditorContext } from "./codeEditorContext"; + +const typeToRenderer: Record (ReactNode[] | ReactNode)> = { + paragraph({ attributes, children }) { + return

{children}

+ }, + divider({ attributes }) { + return
; + }, + ["block-quote"]({ attributes, children }) { + return
{children}
+ }, + ["code-block"]({ attributes, children, element }) { + return ( + + + + + + + + {children} + + + + + ); + }, + ["code-line"]({ element, attributes, children }) { + // Since no index is given + const context = useCodeEditorContext(); + const index = context?.findIndex((x) => x === element) ?? -1; + + return ( + <> + + {index + 1} + + + {children} + + + ); + }, + ["unordered-list"]({ attributes, children }) { + return ( +
    + {children} +
+ ); + }, + ["ordered-list"]({ attributes, children }) { + return ( +
    + {children} +
+ ); + }, + ["list-item"]({ attributes, children }) { + return ( +
  • + {children} +
  • + ); + }, + ["inline-quote"]({ attributes, children }) { + return ( + + {children} + + ); + }, +} + +export default function EditorElement({ attributes, children, element }: RenderElementProps) { + const nodeType = element.type; + const Renderer = typeToRenderer[nodeType]; + console.log("Arguments", arguments); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx b/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx new file mode 100644 index 0000000..71064ad --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx @@ -0,0 +1,35 @@ +import { styled, Typography } from "@mui/joy"; +import type { RenderLeafProps } from "slate-react"; + +const Leaf = styled(Typography, { + name: "Leaf", +})<{ component: string; }>(() => ({ + display: "inline", + "&.bold": { + fontWeight: "bolder", + }, + "&.italic": { + fontStyle: "italic", + }, + "&.strikethrough": { + textDecorationLine: "line-through", + }, + "&.underline": { + textDecorationLine: "underline", + }, + "&.underline.strikethrough": { + textDecorationLine: "line-through underline", + } +})); + +export default function EditorLeaf({ children, leaf, attributes }: RenderLeafProps) { + const { text: _, scope, ...rest } = leaf; + const classes = Object.keys(rest); + const { code } = rest; + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx new file mode 100644 index 0000000..97848ec --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx @@ -0,0 +1,26 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditorInlineElementType } from "./editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: RichEditorInlineElementType; + children: ReactNode[] | ReactNode; +}; + +export default function InlineNodeToggle({ children, format: formatting }: Props) { + const editor = useSlate(); + + const active = CampgroundEditor.isNodeFormatted(editor, formatting); + + const toggleFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + CampgroundEditor.toggleInlineFormatting(editor, formatting); + }; + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx new file mode 100644 index 0000000..f2e3fc4 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx @@ -0,0 +1,27 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditorBlockElementType, RichEditorItemElementType } from "./editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: RichEditorBlockElementType; + itemFormat: RichEditorItemElementType; + children: ReactNode[] | ReactNode; +}; + +export default function ListNodeToggle({ children, format: formatting, itemFormat }: Props) { + const editor = useSlate(); + + const active = CampgroundEditor.isNodeFormatted(editor, itemFormat); + + const toggleFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + CampgroundEditor.toggleListFormatting(editor, formatting, itemFormat); + }; + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx new file mode 100644 index 0000000..ea04fb6 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx @@ -0,0 +1,27 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditorTextFormatting } from "./editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: keyof RichEditorTextFormatting; + children: ReactNode[] | ReactNode; +}; + +export default function MarkNodeToggle({ children, format: formatting }: Props) { + const editor = useSlate(); + + const active = CampgroundEditor.isTextFormatted(editor, formatting); + + const toggleFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + CampgroundEditor.toggleTextFormatting(editor, formatting); + }; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/markdown/PostInput.tsx b/sites/app.campground.gg/src/components/editor/PostInput.tsx similarity index 71% rename from sites/app.campground.gg/src/components/markdown/PostInput.tsx rename to sites/app.campground.gg/src/components/editor/PostInput.tsx index 6dc81ee..48779d2 100644 --- a/sites/app.campground.gg/src/components/markdown/PostInput.tsx +++ b/sites/app.campground.gg/src/components/editor/PostInput.tsx @@ -1,12 +1,10 @@ import { Card, CardContent, Link, Typography } from "@mui/joy"; import type { SxProps } from "@mui/joy/styles/types"; -import { useEditor } from "@tiptap/react"; -import { StarterKit } from "@tiptap/starter-kit"; import { useState } from "react"; import type { User } from "types/user"; import UserAvatar from "../UserAvatar"; -import MarkdownEditor from "./MarkdownEditor"; import { Group } from "components"; +import BlockTextEditor from "./BlockTextEditor"; type Props = { user: User; @@ -15,14 +13,7 @@ type Props = { sx?: SxProps; }; -export default function PostInput({ user, content, placeholder, sx }: Props) { - const editor = useEditor({ - extensions: [StarterKit], - content, - }); - - editor.commands.focus(); - +export default function PostInput({ user, placeholder, sx }: Props) { const [open, setOpen] = useState(false); return ( @@ -31,7 +22,7 @@ export default function PostInput({ user, content, placeholder, sx }: Props) { {open ? - ({ flex: 1, color: theme.vars.palette.text.secondary })} /> + ({ flex: 1, color: theme.vars.palette.text.secondary })} /> : setOpen(!open)} gap={1.5} color="neutral" startDecorator={}> diff --git a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx new file mode 100644 index 0000000..969deda --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx @@ -0,0 +1,69 @@ +import { ButtonGroup } from "@mui/joy"; +import { Group } from "components"; +import { ReactNode } from "react" +import MarkNodeToggle from "./MarkNodeToggle"; +import { IconBlockquote, IconBold, IconBraces, IconCode, IconItalic, IconList, IconListNumbers, IconQuote, IconSeparatorHorizontal, IconStrikethrough, IconUnderline } from "@tabler/icons-react"; +import BlockNodeToggle from "./BlockNodeToggle"; +import ListNodeToggle from "./ListNodeToggle"; +import CodeNodeToggle from "./CodeNodeToggle"; +import BlockNodeInsert from "./BlockNodeInsert"; +import InlineNodeToggle from "./InlineNodeToggle"; + +type Props = { + children: ReactNode[] | ReactNode; +} + +export function RichEditorToolbarInlineFormatting() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +export function RichEditorToolbarBlockFormatting() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export default function RichEditorToolbar({ children }: Props) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/block-decorate.tsx b/sites/app.campground.gg/src/components/editor/block-decorate.tsx new file mode 100644 index 0000000..f9aeebe --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/block-decorate.tsx @@ -0,0 +1,52 @@ +import { useCallback } from "react"; +import { type DecoratedRange, Element, Node, type NodeEntry, type Range } from "slate"; +import type { RichEditorAnyElementType, RichEditorCodeBlock } from "./editor"; +import CodeBlock, { linefyTokens } from "../markdown/CodeBlock"; + +const decorators: Partial DecoratedRange[]>> = { + ["code-block"]([node, path]) { + const content = Node.string(node); + + const { language } = node as unknown as RichEditorCodeBlock; + + if (!language || CodeBlock.nonHighlightedLanguages.includes(language)) + return []; + + const { tokens } = CodeBlock.tokenizeContent(language, content); + const linefied = linefyTokens(tokens); + + const decors: DecoratedRange[] = linefied + .flatMap( + (line, i) => { + // To not need to recalculate with reduce + let offset = 0; + const tokenPath = [...path, i, 0]; + + return line + .map((x) => { + return { + anchor: { + path: tokenPath, + offset + }, + focus: { + path: tokenPath, + offset: offset += CodeBlock.getTokenLength(x) + }, + scope: typeof x === "string" ? undefined : x.scope, + } satisfies Range; + }) + .filter((x) => x.scope); + } + ); + + return decors; + } +}; + +export default function useBlockDecorate() { + return useCallback( + (entry: NodeEntry) => Element.isElement(entry[0]) && decorators[entry[0].type] ? decorators[entry[0].type]!(entry) : [], + [] + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx new file mode 100644 index 0000000..4539b2b --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react"; +import type { RichEditorCodeLine } from "./editor"; + +export const CodeEditorContext = createContext([]); +export const useCodeEditorContext = () => useContext(CodeEditorContext); + +export function CodeEditorContextProvider({ codeLines, children }: React.PropsWithChildren & { codeLines: RichEditorCodeLine[] }) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/editor.ts b/sites/app.campground.gg/src/components/editor/editor.ts new file mode 100644 index 0000000..08b61a0 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/editor.ts @@ -0,0 +1,120 @@ +import type { BaseEditor, BaseRange, Element, Range, Text } from "slate"; +import type { HistoryEditor } from "slate-history"; +import type { ReactEditor } from "slate-react"; + +/** + * Rich text editor text node's available formatting that changes the appearance of the leaves. + */ +export interface RichEditorTextFormatting { + bold?: boolean; + italic?: boolean; + code?: boolean; + underline?: boolean; + strikethrough?: boolean; + scope?: string; +} +/** + * Rich text editor text node's contents without any formatting. May be used in code blocks and whatnot. + */ +export interface RichEditorTextUnformatted { + text: string; +} +/** + * Rich text editor's text node with all of its contents. + */ +export interface RichEditorText extends RichEditorTextFormatting, RichEditorTextUnformatted { } +const _RichEditorBlockElementType = ["paragraph", "block-quote", "code-block", "divider", "unordered-list", "ordered-list"] as const; +/** + * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. +*/ +export type RichEditorBlockElementType = typeof _RichEditorBlockElementType[number]; +/** + * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. +*/ +export const RichEditorBlockElementType = _RichEditorBlockElementType; + +/** + * Rich text editor node elements that depends on the ancestor block element and spans at least a line. +*/ +const _RichEditorItemElementType = ["code-line", "list-item"] as const; +/** + * Rich text editor node elements that depends on the ancestor block element and spans at least a line. +*/ +export type RichEditorItemElementType = typeof _RichEditorItemElementType[number]; +/** + * Rich text editor node elements that depends on the ancestor block element and spans at least a line. +*/ +export const RichEditorItemElementType = _RichEditorItemElementType; + +// Test inline +/** + * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line + */ +const _RichEditorInlineElementType = ["inline-quote"] as const; +/** + * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line. + */ +export type RichEditorInlineElementType = typeof _RichEditorInlineElementType[number]; +/** + * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line + */ +export const RichEditorInlineElementType = _RichEditorInlineElementType; + +export type RichEditorAnyElementType = RichEditorBlockElementType | RichEditorItemElementType | RichEditorInlineElementType; +export const RichEditorAnyElementType = (_RichEditorItemElementType as readonly RichEditorAnyElementType[]) + .concat(_RichEditorInlineElementType) + .concat(_RichEditorBlockElementType); + +/** + * Type representing a base for block and item elements. + */ +export interface RichEditorBlockElement { + type: TType; + children: TDescendant[]; +} +export interface RichEditorInlineElement { + type: TType; + children: TDescendant[]; +} + +export type RichEditorCodeLine = RichEditorBlockElement<"code-line", RichEditorTextUnformatted>; +export type RichEditorListItem = RichEditorBlockElement<"list-item", RichEditorBlockElementType | RichEditorText>; + +export interface RichEditorCodeBlock extends RichEditorBlockElement<"code-block", RichEditorCodeLine> { + language?: null | undefined | string; +} + +export interface RichEditorOrderedList extends RichEditorBlockElement<"ordered-list", RichEditorListItem> { + startingNumber?: null | undefined | number; +} + +export type RichEditorAnyBlockElement = + RichEditorBlockElement<"paragraph", Text> | + RichEditorBlockElement<"block-quote", RichEditorAnyBlockElement> | + RichEditorBlockElement<"divider", RichEditorText> | + RichEditorCodeBlock | + RichEditorBlockElement<"unordered-list", RichEditorListItem> | + RichEditorOrderedList; +export type RichEditorAnyItemElement = + RichEditorCodeLine | + RichEditorListItem; +export type RichEditorAnyInlineElement = + RichEditorInlineElement<"inline-quote", RichEditorAnyInlineElement | RichEditorText>; +export type RichEditorAnyElement = RichEditorAnyInlineElement | RichEditorAnyBlockElement | RichEditorAnyItemElement; + +export type RichEditor = + BaseEditor & ReactEditor & HistoryEditor & + { + nodeToDecorations?: Map + }; + +declare module 'slate' { + interface CustomTypes { + Editor: RichEditor; + Element: RichEditorAnyElement; + Text: RichEditorText; + Range: BaseRange & { + [key: string]: unknown + } + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/keyboard-logic.ts b/sites/app.campground.gg/src/components/editor/keyboard-logic.ts new file mode 100644 index 0000000..b8c0d2f --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/keyboard-logic.ts @@ -0,0 +1,49 @@ +import { Element } from "slate"; +import { RichEditorItemElementType, type RichEditor } from "./editor"; +import { getNeighborPath, getParentPath, paragraph } from "./utils"; +import React from "react"; + +function ArrowVertical(editor: RichEditor, up: boolean) { + const above = editor.above(); + const elementAbove = above?.[0]; + const isElement = Element.isElement(elementAbove); + + editor.move({ distance: 1, unit: "line", reverse: up }); + + const after = editor.above(); + if (!isElement || elementAbove.type === "paragraph" || elementAbove !== after?.[0]) + return; + + const mainBlock = RichEditorItemElementType.includes(elementAbove.type as RichEditorItemElementType) ? getParentPath(above![1]) : above![1]; + + editor.insertNode(paragraph(), { at: up ? mainBlock : getNeighborPath(mainBlock) }); + editor.move({ distance: 1, unit: "line", reverse: up }); +} + +export const editorKeyboardLogic: Record) => void> = { + ArrowUp(editor: RichEditor, _: React.KeyboardEvent) { + return ArrowVertical(editor, true); + }, + ArrowDown(editor: RichEditor, _: React.KeyboardEvent) { + return ArrowVertical(editor, false); + }, + Enter(editor: RichEditor, event: React.KeyboardEvent) { + const above = editor.above(); + + console.log([above?.[0], Element.isElement(above?.[0])]); + + if (above && Element.isElement(above[0]) && RichEditorItemElementType.includes(above[0].type as RichEditorItemElementType) && (!event.shiftKey || above[0].type === "code-line")) { + editor.insertNode({ type: above[0].type, children: [] }, { at: getNeighborPath(above[1]) }); + + return editor.move({ + unit: "line", + distance: 1, + }); + } + + if (event.shiftKey) + editor.insertText("\n"); + else + editor.insertNode(paragraph()); + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/utils.ts b/sites/app.campground.gg/src/components/editor/utils.ts new file mode 100644 index 0000000..326d8e7 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/utils.ts @@ -0,0 +1,16 @@ +import type { RichEditorBlockElement, RichEditorText } from "./editor" + +export const getNeighborPath = (path: number[], distance: number = 1) => + [...getParentPath(path), path[path.length - 1] + distance]; + +export const getParentPath = (path: number[]) => + path.slice(0, path.length - 1); + +export const paragraph: () => RichEditorBlockElement<"paragraph", RichEditorText> = () => ({ + type: "paragraph", + children: [ + { + text: "", + }, + ], +}); \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/withCgMarkdown.tsx b/sites/app.campground.gg/src/components/editor/withCgMarkdown.tsx new file mode 100644 index 0000000..142b494 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/withCgMarkdown.tsx @@ -0,0 +1,126 @@ +import { Editor, Element, Point, Range, Transforms } from "slate"; +import type { RichEditor, RichEditorBlockElementType } from "./editor"; + +const nodePrefixes: Record = { + "> ": "block-quote", + "```": "code-block", +}; + +// Doesn't help a lot, but probably for some performance to do less calculations +const endingCharacters = [" ", "`"]; + +function modifiedInsertText(editor: RichEditor, text: string): boolean { + const { selection } = editor; + + if (!endingCharacters.some((x) => text.endsWith(x)) && !(selection && Range.isCollapsed(selection))) + return false; + + const { anchor } = selection; + + const block = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + }); + + const path = block ? block[1] : []; + const start = Editor.start(editor, path); + const range = { anchor, focus: start }; + const beforeText = Editor.string(editor, range) + text.slice(0, -1); + const type = nodePrefixes[beforeText]; + + if (!type) + return false; + + Transforms.select(editor, range) + + if (!Range.isCollapsed(range)) + Transforms.delete(editor) + + const props: Partial = { + type, + }; + + Transforms.setNodes( + editor, + props, + { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + } + ); + + // if (type === 'list-item') { + // const list: BulletedListElement = { + // type: 'bulleted-list', + // children: [], + // } + // Transforms.wrapNodes(editor, list, { + // match: n => + // !Editor.isEditor(n) && + // SlateElement.isElement(n) && + // n.type === 'list-item', + // }) + // } + + return true; +} + +function modifiedDeleteBackward(editor: RichEditor): boolean { + const { selection } = editor; + + console.log("Selection", { selection, collapsed: Range.isCollapsed(selection!) }); + if (!selection || Range.isCollapsed(selection)) + return false; + + const match = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + }); + + console.log("Match", match); + + if (!match) + return false; + + const [block, path] = match; + const start = Editor.start(editor, path); + + if ( + Editor.isEditor(block) || + !Element.isElement(block) || + block.type === "paragraph" || + !Point.equals(selection.anchor, start) + ) + return false; + + const props: Partial = { + type: 'paragraph', + }; + + Transforms.setNodes(editor, props); + + if ((block as Element).type === "list-item") + Transforms.unwrapNodes(editor, { + match: n => + !Editor.isEditor(n) && + Element.isElement(n) && + n.type === "ordered-list", + split: true, + }); + + return true; +} + +export default function withCgMarkdown(editor: RichEditor) { + const { insertText, deleteBackward } = editor; + + editor.insertText = (text) => { + if (!modifiedInsertText(editor, text)) + insertText(text); + }; + + editor.deleteBackward = (...args) => { + const modified = modifiedDeleteBackward(editor); + + if (!modified) + deleteBackward(...args); + } + return editor; +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/markdown/CodeBlock.tsx b/sites/app.campground.gg/src/components/markdown/CodeBlock.tsx index ab0cca2..338f08a 100644 --- a/sites/app.campground.gg/src/components/markdown/CodeBlock.tsx +++ b/sites/app.campground.gg/src/components/markdown/CodeBlock.tsx @@ -14,7 +14,7 @@ type Props = { } // Code wrapping -const CodeContainer = styled("div", { +export const CodeContainer = styled("div", { name: "CodeContainer", slot: "container", })(({ theme }) => ({ @@ -102,13 +102,13 @@ const CodeContainer = styled("div", { } }, })); -const CodePre = styled("pre", { +export const CodePre = styled("pre", { name: "CodePre", slot: "pre", })(() => ({ margin: 0, })); -const CodeGrid = styled("code", { +export const CodeGrid = styled("code", { name: "CodeGrid", slot: "grid", })(() => ({ @@ -118,7 +118,7 @@ const CodeGrid = styled("code", { })); // Code additional content -const CodeHeader = styled("header", { +export const CodeHeader = styled("header", { name: "CodeHeader", slot: "header", })(({ theme }) => ({ @@ -129,7 +129,7 @@ const CodeHeader = styled("header", { borderBottom: `solid 1px ${theme.vars.palette.neutral[800]}`, padding: "8px 10px", })); -const CodeLanguage = styled(Chip, { +export const CodeLanguage = styled(Chip, { name: "CodeLanguage", slot: "language" })(({ theme }) => ({ @@ -141,7 +141,7 @@ const CodeLanguage = styled(Chip, { })); // Code lines -const CodeLineNumber = styled("div", { +export const CodeLineNumber = styled("div", { name: "CodeLineNumber", })(({ theme }) => ({ color: theme.vars.palette.neutral[400], @@ -156,7 +156,7 @@ const CodeLineNumber = styled("div", { backgroundColor: theme.vars.palette.info[900], }, })); -const CodeLine = styled("div", { +export const CodeLine = styled("div", { name: "CodeLine", })(({ theme }) => ({ paddingLeft: 16, @@ -208,11 +208,11 @@ function insertLineBetween(arr: T[] | string[]): Array { return [firstElem, ...withElemsAfter]; } -function linefyTokens(arr: (0 | T)[]) { +export function linefyTokens(arr: (0 | T)[]): T[][] { // It's basically .split(0), but for the arrays and their elements // [a, b, 0, c, d, e, 0, f, 0] => [[a, b], [c, d, e], [f], []] return arr - .reduce((a: (number | T)[][], b: 0 | T) => + .reduce((a: T[][], b: 0 | T) => typeof b === "number" ? [...a, []] : [...a.slice(0, a.length - 1), [...a[a.length - 1], b]] @@ -221,7 +221,7 @@ function linefyTokens(arr: (0 | T)[]) { export default class CodeBlock extends React.Component { - static nonHighlightedLanguages = ["none", "plain", "plaintext", "txt", "text"]; + public static nonHighlightedLanguages = ["none", "plain", "plaintext", "txt", "text"]; constructor(props: Props) { super(props) @@ -230,10 +230,7 @@ export default class CodeBlock extends React.Component { const { children: text } = this.props; return text.substring(Number(text.startsWith("\n")), text.length - Number(text.endsWith("\n"))); } - get tokenizedContent() { - const { language } = this.props; - const content = this.trimmedText; - + static tokenizeContent(language: string | null | undefined, content: string) { // Nothing to highlight if (!language || CodeBlock.nonHighlightedLanguages.includes(language)) return { @@ -252,13 +249,22 @@ export default class CodeBlock extends React.Component { }, tokens: nodes .flatMap(splitTokens) - .map(componentifyToken) }; } - get tokenizedCodeLines() { - const tokenizedContent = this.tokenizedContent; + get tokenizedContent() { + const { language } = this.props; + const content = this.trimmedText; - return { language: tokenizedContent.language, tokens: linefyTokens(tokenizedContent.tokens) }; + return CodeBlock.tokenizeContent(language, content); + } + static getTokenLength(token: string | { scope: string; text: string; }) { + return ((token as { scope: string; text: string; }).text ?? token).length; + } + static splitByCodeLines(tokenizedContent: { language: undefined, tokens: (string | 0)[] } | { language: { language: string; name: string | undefined; }, tokens: (string | 0 | { scope: string; text: string; })[] }) { + return { language: tokenizedContent.language, tokens: linefyTokens(tokenizedContent.tokens.map(componentifyToken)) }; + } + get tokenizedCodeLines() { + return CodeBlock.splitByCodeLines(this.tokenizedContent); } copyCode() { navigator.clipboard.writeText(this.trimmedText); diff --git a/sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx b/sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx deleted file mode 100644 index 417a958..0000000 --- a/sites/app.campground.gg/src/components/markdown/MarkdownEditor.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { styled } from "@mui/joy"; -import type { SxProps } from "@mui/joy/styles/types"; -import { EditorContent, type Editor } from "@tiptap/react" -import MarkdownWrapper from "./MarkdownWrapper"; - -type Props = { - editor: Editor; - sx?: SxProps; -}; - -const StyledEditor = styled(EditorContent, { - name: "MarkdownEditor", - slot: "editor", -})(() => ({ - "> .tiptap:focus": { - outline: "none", - }, - "> .tiptap > *:first-child": { - marginTop: 0, - }, - "> .tiptap > *:last-child": { - marginBottom: 0, - }, -})); - -const StyledWrapper = styled(MarkdownWrapper, { - name: "MarkdownEditorWrapper", - slot: "root", -})(() => ({})); - -export default function MarkdownEditor(props: Props) { - return ( - - - - ) -} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx index 6ed83c4..8859662 100644 --- a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx +++ b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx @@ -33,6 +33,16 @@ const MarkdownWrapper = styled(Box, { borderRadius: theme.vars.radius.sm, fontFamily: theme.vars.fontFamily.code, }, + "q": { + backgroundColor: theme.vars.palette.background.level4, + padding: `2px 4px`, + borderRadius: theme.vars.radius.sm, + "::after, ::before": { + color: theme.vars.palette.text.quartary, + fontWeight: 900, + margin: `0 4px`, + }, + }, "table": { border: `solid 1px ${theme.vars.palette.neutral[500]}`, borderSpacing: 0, diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx index da3b1a9..aba37ed 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx @@ -3,7 +3,7 @@ import React from "react"; import ProfileFeedPost from "./ProfileFeedPost"; import type { User, UserPostBasic } from "types/user"; import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; -import PostInput from "~/components/markdown/PostInput"; +import PostInput from "~/components/editor/PostInput"; type Props = { user: User; diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx index d20b8e6..57770d3 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx @@ -25,7 +25,6 @@ export default class ProfileFeedPost extends React.Component { - {/* {ms(Date.now() - createdAt, { long: true })} ago */} @@ -34,7 +33,6 @@ export default class ProfileFeedPost extends React.Component { ({ mt: -4.5, color: theme.vars.palette.text.secondary })}> {content} - {/* {content} */} {showCommentsLink && }> From ef7a952fd49a0158b2bd624794c210f6242d51e4 Mon Sep 17 00:00:00 2001 From: PrettyGoodName <31628630+IdkGoodName@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:13:51 +0200 Subject: [PATCH 3/7] feat: editor improvements --- sites/app.campground.gg/package-lock.json | 1 + sites/app.campground.gg/package.json | 1 + .../src/components/editor/BlockNodeInsert.tsx | 2 +- .../components/editor/BlockNodeMenuItem.tsx | 28 +++++ .../src/components/editor/BlockNodeToggle.tsx | 16 ++- .../src/components/editor/BlockTextEditor.tsx | 14 ++- .../components/editor/CampgroundEditor.tsx | 39 +++++- .../editor/CodeBlockEditorHeader.tsx | 6 +- .../src/components/editor/CodeNodeToggle.tsx | 4 +- .../src/components/editor/EditorElement.tsx | 21 +++- .../src/components/editor/EditorLeaf.tsx | 5 + .../components/editor/InlineNodeToggle.tsx | 4 +- .../src/components/editor/ListNodeToggle.tsx | 4 +- .../src/components/editor/MarkNodeToggle.tsx | 4 +- .../components/editor/RichEditorToolbar.tsx | 71 +++++++++-- .../components/editor/codeEditorContext.tsx | 2 +- .../src/components/editor/keyboard-logic.ts | 49 -------- .../components/markdown/MarkdownWrapper.tsx | 14 ++- .../editor/block-decorate.tsx | 4 +- .../src/{components => }/editor/editor.ts | 33 +++++- .../src/editor/keyboard-logic.ts | 111 ++++++++++++++++++ .../src/{components => }/editor/utils.ts | 11 +- .../editor/withCgMarkdown.tsx | 68 ++++++----- .../src/routes/_auth.login/LoginPage.tsx | 9 +- .../src/session/provider.tsx | 3 +- 25 files changed, 384 insertions(+), 140 deletions(-) create mode 100644 sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx delete mode 100644 sites/app.campground.gg/src/components/editor/keyboard-logic.ts rename sites/app.campground.gg/src/{components => }/editor/block-decorate.tsx (92%) rename sites/app.campground.gg/src/{components => }/editor/editor.ts (82%) create mode 100644 sites/app.campground.gg/src/editor/keyboard-logic.ts rename sites/app.campground.gg/src/{components => }/editor/utils.ts (60%) rename sites/app.campground.gg/src/{components => }/editor/withCgMarkdown.tsx (65%) diff --git a/sites/app.campground.gg/package-lock.json b/sites/app.campground.gg/package-lock.json index b071b72..5886a9c 100644 --- a/sites/app.campground.gg/package-lock.json +++ b/sites/app.campground.gg/package-lock.json @@ -21,6 +21,7 @@ "components": "file:../../packages/components", "highlight.js": "^11.11.1", "isbot": "^5", + "mdast-util-from-markdown": "^2.0.2", "ms": "^2.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/sites/app.campground.gg/package.json b/sites/app.campground.gg/package.json index b22c419..274523a 100644 --- a/sites/app.campground.gg/package.json +++ b/sites/app.campground.gg/package.json @@ -23,6 +23,7 @@ "components": "file:../../packages/components", "highlight.js": "^11.11.1", "isbot": "^5", + "mdast-util-from-markdown": "^2.0.2", "ms": "^2.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx index 646aa59..dd5a570 100644 --- a/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx @@ -1,7 +1,7 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType } from "./editor"; +import type { RichEditorBlockElementType } from "../../editor/editor"; type Props = { format: RichEditorBlockElementType; diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx new file mode 100644 index 0000000..2a748ef --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx @@ -0,0 +1,28 @@ +import { MenuItem } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditorBlockElementType } from "../../editor/editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: RichEditorBlockElementType; + additionalProps?: any; + children: ReactNode[] | ReactNode; + onClick?: () => void; +}; + +export default function BlockNodeMenuItem({ children, format: formatting, additionalProps, onClick }: Props) { + const editor = useSlate(); + + const setFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + CampgroundEditor.setBlockFormatting(editor, formatting, additionalProps); + onClick?.(); + }; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx index 8fb6cdc..8851c56 100644 --- a/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx @@ -1,25 +1,29 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType } from "./editor"; +import type { RichEditor, RichEditorBlockElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; -type Props = { - format: RichEditorBlockElementType; +type Props = { + format: T; children: ReactNode[] | ReactNode; + onClick?: (editor: RichEditor, format: T) => void; }; -export default function BlockNodeToggle({ children, format: formatting }: Props) { +export default function BlockNodeToggle({ children, format: formatting, onClick }: Props) { const editor = useSlate(); const active = CampgroundEditor.isNodeFormatted(editor, formatting); + const toggleFormattingFn = onClick ?? CampgroundEditor.toggleBlockFormatting; + const toggleFormatting = (ev: React.MouseEvent) => { ev.preventDefault(); - CampgroundEditor.toggleBlockFormatting(editor, formatting); + toggleFormattingFn(editor, formatting); }; + return ( - + {children} ); diff --git a/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx index 6bc14ff..35b55a3 100644 --- a/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx @@ -5,13 +5,13 @@ import MarkdownWrapper from "../markdown/MarkdownWrapper"; import type { SxProps } from "@mui/joy/styles/types"; import { Divider, styled } from "@mui/joy"; import { withHistory } from "slate-history"; -import type { RichEditor } from "./editor"; +import type { RichEditor } from "../../editor/editor"; import EditorLeaf from "./EditorLeaf"; import EditorElement from "./EditorElement"; -import RichEditorToolbar, { RichEditorToolbarBlockFormatting, RichEditorToolbarInlineFormatting } from "./RichEditorToolbar"; -import withCgMarkdown from "./withCgMarkdown"; -import useBlockDecorate from "./block-decorate"; -import { editorKeyboardLogic } from "./keyboard-logic"; +import RichEditorToolbar, { RichEditorToolbarBlockFormatting, RichEditorToolbarHeading, RichEditorToolbarInlineFormatting } from "./RichEditorToolbar"; +import withCgMarkdown from "../../editor/withCgMarkdown"; +import useBlockDecorate from "../../editor/block-decorate"; +import { editorKeyboardLogic } from "../../editor/keyboard-logic"; type Props = { sx: SxProps; @@ -58,11 +58,13 @@ export default function BlockTextEditor({ sx }: Props) { return ( - console.log("VVVV", v)}> + + + diff --git a/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx index 59287e3..0869b74 100644 --- a/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx +++ b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx @@ -1,5 +1,5 @@ -import { Editor, Element, Text, Transforms } from "slate"; -import { RichEditorInlineElementType, type RichEditor, type RichEditorAnyElement, type RichEditorAnyElementType, type RichEditorBlockElementType, type RichEditorItemElementType, type RichEditorTextFormatting } from "./editor"; +import { Editor, Element, Node, type NodeEntry, Text, Transforms } from "slate"; +import { RichEditorInlineElementType, type RichEditor, type RichEditorAnyElementType, type RichEditorBlockElementType, type RichEditorItemElementType, type RichEditorTextFormatting } from "../../editor/editor"; export default class CampgroundEditor { static isNodeFormatted(editor: RichEditor, type: RichEditorAnyElementType) { @@ -24,16 +24,40 @@ export default class CampgroundEditor { // Returned at least one element, which means it is formatted return Boolean(match); } + static getSelectedNodes(editor: RichEditor): NodeEntry[] | null { + const { selection } = editor; + + // Can't detect nodes; out of focus of editor + if (!selection) + return null; + + return Array.from( + Editor.nodes(editor, { + at: Editor.unhangRange(editor, selection), + match: n => !Editor.isEditor(n) && Element.isElement(n), + }) + ); + } static isTextFormatted(editor: RichEditor, type: keyof RichEditorTextFormatting) { const marks = Editor.marks(editor); return (marks?.[type] as boolean | null) ?? false; } - static toggleBlockFormatting(editor: RichEditor, type: RichEditorBlockElementType) { + static toggleBlockFormatting(editor: RichEditor, type: RichEditorBlockElementType, additionalProps?: any) { const active = this.isNodeFormatted(editor, type); // Simple type change + const props = active ? additionalProps && Object.keys(additionalProps).reduce((obj, prop) => (obj[prop] = null, obj), {} as Record) : additionalProps; + Transforms.setNodes(editor, { type: active ? `paragraph` : type, + ...props, + }); + } + static setBlockFormatting(editor: RichEditor, type: RichEditorBlockElementType, additionalProps?: any) { + // Simple type change + Transforms.setNodes(editor, { + type: type, + ...additionalProps, }); } static toggleInlineFormatting(editor: RichEditor, type: RichEditorInlineElementType) { @@ -90,6 +114,9 @@ export default class CampgroundEditor { } static toggleCodeFormatting(editor: RichEditor, type: RichEditorBlockElementType, itemType: RichEditorItemElementType) { const active = this.isNodeFormatted(editor, type); + const activeElems = this.getSelectedNodes(editor); + + console.log(activeElems); // Simple type change Transforms.setNodes( @@ -103,6 +130,8 @@ export default class CampgroundEditor { } ); + editor.selection + if (active) { } else Transforms.wrapNodes( @@ -110,8 +139,8 @@ export default class CampgroundEditor { { type, children: [], - language: "js" - } as unknown as RichEditorAnyElement, + lang: "js", + }, { match: n => Element.isElement(n) && n.type === itemType } diff --git a/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx index 54c4910..308e907 100644 --- a/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx +++ b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx @@ -1,5 +1,5 @@ import { Option, Select } from "@mui/joy"; -import type { RichEditorCodeBlock } from "./editor"; +import type { RichEditorCodeBlock } from "../../editor/editor"; import hljs from "highlight.js"; import { useSlateStatic } from "slate-react"; import { Element, Transforms } from "slate"; @@ -16,7 +16,7 @@ export default function CodeBlockEditorHeader({ element }: Props) { Transforms.setNodes( editor, { - language, + lang: language, }, { match: m => @@ -32,7 +32,7 @@ export default function CodeBlockEditorHeader({ element }: Props) { return ( - diff --git a/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx index 383085a..10d876a 100644 --- a/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx @@ -1,7 +1,7 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType, RichEditorItemElementType } from "./editor"; +import type { RichEditorBlockElementType, RichEditorItemElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { @@ -20,7 +20,7 @@ export default function CodeNodeToggle({ children, format: formatting, itemForma CampgroundEditor.toggleCodeFormatting(editor, formatting, itemFormat); }; return ( - + {children} ); diff --git a/sites/app.campground.gg/src/components/editor/EditorElement.tsx b/sites/app.campground.gg/src/components/editor/EditorElement.tsx index bcdc67b..fa42607 100644 --- a/sites/app.campground.gg/src/components/editor/EditorElement.tsx +++ b/sites/app.campground.gg/src/components/editor/EditorElement.tsx @@ -1,9 +1,10 @@ import { type RenderElementProps } from "slate-react"; -import type { RichEditorAnyElementType, RichEditorCodeBlock, RichEditorCodeLine } from "./editor"; +import type { RichEditorAnyElementType, RichEditorCodeBlock, RichEditorCodeLine, RichEditorHeading, RichEditorLink } from "../../editor/editor"; import { ReactNode } from "react"; import { CodeContainer, CodeGrid, CodeHeader, CodeLine, CodeLineNumber, CodePre } from "../markdown/CodeBlock"; import CodeBlockEditorHeader from "./CodeBlockEditorHeader"; import { CodeEditorContextProvider, useCodeEditorContext } from "./codeEditorContext"; +import Link from "../Link"; const typeToRenderer: Record (ReactNode[] | ReactNode)> = { paragraph({ attributes, children }) { @@ -12,6 +13,23 @@ const typeToRenderer: Record; }, + heading({ attributes, children, element }) { + const Tag = `h${(element as RichEditorHeading).depth ?? 1}` as "h1"; + return ( + + {children} + + ); + }, + link({ children, element }) { + const link = element as RichEditorLink; + + return ( + + {children} + + ); + }, ["block-quote"]({ attributes, children }) { return
    {children}
    }, @@ -80,7 +98,6 @@ const typeToRenderer: Record diff --git a/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx b/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx index 71064ad..f02bed7 100644 --- a/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx +++ b/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx @@ -5,6 +5,11 @@ const Leaf = styled(Typography, { name: "Leaf", })<{ component: string; }>(() => ({ display: "inline", + fontSize: "inherit", + "h1, h2, h3, h4, h5, h6 &": { + fontSize: "inherit", + fontWeight: "bolder", + }, "&.bold": { fontWeight: "bolder", }, diff --git a/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx index 97848ec..71875cd 100644 --- a/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx @@ -1,7 +1,7 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorInlineElementType } from "./editor"; +import type { RichEditorInlineElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { @@ -19,7 +19,7 @@ export default function InlineNodeToggle({ children, format: formatting }: Props CampgroundEditor.toggleInlineFormatting(editor, formatting); }; return ( - + {children} ); diff --git a/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx index f2e3fc4..459ba72 100644 --- a/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx @@ -1,7 +1,7 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType, RichEditorItemElementType } from "./editor"; +import type { RichEditorBlockElementType, RichEditorItemElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { @@ -20,7 +20,7 @@ export default function ListNodeToggle({ children, format: formatting, itemForma CampgroundEditor.toggleListFormatting(editor, formatting, itemFormat); }; return ( - + {children} ); diff --git a/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx index ea04fb6..b618e9f 100644 --- a/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx @@ -1,7 +1,7 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorTextFormatting } from "./editor"; +import type { RichEditorTextFormatting } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { @@ -20,7 +20,7 @@ export default function MarkNodeToggle({ children, format: formatting }: Props) }; return ( - + {children} ); diff --git a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx index 969deda..b31bec1 100644 --- a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx +++ b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx @@ -1,13 +1,14 @@ -import { ButtonGroup } from "@mui/joy"; +import { ButtonGroup, Dropdown, ListItemContent, ListItemDecorator, Menu, MenuButton } from "@mui/joy"; import { Group } from "components"; import { ReactNode } from "react" import MarkNodeToggle from "./MarkNodeToggle"; -import { IconBlockquote, IconBold, IconBraces, IconCode, IconItalic, IconList, IconListNumbers, IconQuote, IconSeparatorHorizontal, IconStrikethrough, IconUnderline } from "@tabler/icons-react"; +import { IconBlockquote, IconBold, IconBraces, IconCaretDownFilled, IconCode, IconH1, IconH2, IconH3, IconH4, IconH6, IconItalic, IconList, IconListNumbers, IconQuote, IconSeparatorHorizontal, IconStrikethrough, IconUnderline } from "@tabler/icons-react"; import BlockNodeToggle from "./BlockNodeToggle"; import ListNodeToggle from "./ListNodeToggle"; import CodeNodeToggle from "./CodeNodeToggle"; import BlockNodeInsert from "./BlockNodeInsert"; import InlineNodeToggle from "./InlineNodeToggle"; +import BlockNodeMenuItem from "./BlockNodeMenuItem"; type Props = { children: ReactNode[] | ReactNode; @@ -15,7 +16,7 @@ type Props = { export function RichEditorToolbarInlineFormatting() { return ( - + @@ -40,7 +41,7 @@ export function RichEditorToolbarInlineFormatting() { export function RichEditorToolbarBlockFormatting() { return ( - + @@ -50,19 +51,75 @@ export function RichEditorToolbarBlockFormatting() { - + - + ); } +export function RichEditorToolbarHeading() { + return ( + + + + + + + + + + + + + + + + Heading 2 + + + + + + + + Heading 3 + + + + + + + + Heading 4 + + + + + + + + Heading 5 + + + + + + + + Heading 6 + + + + + ); +} export default function RichEditorToolbar({ children }: Props) { return ( - + ({ p: 1, backgroundColor: theme.vars.palette.background.level1 })}> {children} ) diff --git a/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx index 4539b2b..54868f5 100644 --- a/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx +++ b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { RichEditorCodeLine } from "./editor"; +import type { RichEditorCodeLine } from "../../editor/editor"; export const CodeEditorContext = createContext([]); export const useCodeEditorContext = () => useContext(CodeEditorContext); diff --git a/sites/app.campground.gg/src/components/editor/keyboard-logic.ts b/sites/app.campground.gg/src/components/editor/keyboard-logic.ts deleted file mode 100644 index b8c0d2f..0000000 --- a/sites/app.campground.gg/src/components/editor/keyboard-logic.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Element } from "slate"; -import { RichEditorItemElementType, type RichEditor } from "./editor"; -import { getNeighborPath, getParentPath, paragraph } from "./utils"; -import React from "react"; - -function ArrowVertical(editor: RichEditor, up: boolean) { - const above = editor.above(); - const elementAbove = above?.[0]; - const isElement = Element.isElement(elementAbove); - - editor.move({ distance: 1, unit: "line", reverse: up }); - - const after = editor.above(); - if (!isElement || elementAbove.type === "paragraph" || elementAbove !== after?.[0]) - return; - - const mainBlock = RichEditorItemElementType.includes(elementAbove.type as RichEditorItemElementType) ? getParentPath(above![1]) : above![1]; - - editor.insertNode(paragraph(), { at: up ? mainBlock : getNeighborPath(mainBlock) }); - editor.move({ distance: 1, unit: "line", reverse: up }); -} - -export const editorKeyboardLogic: Record) => void> = { - ArrowUp(editor: RichEditor, _: React.KeyboardEvent) { - return ArrowVertical(editor, true); - }, - ArrowDown(editor: RichEditor, _: React.KeyboardEvent) { - return ArrowVertical(editor, false); - }, - Enter(editor: RichEditor, event: React.KeyboardEvent) { - const above = editor.above(); - - console.log([above?.[0], Element.isElement(above?.[0])]); - - if (above && Element.isElement(above[0]) && RichEditorItemElementType.includes(above[0].type as RichEditorItemElementType) && (!event.shiftKey || above[0].type === "code-line")) { - editor.insertNode({ type: above[0].type, children: [] }, { at: getNeighborPath(above[1]) }); - - return editor.move({ - unit: "line", - distance: 1, - }); - } - - if (event.shiftKey) - editor.insertText("\n"); - else - editor.insertNode(paragraph()); - } -} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx index 8859662..fcf73d1 100644 --- a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx +++ b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx @@ -4,17 +4,19 @@ const MarkdownWrapper = styled(Box, { name: "MarkdownWrapper", slot: "root" })(({ theme }) => ({ - "p:first-child": { - marginTop: 0, - }, - "p:last-child": { - marginBottom: 0, + "p, h1, h2, h3, h4, h5, h6, blockquote": { + "&:first-child": { + marginTop: 0, + }, + "&:last-child": { + marginBottom: 0, + }, }, "blockquote": { position: "relative", marginLeft: "20px", marginRight: "0px", - "::before": { + "&::before": { position: "absolute", content: "''", height: "100%", diff --git a/sites/app.campground.gg/src/components/editor/block-decorate.tsx b/sites/app.campground.gg/src/editor/block-decorate.tsx similarity index 92% rename from sites/app.campground.gg/src/components/editor/block-decorate.tsx rename to sites/app.campground.gg/src/editor/block-decorate.tsx index f9aeebe..3a10e48 100644 --- a/sites/app.campground.gg/src/components/editor/block-decorate.tsx +++ b/sites/app.campground.gg/src/editor/block-decorate.tsx @@ -1,13 +1,13 @@ import { useCallback } from "react"; import { type DecoratedRange, Element, Node, type NodeEntry, type Range } from "slate"; import type { RichEditorAnyElementType, RichEditorCodeBlock } from "./editor"; -import CodeBlock, { linefyTokens } from "../markdown/CodeBlock"; +import CodeBlock, { linefyTokens } from "../components/markdown/CodeBlock"; const decorators: Partial DecoratedRange[]>> = { ["code-block"]([node, path]) { const content = Node.string(node); - const { language } = node as unknown as RichEditorCodeBlock; + const { lang: language } = node as unknown as RichEditorCodeBlock; if (!language || CodeBlock.nonHighlightedLanguages.includes(language)) return []; diff --git a/sites/app.campground.gg/src/components/editor/editor.ts b/sites/app.campground.gg/src/editor/editor.ts similarity index 82% rename from sites/app.campground.gg/src/components/editor/editor.ts rename to sites/app.campground.gg/src/editor/editor.ts index 08b61a0..baf47f9 100644 --- a/sites/app.campground.gg/src/components/editor/editor.ts +++ b/sites/app.campground.gg/src/editor/editor.ts @@ -1,4 +1,4 @@ -import type { BaseEditor, BaseRange, Element, Range, Text } from "slate"; +import type { BaseEditor, BasePoint, BaseRange, Element, Range, Text } from "slate"; import type { HistoryEditor } from "slate-history"; import type { ReactEditor } from "slate-react"; @@ -23,7 +23,7 @@ export interface RichEditorTextUnformatted { * Rich text editor's text node with all of its contents. */ export interface RichEditorText extends RichEditorTextFormatting, RichEditorTextUnformatted { } -const _RichEditorBlockElementType = ["paragraph", "block-quote", "code-block", "divider", "unordered-list", "ordered-list"] as const; +const _RichEditorBlockElementType = ["paragraph", "block-quote", "code-block", "divider", "unordered-list", "ordered-list", "heading"] as const; /** * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. */ @@ -46,11 +46,16 @@ export type RichEditorItemElementType = typeof _RichEditorItemElementType[number */ export const RichEditorItemElementType = _RichEditorItemElementType; +export const RichEditorItemToParent: Record = { + "code-line": "code-block", + "list-item": "unordered-list", +}; + // Test inline /** * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line */ -const _RichEditorInlineElementType = ["inline-quote"] as const; +const _RichEditorInlineElementType = ["inline-quote", "link"] as const; /** * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line. */ @@ -78,10 +83,19 @@ export interface RichEditorInlineElement; -export type RichEditorListItem = RichEditorBlockElement<"list-item", RichEditorBlockElementType | RichEditorText>; +export type RichEditorListItem = RichEditorBlockElement<"list-item", RichEditorAnyBlockElement | RichEditorText>; export interface RichEditorCodeBlock extends RichEditorBlockElement<"code-block", RichEditorCodeLine> { - language?: null | undefined | string; + lang?: null | undefined | string; + meta?: null | undefined | string; +} + +export interface RichEditorHeading extends RichEditorBlockElement<"heading", Text> { + depth?: null | undefined | number; +} + +export interface RichEditorLink extends RichEditorInlineElement<"link", Text> { + url: string; } export interface RichEditorOrderedList extends RichEditorBlockElement<"ordered-list", RichEditorListItem> { @@ -90,6 +104,7 @@ export interface RichEditorOrderedList extends RichEditorBlockElement<"ordered-l export type RichEditorAnyBlockElement = RichEditorBlockElement<"paragraph", Text> | + RichEditorHeading | RichEditorBlockElement<"block-quote", RichEditorAnyBlockElement> | RichEditorBlockElement<"divider", RichEditorText> | RichEditorCodeBlock | @@ -99,7 +114,8 @@ export type RichEditorAnyItemElement = RichEditorCodeLine | RichEditorListItem; export type RichEditorAnyInlineElement = - RichEditorInlineElement<"inline-quote", RichEditorAnyInlineElement | RichEditorText>; + RichEditorInlineElement<"inline-quote", RichEditorAnyInlineElement | RichEditorText> | + RichEditorLink; export type RichEditorAnyElement = RichEditorAnyInlineElement | RichEditorAnyBlockElement | RichEditorAnyItemElement; export type RichEditor = @@ -108,11 +124,16 @@ export type RichEditor = nodeToDecorations?: Map }; +export interface RichEditorPoint extends BasePoint { + lineOffset?: number; +} + declare module 'slate' { interface CustomTypes { Editor: RichEditor; Element: RichEditorAnyElement; Text: RichEditorText; + Point: RichEditorPoint; Range: BaseRange & { [key: string]: unknown } diff --git a/sites/app.campground.gg/src/editor/keyboard-logic.ts b/sites/app.campground.gg/src/editor/keyboard-logic.ts new file mode 100644 index 0000000..563a169 --- /dev/null +++ b/sites/app.campground.gg/src/editor/keyboard-logic.ts @@ -0,0 +1,111 @@ +import { Editor, Element, Node, Path } from "slate"; +import { RichEditorItemElementType, type RichEditor } from "./editor"; +import { getNeighborPath, getNewlineIndexes, getParentPath, paragraph } from "./utils"; +import React from "react"; + +function ArrowVertical(editor: RichEditor, up: boolean) { + const above = editor.above(); + const elementAbove = above?.[0]; + const isElement = Element.isElement(elementAbove); + + if (!isElement) + return; + + const elementString = Node.string(elementAbove); + const newlines = getNewlineIndexes(elementString).map((x) => x + 1); + + const selectionOffset = editor.selection?.focus?.offset ?? 0; + + const passedLines = newlines.filter((x) => x <= selectionOffset); + const nextNewlineOffset = newlines.find((x) => x > selectionOffset); + + // Move between lines in a single block + if (!up && nextNewlineOffset || up && passedLines.length) { + // Where the newline is found as offset to move to + const lineThere = up ? passedLines.slice(-2, -1)[0] ?? 0 : nextNewlineOffset!; + + const currentLine = passedLines.slice(-1)[0] ?? 0; + const currentLineOffset = selectionOffset - currentLine; + // Perhaps the line offset was saved + const finalLineOffset = editor.selection?.focus.lineOffset ?? currentLineOffset; + + return editor.select({ lineOffset: finalLineOffset, path: editor.selection!.focus.path, offset: Math.min(lineThere + finalLineOffset, up ? currentLine - lineThere - 1: elementString.length) }); + } + + // Will be used to get neighbours + const abovePath = above![1]; + const aboveParent = getParentPath(abovePath); + + const sequentialItemSettings = { at: abovePath, match: (node: Node, path: Path) => (console.log({ node, path }), !Editor.isEditor(node)) } + + // Positional calc + const itemNext = editor.next(sequentialItemSettings); + const itemPrevious = editor.previous(sequentialItemSettings); + const itemThere = up ? itemPrevious : itemNext; + const itemOppositeOfThere = up ? itemNext : itemPrevious; + + // Don't keep empty paragraph if person moved down or up accidentally, similar the way Guilded does + if (itemThere && !itemOppositeOfThere && elementAbove.type === "paragraph" && Node.string(elementAbove) === "") { + editor.select({ path: itemThere[1], offset: 0 }); + return editor.delete({ at: abovePath }); + } + // Just move there + else if (itemThere) { + const stringifiedThere = Node.string(itemThere[0]); + // Retain the offset from the line cursor is at + const newOffset = editor.selection?.focus.lineOffset ?? (editor.selection?.focus?.offset ?? 0) - (newlines.slice(-1)[0] ?? 0); + const newlinesThere = getNewlineIndexes(stringifiedThere).map((x) => x + 1); + + // Retain offset in the last line of the node 'there' + return editor.select({ + path: itemThere[1], + lineOffset: newOffset, + offset: Math.min((newlinesThere.slice(-1)[0] ?? 0) + newOffset, stringifiedThere.length), + }); + } + // The end of editor and no point trying to escape blocks + else if (elementAbove.type === "paragraph" && abovePath.length < 2) + return; + + const newNodePath = getNeighborPath(aboveParent, (Number(up) * -2) + 1); + + // Insert and set cursor to it. Made to escape blocks. + editor.insertNode( + { + type: "paragraph", + children: [], + }, + { + at: newNodePath, + }, + ); + return editor.select({ path: [...newNodePath, 0], offset: 0, lineOffset: 0 }); +} + +export const editorKeyboardLogic: Record) => void> = { + ArrowUp(editor: RichEditor, _: React.KeyboardEvent) { + return ArrowVertical(editor, true); + }, + ArrowDown(editor: RichEditor, _: React.KeyboardEvent) { + return ArrowVertical(editor, false); + }, + Enter(editor: RichEditor, event: React.KeyboardEvent) { + const above = editor.above(); + + // Override others for code blocks and list + if (above && Element.isElement(above[0]) && RichEditorItemElementType.includes(above[0].type as RichEditorItemElementType) && (!event.shiftKey || above[0].type === "code-line")) { + editor.insertNode({ type: above[0].type, children: [] }, { at: getNeighborPath(above[1]) }); + + return editor.move({ + unit: "line", + distance: 1, + }); + } + + // Soft break + if (event.shiftKey) + return editor.insertText("\n"); + + editor.insertNode(paragraph()); + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/utils.ts b/sites/app.campground.gg/src/editor/utils.ts similarity index 60% rename from sites/app.campground.gg/src/components/editor/utils.ts rename to sites/app.campground.gg/src/editor/utils.ts index 326d8e7..cc08d85 100644 --- a/sites/app.campground.gg/src/components/editor/utils.ts +++ b/sites/app.campground.gg/src/editor/utils.ts @@ -1,7 +1,16 @@ import type { RichEditorBlockElement, RichEditorText } from "./editor" export const getNeighborPath = (path: number[], distance: number = 1) => - [...getParentPath(path), path[path.length - 1] + distance]; + [...getParentPath(path), negativeFloor(path[path.length - 1] + distance)]; + +export const getNewlineIndexes = (str: string) => + str + .split("\n") + .map((x) => x.length) + .slice(0, -1); + +const negativeFloor = (a: number) => + a < 0 ? 0 : a; export const getParentPath = (path: number[]) => path.slice(0, path.length - 1); diff --git a/sites/app.campground.gg/src/components/editor/withCgMarkdown.tsx b/sites/app.campground.gg/src/editor/withCgMarkdown.tsx similarity index 65% rename from sites/app.campground.gg/src/components/editor/withCgMarkdown.tsx rename to sites/app.campground.gg/src/editor/withCgMarkdown.tsx index 142b494..fc177cd 100644 --- a/sites/app.campground.gg/src/components/editor/withCgMarkdown.tsx +++ b/sites/app.campground.gg/src/editor/withCgMarkdown.tsx @@ -1,9 +1,17 @@ import { Editor, Element, Point, Range, Transforms } from "slate"; -import type { RichEditor, RichEditorBlockElementType } from "./editor"; - -const nodePrefixes: Record = { - "> ": "block-quote", - "```": "code-block", +import { type RichEditor, RichEditorBlockElementType, RichEditorItemElementType } from "./editor"; + +const unorderedList = { + type: "list-item", + wrapper: "unordered-list" +} as const; + +const nodePrefixes: Record = { + "> ": { type: "paragraph", wrapper: "block-quote" }, + "```": { wrapper: "code-block", type: "code-line" }, + "- ": unorderedList, + "+ ": unorderedList, + "* ": unorderedList, }; // Doesn't help a lot, but probably for some performance to do less calculations @@ -12,31 +20,37 @@ const endingCharacters = [" ", "`"]; function modifiedInsertText(editor: RichEditor, text: string): boolean { const { selection } = editor; - if (!endingCharacters.some((x) => text.endsWith(x)) && !(selection && Range.isCollapsed(selection))) + if (!endingCharacters.some((x) => text.endsWith(x)) || !(selection && Range.isCollapsed(selection))) return false; - const { anchor } = selection; + const { anchor } = (selection as Range); const block = Editor.above(editor, { match: n => Element.isElement(n) && Editor.isBlock(editor, n), }); + if ((block?.[0] as Element | null)?.type !== "paragraph") + return false; + const path = block ? block[1] : []; const start = Editor.start(editor, path); const range = { anchor, focus: start }; - const beforeText = Editor.string(editor, range) + text.slice(0, -1); - const type = nodePrefixes[beforeText]; + const beforeText = Editor.string(editor, range); + - if (!type) + const elem = nodePrefixes[beforeText + text]; + + if (!elem) return false; - Transforms.select(editor, range) + Transforms.select(editor, range); if (!Range.isCollapsed(range)) Transforms.delete(editor) const props: Partial = { - type, + type: elem.type, + children: [] }; Transforms.setNodes( @@ -47,26 +61,25 @@ function modifiedInsertText(editor: RichEditor, text: string): boolean { } ); - // if (type === 'list-item') { - // const list: BulletedListElement = { - // type: 'bulleted-list', - // children: [], - // } - // Transforms.wrapNodes(editor, list, { - // match: n => - // !Editor.isEditor(n) && - // SlateElement.isElement(n) && - // n.type === 'list-item', - // }) - // } + if (elem.wrapper) + Transforms.wrapNodes(editor, + { + type: elem.wrapper, + children: [], + }, + { + match: n => + !Editor.isEditor(n) && + Element.isElement(n) && + n.type === elem.type, + }) return true; } function modifiedDeleteBackward(editor: RichEditor): boolean { const { selection } = editor; - - console.log("Selection", { selection, collapsed: Range.isCollapsed(selection!) }); + if (!selection || Range.isCollapsed(selection)) return false; @@ -74,8 +87,6 @@ function modifiedDeleteBackward(editor: RichEditor): boolean { match: n => Element.isElement(n) && Editor.isBlock(editor, n), }); - console.log("Match", match); - if (!match) return false; @@ -122,5 +133,6 @@ export default function withCgMarkdown(editor: RichEditor) { if (!modified) deleteBackward(...args); } + return editor; } \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/_auth.login/LoginPage.tsx b/sites/app.campground.gg/src/routes/_auth.login/LoginPage.tsx index 637e15a..32e880d 100644 --- a/sites/app.campground.gg/src/routes/_auth.login/LoginPage.tsx +++ b/sites/app.campground.gg/src/routes/_auth.login/LoginPage.tsx @@ -3,7 +3,6 @@ import { FormattedMessage } from "react-intl"; import Form from "../../components/form/Form"; import { Alert, Link } from "@mui/joy"; import { useSession } from "~/session"; -import { redirect } from "react-router"; import { IconExclamationCircleFilled } from "@tabler/icons-react"; export default function LoginPage() { @@ -19,18 +18,12 @@ export default function LoginPage() { password: fieldValues.password, }; - console.log(details); - return await session.login(details) - .then(() => { - console.log("Logged in"); - throw redirect("/"); - }) + .then(() => (window.location.href = "/", undefined)) .catch((err) => setError(err) ); }; - console.log("Session is", session); return (
    { const data = await RESTClient.login(details); + if (data.ok) { - setAuth(data.content); + setAuth({ authenticated: true, user: data.content }); setRestClient(new RESTClient({ auth: data.content.accessJwt, refreshAuth: data.content.refreshJwt }, refreshLogin)) } else throw new Error(data.errorDescription); From dca060588eba9984faabf5ef8275cfe865aa7977 Mon Sep 17 00:00:00 2001 From: PrettyGoodName <31628630+IdkGoodName@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:41:43 +0200 Subject: [PATCH 4/7] feat: creatable posts --- sites/app.campground.gg/api/RESTClient.ts | 24 +++ sites/app.campground.gg/package-lock.json | 2 + sites/app.campground.gg/package.json | 2 + .../src/components/editor/BlockNodeInsert.tsx | 4 +- .../components/editor/BlockNodeMenuItem.tsx | 4 +- .../src/components/editor/BlockNodeToggle.tsx | 6 +- .../src/components/editor/BlockTextEditor.tsx | 14 +- .../components/editor/CampgroundEditor.tsx | 26 +-- .../editor/CodeBlockEditorHeader.tsx | 4 +- .../src/components/editor/CodeNodeToggle.tsx | 6 +- .../src/components/editor/EditorElement.tsx | 26 +-- .../components/editor/InlineNodeToggle.tsx | 4 +- .../src/components/editor/ListNodeToggle.tsx | 6 +- .../src/components/editor/MarkNodeToggle.tsx | 4 +- .../src/components/editor/PostInput.tsx | 24 ++- .../components/editor/RichEditorToolbar.tsx | 7 +- .../components/editor/codeEditorContext.tsx | 6 +- .../src/editor/block-decorate.tsx | 6 +- sites/app.campground.gg/src/editor/editor.ts | 159 +++++------------- sites/app.campground.gg/src/editor/element.ts | 100 +++++++++++ .../src/editor/keyboard-logic.ts | 10 +- .../src/editor/mdast/editor.ts | 10 ++ .../src/editor/mdast/element.ts | 48 ++++++ .../src/editor/mdast/index.ts | 4 + .../src/editor/mdast/nodes.ts | 14 ++ .../src/editor/mdast/text.ts | 24 +++ sites/app.campground.gg/src/editor/text.ts | 21 +++ sites/app.campground.gg/src/editor/utils.ts | 5 +- .../src/editor/withCgMarkdown.tsx | 4 +- .../src/layout/profile/ProfileFeed.tsx | 56 +++--- .../src/session/SessionMiddleware.ts | 2 +- .../src/session/provider.tsx | 4 +- sites/app.campground.gg/types/record.ts | 21 +++ 33 files changed, 436 insertions(+), 221 deletions(-) create mode 100644 sites/app.campground.gg/src/editor/element.ts create mode 100644 sites/app.campground.gg/src/editor/mdast/editor.ts create mode 100644 sites/app.campground.gg/src/editor/mdast/element.ts create mode 100644 sites/app.campground.gg/src/editor/mdast/index.ts create mode 100644 sites/app.campground.gg/src/editor/mdast/nodes.ts create mode 100644 sites/app.campground.gg/src/editor/mdast/text.ts create mode 100644 sites/app.campground.gg/src/editor/text.ts create mode 100644 sites/app.campground.gg/types/record.ts diff --git a/sites/app.campground.gg/api/RESTClient.ts b/sites/app.campground.gg/api/RESTClient.ts index 61768bb..ded904d 100644 --- a/sites/app.campground.gg/api/RESTClient.ts +++ b/sites/app.campground.gg/api/RESTClient.ts @@ -3,6 +3,7 @@ import type { RestResponseError, RestResponseOkWithContent, RestResponseWithCont import type { User, UserPostBasic, UserPostDetailed } from "types/user"; import type { RESTRefreshLogin } from "./RESTErrorHandler"; import type { SessionAuthRefresh } from "~/session/types"; +import type { AtprotoRecord, AtprotoValueBase, GetRecordListResponse, PutRecordResponse } from "types/record"; type HTTPMethod = "GET" | "OPTION" | "PUT" | "POST" | "PATCH" | "DELETE"; @@ -14,6 +15,7 @@ export interface RESTClientConfig extends RequestPrefixed { atprotoProxy: string; auth: string; refreshAuth: string; + userDid: string; }; export interface RequestConfig { @@ -30,6 +32,7 @@ export default class RESTClient { routePrefix: defaultXrpcPrefix, auth: `...`, refreshAuth: `...`, + userDid: `...`, atprotoProxy: `did:web:${defaultBackendDomain.replace(":", "%3A")}#campground_appview` }; @@ -128,6 +131,15 @@ export default class RESTClient { get(config: Omit) { return this.fetch({ method: "GET", ...config }); } + getRecord(config: { repo: string; rkey: string; collection: string; }) { + return this.fetch>({ route: "com.atproto.repo.getRecord", queries: config, method: "GET", request: { headers: { "atproto-proxy": "" } }, ...config }); + } + getRecordList(config: { repo: string; collection: string; }) { + return this.fetch>({ route: "com.atproto.repo.listRecords", queries: config, method: "GET", request: { headers: { "atproto-proxy": "" } }, ...config }); + } + putRecord(config: { repo: string; rkey: string; collection: string; record: T; }) { + return this.fetch({ route: "com.atproto.repo.putRecord", method: "POST", request: { headers: { "atproto-proxy": "" } }, body: config, ...config }); + } post(config: Omit) { return this.fetch({ method: "POST", ...config }); } @@ -179,4 +191,16 @@ export default class RESTClient { }, }); } + + createPost(record: { parentUri?: string | undefined; content: string; tags: string[]; createdAt: string; updatedAt: string; }) { + return this.putRecord({ + repo: this._config.userDid, + collection: "gg.campground.profile.post", + rkey: "", + record: { + ...record, + "$type": "gg.campground.profile.post", + }, + }); + } } \ No newline at end of file diff --git a/sites/app.campground.gg/package-lock.json b/sites/app.campground.gg/package-lock.json index 5886a9c..110d72d 100644 --- a/sites/app.campground.gg/package-lock.json +++ b/sites/app.campground.gg/package-lock.json @@ -18,10 +18,12 @@ "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tabler/icons-react": "^3.35.0", + "@types/mdast": "^4.0.4", "components": "file:../../packages/components", "highlight.js": "^11.11.1", "isbot": "^5", "mdast-util-from-markdown": "^2.0.2", + "mdast-util-to-markdown": "^2.1.2", "ms": "^2.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/sites/app.campground.gg/package.json b/sites/app.campground.gg/package.json index 274523a..9d6fbb2 100644 --- a/sites/app.campground.gg/package.json +++ b/sites/app.campground.gg/package.json @@ -20,10 +20,12 @@ "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tabler/icons-react": "^3.35.0", + "@types/mdast": "^4.0.4", "components": "file:../../packages/components", "highlight.js": "^11.11.1", "isbot": "^5", "mdast-util-from-markdown": "^2.0.2", + "mdast-util-to-markdown": "^2.1.2", "ms": "^2.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx index dd5a570..a06a023 100644 --- a/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx @@ -1,10 +1,10 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType } from "../../editor/editor"; +import type { EditorBlockElementType } from "../../editor/editor"; type Props = { - format: RichEditorBlockElementType; + format: EditorBlockElementType; children: ReactNode[] | ReactNode; }; diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx index 2a748ef..eb28ebe 100644 --- a/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx @@ -1,11 +1,11 @@ import { MenuItem } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType } from "../../editor/editor"; +import type { EditorBlockElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { - format: RichEditorBlockElementType; + format: EditorBlockElementType; additionalProps?: any; children: ReactNode[] | ReactNode; onClick?: () => void; diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx index 8851c56..2cba810 100644 --- a/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx @@ -1,16 +1,16 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditor, RichEditorBlockElementType } from "../../editor/editor"; +import type { RichEditor, EditorBlockElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; -type Props = { +type Props = { format: T; children: ReactNode[] | ReactNode; onClick?: (editor: RichEditor, format: T) => void; }; -export default function BlockNodeToggle({ children, format: formatting, onClick }: Props) { +export default function BlockNodeToggle({ children, format: formatting, onClick }: Props) { const editor = useSlate(); const active = CampgroundEditor.isNodeFormatted(editor, formatting); diff --git a/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx index 35b55a3..8643e01 100644 --- a/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx @@ -1,20 +1,18 @@ -import { useState } from "react"; -import { createEditor } from "slate"; -import { Editable, Slate, withReact } from "slate-react"; +import { Editable, Slate } from "slate-react"; import MarkdownWrapper from "../markdown/MarkdownWrapper"; import type { SxProps } from "@mui/joy/styles/types"; import { Divider, styled } from "@mui/joy"; -import { withHistory } from "slate-history"; import type { RichEditor } from "../../editor/editor"; import EditorLeaf from "./EditorLeaf"; import EditorElement from "./EditorElement"; import RichEditorToolbar, { RichEditorToolbarBlockFormatting, RichEditorToolbarHeading, RichEditorToolbarInlineFormatting } from "./RichEditorToolbar"; -import withCgMarkdown from "../../editor/withCgMarkdown"; import useBlockDecorate from "../../editor/block-decorate"; import { editorKeyboardLogic } from "../../editor/keyboard-logic"; type Props = { + editor: RichEditor; sx: SxProps; + placeholder?: string; }; const StyledEditor = styled(Editable, { @@ -52,13 +50,12 @@ const StyledWrapper = styled(MarkdownWrapper, { height: "100%", })); -export default function BlockTextEditor({ sx }: Props) { - const [editor] = useState(() => withCgMarkdown(withHistory(withReact(createEditor()))) as RichEditor); +export default function BlockTextEditor({ editor, sx, placeholder }: Props) { const blockDecorate = useBlockDecorate(); return ( - + @@ -69,6 +66,7 @@ export default function BlockTextEditor({ sx }: Props) { (editor, { type: type, ...additionalProps, }); } - static toggleInlineFormatting(editor: RichEditor, type: RichEditorInlineElementType) { + static toggleInlineFormatting(editor: RichEditor, type: EditorInlineElementType) { const active = this.isNodeFormatted(editor, type); if (active) @@ -68,23 +69,24 @@ export default class CampgroundEditor { match: (n) => !Editor.isEditor(n) && Element.isElement(n) && - (RichEditorInlineElementType as readonly string[]).includes(n.type) + (EditorInlineElementType as readonly string[]).includes(n.type) }); Transforms.wrapNodes( editor, { - type: "inline-quote", + type: "link", + url: "#", children: [] }, { match: (n) => !Editor.isEditor(n) && - ((Element.isElement(n) && (RichEditorInlineElementType as readonly string[]).includes(n.type)) || Text.isText(n)) + ((Element.isElement(n) && (EditorInlineElementType as readonly string[]).includes(n.type)) || Text.isText(n)) } ); } - static toggleListFormatting(editor: RichEditor, type: RichEditorBlockElementType, itemType: RichEditorItemElementType) { + static toggleListFormatting(editor: RichEditor, type: EditorBlockElementType, itemType: EditorItemElementType) { const active = this.isNodeFormatted(editor, type); if (active) @@ -112,7 +114,7 @@ export default class CampgroundEditor { }, ); } - static toggleCodeFormatting(editor: RichEditor, type: RichEditorBlockElementType, itemType: RichEditorItemElementType) { + static toggleCodeFormatting(editor: RichEditor, type: EditorBlockElementType, itemType: EditorItemElementType) { const active = this.isNodeFormatted(editor, type); const activeElems = this.getSelectedNodes(editor); @@ -146,7 +148,7 @@ export default class CampgroundEditor { } ); } - static toggleTextFormatting(editor: RichEditor, type: keyof RichEditorTextFormatting) { + static toggleTextFormatting(editor: RichEditor, type: keyof EditorTextFormatting) { const active = this.isTextFormatted(editor, type); if (active) diff --git a/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx index 308e907..5bce0ea 100644 --- a/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx +++ b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx @@ -1,12 +1,12 @@ import { Option, Select } from "@mui/joy"; -import type { RichEditorCodeBlock } from "../../editor/editor"; +import type { EditorCodeBlock } from "../../editor/editor"; import hljs from "highlight.js"; import { useSlateStatic } from "slate-react"; import { Element, Transforms } from "slate"; import { Group } from "components"; type Props = { - element: RichEditorCodeBlock; + element: EditorCodeBlock; }; export default function CodeBlockEditorHeader({ element }: Props) { diff --git a/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx index 10d876a..ea4e3ac 100644 --- a/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/CodeNodeToggle.tsx @@ -1,12 +1,12 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType, RichEditorItemElementType } from "../../editor/editor"; +import type { EditorBlockElementType, EditorItemElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { - format: RichEditorBlockElementType; - itemFormat: RichEditorItemElementType; + format: EditorBlockElementType; + itemFormat: EditorItemElementType; children: ReactNode[] | ReactNode; }; diff --git a/sites/app.campground.gg/src/components/editor/EditorElement.tsx b/sites/app.campground.gg/src/components/editor/EditorElement.tsx index fa42607..dadd8eb 100644 --- a/sites/app.campground.gg/src/components/editor/EditorElement.tsx +++ b/sites/app.campground.gg/src/components/editor/EditorElement.tsx @@ -1,12 +1,12 @@ import { type RenderElementProps } from "slate-react"; -import type { RichEditorAnyElementType, RichEditorCodeBlock, RichEditorCodeLine, RichEditorHeading, RichEditorLink } from "../../editor/editor"; +import type { EditorElementType, EditorCodeBlock, EditorCodeLine, EditorHeading, EditorLink } from "../../editor/editor"; import { ReactNode } from "react"; import { CodeContainer, CodeGrid, CodeHeader, CodeLine, CodeLineNumber, CodePre } from "../markdown/CodeBlock"; import CodeBlockEditorHeader from "./CodeBlockEditorHeader"; import { CodeEditorContextProvider, useCodeEditorContext } from "./codeEditorContext"; import Link from "../Link"; -const typeToRenderer: Record (ReactNode[] | ReactNode)> = { +const typeToRenderer: Record (ReactNode[] | ReactNode)> = { paragraph({ attributes, children }) { return

    {children}

    }, @@ -14,7 +14,7 @@ const typeToRenderer: Record; }, heading({ attributes, children, element }) { - const Tag = `h${(element as RichEditorHeading).depth ?? 1}` as "h1"; + const Tag = `h${(element as EditorHeading).depth ?? 1}` as "h1"; return ( {children} @@ -22,7 +22,7 @@ const typeToRenderer: Record @@ -37,10 +37,10 @@ const typeToRenderer: Record - + - + {children} @@ -86,13 +86,13 @@ const typeToRenderer: Record ); }, - ["inline-quote"]({ attributes, children }) { - return ( - - {children} - - ); - }, + // ["inline-quote"]({ attributes, children }) { + // return ( + // + // {children} + // + // ); + // }, } export default function EditorElement({ attributes, children, element }: RenderElementProps) { diff --git a/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx index 71875cd..7e7fc3d 100644 --- a/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/InlineNodeToggle.tsx @@ -1,11 +1,11 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorInlineElementType } from "../../editor/editor"; +import type { EditorInlineElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { - format: RichEditorInlineElementType; + format: EditorInlineElementType; children: ReactNode[] | ReactNode; }; diff --git a/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx index 459ba72..e20d193 100644 --- a/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/ListNodeToggle.tsx @@ -1,12 +1,12 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorBlockElementType, RichEditorItemElementType } from "../../editor/editor"; +import type { EditorBlockElementType, EditorItemElementType } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { - format: RichEditorBlockElementType; - itemFormat: RichEditorItemElementType; + format: EditorBlockElementType; + itemFormat: EditorItemElementType; children: ReactNode[] | ReactNode; }; diff --git a/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx b/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx index b618e9f..070bf3e 100644 --- a/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx +++ b/sites/app.campground.gg/src/components/editor/MarkNodeToggle.tsx @@ -1,11 +1,11 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { RichEditorTextFormatting } from "../../editor/editor"; +import type { EditorTextFormatting } from "../../editor/editor"; import CampgroundEditor from "./CampgroundEditor"; type Props = { - format: keyof RichEditorTextFormatting; + format: keyof EditorTextFormatting; children: ReactNode[] | ReactNode; }; diff --git a/sites/app.campground.gg/src/components/editor/PostInput.tsx b/sites/app.campground.gg/src/components/editor/PostInput.tsx index 48779d2..74dd079 100644 --- a/sites/app.campground.gg/src/components/editor/PostInput.tsx +++ b/sites/app.campground.gg/src/components/editor/PostInput.tsx @@ -1,20 +1,30 @@ -import { Card, CardContent, Link, Typography } from "@mui/joy"; +import { Card, CardContent, Link, Stack, Typography } from "@mui/joy"; import type { SxProps } from "@mui/joy/styles/types"; import { useState } from "react"; import type { User } from "types/user"; import UserAvatar from "../UserAvatar"; -import { Group } from "components"; +import { Group, PrimaryButton } from "components"; import BlockTextEditor from "./BlockTextEditor"; +import withCgMarkdown from "~/editor/withCgMarkdown"; +import { withHistory } from "slate-history"; +import { withReact } from "slate-react"; +import { createEditor } from "slate"; +import type { RichEditor } from "~/editor/editor"; +import { IconArrowRight } from "@tabler/icons-react"; +import { mdastifyEditor } from "~/editor/mdast"; +import { toMarkdown } from "mdast-util-to-markdown"; type Props = { user: User; content?: string; placeholder?: string; sx?: SxProps; + onPost: (content: string) => void | Promise; }; -export default function PostInput({ user, placeholder, sx }: Props) { +export default function PostInput({ user, placeholder, onPost, sx }: Props) { const [open, setOpen] = useState(false); + const [editor] = useState(() => withCgMarkdown(withHistory(withReact(createEditor()))) as RichEditor); return ( @@ -22,7 +32,13 @@ export default function PostInput({ user, placeholder, sx }: Props) { {open ? - ({ flex: 1, color: theme.vars.palette.text.secondary })} /> + + ({ color: theme.vars.palette.text.secondary })} placeholder="What is your current mood?" /> + + } onClick={() => (setOpen(false), onPost(toMarkdown(mdastifyEditor(editor), { bullet: "-", emphasis: "_" })))}>Post + setOpen(false)}>Cancel + + : setOpen(!open)} gap={1.5} color="neutral" startDecorator={}> diff --git a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx index b31bec1..394dac8 100644 --- a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx +++ b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx @@ -2,12 +2,11 @@ import { ButtonGroup, Dropdown, ListItemContent, ListItemDecorator, Menu, MenuBu import { Group } from "components"; import { ReactNode } from "react" import MarkNodeToggle from "./MarkNodeToggle"; -import { IconBlockquote, IconBold, IconBraces, IconCaretDownFilled, IconCode, IconH1, IconH2, IconH3, IconH4, IconH6, IconItalic, IconList, IconListNumbers, IconQuote, IconSeparatorHorizontal, IconStrikethrough, IconUnderline } from "@tabler/icons-react"; +import { IconBlockquote, IconBold, IconBraces, IconCaretDownFilled, IconCode, IconH1, IconH2, IconH3, IconH4, IconH6, IconItalic, IconList, IconListNumbers, IconSeparatorHorizontal, IconStrikethrough, IconUnderline } from "@tabler/icons-react"; import BlockNodeToggle from "./BlockNodeToggle"; import ListNodeToggle from "./ListNodeToggle"; import CodeNodeToggle from "./CodeNodeToggle"; import BlockNodeInsert from "./BlockNodeInsert"; -import InlineNodeToggle from "./InlineNodeToggle"; import BlockNodeMenuItem from "./BlockNodeMenuItem"; type Props = { @@ -32,9 +31,9 @@ export function RichEditorToolbarInlineFormatting() { - + {/* - + */} ); } diff --git a/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx index 54868f5..cf0c34e 100644 --- a/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx +++ b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx @@ -1,10 +1,10 @@ import { createContext, useContext } from "react"; -import type { RichEditorCodeLine } from "../../editor/editor"; +import type { EditorCodeLine } from "../../editor/editor"; -export const CodeEditorContext = createContext([]); +export const CodeEditorContext = createContext([]); export const useCodeEditorContext = () => useContext(CodeEditorContext); -export function CodeEditorContextProvider({ codeLines, children }: React.PropsWithChildren & { codeLines: RichEditorCodeLine[] }) { +export function CodeEditorContextProvider({ codeLines, children }: React.PropsWithChildren & { codeLines: EditorCodeLine[] }) { return ( {children} diff --git a/sites/app.campground.gg/src/editor/block-decorate.tsx b/sites/app.campground.gg/src/editor/block-decorate.tsx index 3a10e48..9b23723 100644 --- a/sites/app.campground.gg/src/editor/block-decorate.tsx +++ b/sites/app.campground.gg/src/editor/block-decorate.tsx @@ -1,13 +1,13 @@ import { useCallback } from "react"; import { type DecoratedRange, Element, Node, type NodeEntry, type Range } from "slate"; -import type { RichEditorAnyElementType, RichEditorCodeBlock } from "./editor"; +import type { EditorElementType, EditorCodeBlock } from "./editor"; import CodeBlock, { linefyTokens } from "../components/markdown/CodeBlock"; -const decorators: Partial DecoratedRange[]>> = { +const decorators: Partial DecoratedRange[]>> = { ["code-block"]([node, path]) { const content = Node.string(node); - const { lang: language } = node as unknown as RichEditorCodeBlock; + const { lang: language } = node as unknown as EditorCodeBlock; if (!language || CodeBlock.nonHighlightedLanguages.includes(language)) return []; diff --git a/sites/app.campground.gg/src/editor/editor.ts b/sites/app.campground.gg/src/editor/editor.ts index baf47f9..aab5dda 100644 --- a/sites/app.campground.gg/src/editor/editor.ts +++ b/sites/app.campground.gg/src/editor/editor.ts @@ -1,122 +1,39 @@ -import type { BaseEditor, BasePoint, BaseRange, Element, Range, Text } from "slate"; +import type { BaseEditor, BasePoint, BaseRange, Element, Range } from "slate"; import type { HistoryEditor } from "slate-history"; import type { ReactEditor } from "slate-react"; - -/** - * Rich text editor text node's available formatting that changes the appearance of the leaves. - */ -export interface RichEditorTextFormatting { - bold?: boolean; - italic?: boolean; - code?: boolean; - underline?: boolean; - strikethrough?: boolean; - scope?: string; -} -/** - * Rich text editor text node's contents without any formatting. May be used in code blocks and whatnot. - */ -export interface RichEditorTextUnformatted { - text: string; -} -/** - * Rich text editor's text node with all of its contents. - */ -export interface RichEditorText extends RichEditorTextFormatting, RichEditorTextUnformatted { } -const _RichEditorBlockElementType = ["paragraph", "block-quote", "code-block", "divider", "unordered-list", "ordered-list", "heading"] as const; -/** - * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. -*/ -export type RichEditorBlockElementType = typeof _RichEditorBlockElementType[number]; -/** - * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. -*/ -export const RichEditorBlockElementType = _RichEditorBlockElementType; - -/** - * Rich text editor node elements that depends on the ancestor block element and spans at least a line. -*/ -const _RichEditorItemElementType = ["code-line", "list-item"] as const; -/** - * Rich text editor node elements that depends on the ancestor block element and spans at least a line. -*/ -export type RichEditorItemElementType = typeof _RichEditorItemElementType[number]; -/** - * Rich text editor node elements that depends on the ancestor block element and spans at least a line. -*/ -export const RichEditorItemElementType = _RichEditorItemElementType; - -export const RichEditorItemToParent: Record = { - "code-line": "code-block", - "list-item": "unordered-list", -}; - -// Test inline -/** - * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line - */ -const _RichEditorInlineElementType = ["inline-quote", "link"] as const; -/** - * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line. - */ -export type RichEditorInlineElementType = typeof _RichEditorInlineElementType[number]; -/** - * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line - */ -export const RichEditorInlineElementType = _RichEditorInlineElementType; - -export type RichEditorAnyElementType = RichEditorBlockElementType | RichEditorItemElementType | RichEditorInlineElementType; -export const RichEditorAnyElementType = (_RichEditorItemElementType as readonly RichEditorAnyElementType[]) - .concat(_RichEditorInlineElementType) - .concat(_RichEditorBlockElementType); - -/** - * Type representing a base for block and item elements. - */ -export interface RichEditorBlockElement { - type: TType; - children: TDescendant[]; -} -export interface RichEditorInlineElement { - type: TType; - children: TDescendant[]; -} - -export type RichEditorCodeLine = RichEditorBlockElement<"code-line", RichEditorTextUnformatted>; -export type RichEditorListItem = RichEditorBlockElement<"list-item", RichEditorAnyBlockElement | RichEditorText>; - -export interface RichEditorCodeBlock extends RichEditorBlockElement<"code-block", RichEditorCodeLine> { - lang?: null | undefined | string; - meta?: null | undefined | string; -} - -export interface RichEditorHeading extends RichEditorBlockElement<"heading", Text> { - depth?: null | undefined | number; -} - -export interface RichEditorLink extends RichEditorInlineElement<"link", Text> { - url: string; -} - -export interface RichEditorOrderedList extends RichEditorBlockElement<"ordered-list", RichEditorListItem> { - startingNumber?: null | undefined | number; -} - -export type RichEditorAnyBlockElement = - RichEditorBlockElement<"paragraph", Text> | - RichEditorHeading | - RichEditorBlockElement<"block-quote", RichEditorAnyBlockElement> | - RichEditorBlockElement<"divider", RichEditorText> | - RichEditorCodeBlock | - RichEditorBlockElement<"unordered-list", RichEditorListItem> | - RichEditorOrderedList; -export type RichEditorAnyItemElement = - RichEditorCodeLine | - RichEditorListItem; -export type RichEditorAnyInlineElement = - RichEditorInlineElement<"inline-quote", RichEditorAnyInlineElement | RichEditorText> | - RichEditorLink; -export type RichEditorAnyElement = RichEditorAnyInlineElement | RichEditorAnyBlockElement | RichEditorAnyItemElement; +import type { EditorText } from "./text"; + +import type { EditorElement } from "./element"; + +export { + type EditorElement as EditorAnyElement, + type EditorBlockElement, + type EditorBlockElementBase, + EditorBlockElementType, + type EditorBlockQuote, + type EditorCodeBlock, + type EditorCodeLine, + type EditorDivider, + EditorElementType, + type EditorHeading, + type EditorInlineElement, + type EditorInlineElementBase, + EditorInlineElementType, + type EditorItemElement, + EditorItemElementType, + EditorItemToParent, + type EditorLink, + type EditorListItem, + type EditorOrderedList, + type EditorParagraph, + type EditorUnorderedList, +} from "./element"; + +export { + type EditorText, + type EditorTextFormatting, + type EditorTextUnformatted, +} from "./text"; export type RichEditor = BaseEditor & ReactEditor & HistoryEditor & @@ -124,16 +41,16 @@ export type RichEditor = nodeToDecorations?: Map }; -export interface RichEditorPoint extends BasePoint { +export interface EditorPoint extends BasePoint { lineOffset?: number; } declare module 'slate' { interface CustomTypes { Editor: RichEditor; - Element: RichEditorAnyElement; - Text: RichEditorText; - Point: RichEditorPoint; + Element: EditorElement; + Text: EditorText; + Point: EditorPoint; Range: BaseRange & { [key: string]: unknown } diff --git a/sites/app.campground.gg/src/editor/element.ts b/sites/app.campground.gg/src/editor/element.ts new file mode 100644 index 0000000..cf7ceb2 --- /dev/null +++ b/sites/app.campground.gg/src/editor/element.ts @@ -0,0 +1,100 @@ +import type { Text } from "slate"; +import type { EditorText, EditorTextUnformatted } from "./text"; + +const _EditorBlockElementType = ["paragraph", "block-quote", "code-block", "divider", "unordered-list", "ordered-list", "heading"] as const; +/** + * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. +*/ +export type EditorBlockElementType = typeof _EditorBlockElementType[number]; +/** + * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. +*/ +export const EditorBlockElementType = _EditorBlockElementType; + +/** + * Rich text editor node elements that depends on the ancestor block element and spans at least a line. +*/ +const _EditorItemElementType = ["code-line", "list-item"] as const; +/** + * Rich text editor node elements that depends on the ancestor block element and spans at least a line. +*/ +export type EditorItemElementType = typeof _EditorItemElementType[number]; +/** + * Rich text editor node elements that depends on the ancestor block element and spans at least a line. +*/ +export const EditorItemElementType = _EditorItemElementType; + +export const EditorItemToParent: Record = { + "code-line": "code-block", + "list-item": "unordered-list", +}; + +// Test inline +/** + * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line + */ +const _RichEditorInlineElementType = ["link"] as const; +/** + * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line. + */ +export type EditorInlineElementType = typeof _RichEditorInlineElementType[number]; +/** + * Rich text editor node elements that depends on the ancestor block element and does not necessarily span a line + */ +export const EditorInlineElementType = _RichEditorInlineElementType; + +export type EditorElementType = EditorBlockElementType | EditorItemElementType | EditorInlineElementType; +export const EditorElementType = (_EditorItemElementType as readonly EditorElementType[]) + .concat(_RichEditorInlineElementType) + .concat(_EditorBlockElementType); + +/** + * Type representing a base for block and item elements. + */ +export interface EditorBlockElementBase { + type: TType; + children: TDescendant[]; +} +export interface EditorInlineElementBase { + type: TType; + children: TDescendant[]; +} + +export type EditorParagraph = EditorBlockElementBase<"paragraph", Text>; +export type EditorBlockQuote = EditorBlockElementBase<"block-quote", EditorBlockElement>; +export type EditorDivider = EditorBlockElementBase<"divider", EditorText>; +export type EditorUnorderedList = EditorBlockElementBase<"unordered-list", EditorListItem>; +export interface EditorCodeBlock extends EditorBlockElementBase<"code-block", EditorCodeLine> { + lang?: null | undefined | string; + meta?: null | undefined | string; +} + +export interface EditorHeading extends EditorBlockElementBase<"heading", Text> { + depth?: null | undefined | number; +} + +export interface EditorOrderedList extends EditorBlockElementBase<"ordered-list", EditorListItem> { + start?: null | undefined | number; +} + +export type EditorBlockElement = + EditorParagraph | + EditorHeading | + EditorBlockQuote | + EditorDivider | + EditorCodeBlock | + EditorUnorderedList | + EditorOrderedList; + +export type EditorCodeLine = EditorBlockElementBase<"code-line", EditorTextUnformatted>; +export type EditorListItem = EditorBlockElementBase<"list-item", EditorBlockElement | EditorText>; + +export type EditorItemElement = + EditorCodeLine | + EditorListItem; + +export interface EditorLink extends EditorInlineElementBase<"link", Text> { + url: string; +} +export type EditorInlineElement = EditorLink; +export type EditorElement = EditorInlineElement | EditorBlockElement | EditorItemElement; \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/keyboard-logic.ts b/sites/app.campground.gg/src/editor/keyboard-logic.ts index 563a169..d37a4e2 100644 --- a/sites/app.campground.gg/src/editor/keyboard-logic.ts +++ b/sites/app.campground.gg/src/editor/keyboard-logic.ts @@ -1,5 +1,5 @@ -import { Editor, Element, Node, Path } from "slate"; -import { RichEditorItemElementType, type RichEditor } from "./editor"; +import { Editor, Element, Node } from "slate"; +import { EditorItemElementType, type RichEditor } from "./editor"; import { getNeighborPath, getNewlineIndexes, getParentPath, paragraph } from "./utils"; import React from "react"; @@ -36,7 +36,7 @@ function ArrowVertical(editor: RichEditor, up: boolean) { const abovePath = above![1]; const aboveParent = getParentPath(abovePath); - const sequentialItemSettings = { at: abovePath, match: (node: Node, path: Path) => (console.log({ node, path }), !Editor.isEditor(node)) } + const sequentialItemSettings = { at: abovePath, match: (node: Node) => !Editor.isEditor(node) } // Positional calc const itemNext = editor.next(sequentialItemSettings); @@ -93,8 +93,8 @@ export const editorKeyboardLogic: Record RootContentMap[keyof RootContentMap]> = { + paragraph(element) { + return { type: "paragraph", children: element.children.map(mdastifyNode) as PhrasingContent[] }; + }, + ["block-quote"](element) { + return { type: "blockquote", children: element.children.map(mdastifyNode) as (BlockContent | DefinitionContent)[] }; + }, + ["code-block"](element) { + const codeBlock = element as EditorCodeBlock; + + return { ...codeBlock, type: "code", value: element.children.map(Node.string).join("\n"), }; + }, + ["unordered-list"](element) { + return { type: "list", spread: false, children: element.children.map(mdastifyNode) as ListItem[] }; + }, + ["ordered-list"](element) { + const orderedList = element as EditorOrderedList; + + return { ...orderedList, type: "list", ordered: true, spread: false, children: element.children.map(mdastifyNode) as ListItem[] }; + }, + ["list-item"](element) { + return { type: "listItem", spread: false, children: element.children.map(mdastifyNode) as (BlockContent | DefinitionContent)[] }; + }, + ["code-line"](element) { + return { type: "paragraph", children: element.children.map(mdastifyNode) as PhrasingContent[] }; + }, + divider() { + return { type: "thematicBreak" }; + }, + heading(element) { + const heading = element as EditorHeading; + return { type: "heading", depth: (heading.depth ?? 1) as (1 | 2 | 3 | 4 | 5 | 6), children: element.children.map(mdastifyNode) as PhrasingContent[] }; + }, + link(element) { + const link = element as EditorLink; + + return { ...link, children: element.children.map(mdastifyNode) as PhrasingContent[] }; + } +}; + +export function mdastifyElement(element: Element) { + return nodeSerializers[element.type](element); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/mdast/index.ts b/sites/app.campground.gg/src/editor/mdast/index.ts new file mode 100644 index 0000000..1f9ac06 --- /dev/null +++ b/sites/app.campground.gg/src/editor/mdast/index.ts @@ -0,0 +1,4 @@ +export { mdastifyEditor } from "./editor"; +export { mdastifyNode } from "./nodes"; +export { mdastifyText } from "./text"; +export { mdastifyElement } from "./element"; \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/mdast/nodes.ts b/sites/app.campground.gg/src/editor/mdast/nodes.ts new file mode 100644 index 0000000..e9f7c28 --- /dev/null +++ b/sites/app.campground.gg/src/editor/mdast/nodes.ts @@ -0,0 +1,14 @@ +import { Editor, Node as SlateNode, Element, Text } from "slate"; +import type { Node as MdastNode } from "mdast"; +import { mdastifyEditor } from "./editor"; +import { mdastifyText } from "./text"; +import { mdastifyElement } from "./element"; + +export function mdastifyNode(node: SlateNode): MdastNode { + if (Editor.isEditor(node)) + return mdastifyEditor(node); + else if (Text.isText(node)) + return mdastifyText(node); + + return mdastifyElement(node as Element); +} diff --git a/sites/app.campground.gg/src/editor/mdast/text.ts b/sites/app.campground.gg/src/editor/mdast/text.ts new file mode 100644 index 0000000..141e2f5 --- /dev/null +++ b/sites/app.campground.gg/src/editor/mdast/text.ts @@ -0,0 +1,24 @@ +import { Text as SlateText } from "slate"; +import type { PhrasingContentMap } from "mdast"; + +export function mdastifyText(node: SlateText): PhrasingContentMap[keyof PhrasingContentMap] { + const marks = [ + node.bold ? "strong" : null, + node.italic ? "emphasis" : null, + node.strikethrough ? "delete" : null, + node.underline ? "emphasis" : null, + node.underline ? "emphasis" : null, + ].filter((x) => x) as (keyof PhrasingContentMap)[]; + + return wrapped( + { type: node.code ? "inlineCode" : "text", value: node.text }, + marks + ); +} + +function wrapped(toWrap: PhrasingContentMap[keyof PhrasingContentMap], marks: (keyof PhrasingContentMap)[]): PhrasingContentMap[keyof PhrasingContentMap] { + if (marks.length < 1) + return toWrap; + + return { type: marks[0] as "strong" | "emphasis" | "delete", children: [wrapped(toWrap, marks.slice(1))] }; +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/text.ts b/sites/app.campground.gg/src/editor/text.ts new file mode 100644 index 0000000..bfc93f9 --- /dev/null +++ b/sites/app.campground.gg/src/editor/text.ts @@ -0,0 +1,21 @@ +/** + * Rich text editor text node's available formatting that changes the appearance of the leaves. + */ +export interface EditorTextFormatting { + bold?: boolean; + italic?: boolean; + code?: boolean; + underline?: boolean; + strikethrough?: boolean; + scope?: string; +} +/** + * Rich text editor text node's contents without any formatting. May be used in code blocks and whatnot. + */ +export interface EditorTextUnformatted { + text: string; +} +/** + * Rich text editor's text node with all of its contents. + */ +export interface EditorText extends EditorTextFormatting, EditorTextUnformatted { } \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/utils.ts b/sites/app.campground.gg/src/editor/utils.ts index cc08d85..2e421ec 100644 --- a/sites/app.campground.gg/src/editor/utils.ts +++ b/sites/app.campground.gg/src/editor/utils.ts @@ -1,4 +1,5 @@ -import type { RichEditorBlockElement, RichEditorText } from "./editor" +import { Text } from "slate"; +import type { EditorBlockElementBase } from "./editor" export const getNeighborPath = (path: number[], distance: number = 1) => [...getParentPath(path), negativeFloor(path[path.length - 1] + distance)]; @@ -15,7 +16,7 @@ const negativeFloor = (a: number) => export const getParentPath = (path: number[]) => path.slice(0, path.length - 1); -export const paragraph: () => RichEditorBlockElement<"paragraph", RichEditorText> = () => ({ +export const paragraph: () => EditorBlockElementBase<"paragraph", Text> = () => ({ type: "paragraph", children: [ { diff --git a/sites/app.campground.gg/src/editor/withCgMarkdown.tsx b/sites/app.campground.gg/src/editor/withCgMarkdown.tsx index fc177cd..970eeeb 100644 --- a/sites/app.campground.gg/src/editor/withCgMarkdown.tsx +++ b/sites/app.campground.gg/src/editor/withCgMarkdown.tsx @@ -1,12 +1,12 @@ import { Editor, Element, Point, Range, Transforms } from "slate"; -import { type RichEditor, RichEditorBlockElementType, RichEditorItemElementType } from "./editor"; +import { type RichEditor, EditorBlockElementType, EditorItemElementType } from "./editor"; const unorderedList = { type: "list-item", wrapper: "unordered-list" } as const; -const nodePrefixes: Record = { +const nodePrefixes: Record = { "> ": { type: "paragraph", wrapper: "block-quote" }, "```": { wrapper: "code-block", type: "code-line" }, "- ": unorderedList, diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx index aba37ed..513f865 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx @@ -1,9 +1,10 @@ import { Box, Stack, Typography } from "@mui/joy"; -import React from "react"; import ProfileFeedPost from "./ProfileFeedPost"; import type { User, UserPostBasic } from "types/user"; import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; import PostInput from "~/components/editor/PostInput"; +import { useSession } from "~/session"; +import { useState } from "react"; type Props = { user: User; @@ -11,27 +12,38 @@ type Props = { posts: UserPostBasic[] | undefined; }; -export default class ProfileFeed extends React.Component { - render(): React.ReactNode { - const { user, posts, isSelf } = this.props; +export default function ProfileFeed({ user, posts, isSelf }: Props) { + const session = useSession(); + const [newPosts, setNewPosts] = useState([]); - return ( - - Feed - {isSelf && } - - {posts?.map((x) => - - )} - - - This user has no more posts to be found! Come back later! - - - ); + const onPostCreated = (content: string) => { + const newPost = { + content, + tags: ["Test tag", "tag"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return session.restClient?.createPost(newPost) + .then((x) => setNewPosts([{ ...newPost, author: user, replyCount: 0, uri: x.content!.uri, indexedAt: new Date().toISOString() } satisfies UserPostBasic, ...newPosts])) + .catch((e) => console.error("Got an error while making a post", e)); } + + return ( + + Feed + {session.restClient && isSelf && } + + {newPosts.concat(posts ?? []).map((x) => + + )} + + + This user has no more posts to be found! Come back later! + + + ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/session/SessionMiddleware.ts b/sites/app.campground.gg/src/session/SessionMiddleware.ts index d01a236..c69262f 100644 --- a/sites/app.campground.gg/src/session/SessionMiddleware.ts +++ b/sites/app.campground.gg/src/session/SessionMiddleware.ts @@ -33,6 +33,6 @@ export default class SessionMiddleware { storage.setItem("auth", JSON.stringify(this.auth)); }; - this.restClient = this.auth.authenticated ? new RESTClient({ auth: this.auth.user.accessJwt, refreshAuth: this.auth.user.refreshJwt }, onRefresh) : null; + this.restClient = this.auth.authenticated ? new RESTClient({ auth: this.auth.user.accessJwt, refreshAuth: this.auth.user.refreshJwt, userDid: this.auth.user.did }, onRefresh) : null; } } \ No newline at end of file diff --git a/sites/app.campground.gg/src/session/provider.tsx b/sites/app.campground.gg/src/session/provider.tsx index 5e956f2..e5fbcb1 100644 --- a/sites/app.campground.gg/src/session/provider.tsx +++ b/sites/app.campground.gg/src/session/provider.tsx @@ -16,7 +16,7 @@ export function SessionProvider({ children }: React.PropsWithChildren) { // To have rest client if (parsed.authenticated) - setRestClient(new RESTClient({ auth: parsed.user.accessJwt, refreshAuth: parsed.user.refreshJwt }, refreshLogin)); + setRestClient(new RESTClient({ auth: parsed.user.accessJwt, refreshAuth: parsed.user.refreshJwt, userDid: parsed.user.did }, refreshLogin)); return parsed; } return { authenticated: false, }; @@ -42,7 +42,7 @@ export function SessionProvider({ children }: React.PropsWithChildren) { if (data.ok) { setAuth({ authenticated: true, user: data.content }); - setRestClient(new RESTClient({ auth: data.content.accessJwt, refreshAuth: data.content.refreshJwt }, refreshLogin)) + setRestClient(new RESTClient({ auth: data.content.accessJwt, refreshAuth: data.content.refreshJwt, userDid: data.content.did }, refreshLogin)) } else throw new Error(data.errorDescription); }; diff --git a/sites/app.campground.gg/types/record.ts b/sites/app.campground.gg/types/record.ts new file mode 100644 index 0000000..44d96e4 --- /dev/null +++ b/sites/app.campground.gg/types/record.ts @@ -0,0 +1,21 @@ +export interface PutRecordResponse { + uri: string; + cid: string; + commit: { + cid: string; + rev: string; + }; + validationStatus: "unknown"; +} +export interface GetRecordListResponse { + records: Array>; + cursor: string; +} +export interface AtprotoRecord { + uri: string; + cid: string; + value: T; +} +export interface AtprotoValueBase { + ["$type"]: string; +} \ No newline at end of file From 63f76f9d98bdee47716e8330e759f404f09704a6 Mon Sep 17 00:00:00 2001 From: PrettyGoodName <31628630+IdkGoodName@users.noreply.github.com> Date: Fri, 7 Nov 2025 02:13:42 +0200 Subject: [PATCH 5/7] feat: the ability to delete and edit profile posts, Markdown deserializing and serializing, mdast conversion to slate --- sites/app.campground.gg/api/RESTClient.ts | 25 +++ sites/app.campground.gg/package-lock.json | 2 + sites/app.campground.gg/package.json | 2 + .../components/content/ContentOverflow.tsx | 31 +++ .../src/components/editor/BasicPostEditor.tsx | 36 ++++ .../src/components/editor/BlockNodeInsert.tsx | 16 +- .../src/components/editor/BlockTextEditor.tsx | 12 +- .../components/editor/CampgroundEditor.tsx | 102 ++++++++- .../src/components/editor/EditorElement.tsx | 51 ++++- .../components/editor/RichEditorToolbar.tsx | 33 ++- .../src/components/editor/TableNodeInsert.tsx | 24 +++ .../components/editor/tableHeadContext.tsx | 29 +++ .../src/components/markdown/Markdown.tsx | 9 +- .../components/markdown/MarkdownWrapper.tsx | 8 +- sites/app.campground.gg/src/editor/element.ts | 22 +- .../src/editor/mdast/editor.ts | 6 +- .../src/editor/mdast/element.ts | 202 +++++++++++++++++- .../src/editor/mdast/markdown.ts | 12 ++ .../src/editor/mdast/nodes.ts | 2 +- .../src/editor/mdast/text.ts | 20 +- .../src/layout/profile/ProfileFeed.tsx | 36 +++- .../src/layout/profile/ProfileFeedPost.tsx | 136 ++++++++---- .../profile/ProfilePostCreator.tsx} | 10 +- 23 files changed, 725 insertions(+), 101 deletions(-) create mode 100644 sites/app.campground.gg/src/components/content/ContentOverflow.tsx create mode 100644 sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx create mode 100644 sites/app.campground.gg/src/components/editor/TableNodeInsert.tsx create mode 100644 sites/app.campground.gg/src/components/editor/tableHeadContext.tsx create mode 100644 sites/app.campground.gg/src/editor/mdast/markdown.ts rename sites/app.campground.gg/src/{components/editor/PostInput.tsx => layout/profile/ProfilePostCreator.tsx} (84%) diff --git a/sites/app.campground.gg/api/RESTClient.ts b/sites/app.campground.gg/api/RESTClient.ts index ded904d..b86c079 100644 --- a/sites/app.campground.gg/api/RESTClient.ts +++ b/sites/app.campground.gg/api/RESTClient.ts @@ -140,6 +140,9 @@ export default class RESTClient { putRecord(config: { repo: string; rkey: string; collection: string; record: T; }) { return this.fetch({ route: "com.atproto.repo.putRecord", method: "POST", request: { headers: { "atproto-proxy": "" } }, body: config, ...config }); } + deleteRecord(config: { repo: string; rkey: string; collection: string; }) { + return this.fetch({ route: "com.atproto.repo.deleteRecord", method: "POST", request: { headers: { "atproto-proxy": "" } }, body: config, ...config }); + } post(config: Omit) { return this.fetch({ method: "POST", ...config }); } @@ -203,4 +206,26 @@ export default class RESTClient { }, }); } + + updatePost(uri: string, record: { content?: string; tags?: string[]; }) { + return this.putRecord({ + repo: this._config.userDid, + collection: "gg.campground.profile.post", + rkey: uri.split("/")[4], + record: { + ...record, + "updatedAt": new Date().toISOString(), + "$type": "gg.campground.profile.post", + }, + }); + } + + deletePost(uri: string) { + return this.deleteRecord({ + repo: this._config.userDid, + collection: "gg.campground.profile.post", + // at://did:.../gg.campground.profile.post/... + rkey: uri.split("/")[4], + }); + } } \ No newline at end of file diff --git a/sites/app.campground.gg/package-lock.json b/sites/app.campground.gg/package-lock.json index 110d72d..47c35d1 100644 --- a/sites/app.campground.gg/package-lock.json +++ b/sites/app.campground.gg/package-lock.json @@ -23,7 +23,9 @@ "highlight.js": "^11.11.1", "isbot": "^5", "mdast-util-from-markdown": "^2.0.2", + "mdast-util-gfm": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", + "micromark-extension-gfm": "^3.0.0", "ms": "^2.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/sites/app.campground.gg/package.json b/sites/app.campground.gg/package.json index 9d6fbb2..29beb8a 100644 --- a/sites/app.campground.gg/package.json +++ b/sites/app.campground.gg/package.json @@ -25,7 +25,9 @@ "highlight.js": "^11.11.1", "isbot": "^5", "mdast-util-from-markdown": "^2.0.2", + "mdast-util-gfm": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", + "micromark-extension-gfm": "^3.0.0", "ms": "^2.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/sites/app.campground.gg/src/components/content/ContentOverflow.tsx b/sites/app.campground.gg/src/components/content/ContentOverflow.tsx new file mode 100644 index 0000000..4e491fc --- /dev/null +++ b/sites/app.campground.gg/src/components/content/ContentOverflow.tsx @@ -0,0 +1,31 @@ +import { CardOverflow, Dropdown, Menu, MenuButton, styled } from "@mui/joy"; +import { IconDotsVertical } from "@tabler/icons-react"; + +const OverflowButtonWrapper = styled(CardOverflow, { + name: "CampgroundOverflow", +})(() => ({ + position: "absolute", + top: 5, + right: 5, + zIndex: 10, + transition: "opacity 0.5s", + opacity: 0, + ".MuiCard-root:hover &": { + opacity: 1, + } +})); + +export default function ContentOverflow({ children }: React.PropsWithChildren) { + return ( + + + + + + + { children } + + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx b/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx new file mode 100644 index 0000000..0ed77fb --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx @@ -0,0 +1,36 @@ +import { Box, Link } from "@mui/joy"; +import type { SxProps } from "@mui/joy/styles/types"; +import { useState } from "react"; +import { Group, PrimaryButton } from "components"; +import BlockTextEditor from "./BlockTextEditor"; +import withCgMarkdown from "~/editor/withCgMarkdown"; +import { withHistory } from "slate-history"; +import { withReact } from "slate-react"; +import { createEditor } from "slate"; +import type { RichEditor } from "~/editor/editor"; +import { IconArrowRight } from "@tabler/icons-react"; +import { mdastifyEditor } from "~/editor/mdast"; +import { serializeMarkdown } from "~/editor/mdast/markdown"; + +type Props = { + content?: string; + placeholder?: string; + sx?: SxProps; + confirmButton?: string; + onConfirm: (content: string) => void | Promise; + onCancel?: () => void | Promise; +}; + +export default function BasicPostEditor({ placeholder, onConfirm, onCancel, content, confirmButton, sx }: Props) { + const [editor] = useState(() => withCgMarkdown(withHistory(withReact(createEditor()))) as RichEditor); + + return ( + + ({ color: theme.vars.palette.text.secondary })} placeholder={placeholder ?? "What is your current mood?"} /> + + } onClick={() => onConfirm(serializeMarkdown(mdastifyEditor(editor)))}>{confirmButton ?? "Post"} + {onCancel && Cancel} + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx index a06a023..39f4eb4 100644 --- a/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx @@ -1,23 +1,25 @@ import { IconButton } from "@mui/joy"; import { ReactNode } from "react"; import { useSlate } from "slate-react"; -import type { EditorBlockElementType } from "../../editor/editor"; +import type { EditorBlockElementType, EditorItemElementType } from "../../editor/editor"; type Props = { - format: EditorBlockElementType; + format: EditorBlockElementType | EditorItemElementType; children: ReactNode[] | ReactNode; }; -export default function BlockNodeInsert({ children, format: formatting }: Props) { +export default function BlockNodeInsert({ children, format: formatting, }: Props) { const editor = useSlate(); const addFormatting = (ev: React.MouseEvent) => { ev.preventDefault(); - editor.insertNode({ - type: formatting, - children: [], - }); + editor.insertNode( + { + type: formatting, + children: [], + }, + ); }; return ( diff --git a/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx index 8643e01..7faeb4f 100644 --- a/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx +++ b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx @@ -5,14 +5,18 @@ import { Divider, styled } from "@mui/joy"; import type { RichEditor } from "../../editor/editor"; import EditorLeaf from "./EditorLeaf"; import EditorElement from "./EditorElement"; -import RichEditorToolbar, { RichEditorToolbarBlockFormatting, RichEditorToolbarHeading, RichEditorToolbarInlineFormatting } from "./RichEditorToolbar"; +import RichEditorToolbar, { RichEditorToolbarBlockFormatting, RichEditorToolbarHeading, RichEditorToolbarInlineFormatting, RichEditorToolbarTableFormatting } from "./RichEditorToolbar"; import useBlockDecorate from "../../editor/block-decorate"; import { editorKeyboardLogic } from "../../editor/keyboard-logic"; +import { paragraph } from "~/editor/utils"; +import { deserializeMarkdown } from "~/editor/mdast/markdown"; +import { slatefyRoot } from "~/editor/mdast/editor"; type Props = { editor: RichEditor; sx: SxProps; placeholder?: string; + defaultValue?: string; }; const StyledEditor = styled(Editable, { @@ -50,18 +54,20 @@ const StyledWrapper = styled(MarkdownWrapper, { height: "100%", })); -export default function BlockTextEditor({ editor, sx, placeholder }: Props) { +export default function BlockTextEditor({ defaultValue, editor, sx, placeholder }: Props) { const blockDecorate = useBlockDecorate(); return ( - + + + diff --git a/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx index 1a9b372..c7f1619 100644 --- a/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx +++ b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx @@ -1,6 +1,8 @@ import { Editor, Element, Node, type NodeEntry, Text, Transforms } from "slate"; import { EditorInlineElementType, type RichEditor, type EditorElementType, type EditorBlockElementType, type EditorItemElementType } from "../../editor/editor"; import type { EditorTextFormatting } from "~/editor/text"; +import type { EditorTable } from "~/editor/element"; +import { getNeighborPath } from "~/editor/utils"; export default class CampgroundEditor { static isNodeFormatted(editor: RichEditor, type: EditorElementType) { @@ -25,7 +27,7 @@ export default class CampgroundEditor { // Returned at least one element, which means it is formatted return Boolean(match); } - static getSelectedNodes(editor: RichEditor): NodeEntry[] | null { + static getSelectedNodes(editor: RichEditor, type?: EditorElementType): NodeEntry[] | null { const { selection } = editor; // Can't detect nodes; out of focus of editor @@ -35,7 +37,7 @@ export default class CampgroundEditor { return Array.from( Editor.nodes(editor, { at: Editor.unhangRange(editor, selection), - match: n => !Editor.isEditor(n) && Element.isElement(n), + match: n => !Editor.isEditor(n) && Element.isElement(n) && (!type || n.type === type), }) ); } @@ -54,6 +56,53 @@ export default class CampgroundEditor { ...props, }); } + static insertTableRow(editor: RichEditor, table: EditorTable) { + if (!editor.selection) + return; + + const currentPath = editor.selection.focus.path; + const insertedPath = getNeighborPath(currentPath.slice(0, -2)); + + editor.insertNode( + { + type: "table-row", + children: table.children[0].children.map((_, i) => ({ + type: "table-cell", + children: [ + { text: `Cell #${i + 1}` } + ] + })) + }, + { + at: insertedPath, + } + ); + editor.select({ path: [...insertedPath, 0, 0], offset: 1 }); + } + static insertTableColumn(editor: RichEditor, table: EditorTable) { + if (!editor.selection) + return; + + const currentPath = editor.selection.focus.path; + const [column] = currentPath.slice(-2); + + for (let row = 0; row < table.children.length; row++) { + const columnPath = [...currentPath.slice(0, -3), row, column + 1]; + + editor.insertNode( + { + type: "table-cell", + children: [ + { text: `Cell #${row + 1}` } + ] + }, + { + at: columnPath, + } + ); + } + // editor.select({ path: [...insertedPath, 0, 0], offset: 1 }); + } static setBlockFormatting(editor: RichEditor, type: EditorBlockElementType, additionalProps?: any) { // Simple type change Transforms.setNodes(editor, { @@ -132,8 +181,6 @@ export default class CampgroundEditor { } ); - editor.selection - if (active) { } else Transforms.wrapNodes( @@ -148,12 +195,45 @@ export default class CampgroundEditor { } ); } - static toggleTextFormatting(editor: RichEditor, type: keyof EditorTextFormatting) { - const active = this.isTextFormatted(editor, type); - - if (active) - Editor.removeMark(editor, type); - else - Editor.addMark(editor, type, true); + static insertTableFormatting(editor: RichEditor) { + editor.insertNode({ + type: "table", + children: [ + { + type: "table-row", + children: [ + { + type: "table-cell", + children: [ + { text: "Cell #1" } + ] + }, + { + type: "table-cell", + children: [ + { text: "Cell #2" } + ] + } + ] + }, + { + type: "table-row", + children: [ + { + type: "table-cell", + children: [ + { text: "Cell #3" } + ] + }, + { + type: "table-cell", + children: [ + { text: "Cell #4" } + ] + } + ] + } + ] + }); } } \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/EditorElement.tsx b/sites/app.campground.gg/src/components/editor/EditorElement.tsx index dadd8eb..2ff443c 100644 --- a/sites/app.campground.gg/src/components/editor/EditorElement.tsx +++ b/sites/app.campground.gg/src/components/editor/EditorElement.tsx @@ -1,10 +1,12 @@ import { type RenderElementProps } from "slate-react"; import type { EditorElementType, EditorCodeBlock, EditorCodeLine, EditorHeading, EditorLink } from "../../editor/editor"; -import { ReactNode } from "react"; +import React, { ReactNode } from "react"; import { CodeContainer, CodeGrid, CodeHeader, CodeLine, CodeLineNumber, CodePre } from "../markdown/CodeBlock"; import CodeBlockEditorHeader from "./CodeBlockEditorHeader"; import { CodeEditorContextProvider, useCodeEditorContext } from "./codeEditorContext"; import Link from "../Link"; +import { TableAlignContextProvider, TableHeadContextProvider, useTableAlignContext, useTableHeadContext } from "./tableHeadContext"; +import type { EditorTable } from "~/editor/element"; const typeToRenderer: Record (ReactNode[] | ReactNode)> = { paragraph({ attributes, children }) { @@ -53,7 +55,7 @@ const typeToRenderer: Record ( // Since no index is given const context = useCodeEditorContext(); const index = context?.findIndex((x) => x === element) ?? -1; - + return ( <> @@ -86,6 +88,51 @@ const typeToRenderer: Record ( ); }, + ["table"]({ attributes, children, element }) { + const table = element as EditorTable; + const headRow = children[0]; + + return ( + + + + + {headRow} + + + + + {children.slice(1)} + + + +
    + ); + }, + ["table-row"]({ attributes, children }) { + const tableAlign = useTableAlignContext(); + + return ( + + {(children as React.ReactElement[]).map((x, i) => + + {x} + + )} + + ); + }, + ["table-cell"]({ attributes, children }) { + const tableHead = useTableHeadContext(); + const tableAlign = useTableAlignContext(); + const Component = tableHead ? "th" : "td"; + + return ( + + {children} + + ); + }, // ["inline-quote"]({ attributes, children }) { // return ( // diff --git a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx index 394dac8..8e0eb0a 100644 --- a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx +++ b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx @@ -1,13 +1,17 @@ -import { ButtonGroup, Dropdown, ListItemContent, ListItemDecorator, Menu, MenuButton } from "@mui/joy"; +import { ButtonGroup, Dropdown, IconButton, ListItemContent, ListItemDecorator, Menu, MenuButton } from "@mui/joy"; import { Group } from "components"; import { ReactNode } from "react" import MarkNodeToggle from "./MarkNodeToggle"; -import { IconBlockquote, IconBold, IconBraces, IconCaretDownFilled, IconCode, IconH1, IconH2, IconH3, IconH4, IconH6, IconItalic, IconList, IconListNumbers, IconSeparatorHorizontal, IconStrikethrough, IconUnderline } from "@tabler/icons-react"; +import { IconBlockquote, IconBold, IconBraces, IconCaretDownFilled, IconCode, IconH1, IconH2, IconH3, IconH4, IconH6, IconItalic, IconList, IconListNumbers, IconSeparatorHorizontal, IconStrikethrough, IconTable, IconTableColumn, IconTableRow, IconUnderline } from "@tabler/icons-react"; import BlockNodeToggle from "./BlockNodeToggle"; import ListNodeToggle from "./ListNodeToggle"; import CodeNodeToggle from "./CodeNodeToggle"; import BlockNodeInsert from "./BlockNodeInsert"; import BlockNodeMenuItem from "./BlockNodeMenuItem"; +import TableNodeInsert from "./TableNodeInsert"; +import { useSlate } from "slate-react"; +import CampgroundEditor from "./CampgroundEditor"; +import type { EditorTable } from "~/editor/element"; type Props = { children: ReactNode[] | ReactNode; @@ -53,12 +57,37 @@ export function RichEditorToolbarBlockFormatting() { + + + ); } + +export function RichEditorToolbarTableFormatting() { + const editor = useSlate(); + + const activeTable = CampgroundEditor.getSelectedNodes(editor, "table"); + + if (!activeTable?.length) + return <>; + + console.log("Active table", activeTable); + + return ( + + CampgroundEditor.insertTableRow(editor, activeTable[0][0] as EditorTable)}> + + + CampgroundEditor.insertTableColumn(editor, activeTable[0][0] as EditorTable)}> + + + + ); +} export function RichEditorToolbarHeading() { return ( diff --git a/sites/app.campground.gg/src/components/editor/TableNodeInsert.tsx b/sites/app.campground.gg/src/components/editor/TableNodeInsert.tsx new file mode 100644 index 0000000..c318748 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/TableNodeInsert.tsx @@ -0,0 +1,24 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + children: ReactNode[] | ReactNode; +}; + +export default function TableNodeInsert({ children }: Props) { + const editor = useSlate(); + + const active = CampgroundEditor.isNodeFormatted(editor, "table-cell"); + + const toggleFormatting = (ev: React.MouseEvent) => { + ev.preventDefault(); + CampgroundEditor.insertTableFormatting(editor); + }; + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/tableHeadContext.tsx b/sites/app.campground.gg/src/components/editor/tableHeadContext.tsx new file mode 100644 index 0000000..cb9b5c2 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/tableHeadContext.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext } from "react"; +import type { BlockAlignment } from "~/editor/element"; + +export const TableHeadContext = createContext(false); +export const useTableHeadContext = () => useContext(TableHeadContext); + +export function TableHeadContextProvider({ isHead, children }: React.PropsWithChildren & { isHead?: boolean; }) { + return ( + + {children} + + ) +} + +export type TableAlign = { + align: BlockAlignment; + allAligns: BlockAlignment[] | undefined | null; +}; + +export const TableAlignContext = createContext({ align: "left", allAligns: [] }); +export const useTableAlignContext = () => useContext(TableAlignContext); + +export function TableAlignContextProvider({ value, children }: React.PropsWithChildren & { value: TableAlign; }) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/markdown/Markdown.tsx b/sites/app.campground.gg/src/components/markdown/Markdown.tsx index 951da61..d5a46ff 100644 --- a/sites/app.campground.gg/src/components/markdown/Markdown.tsx +++ b/sites/app.campground.gg/src/components/markdown/Markdown.tsx @@ -27,14 +27,7 @@ function parseMeta(raw: string) { const TableWrapper = styled(Box, { name: "Table" -})(({ theme }) => ({ - border: `solid 1px ${theme.vars.palette.neutral[500]}`, - overflowX: "auto", - borderRadius: theme.vars.radius.md, - width: "min-content", - maxWidth: "100%", - margin: "8px 0", -})) +})(); const markdownComponents: Components = { pre({ node }) { diff --git a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx index fcf73d1..ef4575b 100644 --- a/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx +++ b/sites/app.campground.gg/src/components/markdown/MarkdownWrapper.tsx @@ -49,12 +49,18 @@ const MarkdownWrapper = styled(Box, { border: `solid 1px ${theme.vars.palette.neutral[500]}`, borderSpacing: 0, maxWidth: "100%", + // overflowX: "auto", + overflow: "hidden", + borderRadius: theme.vars.radius.md, + margin: "8px 0", + position: "relative", }, "th, td": { - padding: `4px 8px`, + padding: `6px 12px`, }, "tr": { backgroundColor: theme.vars.palette.background.level1, + position: "relative", }, "tr:nth-child(odd)": { backgroundColor: theme.vars.palette.background.level2, diff --git a/sites/app.campground.gg/src/editor/element.ts b/sites/app.campground.gg/src/editor/element.ts index cf7ceb2..09ea380 100644 --- a/sites/app.campground.gg/src/editor/element.ts +++ b/sites/app.campground.gg/src/editor/element.ts @@ -1,7 +1,7 @@ import type { Text } from "slate"; import type { EditorText, EditorTextUnformatted } from "./text"; -const _EditorBlockElementType = ["paragraph", "block-quote", "code-block", "divider", "unordered-list", "ordered-list", "heading"] as const; +const _EditorBlockElementType = ["paragraph", "block-quote", "code-block", "divider", "unordered-list", "ordered-list", "heading", "table"] as const; /** * Rich text editor node elements that don't necessarily depend on the ancestor element and spans at least a line. */ @@ -14,7 +14,7 @@ export const EditorBlockElementType = _EditorBlockElementType; /** * Rich text editor node elements that depends on the ancestor block element and spans at least a line. */ -const _EditorItemElementType = ["code-line", "list-item"] as const; +const _EditorItemElementType = ["code-line", "list-item", "table-row", "table-cell"] as const; /** * Rich text editor node elements that depends on the ancestor block element and spans at least a line. */ @@ -24,9 +24,11 @@ export type EditorItemElementType = typeof _EditorItemElementType[number]; */ export const EditorItemElementType = _EditorItemElementType; -export const EditorItemToParent: Record = { +export const EditorItemToParent: Record = { "code-line": "code-block", "list-item": "unordered-list", + "table-cell": "table-row", + "table-row": "table", }; // Test inline @@ -76,6 +78,10 @@ export interface EditorHeading extends EditorBlockElementBase<"heading", Text> { export interface EditorOrderedList extends EditorBlockElementBase<"ordered-list", EditorListItem> { start?: null | undefined | number; } +export type BlockAlignment = "left" | "center" | "right"; +export interface EditorTable extends EditorBlockElementBase<"table", EditorTableRow> { + align?: BlockAlignment[] | undefined | null; +} export type EditorBlockElement = EditorParagraph | @@ -84,17 +90,23 @@ export type EditorBlockElement = EditorDivider | EditorCodeBlock | EditorUnorderedList | - EditorOrderedList; + EditorOrderedList | + EditorTable; export type EditorCodeLine = EditorBlockElementBase<"code-line", EditorTextUnformatted>; export type EditorListItem = EditorBlockElementBase<"list-item", EditorBlockElement | EditorText>; +export type EditorTableRow = EditorBlockElementBase<"table-row", EditorTableCell>; +export type EditorTableCell = EditorBlockElementBase<"table-cell", Text>; export type EditorItemElement = EditorCodeLine | - EditorListItem; + EditorListItem | + EditorTableRow | + EditorTableCell; export interface EditorLink extends EditorInlineElementBase<"link", Text> { url: string; + title?: string | undefined | null; } export type EditorInlineElement = EditorLink; export type EditorElement = EditorInlineElement | EditorBlockElement | EditorItemElement; \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/mdast/editor.ts b/sites/app.campground.gg/src/editor/mdast/editor.ts index 8dca8af..fb89d2c 100644 --- a/sites/app.campground.gg/src/editor/mdast/editor.ts +++ b/sites/app.campground.gg/src/editor/mdast/editor.ts @@ -1,10 +1,14 @@ -import { Editor } from "slate"; +import { Editor, Element } from "slate"; import { mdastifyNode } from "./nodes"; import type { Root, RootContent } from "mdast"; +import { slatefyElement } from "./element"; export function mdastifyEditor(editor: Editor): Root { return { type: "root", children: editor.children.map(mdastifyNode) as RootContent[], }; +} +export function slatefyRoot(root: Root): Element[] { + return root.children.flatMap((x) => slatefyElement(x, [])) as Element[]; } \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/mdast/element.ts b/sites/app.campground.gg/src/editor/mdast/element.ts index b8b1a19..8d46d47 100644 --- a/sites/app.campground.gg/src/editor/mdast/element.ts +++ b/sites/app.campground.gg/src/editor/mdast/element.ts @@ -1,9 +1,11 @@ import { Element, Node } from "slate"; -import type { EditorCodeBlock, EditorElementType, EditorHeading, EditorLink, EditorOrderedList } from "../editor"; -import type { BlockContent, DefinitionContent, ListItem, PhrasingContent, RootContentMap } from "mdast"; +import type { EditorBlockElement, EditorCodeBlock, EditorElementType, EditorHeading, EditorLink, EditorListItem, EditorOrderedList, EditorText } from "../editor"; +import type { BlockContent, DefinitionContent, ListItem, PhrasingContent, RootContentMap, Node as MdastNode, Paragraph, Parent, Blockquote, Code, List, Heading, Link, Html, Text, InlineCode, FootnoteDefinition, FootnoteReference, Definition, Image, ImageReference, Yaml, Table, TableRow, TableCell, LinkReference } from "mdast"; import { mdastifyNode } from "./nodes"; +import { slatefyText } from "./text"; +import type { BlockAlignment, EditorParagraph, EditorTable, EditorTableCell, EditorTableRow } from "../element"; -const nodeSerializers: Record RootContentMap[keyof RootContentMap]> = { +const nodeSerializers: Record RootContentMap[keyof RootContentMap] | TableRow | TableCell> = { paragraph(element) { return { type: "paragraph", children: element.children.map(mdastifyNode) as PhrasingContent[] }; }, @@ -12,9 +14,31 @@ const nodeSerializers: Record RootConte }, ["code-block"](element) { const codeBlock = element as EditorCodeBlock; - + return { ...codeBlock, type: "code", value: element.children.map(Node.string).join("\n"), }; }, + table(element) { + const table = element as EditorTable; + + return { + type: "table", + align: table.align, + children: + table.children.map(mdastifyNode) as TableRow[], + }; + }, + ["table-row"](element) { + return { + type: "tableRow", + children: element.children.map(mdastifyNode) as TableCell[], + } satisfies TableRow; + }, + ["table-cell"](element) { + return { + type: "tableCell", + children: element.children.map(mdastifyNode) as PhrasingContent[], + } satisfies TableCell; + }, ["unordered-list"](element) { return { type: "list", spread: false, children: element.children.map(mdastifyNode) as ListItem[] }; }, @@ -43,6 +67,176 @@ const nodeSerializers: Record RootConte } }; +type ContentWithUnderline = keyof RootContentMap | "underline"; + +const nodeDeserializers: Record Node | Node[]> = { + paragraph(element, nesting) { + return { type: "paragraph", children: (element as Paragraph).children.flatMap((x) => slatefyElement(x, nesting)) as unknown as EditorText[] }; + }, + definition(element, nesting) { + const definition = element as Definition; + return [ + { type: "paragraph", children: [slatefyText(definition.title ?? definition.label ?? definition.identifier, nesting)] }, + { type: "paragraph", children: (element as Paragraph).children.flatMap((x) => slatefyElement(x, nesting)) as unknown as EditorText[] } + ]; + }, + blockquote(element, nesting) { + return { type: "block-quote", children: (element as Blockquote).children.flatMap((x) => slatefyElement(x, nesting)) as EditorBlockElement[] }; + }, + code(element) { + const codeBlock = element as Code; + + return { + ...codeBlock, + type: "code-block", + children: codeBlock + .value + .split("\n") + .map((x) => ({ + type: "code-line", + children: [ + { + text: x, + }, + ], + })), + }; + }, + list(element, nesting) { + const list = element as List; + + return { type: list.ordered ? "ordered-list" : "unordered-list", children: list.children.flatMap((x) => slatefyElement(x, nesting)) as EditorListItem[] }; + }, + table(element, nesting) { + const table = element as Table; + + return { type: "table", align: table.align as (BlockAlignment[] | null | undefined), children: table.children.flatMap((x) => slatefyElement(x, nesting)) as EditorTableRow[] }; + }, + tableRow(element, nesting) { + const tableRow = element as TableRow; + + return { type: "table-row", children: tableRow.children.flatMap((x) => slatefyElement(x, nesting)) as EditorTableCell[] }; + }, + tableCell(element, nesting) { + const tableCell = element as TableCell; + + return { type: "table-cell", children: tableCell.children.flatMap((x) => slatefyElement(x, nesting)) as EditorText[] }; + }, + listItem(element, nesting) { + const listItem = element as ListItem; + + return { type: "list-item", children: listItem.children.flatMap((x) => slatefyElement(x, nesting)) as (EditorBlockElement | EditorText)[] }; + }, + thematicBreak() { + return { type: "divider", children: [{ text: "" }] }; + }, + heading(element, nesting) { + const heading = element as Heading; + + return { type: "heading", depth: heading.depth, children: heading.children.flatMap((x) => slatefyElement(x, nesting)) as EditorText[] }; + }, + footnoteDefinition(element, nesting) { + const footnoteDef = element as FootnoteDefinition; + + return { + type: "paragraph", + children: [ + slatefyText(`[^${footnoteDef.label}]: `, nesting), + ...footnoteDef.children.flatMap((x) => slatefyElement(x, nesting)) as EditorText[], + ] + } satisfies EditorParagraph; + }, + image(element, nesting) { + const image = element as Image; + + return [ + slatefyText("!", nesting), + { type: "link", url: image.url, title: image.title, children: [slatefyText(image.alt ?? "", nesting)] } + ]; + }, + imageReference(element, nesting) { + const image = element as ImageReference; + + return [ + slatefyText("!", nesting), + { type: "link", url: image.identifier, title: image.label, children: [slatefyText(image.alt ?? "", nesting)] } + ]; + }, + linkReference(element, nesting) { + const link = element as LinkReference; + + return { type: "link", url: link.identifier, title: link.label, children: link.children.flatMap((x) => slatefyElement(x, nesting)) as EditorText[] } satisfies EditorLink; + }, + link(element, nesting) { + const link = element as Link; + + return { ...link, children: link.children.flatMap((x) => slatefyElement(x, nesting)) as EditorText[] }; + }, + yaml(element, nesting) { + const yaml = element as Yaml; + + return [ + { + type: "divider", + children: [ + { text: " "} + ] + }, + { + type: "paragraph", + children: [ + slatefyText(yaml.value, nesting), + ] + }, + { + type: "divider", + children: [ + { text: " "} + ] + } + ]; + }, + break() { + return { text: "\n" }; + }, + emphasis(element, nesting) { + // Since emphasis doesn't exist in mdast, we need to detect __ + if (nesting.slice(-2)[0] === "emphasis") { + nesting.splice(-2); + nesting.push("underline"); + } + + return (element as Parent).children.flatMap((x) => slatefyElement(x, nesting)); + }, + delete(element, nesting) { + return (element as Parent).children.flatMap((x) => slatefyElement(x, nesting)); + }, + strong(element, nesting) { + return (element as Parent).children.flatMap((x) => slatefyElement(x, nesting)); + }, + footnoteReference(element, nesting) { + const ref = element as FootnoteReference; + return slatefyText(`[^${ref.label ?? ref.identifier}]`, nesting); + }, + html(element, nesting) { + const html = element as Html; + + return slatefyText(html.value, nesting); + }, + inlineCode(element, nesting) { + const text = element as InlineCode; + return slatefyText(text.value, nesting); + }, + text(element, nesting) { + const text = element as Text; + return slatefyText(text.value, nesting); + }, +}; + export function mdastifyElement(element: Element) { return nodeSerializers[element.type](element); +} +export function slatefyElement(node: MdastNode, nesting: ContentWithUnderline[]) { + nesting.push(node.type as ContentWithUnderline); + return nodeDeserializers[node.type as keyof RootContentMap](node, nesting); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/mdast/markdown.ts b/sites/app.campground.gg/src/editor/mdast/markdown.ts new file mode 100644 index 0000000..d0b9fae --- /dev/null +++ b/sites/app.campground.gg/src/editor/mdast/markdown.ts @@ -0,0 +1,12 @@ +import type { Root } from "mdast"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown, gfmToMarkdown } from "mdast-util-gfm"; +import { toMarkdown } from "mdast-util-to-markdown"; +import { gfm } from "micromark-extension-gfm"; + +export function serializeMarkdown(root: Root) { + return toMarkdown(root, { emphasis: "_", bullet: "-", bulletOther: "*", strong: "*", extensions: [gfmToMarkdown()] }); +} +export function deserializeMarkdown(code: string) { + return fromMarkdown(code, "utf-8", { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/mdast/nodes.ts b/sites/app.campground.gg/src/editor/mdast/nodes.ts index e9f7c28..1e14e26 100644 --- a/sites/app.campground.gg/src/editor/mdast/nodes.ts +++ b/sites/app.campground.gg/src/editor/mdast/nodes.ts @@ -11,4 +11,4 @@ export function mdastifyNode(node: SlateNode): MdastNode { return mdastifyText(node); return mdastifyElement(node as Element); -} +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/mdast/text.ts b/sites/app.campground.gg/src/editor/mdast/text.ts index 141e2f5..346037c 100644 --- a/sites/app.campground.gg/src/editor/mdast/text.ts +++ b/sites/app.campground.gg/src/editor/mdast/text.ts @@ -1,5 +1,6 @@ import { Text as SlateText } from "slate"; -import type { PhrasingContentMap } from "mdast"; +import type { PhrasingContentMap, RootContentMap } from "mdast"; +import type { EditorText, EditorTextFormatting } from "../text"; export function mdastifyText(node: SlateText): PhrasingContentMap[keyof PhrasingContentMap] { const marks = [ @@ -16,6 +17,23 @@ export function mdastifyText(node: SlateText): PhrasingContentMap[keyof Phrasing ); } +const markTypes: (keyof RootContentMap | "underline")[] = ["strong", "emphasis", "underline", "delete", "inlineCode"]; +const markTypesToFormatting: Partial> = { + strong: "bold", + emphasis: "italic", + underline: "underline", + delete: "strikethrough", + inlineCode: "code", +}; + +export function slatefyText(text: string, nesting: (keyof RootContentMap | "underline")[]): EditorText { + const marks = nesting + .filter((x) => markTypes.includes(x)) + .map((x) => [markTypesToFormatting[x]!, true]); + + return Object.assign(Object.fromEntries(marks), { text }); +} + function wrapped(toWrap: PhrasingContentMap[keyof PhrasingContentMap], marks: (keyof PhrasingContentMap)[]): PhrasingContentMap[keyof PhrasingContentMap] { if (marks.length < 1) return toWrap; diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx index 513f865..08b7974 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx @@ -2,39 +2,61 @@ import { Box, Stack, Typography } from "@mui/joy"; import ProfileFeedPost from "./ProfileFeedPost"; import type { User, UserPostBasic } from "types/user"; import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; -import PostInput from "~/components/editor/PostInput"; +import ProfilePostCreator from "~/layout/profile/ProfilePostCreator"; import { useSession } from "~/session"; import { useState } from "react"; type Props = { user: User; isSelf: boolean; - posts: UserPostBasic[] | undefined; + posts: UserPostBasic[]; }; export default function ProfileFeed({ user, posts, isSelf }: Props) { const session = useSession(); - const [newPosts, setNewPosts] = useState([]); + const [postList, setPostList] = useState(posts); const onPostCreated = (content: string) => { const newPost = { content, - tags: ["Test tag", "tag"], + tags: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; return session.restClient?.createPost(newPost) - .then((x) => setNewPosts([{ ...newPost, author: user, replyCount: 0, uri: x.content!.uri, indexedAt: new Date().toISOString() } satisfies UserPostBasic, ...newPosts])) + .then((x) => setPostList([{ ...newPost, author: user, replyCount: 0, uri: x.content!.uri, indexedAt: new Date().toISOString() } satisfies UserPostBasic, ...posts])) .catch((e) => console.error("Got an error while making a post", e)); } + const onPostDeleted = (uri: string) => { + return session.restClient?.deletePost(uri) + .then(() => setPostList(postList.filter((x) => x.uri != uri))) + .catch((e) => console.error("Got an error while deleting a post", e)); + }; + const onPostUpdated = (uri: string, content: string) => { + return session.restClient?.updatePost(uri, { content }) + .then(() => { + const postIndex = postList.findIndex((x) => x.uri === uri); + if (postIndex < 0) + return; + + // Update post in post list + const post = postList[postIndex]; + setPostList([...postList.slice(0, postIndex), { ...post, content }, ...postList.slice(postIndex + 1) ]) + }) + .catch((e) => console.error("Got an error while editing a post", e)); + }; return ( Feed - {session.restClient && isSelf && } + {session.restClient && isSelf && } - {newPosts.concat(posts ?? []).map((x) => + {postList.map((x, i) => void | Promise; + onPostUpdate: (uri: string, content: string) => void | Promise; }; -export default class ProfileFeedPost extends React.Component { - render(): React.ReactNode { - const { showCommentsLink } = this.props; - const { uri, content, createdAt, replies, replyCount, tags, author } = this.props.post as EitherUserPost; - const postTid = uri.split("/")[4]; +const appearAnimation = keyframes` + 0% { + opacity: 0%; + transform: translateY(-20px); + } + 100% { + opacity: 100%; + transform: translateY(0px); + } +`; - return ( - - - - - - - - - - - ({ mt: -4.5, color: theme.vars.palette.text.secondary })}> - {content} - - - - {showCommentsLink && }> - {replyCount ?? replies.length}{" "} - } - {showCommentsLink && }> - {replyCount ?? replies.length}{" "} - } - {showCommentsLink && }> - {" "} - } - - - - {tags.map((tag, i) => {tag})} - - +function ProfileFeedPostHeader({ author, createdAt }: { author: UserPost["author"], createdAt: Date }) { + return ( + + + + + + ); +} + +export default function ProfileFeedPost(props: Props) { + const { showCommentsLink, appear, isOwnPost, onPostDelete, onPostUpdate } = props; + const { uri, content, createdAt, replies, replyCount, author } = props.post as EitherUserPost; + const postTid = uri.split("/")[4]; + const [editing, setEditing] = useState(false); + + return ( + + + + + {isOwnPost && + setEditing(!editing)}> + + + + + Edit post + + + onPostDelete(uri)}> + + + + + Delete post + + + } + + + {editing + ? (onPostUpdate(uri, newContent), setEditing(false))} + onCancel={() => setEditing(false)} + confirmButton="Edit" + /> + : ({ mt: -4.5, color: theme.vars.palette.text.secondary })}> + {content} + } + + + {showCommentsLink && }> + {replyCount ?? replies.length}{" "} + } + {showCommentsLink && }> + {replyCount ?? replies.length}{" "} + } + {showCommentsLink && }> + {" "} + } + {/* + + {tags.map((tag, i) => {tag})} + + */} - - - ); - } + + + + ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/PostInput.tsx b/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx similarity index 84% rename from sites/app.campground.gg/src/components/editor/PostInput.tsx rename to sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx index 74dd079..500cd16 100644 --- a/sites/app.campground.gg/src/components/editor/PostInput.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx @@ -2,9 +2,9 @@ import { Card, CardContent, Link, Stack, Typography } from "@mui/joy"; import type { SxProps } from "@mui/joy/styles/types"; import { useState } from "react"; import type { User } from "types/user"; -import UserAvatar from "../UserAvatar"; +import UserAvatar from "../../components/UserAvatar"; import { Group, PrimaryButton } from "components"; -import BlockTextEditor from "./BlockTextEditor"; +import BlockTextEditor from "../../components/editor/BlockTextEditor"; import withCgMarkdown from "~/editor/withCgMarkdown"; import { withHistory } from "slate-history"; import { withReact } from "slate-react"; @@ -12,7 +12,7 @@ import { createEditor } from "slate"; import type { RichEditor } from "~/editor/editor"; import { IconArrowRight } from "@tabler/icons-react"; import { mdastifyEditor } from "~/editor/mdast"; -import { toMarkdown } from "mdast-util-to-markdown"; +import { serializeMarkdown } from "~/editor/mdast/markdown"; type Props = { user: User; @@ -22,7 +22,7 @@ type Props = { onPost: (content: string) => void | Promise; }; -export default function PostInput({ user, placeholder, onPost, sx }: Props) { +export default function ProfilePostCreator({ user, placeholder, onPost, sx }: Props) { const [open, setOpen] = useState(false); const [editor] = useState(() => withCgMarkdown(withHistory(withReact(createEditor()))) as RichEditor); @@ -35,7 +35,7 @@ export default function PostInput({ user, placeholder, onPost, sx }: Props) { ({ color: theme.vars.palette.text.secondary })} placeholder="What is your current mood?" /> - } onClick={() => (setOpen(false), onPost(toMarkdown(mdastifyEditor(editor), { bullet: "-", emphasis: "_" })))}>Post + } onClick={() => (setOpen(false), onPost(serializeMarkdown(mdastifyEditor(editor))))}>Post setOpen(false)}>Cancel From 11c84574d37dba8346fda9a3e5bdf6e3779e6ab8 Mon Sep 17 00:00:00 2001 From: PrettyGoodName <31628630+IdkGoodName@users.noreply.github.com> Date: Mon, 10 Nov 2025 05:15:35 +0200 Subject: [PATCH 6/7] feat: the ability to edit and delete posts, markdown serialization and deserialization, some improvements on profile post looks --- packages/components/theme/dark.ts | 1 + packages/components/theme/index.ts | 7 + .../src/components/UserAvatar.tsx | 13 +- .../src/components/UserDisplay.tsx | 4 +- .../src/components/UserProfileCard.tsx | 2 +- .../src/components/editor/BasicPostEditor.tsx | 6 +- .../components/editor/CampgroundEditor.tsx | 20 ++- .../components/editor/RichEditorToolbar.tsx | 10 +- .../src/editor/keyboard-logic.ts | 60 +++++-- .../src/layout/profile/ProfileFeed.tsx | 2 +- .../src/layout/profile/ProfileFeedComment.tsx | 46 ------ .../src/layout/profile/ProfileFeedPost.tsx | 50 +++--- .../src/layout/profile/ProfileLayout.tsx | 2 +- .../src/layout/profile/ProfilePostCreator.tsx | 30 +--- .../src/layout/profile/ProfilePostView.tsx | 58 ------- .../src/layout/profile/ProfileView.tsx | 4 +- .../app.campground.gg/src/middleware/auth.ts | 24 ++- .../PostParentLine.tsx | 28 ++++ .../ProfilePostView.tsx | 148 ++++++++++++++++++ .../route.tsx} | 27 ++-- .../src/session/SessionMiddleware.ts | 4 + sites/app.campground.gg/src/session/index.ts | 2 + sites/app.campground.gg/types/user.ts | 1 + 23 files changed, 348 insertions(+), 201 deletions(-) delete mode 100644 sites/app.campground.gg/src/layout/profile/ProfileFeedComment.tsx delete mode 100644 sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx create mode 100644 sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx create mode 100644 sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx rename sites/app.campground.gg/src/routes/{profile.($id).posts.$postId.tsx => profile.($id).posts.$postId/route.tsx} (53%) diff --git a/packages/components/theme/dark.ts b/packages/components/theme/dark.ts index 59f3cd7..fa60152 100644 --- a/packages/components/theme/dark.ts +++ b/packages/components/theme/dark.ts @@ -18,6 +18,7 @@ const shades = { }; const darkColorScheme: ColorSystemOptions = { + shadowOpacity: "0.35", palette: { neutral: { ...shades, diff --git a/packages/components/theme/index.ts b/packages/components/theme/index.ts index 056d781..3003652 100644 --- a/packages/components/theme/index.ts +++ b/packages/components/theme/index.ts @@ -30,6 +30,13 @@ const theme = extendTheme({ transition: "background 0.3s", } }, + }, + JoyCard: { + styleOverrides: { + root: ({ theme }) => ({ + boxShadow: theme.vars.shadow.sm, + }), + } } } }); diff --git a/sites/app.campground.gg/src/components/UserAvatar.tsx b/sites/app.campground.gg/src/components/UserAvatar.tsx index 6f86ddd..7d601d6 100644 --- a/sites/app.campground.gg/src/components/UserAvatar.tsx +++ b/sites/app.campground.gg/src/components/UserAvatar.tsx @@ -3,7 +3,7 @@ import type { SxProps } from "@mui/joy/styles/types"; const availableBadgeSizes = ["sm", "md", "lg"]; -type Size = "sm" | "md" | "lg" | "xl" | "xxl"; +type Size = "sm" | "md" | "lg" | "xl" | "xxl" | "xxxl"; type Props = { did: string; avatar?: string | null; @@ -13,17 +13,18 @@ type Props = { badgeSx?: SxProps; }; const sizeToPx: Record = { - sm: 20, - md: 24, + sm: 24, + md: 32, lg: 48, - xl: 80, - xxl: 128, + xl: 56, + xxl: 80, + xxxl: 128, }; const StyledAvatar = styled(Avatar)(({ theme, size }) => ({ width: sizeToPx[size ?? "md"], height: sizeToPx[size ?? "md"], - borderRadius: theme.vars.radius[(size as Size) === "xxl" ? "xl" : size ?? "md"] + borderRadius: theme.vars.radius[(size as Size) === "xxl" || (size as Size) === "xxxl" ? "xl" : size ?? "md"] })); export default function UserAvatar({ did, avatar, status, size, badgeSx, sx }: Props) { diff --git a/sites/app.campground.gg/src/components/UserDisplay.tsx b/sites/app.campground.gg/src/components/UserDisplay.tsx index ce1058b..f816010 100644 --- a/sites/app.campground.gg/src/components/UserDisplay.tsx +++ b/sites/app.campground.gg/src/components/UserDisplay.tsx @@ -9,7 +9,7 @@ type Props = { user: User; color?: string; size?: Size; - avatarSize?: Size; + avatarSize?: Size | "xl"; showHandle?: boolean; alignItems?: "center" | "start" | "end"; }; @@ -36,7 +36,7 @@ export default function UserDisplay({ color, user, size, avatarSize, alignItems,
    { showHandle && <> - @{user.handle.split("/")[2]} + @{user.handle.split("/")[2]} } diff --git a/sites/app.campground.gg/src/components/UserProfileCard.tsx b/sites/app.campground.gg/src/components/UserProfileCard.tsx index 304efe4..0a7899a 100644 --- a/sites/app.campground.gg/src/components/UserProfileCard.tsx +++ b/sites/app.campground.gg/src/components/UserProfileCard.tsx @@ -45,7 +45,7 @@ export default function UserProfileCard({ did, user, self }: Props) { - ({ border: `solid 4px ${theme.vars.palette.background.tooltip}` })} /> + ({ border: `solid 4px ${theme.vars.palette.background.tooltip}` })} /> diff --git a/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx b/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx index 0ed77fb..b2fab22 100644 --- a/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx +++ b/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx @@ -1,4 +1,4 @@ -import { Box, Link } from "@mui/joy"; +import { Link, Stack } from "@mui/joy"; import type { SxProps } from "@mui/joy/styles/types"; import { useState } from "react"; import { Group, PrimaryButton } from "components"; @@ -25,12 +25,12 @@ export default function BasicPostEditor({ placeholder, onConfirm, onCancel, cont const [editor] = useState(() => withCgMarkdown(withHistory(withReact(createEditor()))) as RichEditor); return ( - + ({ color: theme.vars.palette.text.secondary })} placeholder={placeholder ?? "What is your current mood?"} /> } onClick={() => onConfirm(serializeMarkdown(mdastifyEditor(editor)))}>{confirmButton ?? "Post"} {onCancel && Cancel} - + ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx index c7f1619..1f35579 100644 --- a/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx +++ b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx @@ -46,7 +46,7 @@ export default class CampgroundEditor { return (marks?.[type] as boolean | null) ?? false; } static toggleBlockFormatting(editor: RichEditor, type: EditorBlockElementType, additionalProps?: any) { - const active = this.isNodeFormatted(editor, type); + const active = CampgroundEditor.isNodeFormatted(editor, type); // Simple type change const props = active ? additionalProps && Object.keys(additionalProps).reduce((obj, prop) => (obj[prop] = null, obj), {} as Record) : additionalProps; @@ -111,7 +111,7 @@ export default class CampgroundEditor { }); } static toggleInlineFormatting(editor: RichEditor, type: EditorInlineElementType) { - const active = this.isNodeFormatted(editor, type); + const active = CampgroundEditor.isNodeFormatted(editor, type); if (active) return Transforms.unwrapNodes(editor, { @@ -136,7 +136,7 @@ export default class CampgroundEditor { ); } static toggleListFormatting(editor: RichEditor, type: EditorBlockElementType, itemType: EditorItemElementType) { - const active = this.isNodeFormatted(editor, type); + const active = CampgroundEditor.isNodeFormatted(editor, type); if (active) Transforms.unwrapNodes(editor, { @@ -164,8 +164,8 @@ export default class CampgroundEditor { ); } static toggleCodeFormatting(editor: RichEditor, type: EditorBlockElementType, itemType: EditorItemElementType) { - const active = this.isNodeFormatted(editor, type); - const activeElems = this.getSelectedNodes(editor); + const active = CampgroundEditor.isNodeFormatted(editor, type); + const activeElems = CampgroundEditor.getSelectedNodes(editor); console.log(activeElems); @@ -176,12 +176,18 @@ export default class CampgroundEditor { type: active ? `paragraph` : itemType, }, { - match: n => Element.isElement(n) && n.type === "paragraph", + match: n => Element.isElement(n), split: true, } ); - if (active) { } + if (active) + Transforms.unwrapNodes( + editor, + { + match: n => Element.isElement(n) && n.type === type + } + ) else Transforms.wrapNodes( editor, diff --git a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx index 8e0eb0a..eddd514 100644 --- a/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx +++ b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx @@ -100,7 +100,7 @@ export function RichEditorToolbarHeading() { - + @@ -108,7 +108,7 @@ export function RichEditorToolbarHeading() { Heading 2 - + @@ -116,7 +116,7 @@ export function RichEditorToolbarHeading() { Heading 3 - + @@ -124,7 +124,7 @@ export function RichEditorToolbarHeading() { Heading 4 - + @@ -132,7 +132,7 @@ export function RichEditorToolbarHeading() { Heading 5 - + diff --git a/sites/app.campground.gg/src/editor/keyboard-logic.ts b/sites/app.campground.gg/src/editor/keyboard-logic.ts index d37a4e2..abea0d7 100644 --- a/sites/app.campground.gg/src/editor/keyboard-logic.ts +++ b/sites/app.campground.gg/src/editor/keyboard-logic.ts @@ -1,9 +1,13 @@ -import { Editor, Element, Node } from "slate"; +import { type BaseSelection, Editor, Element, Node, Point } from "slate"; import { EditorItemElementType, type RichEditor } from "./editor"; import { getNeighborPath, getNewlineIndexes, getParentPath, paragraph } from "./utils"; import React from "react"; -function ArrowVertical(editor: RichEditor, up: boolean) { +function selectWithOptionalShift(editor: RichEditor, currentSelection: BaseSelection, shift: boolean, newPosition: Point) { + return editor.select({ anchor: shift ? currentSelection!.anchor : newPosition, focus: newPosition }); +} + +function ArrowVertical(editor: RichEditor, up: boolean, shift: boolean) { const above = editor.above(); const elementAbove = above?.[0]; const isElement = Element.isElement(elementAbove); @@ -14,7 +18,8 @@ function ArrowVertical(editor: RichEditor, up: boolean) { const elementString = Node.string(elementAbove); const newlines = getNewlineIndexes(elementString).map((x) => x + 1); - const selectionOffset = editor.selection?.focus?.offset ?? 0; + const currentSelection = editor.selection; + const selectionOffset = currentSelection?.focus?.offset ?? 0; const passedLines = newlines.filter((x) => x <= selectionOffset); const nextNewlineOffset = newlines.find((x) => x > selectionOffset); @@ -29,7 +34,16 @@ function ArrowVertical(editor: RichEditor, up: boolean) { // Perhaps the line offset was saved const finalLineOffset = editor.selection?.focus.lineOffset ?? currentLineOffset; - return editor.select({ lineOffset: finalLineOffset, path: editor.selection!.focus.path, offset: Math.min(lineThere + finalLineOffset, up ? currentLine - lineThere - 1: elementString.length) }); + return selectWithOptionalShift( + editor, + currentSelection!, + shift, + { + lineOffset: finalLineOffset, + path: currentSelection!.focus.path, + offset: Math.min(lineThere + finalLineOffset, up ? currentLine - lineThere - 1: elementString.length) + }, + ); } // Will be used to get neighbours @@ -57,17 +71,24 @@ function ArrowVertical(editor: RichEditor, up: boolean) { const newlinesThere = getNewlineIndexes(stringifiedThere).map((x) => x + 1); // Retain offset in the last line of the node 'there' - return editor.select({ - path: itemThere[1], - lineOffset: newOffset, - offset: Math.min((newlinesThere.slice(-1)[0] ?? 0) + newOffset, stringifiedThere.length), - }); + return selectWithOptionalShift( + editor, + currentSelection!, + shift, + { + path: itemThere[1], + lineOffset: newOffset, + offset: Math.min((newlinesThere.slice(-1)[0] ?? 0) + newOffset, stringifiedThere.length), + } + ); } // The end of editor and no point trying to escape blocks else if (elementAbove.type === "paragraph" && abovePath.length < 2) return; - const newNodePath = getNeighborPath(aboveParent, (Number(up) * -2) + 1); + // 1 or -1 + const whichNeighbor = (Number(up) * -2) + 1; + const newNodePath = elementAbove.type === "table-cell" ? getNeighborPath(getParentPath(aboveParent), whichNeighbor) : getNeighborPath(aboveParent, whichNeighbor); // Insert and set cursor to it. Made to escape blocks. editor.insertNode( @@ -79,15 +100,24 @@ function ArrowVertical(editor: RichEditor, up: boolean) { at: newNodePath, }, ); - return editor.select({ path: [...newNodePath, 0], offset: 0, lineOffset: 0 }); + return selectWithOptionalShift( + editor, + currentSelection!, + shift, + { + path: [...newNodePath, 0], + offset: 0, + lineOffset: 0 + } + ); } export const editorKeyboardLogic: Record) => void> = { - ArrowUp(editor: RichEditor, _: React.KeyboardEvent) { - return ArrowVertical(editor, true); + ArrowUp(editor: RichEditor, event: React.KeyboardEvent) { + return ArrowVertical(editor, true, event.shiftKey); }, - ArrowDown(editor: RichEditor, _: React.KeyboardEvent) { - return ArrowVertical(editor, false); + ArrowDown(editor: RichEditor, event: React.KeyboardEvent) { + return ArrowVertical(editor, false, event.shiftKey); }, Enter(editor: RichEditor, event: React.KeyboardEvent) { const above = editor.above(); diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx index 08b7974..b1e3ff2 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx @@ -58,7 +58,7 @@ export default function ProfileFeed({ user, posts, isSelf }: Props) { onPostUpdate={onPostUpdated} appear={Boolean(posts.length && !i)} key={`post-${x.uri}`} - showCommentsLink + showComments post={x} /> )} diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeedComment.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeedComment.tsx deleted file mode 100644 index ef26719..0000000 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeedComment.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Card, CardContent, Stack, Typography } from "@mui/joy"; -import React from "react"; -import UserDisplay from "~/components/UserDisplay"; -import { IconShare } from "@tabler/icons-react"; -import Datestamp from "~/components/Datestamp"; -import type { UserPostComment } from "types/user"; -import Link from "~/components/Link"; -import MarkdownWrapper from "~/components/markdown/MarkdownWrapper"; -import { LargeContentMarkdown } from "~/components/markdown/Markdown"; - -type Props = { - comment: UserPostComment; - linkTitle?: boolean; -}; - -export default class ProfileFeedComment extends React.Component { - render(): React.ReactNode { - const { content, createdAt, author } = this.props.comment; - - return ( - - - - - {content} - - {/* {content} */} - - - - - {/* {ms(Date.now() - createdAt, { long: true })} ago */} - - - - }> - Share - - - - - - - ); - } -} \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx index 82951d6..b190e0a 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx @@ -1,4 +1,4 @@ -import { Card, CardContent, CardOverflow, ListItemContent, ListItemDecorator, MenuItem, Stack, Typography } from "@mui/joy"; +import { Card, CardContent, CardOverflow, Divider, ListItemContent, ListItemDecorator, MenuItem, Stack, Typography } from "@mui/joy"; import { useState } from "react"; import UserDisplay from "~/components/UserDisplay"; import { IconCornerUpRightDouble, IconMessage, IconMoodPlus, IconPencil, IconTrashFilled } from "@tabler/icons-react"; @@ -10,14 +10,16 @@ import { LargeContentMarkdown } from "~/components/markdown/Markdown"; import { keyframes } from "@emotion/react"; import ContentOverflow from "~/components/content/ContentOverflow"; import BasicPostEditor from "~/components/editor/BasicPostEditor"; +import { Group } from "components"; type Props = { appear?: boolean; post: UserPost; - showCommentsLink?: boolean; + showComments?: boolean; + bigger?: boolean; isOwnPost?: boolean; - onPostDelete: (uri: string) => void | Promise; - onPostUpdate: (uri: string, content: string) => void | Promise; + onPostDelete: (uri: string) => void | Promise; + onPostUpdate: (uri: string, content: string) => void | Promise; }; const appearAnimation = keyframes` @@ -31,26 +33,27 @@ const appearAnimation = keyframes` } `; -function ProfileFeedPostHeader({ author, createdAt }: { author: UserPost["author"], createdAt: Date }) { +function ProfileFeedPostHeader({ bigger, author, createdAt }: { bigger: boolean; author: UserPost["author"], createdAt: Date }) { return ( - - - + + {!bigger && } + {!bigger && } ); } export default function ProfileFeedPost(props: Props) { - const { showCommentsLink, appear, isOwnPost, onPostDelete, onPostUpdate } = props; + const { showComments: showCommentsLink, bigger, appear, isOwnPost, onPostDelete, onPostUpdate } = props; const { uri, content, createdAt, replies, replyCount, author } = props.post as EitherUserPost; const postTid = uri.split("/")[4]; const [editing, setEditing] = useState(false); + const createdAtDate = new Date(createdAt); return ( - + ({ boxShadow: bigger ? theme.vars.shadow.md : theme.vars.shadow.sm, animation: `${appearAnimation} ${appear ? 0.75 : 0}s`, zIndex: 2, })}> - + {isOwnPost && setEditing(!editing)}> @@ -70,7 +73,7 @@ export default function ProfileFeedPost(props: Props) { } - + {editing ? setEditing(false)} confirmButton="Edit" /> - : ({ mt: -4.5, color: theme.vars.palette.text.secondary })}> + : ({ mt: bigger ? -5.5 : -5, color: theme.vars.palette.text.secondary })}> {content} } - + {bigger && + + + + + + + + } + {showCommentsLink && }> {replyCount ?? replies.length}{" "} } - {showCommentsLink && }> + }> {replyCount ?? replies.length}{" "} - } - {showCommentsLink && }> + + }> {" "} - } + {/* {tags.map((tag, i) => {tag})} */} - + diff --git a/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx b/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx index 867366d..369ce16 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx @@ -28,7 +28,7 @@ export default class ProfileLayout extends React.Component { - ({ border: `solid 4px ${theme.vars.palette.background.level1}` })} /> + ({ border: `solid 4px ${theme.vars.palette.background.level1}` })} /> {user.displayName} diff --git a/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx b/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx index 500cd16..8b01189 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx @@ -1,48 +1,34 @@ -import { Card, CardContent, Link, Stack, Typography } from "@mui/joy"; +import { Card, CardContent, Link, Typography } from "@mui/joy"; import type { SxProps } from "@mui/joy/styles/types"; import { useState } from "react"; import type { User } from "types/user"; import UserAvatar from "../../components/UserAvatar"; -import { Group, PrimaryButton } from "components"; -import BlockTextEditor from "../../components/editor/BlockTextEditor"; -import withCgMarkdown from "~/editor/withCgMarkdown"; -import { withHistory } from "slate-history"; -import { withReact } from "slate-react"; -import { createEditor } from "slate"; -import type { RichEditor } from "~/editor/editor"; -import { IconArrowRight } from "@tabler/icons-react"; -import { mdastifyEditor } from "~/editor/mdast"; -import { serializeMarkdown } from "~/editor/mdast/markdown"; +import { Group } from "components"; +import BasicPostEditor from "~/components/editor/BasicPostEditor"; type Props = { user: User; content?: string; placeholder?: string; sx?: SxProps; - onPost: (content: string) => void | Promise; + onPost: (content: string) => void | Promise; }; export default function ProfilePostCreator({ user, placeholder, onPost, sx }: Props) { const [open, setOpen] = useState(false); - const [editor] = useState(() => withCgMarkdown(withHistory(withReact(createEditor()))) as RichEditor); + const finalPlaceholder = placeholder ?? "What are you thinking?"; return ( - + {open ? - - ({ color: theme.vars.palette.text.secondary })} placeholder="What is your current mood?" /> - - } onClick={() => (setOpen(false), onPost(serializeMarkdown(mdastifyEditor(editor))))}>Post - setOpen(false)}>Cancel - - + (setOpen(false), onPost(content))} onCancel={() => setOpen(false)} sx={{ flex: 1 }} placeholder={finalPlaceholder} /> : setOpen(!open)} gap={1.5} color="neutral" startDecorator={}> - {placeholder ?? "What are you thinking?"} + {finalPlaceholder} } diff --git a/sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx b/sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx deleted file mode 100644 index 7105eec..0000000 --- a/sites/app.campground.gg/src/layout/profile/ProfilePostView.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Box, Stack, Typography } from "@mui/joy"; -import React from "react"; -import type { EitherUserPost, User, UserPost } from "types/user"; -import ProfileLayout from "./ProfileLayout"; -import ProfileFeedPost from "./ProfileFeedPost"; -import { IconArrowNarrowLeft } from "@tabler/icons-react"; -import Link from "~/components/Link"; -import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; - -type Props = { - user: User; - post: UserPost; -}; - -export default class ProfilePostView extends React.Component { - render(): React.ReactNode { - const { user, post } = this.props; - const { replyCount, replies } = post as EitherUserPost; - - return ( - - - - - - - }>Go back to the profile - - - - - Comments ({replyCount ?? replies.length}) - - - { - replies.length - ? replies.map((x) => ( - - )) - : There are no comments. - } - - - Come back later to see new comments! - - - - - - - - ) - } -} \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfileView.tsx b/sites/app.campground.gg/src/layout/profile/ProfileView.tsx index fedcafc..a2624b7 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileView.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileView.tsx @@ -3,12 +3,12 @@ import React from "react"; import ProfileFeed from "./ProfileFeed"; import ProfileAbout from "./ProfileAbout"; import type { User, UserPostBasic } from "types/user"; -import ProfileLayout from "./ProfileLayout"; import ProfileGames from "./ProfileGames"; +import ProfileLayout from "./ProfileLayout"; type Props = { user: User; - posts: UserPostBasic[] | undefined; + posts: UserPostBasic[]; isSelf: boolean; }; diff --git a/sites/app.campground.gg/src/middleware/auth.ts b/sites/app.campground.gg/src/middleware/auth.ts index c3c2944..6b22fc0 100644 --- a/sites/app.campground.gg/src/middleware/auth.ts +++ b/sites/app.campground.gg/src/middleware/auth.ts @@ -1,8 +1,28 @@ -import { sessionRouterContext } from "~/session"; +import { sessionRouterContext, sessionUserRouterContext } from "~/session"; import SessionMiddleware from "~/session/SessionMiddleware"; export const authMiddleware = ({ context }: any) => { const session = new SessionMiddleware(window.localStorage); console.log("Middleware called"); context.set(sessionRouterContext, session); -} \ No newline at end of file +}; + +export const authGetUserMiddleware = async ({ context }: any) => { + const session: SessionMiddleware = context.get(sessionRouterContext); + + const user = await session.fetchUserIfAuthed() + .then((x) => + x?.ok + ? x.content + : ( + console.error("Error fetching authed user profile:", { + errorDescription: x?.errorDescription, + errorHeader: x?.errorHeader + }), + null + ) + ) + .catch((x) => (console.error("Error fetching authed user profile", x), null)); + + context.set(sessionUserRouterContext, user); +}; \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx new file mode 100644 index 0000000..e5ad50f --- /dev/null +++ b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx @@ -0,0 +1,28 @@ +import { Box, styled } from "@mui/joy"; +import { Group } from "components"; +import React from "react"; + +const Line = styled(`div`, { + name: "Line" +})(({ theme }) => ({ + position: "absolute", + border: `solid 3px ${theme.vars.palette.neutral[500]}`, + borderTop: 0, + borderRight: 0, + borderBottomLeftRadius: theme.vars.radius.lg, + height: "300%", + width: 30, + left: 20, + bottom: "50%", +})); + +export default function PostParentLine({ children, leftPadding, }: React.PropsWithChildren & { leftPadding?: number; }) { + return ( + + + + {children} + + + ) +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx new file mode 100644 index 0000000..ae4f44a --- /dev/null +++ b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx @@ -0,0 +1,148 @@ +import { Alert, Box, Stack, } from "@mui/joy"; +import { useState } from "react"; +import type { EitherUserPost, User, UserPost } from "types/user"; +import ProfileFeedPost from "../../layout/profile/ProfileFeedPost"; +import { IconExclamationCircleFilled } from "@tabler/icons-react"; +import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; +import { useSession } from "~/session"; +import ProfilePostCreator from "../../layout/profile/ProfilePostCreator"; +import PostParentLine from "./PostParentLine"; + +type Props = { + currentUser: User | null; + parentPost?: UserPost | undefined | null; + parentPostDeleted: boolean; + post: UserPost; +}; + +export default function ProfilePostView({ currentUser: user, post, parentPost, parentPostDeleted }: Props) { + const [replies, setReplies] = useState((post as EitherUserPost).replies); + const [currentPost, setCurrentPost] = useState(post); + const session = useSession(); + const { replyCount } = post as EitherUserPost; + + const onReply = (content: string) => { + const newPost = { + parentUri: post.uri, + content, + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return session.restClient?.createPost(newPost) + .then((x) => + x.ok + ? ( + setReplies([ + {...newPost, author: user!, replyCount: 0, uri: x.content.uri, indexedAt: new Date().toISOString() }, + ...replies + ]), + replyCount ? (post as EitherUserPost).replyCount++ : null + ) + : null + ); + } + + const onPostDeleted = (uri: string) => + session.restClient?.deletePost(uri) + .then((x) => x.ok) + .catch((e) => (console.error("Got an error while deleting a post", e), false)); + const onPostUpdated = (uri: string, content: string) => + session.restClient?.updatePost(uri, { content }) + .then((x) => x.ok) + .catch((e) => (console.error("Got an error while editing a post", e), false)); + + const onCommentDeleted = (uri: string) => + onPostDeleted(uri) + ?.then(() => setReplies(replies.filter((x) => x.uri != uri))); + const onCommentUpdated = (uri: string, content: string) => + onPostUpdated(uri, content) + ?.then(() => { + const postIndex = replies.findIndex((x) => x.uri === uri); + if (postIndex < 0) + return; + + // Update post in post list + const post = replies[postIndex]; + setReplies([...replies.slice(0, postIndex), { ...post, content }, ...replies.slice(postIndex + 1) ]) + }); + + const deletedParentCard = parentPostDeleted && ( + }> + This post has been deleted by the author. + + ); + const parentPostIfExists = parentPost && ( + onPostDeleted(uri)} + onPostUpdate={(uri: string, content: string) => onPostUpdated(uri, content)} + /> + ); + const mainPost = ( + onPostDeleted(uri)?.then((ok) => ok && (window.location.href = `profile/${post.author.did}`))} + onPostUpdate={(uri: string, content: string) => onPostUpdated(uri, content)?.then((ok) => ok && setCurrentPost({ ...post, content }))} + /> + ); + + const replyPadding = parentPostDeleted || parentPostIfExists ? 5 : 0; + + return ( + + ({ pt: 4, minHeight: "100%", pb: 16, backgroundColor: theme.vars.palette.background.level1 })}> + + + + + {/* + }>View user profile + */} + {deletedParentCard || parentPostIfExists} + {deletedParentCard || parentPostIfExists ? {mainPost} : mainPost} + + {/* + Comments ({replyCount ?? replies.length}) + */} + {user && + + + + } + + { + replies.length + ? replies.map((x) => ( + + + + )) + : <> + } + + + Come back later to see new comments! + + + + + + + + + // + // + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/route.tsx similarity index 53% rename from sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx rename to sites/app.campground.gg/src/routes/profile.($id).posts.$postId/route.tsx index be5e3e9..319ec2a 100644 --- a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx +++ b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/route.tsx @@ -1,20 +1,21 @@ -import type { Route } from "./+types/profile.($id).posts.$postId"; +import type { Route } from "./+types/route"; import { Typography } from "@mui/joy"; import { redirect } from "react-router"; -import ProfilePostView from "~/layout/profile/ProfilePostView"; -import { authMiddleware } from "~/middleware/auth"; +import ProfilePostView from "~/routes/profile.($id).posts.$postId/ProfilePostView"; +import { authGetUserMiddleware, authMiddleware } from "~/middleware/auth"; import { loginRequiredMiddleware } from "~/middleware/login"; -import { sessionRouterContext } from "~/session"; +import { sessionRouterContext, sessionUserRouterContext } from "~/session"; export function meta({ loaderData }: Route.MetaArgs) { return [ - { title: `Campground — ${loaderData.ok ? loaderData.user!.displayName : `Profile`}` }, - { name: "description", content: loaderData.ok ? loaderData.user!.tagline : "Gather around the fire, friends" }, + { title: `Campground — ${loaderData.ok ? loaderData.post!.author.displayName : `Profile`}` }, + { name: "description", content: loaderData.ok ? loaderData.post!.author.tagline : "Gather around the fire, friends" }, ]; } export const clientMiddleware: Route.ClientMiddlewareFunction[] = [ authMiddleware, + authGetUserMiddleware, loginRequiredMiddleware, ]; @@ -24,6 +25,7 @@ export async function clientLoader({ context, params: { id, postId } }: Route.Cl throw redirect("/profile"); const session = context.get(sessionRouterContext); + const user = context.get(sessionUserRouterContext); // Can't fetch if (!session.restClient) @@ -35,8 +37,9 @@ export async function clientLoader({ context, params: { id, postId } }: Route.Cl // const userRequest = await session.restClient.fetchProfile(id); const postRequest = await session.restClient.fetchPost(id, postId); console.log(postRequest); - + const { errorDescription, errorHeader, content, ok, status } = postRequest; + const parentPostRequest = ok && content.parentUri ? await session.restClient.fetchPost(id, content.parentUri.split("/")[4]) : null; return { id, @@ -44,16 +47,18 @@ export async function clientLoader({ context, params: { id, postId } }: Route.Cl errorHeader, errorDescription, ok, - user: content?.author, + currentUser: user, post: content, + parentPostDeleted: parentPostRequest?.status === 404, + parentPost: parentPostRequest?.content, }; } clientLoader.hydrate = true as const; -export default function ProfilePosts_Id({ loaderData: { ok, errorHeader, errorDescription, status, user, post } }: Route.ComponentProps) { +export default function ProfilePosts_Id({ loaderData: { ok, errorHeader, status, currentUser, post, parentPostDeleted, parentPost } }: Route.ComponentProps) { return ( ok - ? - : Error {status} + ? + : Error {errorHeader ?? status} ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/session/SessionMiddleware.ts b/sites/app.campground.gg/src/session/SessionMiddleware.ts index c69262f..f52de95 100644 --- a/sites/app.campground.gg/src/session/SessionMiddleware.ts +++ b/sites/app.campground.gg/src/session/SessionMiddleware.ts @@ -35,4 +35,8 @@ export default class SessionMiddleware { this.restClient = this.auth.authenticated ? new RESTClient({ auth: this.auth.user.accessJwt, refreshAuth: this.auth.user.refreshJwt, userDid: this.auth.user.did }, onRefresh) : null; } + + async fetchUserIfAuthed() { + return this.auth.authenticated ? await this.restClient!.fetchProfile(this.auth.user.did) : null; + } } \ No newline at end of file diff --git a/sites/app.campground.gg/src/session/index.ts b/sites/app.campground.gg/src/session/index.ts index 0e482c6..5321766 100644 --- a/sites/app.campground.gg/src/session/index.ts +++ b/sites/app.campground.gg/src/session/index.ts @@ -4,8 +4,10 @@ import { type Session } from './types'; import { createContext, useContext } from 'react'; import { createContext as createRouterContext } from 'react-router'; import SessionMiddleware from './SessionMiddleware'; +import type { User } from 'types/user'; export const SessionContext = createContext(null!); export const sessionRouterContext = createRouterContext(null!); +export const sessionUserRouterContext = createRouterContext(null!); export const useSession = () => useContext(SessionContext); \ No newline at end of file diff --git a/sites/app.campground.gg/types/user.ts b/sites/app.campground.gg/types/user.ts index fd5ecf9..bab98a1 100644 --- a/sites/app.campground.gg/types/user.ts +++ b/sites/app.campground.gg/types/user.ts @@ -14,6 +14,7 @@ export interface User { } export interface UserPost { uri: string; + parentUri?: string | null; content: string; tags: string[]; createdAt: string; From 9378d022780f47555b1498bb189aa8d40e2f3e6e Mon Sep 17 00:00:00 2001 From: PrettyGoodName <31628630+IdkGoodName@users.noreply.github.com> Date: Thu, 11 Dec 2025 20:43:12 +0200 Subject: [PATCH 7/7] feat: the ability to view user's replies to posts in the profile, thread line component fix, add error boundaries, fix up profile feeds --- packages/components/theme/index.ts | 23 ++- sites/app.campground.gg/api/RESTClient.ts | 32 +++- .../src/components/Datestamp.tsx | 13 +- .../src/components/ErrorBoundary.tsx | 70 +++++++++ .../app.campground.gg/src/components/Link.tsx | 21 +-- .../src/components/ThreadLine.tsx | 75 +++++++++ .../src/layout/profile/ProfileFeed.tsx | 88 +++++++---- .../src/layout/profile/ProfileFeedPost.tsx | 145 +++++------------- .../src/layout/profile/ProfileLayout.tsx | 2 +- .../src/layout/profile/ProfilePost.tsx | 124 +++++++++++++++ .../src/layout/profile/ProfilePostCreator.tsx | 3 +- .../src/layout/profile/ProfileView.tsx | 42 +++-- .../src/routes/profile.$id.tsx | 8 +- .../PostParentLine.tsx | 28 ---- .../ProfilePostView.tsx | 112 ++++++++------ sites/app.campground.gg/src/util/RestError.ts | 13 ++ sites/app.campground.gg/types/user.ts | 7 +- 17 files changed, 540 insertions(+), 266 deletions(-) create mode 100644 sites/app.campground.gg/src/components/ErrorBoundary.tsx create mode 100644 sites/app.campground.gg/src/components/ThreadLine.tsx create mode 100644 sites/app.campground.gg/src/layout/profile/ProfilePost.tsx delete mode 100644 sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx create mode 100644 sites/app.campground.gg/src/util/RestError.ts diff --git a/packages/components/theme/index.ts b/packages/components/theme/index.ts index 3003652..dd5dc7d 100644 --- a/packages/components/theme/index.ts +++ b/packages/components/theme/index.ts @@ -37,7 +37,28 @@ const theme = extendTheme({ boxShadow: theme.vars.shadow.sm, }), } - } + }, + JoyTabList: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: theme.vars.palette.background.body, + borderRadius: theme.vars.radius.md, + borderBottom: "none", + }) + } + }, + JoyTab: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: theme.vars.radius.md, + borderBottom: "none", + flex: 1, + "::after": { + display: "none", + } + }) + } + }, } }); diff --git a/sites/app.campground.gg/api/RESTClient.ts b/sites/app.campground.gg/api/RESTClient.ts index b86c079..c7a045f 100644 --- a/sites/app.campground.gg/api/RESTClient.ts +++ b/sites/app.campground.gg/api/RESTClient.ts @@ -1,6 +1,6 @@ import { defaultXrpcPrefix, defaultAppApiUrl, defaultBackendDomain } from "api.config"; import type { RestResponseError, RestResponseOkWithContent, RestResponseWithContent } from "./RESTResponse"; -import type { User, UserPostBasic, UserPostDetailed } from "types/user"; +import type { User, UserPostBasic, UserPostDetailed, UserPostParented } from "types/user"; import type { RESTRefreshLogin } from "./RESTErrorHandler"; import type { SessionAuthRefresh } from "~/session/types"; import type { AtprotoRecord, AtprotoValueBase, GetRecordListResponse, PutRecordResponse } from "types/record"; @@ -168,20 +168,25 @@ export default class RESTClient { }); } - fetchPosts(actor: string) { - return this.get<{ posts: UserPostBasic[] }>({ + fetchPosts(actor: string, replies: boolean = false, offset: number = 0) { + return this.get<{ posts: UserPostParented[] }>({ route: `gg.campground.profile.getPosts`, queries: { - uri: `at://${actor}/gg.campground.profile.post`, + actor: actor, + limit: "50", + offset: offset.toString(), + replies: replies.toString() }, }); } - fetchPostReplies(actor: string, post_tid: string) { + fetchPostReplies(actor: string, post_tid: string, offset: number = 0) { return this.get<{ posts: UserPostBasic[] }>({ - route: `gg.campground.profile.getPosts`, + route: `gg.campground.profile.getReplies`, queries: { uri: `at://${actor}/gg.campground.profile.post/${post_tid}`, + limit: "50", + offset: offset.toString(), }, }); } @@ -195,6 +200,15 @@ export default class RESTClient { }); } + unindexPost(uri: string) { + return this.post({ + route: `gg.campground.profile.unindexPost`, + queries: { + uri, + }, + }); + } + createPost(record: { parentUri?: string | undefined; content: string; tags: string[]; createdAt: string; updatedAt: string; }) { return this.putRecord({ repo: this._config.userDid, @@ -226,6 +240,10 @@ export default class RESTClient { collection: "gg.campground.profile.post", // at://did:.../gg.campground.profile.post/... rkey: uri.split("/")[4], - }); + }) + .then((a) => + this.unindexPost(uri) + .then(() => a) + ); } } \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/Datestamp.tsx b/sites/app.campground.gg/src/components/Datestamp.tsx index ea9e2d0..d97834e 100644 --- a/sites/app.campground.gg/src/components/Datestamp.tsx +++ b/sites/app.campground.gg/src/components/Datestamp.tsx @@ -3,22 +3,19 @@ import ms from "ms"; type Props = { date: Date; + long?: boolean; noAgo?: boolean; displayDate?: boolean; }; const DateTooltip = styled(Tooltip, { slot: "tooltip", -})((theme) => ({ - -})); +})(); const DatestampText = styled(Typography, { slot: "text", -})((theme) => ({ - -})); +})(); -export default function Datestamp({ noAgo, displayDate, date }: Props) { +export default function Datestamp({ noAgo, displayDate, date, long }: Props) { const isInvalid = !date || Number.isNaN(date.getSeconds()); if (isInvalid) @@ -30,7 +27,7 @@ export default function Datestamp({ noAgo, displayDate, date }: Props) { ); - const time = `${ms(Date.now() - date.getTime(), { long: true })} ${noAgo ? "" : "ago"}`; + const time = `${ms(Date.now() - date.getTime(), { long: long ?? false })} ${noAgo ? "" : "ago"}`; const dateFormat = date.toLocaleDateString("en-US"); return ( diff --git a/sites/app.campground.gg/src/components/ErrorBoundary.tsx b/sites/app.campground.gg/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..fd28690 --- /dev/null +++ b/sites/app.campground.gg/src/components/ErrorBoundary.tsx @@ -0,0 +1,70 @@ +import React, { PropsWithChildren } from "react"; +import RestError from "~/util/RestError"; +import PagePlaceholder, { PagePlaceholderIcon } from "./PagePlaceholder"; +import { Typography } from "@mui/joy"; + +interface ErrorBoundaryError { + message: string; + header: string; + status: number | null; +} +type State = { + error: ErrorBoundaryError | null; +}; + +export default class ErrorBoundary extends React.Component { + constructor(props: PropsWithChildren) { + super(props); + this.state = { error: null }; + } + componentDidCatch(error: Error, _errorInfo: React.ErrorInfo): void { + return this.setState(ErrorBoundary.getDerivedStateFromError(error)); + } + render() { + const { error } = this.state; + + if (error) + return ( + + {error.status ? {error.status} : null} + {error.header} + + }> + {error.message} + + ); + + const { children } = this.props; + + const Renderer = () => { + return children; + } + + try { + return ( + + ); + } catch(e) { + console.log("C"); + } + } + static getDerivedStateFromError(error: Error): { error: ErrorBoundaryError } { + if (error instanceof RestError) + return { + error: { + message: error.message, + header: error.header, + status: error.status, + } + }; + + return { + error: { + message: error.message, + header: error.name, + status: null, + } + }; + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/Link.tsx b/sites/app.campground.gg/src/components/Link.tsx index f66316c..8d3ff58 100644 --- a/sites/app.campground.gg/src/components/Link.tsx +++ b/sites/app.campground.gg/src/components/Link.tsx @@ -1,5 +1,6 @@ -import { Link as JoyLink, type LinkProps, styled } from "@mui/joy"; -import { Link as RouterLink } from "react-router"; +import { CircularProgress, Link as JoyLink, type LinkProps, styled } from "@mui/joy"; +import { useState } from "react"; +import { useNavigate } from "react-router"; const LinkRoot = styled(JoyLink, { slot: "root" @@ -7,19 +8,13 @@ const LinkRoot = styled(JoyLink, { })); -const LinkInner = styled(RouterLink, { - name: "JoyLink" -})(() => ({ - textDecoration: "inherit", - color: "inherit" -})); - export default function Link({ children, href, ...props }: LinkProps) { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + return ( - - - {children} - + : props.startDecorator} onClick={href ? () => (setLoading(true), navigate(href)) : undefined}> + {children} ) } \ No newline at end of file diff --git a/sites/app.campground.gg/src/components/ThreadLine.tsx b/sites/app.campground.gg/src/components/ThreadLine.tsx new file mode 100644 index 0000000..8a614d8 --- /dev/null +++ b/sites/app.campground.gg/src/components/ThreadLine.tsx @@ -0,0 +1,75 @@ +import { Box, Stack, styled } from "@mui/joy"; +import { Group } from "components"; +import React from "react"; + +const ThreadItemHook = styled(`div`, { + name: "Hook" +})(({ theme }) => ({ + position: "absolute", + border: `solid 3px ${theme.vars.palette.neutral[500]}`, + borderTop: 0, + borderRight: 0, + borderBottomLeftRadius: theme.vars.radius.lg, + height: "50%", + width: 30, + left: 20, + bottom: "50%", +})); +const ThreadItemLine = styled(`div`, { + name: "Line" +})(({ theme }) => ({ + position: "absolute", + border: `solid 3px ${theme.vars.palette.neutral[500]}`, + borderTop: 0, + borderRight: 0, + height: `calc(50% + ${theme.vars.radius.lg})`, + width: 3, + left: 20, + bottom: 0, +})); + +// const WrapperLine = styled(`div`, { +// name: "Line" +// })(({ theme }) => ({ +// // position: "absolute", +// backgroundColor: theme.vars.palette.neutral[500], +// borderTop: 0, +// borderRight: 0, +// height: "100%", +// width: 3, +// marginLeft: 25, +// // left: 20, +// })); +const ThreadLineItemWrapper = styled(Group)(() => ({ + position: "relative", + width: "100%", + "&:last-child .hook": { + opacity: 0, + } +})); + +export function ThreadLineItem({ children, top, }: React.PropsWithChildren & { top?: number; }) { + return ( + + + + + {children} + + + ) +} +export function ThreadLineWrapper({ children, }: { children: React.ReactElement[] }) { + return ( + + + {children[0]} + + + {/* + */} + {children.slice(1)} + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx index b1e3ff2..dc061eb 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx @@ -1,20 +1,39 @@ -import { Box, Stack, Typography } from "@mui/joy"; -import ProfileFeedPost from "./ProfileFeedPost"; -import type { User, UserPostBasic } from "types/user"; +import { Box, LinearProgress, Stack, Tab, TabList, Tabs } from "@mui/joy"; +import type { User, UserPostParented } from "types/user"; import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; import ProfilePostCreator from "~/layout/profile/ProfilePostCreator"; import { useSession } from "~/session"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import ProfileFeedPost from "./ProfileFeedPost"; +import { IconArticleFilled, IconFlameFilled } from "@tabler/icons-react"; +import RestError from "~/util/RestError"; type Props = { user: User; isSelf: boolean; - posts: UserPostBasic[]; }; -export default function ProfileFeed({ user, posts, isSelf }: Props) { +export default function ProfileFeed({ user, isSelf }: Props) { const session = useSession(); - const [postList, setPostList] = useState(posts); + const [isLoading, setIsLoading] = useState(true); + const [fetchReplies, setFetchReplies] = useState(false); + const [postList, setPostList] = useState([]); + const [error, setError] = useState(null); + + if (error) + throw error; + + useEffect(() => { + setIsLoading(true); + session.restClient?.fetchPosts(user.did, fetchReplies) + .then((posts) => { + if (posts.ok) + setPostList(posts.content.posts); + else + setError(new RestError(posts.errorDescription, posts.status, posts.errorHeader)); + setIsLoading(false); + }); + }, [fetchReplies]); const onPostCreated = (content: string) => { const newPost = { @@ -24,7 +43,7 @@ export default function ProfileFeed({ user, posts, isSelf }: Props) { updatedAt: new Date().toISOString(), }; return session.restClient?.createPost(newPost) - .then((x) => setPostList([{ ...newPost, author: user, replyCount: 0, uri: x.content!.uri, indexedAt: new Date().toISOString() } satisfies UserPostBasic, ...posts])) + .then((x) => setPostList([{ ...newPost, author: user, replyCount: 0, uri: x.content!.uri, indexedAt: new Date().toISOString(), parent: null } satisfies UserPostParented, ...postList])) .catch((e) => console.error("Got an error while making a post", e)); } const onPostDeleted = (uri: string) => { @@ -48,24 +67,41 @@ export default function ProfileFeed({ user, posts, isSelf }: Props) { return ( - Feed - {session.restClient && isSelf && } - - {postList.map((x, i) => - - )} - - - This user has no more posts to be found! Come back later! - + {/* Feed */} + setFetchReplies(Boolean(v))} size="lg" sx={{ mb: 2 }}> + + + + Feed + + + + Posts & Replies + + + + {!isLoading && !fetchReplies && session.restClient && isSelf && } + { + isLoading + ? + : <> + + {postList.map((x, i) => + + )} + + + This user has no more posts to be found! Come back later! + + + } ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx index b190e0a..cf66d58 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx @@ -1,121 +1,52 @@ -import { Card, CardContent, CardOverflow, Divider, ListItemContent, ListItemDecorator, MenuItem, Stack, Typography } from "@mui/joy"; +import { Alert, Box } from "@mui/joy"; import { useState } from "react"; -import UserDisplay from "~/components/UserDisplay"; -import { IconCornerUpRightDouble, IconMessage, IconMoodPlus, IconPencil, IconTrashFilled } from "@tabler/icons-react"; -import Datestamp from "~/components/Datestamp"; -import type { EitherUserPost, UserPost } from "types/user"; -import Link from "~/components/Link"; -import MarkdownWrapper from "~/components/markdown/MarkdownWrapper"; -import { LargeContentMarkdown } from "~/components/markdown/Markdown"; -import { keyframes } from "@emotion/react"; -import ContentOverflow from "~/components/content/ContentOverflow"; -import BasicPostEditor from "~/components/editor/BasicPostEditor"; -import { Group } from "components"; +import { IconTrashFilled } from "@tabler/icons-react"; +import type { EitherUserPost, UserPostParented } from "types/user"; +import ProfilePost, { appearAnimation } from "./ProfilePost"; +import { ThreadLineItem, ThreadLineWrapper } from "~/components/ThreadLine"; type Props = { appear?: boolean; - post: UserPost; - showComments?: boolean; - bigger?: boolean; + post: UserPostParented; isOwnPost?: boolean; + opacity?: number; onPostDelete: (uri: string) => void | Promise; onPostUpdate: (uri: string, content: string) => void | Promise; }; -const appearAnimation = keyframes` - 0% { - opacity: 0%; - transform: translateY(-20px); - } - 100% { - opacity: 100%; - transform: translateY(0px); - } -`; - -function ProfileFeedPostHeader({ bigger, author, createdAt }: { bigger: boolean; author: UserPost["author"], createdAt: Date }) { - return ( - - - {!bigger && } - {!bigger && } - - ); -} - export default function ProfileFeedPost(props: Props) { - const { showComments: showCommentsLink, bigger, appear, isOwnPost, onPostDelete, onPostUpdate } = props; - const { uri, content, createdAt, replies, replyCount, author } = props.post as EitherUserPost; - const postTid = uri.split("/")[4]; - const [editing, setEditing] = useState(false); - const createdAtDate = new Date(createdAt); + const { appear } = props; + const { parent, parentUri } = props.post as EitherUserPost; + const [parentPost, setParentPost] = useState(parent); - return ( - ({ boxShadow: bigger ? theme.vars.shadow.md : theme.vars.shadow.sm, animation: `${appearAnimation} ${appear ? 0.75 : 0}s`, zIndex: 2, })}> - - - - {isOwnPost && - setEditing(!editing)}> - - - - - Edit post - - - onPostDelete(uri)}> - - - - - Delete post - - - } - - - {editing - ? (onPostUpdate(uri, newContent), setEditing(false))} - onCancel={() => setEditing(false)} - confirmButton="Edit" + if (parentUri) + return ( + + + {parent + ? setParentPost({ ...parentPost!, content })} + onPostDelete={() => setParentPost(null)} /> - : ({ mt: bigger ? -5.5 : -5, color: theme.vars.palette.text.secondary })}> - {content} - } - {bigger && - - - - - - - - } - - - {showCommentsLink && }> - {replyCount ?? replies.length}{" "} - } - }> - {replyCount ?? replies.length}{" "} - - }> - {" "} - - - {/* - - {tags.map((tag, i) => {tag})} - - */} - - - - + : }>This post has been deleted.} + + + + + + ); + + return ( + + + ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx b/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx index 369ce16..4ea448b 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx @@ -27,7 +27,7 @@ export default class ProfileLayout extends React.Component { - + ({ border: `solid 4px ${theme.vars.palette.background.level1}` })} /> diff --git a/sites/app.campground.gg/src/layout/profile/ProfilePost.tsx b/sites/app.campground.gg/src/layout/profile/ProfilePost.tsx new file mode 100644 index 0000000..5352619 --- /dev/null +++ b/sites/app.campground.gg/src/layout/profile/ProfilePost.tsx @@ -0,0 +1,124 @@ +import { Card, CardContent, CardOverflow, Divider, ListItemContent, ListItemDecorator, MenuItem, Stack, Typography } from "@mui/joy"; +import { useState } from "react"; +import UserDisplay from "~/components/UserDisplay"; +import { IconMessage, IconPencil, IconTrashFilled } from "@tabler/icons-react"; +import Datestamp from "~/components/Datestamp"; +import type { EitherUserPost, UserPost } from "types/user"; +import Link from "~/components/Link"; +import MarkdownWrapper from "~/components/markdown/MarkdownWrapper"; +import { LargeContentMarkdown } from "~/components/markdown/Markdown"; +import { keyframes } from "@emotion/react"; +import ContentOverflow from "~/components/content/ContentOverflow"; +import BasicPostEditor from "~/components/editor/BasicPostEditor"; +import { Group } from "components"; + +type Props = { + appear?: boolean; + post: UserPost; + showComments?: boolean; + bigger?: boolean; + isOwnPost?: boolean; + opacity?: number; + mb?: number; + mt?: number; + onPostDelete: (uri: string) => void | Promise; + onPostUpdate: (uri: string, content: string) => void | Promise; +}; + +export const appearAnimation = keyframes` + 0% { + opacity: 0%; + transform: translateY(-20px); + } + 100% { + opacity: 100%; + transform: translateY(0px); + } +`; + +function ProfilePostHeader({ bigger, author, createdAt }: { bigger: boolean; author: UserPost["author"], createdAt: Date }) { + return ( + + + {!bigger && } + {!bigger && } + + ); +} + +export default function ProfilePost(props: Props) { + const { showComments: showCommentsLink, bigger, appear, isOwnPost, onPostDelete, onPostUpdate, opacity, mb, mt } = props; + const { uri, content, createdAt, replies, replyCount, author } = props.post as EitherUserPost; + const postTid = uri.split("/")[4]; + const [editing, setEditing] = useState(false); + const createdAtDate = new Date(createdAt); + + return ( + ({ mb, mt, opacity, boxShadow: bigger ? theme.vars.shadow.md : theme.vars.shadow.sm, animation: `${appearAnimation} ${appear ? 0.75 : 0}s`, zIndex: 2 })}> + + + + {isOwnPost && + setEditing(!editing)}> + + + + + Edit post + + + onPostDelete(uri)}> + + + + + Delete post + + + } + + + {editing + ? (onPostUpdate(uri, newContent), setEditing(false))} + onCancel={() => setEditing(false)} + confirmButton="Edit" + /> + : ({ mt: bigger ? -5.5 : -5, color: theme.vars.palette.text.secondary })}> + {content} + } + {bigger && + + + + + + + + } + + + {showCommentsLink && }> + {replyCount ?? replies.length}{" "} + } + {/* }> + {replyCount ?? replies.length}{" "} + */} + {/* }> + {" "} + */} + + {/* + + {tags.map((tag, i) => {tag})} + + */} + + + + + ); +} \ No newline at end of file diff --git a/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx b/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx index 8b01189..43f282d 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx @@ -5,6 +5,7 @@ import type { User } from "types/user"; import UserAvatar from "../../components/UserAvatar"; import { Group } from "components"; import BasicPostEditor from "~/components/editor/BasicPostEditor"; +import { IconPencil } from "@tabler/icons-react"; type Props = { user: User; @@ -27,7 +28,7 @@ export default function ProfilePostCreator({ user, placeholder, onPost, sx }: Pr (setOpen(false), onPost(content))} onCancel={() => setOpen(false)} sx={{ flex: 1 }} placeholder={finalPlaceholder} /> : setOpen(!open)} gap={1.5} color="neutral" startDecorator={}> - + }> {finalPlaceholder} diff --git a/sites/app.campground.gg/src/layout/profile/ProfileView.tsx b/sites/app.campground.gg/src/layout/profile/ProfileView.tsx index a2624b7..aded3d2 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileView.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileView.tsx @@ -1,35 +1,33 @@ import { Box, Stack } from "@mui/joy"; -import React from "react"; import ProfileFeed from "./ProfileFeed"; import ProfileAbout from "./ProfileAbout"; -import type { User, UserPostBasic } from "types/user"; +import type { User } from "types/user"; import ProfileGames from "./ProfileGames"; import ProfileLayout from "./ProfileLayout"; +import ErrorBoundary from "~/components/ErrorBoundary"; type Props = { user: User; - posts: UserPostBasic[]; isSelf: boolean; }; -export default class ProfileView extends React.Component { - render(): React.ReactNode { - const { user, posts, isSelf } = this.props; +export default function ProfileView({ user, isSelf }: Props) { - return ( - - - - - - - - - - - - - - ) - } + return ( + + + + + + + + + + + + + + + + ); } \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile.$id.tsx b/sites/app.campground.gg/src/routes/profile.$id.tsx index 7e4c242..b163218 100644 --- a/sites/app.campground.gg/src/routes/profile.$id.tsx +++ b/sites/app.campground.gg/src/routes/profile.$id.tsx @@ -28,8 +28,7 @@ export async function clientLoader({ context, params: { id } }: Route.ClientLoad }; const userRequest = await session.restClient.fetchProfile(id); - const postsRequest = await session.restClient.fetchPosts(id); - console.log(userRequest, postsRequest); + console.log(userRequest); const { errorDescription, errorHeader, content, ok, status } = userRequest; @@ -41,17 +40,16 @@ export async function clientLoader({ context, params: { id } }: Route.ClientLoad ok, user: content, isSelf: session.auth.authenticated && session.auth.user.did === content?.did, - posts: postsRequest.content?.posts!, }; } clientLoader.hydrate = true as const; -export default function Index({ loaderData: { status, ok, isSelf, user, posts, errorHeader, errorDescription } }: Route.ComponentProps) { +export default function Index({ loaderData: { status, ok, isSelf, user, errorHeader, errorDescription } }: Route.ComponentProps) { return ( ok - ? + ? : status === 404 ? There is no such user with that DID. Have you entered the wrong DID? diff --git a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx deleted file mode 100644 index e5ad50f..0000000 --- a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/PostParentLine.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Box, styled } from "@mui/joy"; -import { Group } from "components"; -import React from "react"; - -const Line = styled(`div`, { - name: "Line" -})(({ theme }) => ({ - position: "absolute", - border: `solid 3px ${theme.vars.palette.neutral[500]}`, - borderTop: 0, - borderRight: 0, - borderBottomLeftRadius: theme.vars.radius.lg, - height: "300%", - width: 30, - left: 20, - bottom: "50%", -})); - -export default function PostParentLine({ children, leftPadding, }: React.PropsWithChildren & { leftPadding?: number; }) { - return ( - - - - {children} - - - ) -} \ No newline at end of file diff --git a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx index ae4f44a..4fc6674 100644 --- a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx +++ b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx @@ -1,12 +1,13 @@ import { Alert, Box, Stack, } from "@mui/joy"; import { useState } from "react"; -import type { EitherUserPost, User, UserPost } from "types/user"; -import ProfileFeedPost from "../../layout/profile/ProfileFeedPost"; +import type { EitherUserPost, User, UserPost, UserPostBasic } from "types/user"; +import ProfilePost from "../../layout/profile/ProfilePost"; import { IconExclamationCircleFilled } from "@tabler/icons-react"; import PagePlaceholder, { PagePlaceholderIcon } from "~/components/PagePlaceholder"; import { useSession } from "~/session"; import ProfilePostCreator from "../../layout/profile/ProfilePostCreator"; -import PostParentLine from "./PostParentLine"; +import { ThreadLineItem, ThreadLineWrapper } from "../../components/ThreadLine"; +import type { Session } from "~/session/types"; type Props = { currentUser: User | null; @@ -15,6 +16,39 @@ type Props = { post: UserPost; }; +type RepliesProps = { + currentUser: User | null; + post: UserPost; + replies: UserPostBasic[]; + onReply: (content: string) => Promise | undefined; + onCommentUpdated: (uri: string, content: string) => Promise | undefined; + onCommentDeleted: (uri: string) => Promise | undefined; + session: Session; +}; + +function ProfilePostViewReplies({ session, currentUser, onReply, onCommentUpdated, onCommentDeleted, replies }: RepliesProps) { + return [ + currentUser && + + + , + replies.length && replies.map((x) => ( + + + + )) + ].filter((x) => x); +} + export default function ProfilePostView({ currentUser: user, post, parentPost, parentPostDeleted }: Props) { const [replies, setReplies] = useState((post as EitherUserPost).replies); const [currentPost, setCurrentPost] = useState(post); @@ -73,16 +107,21 @@ export default function ProfilePostView({ currentUser: user, post, parentPost, p ); const parentPostIfExists = parentPost && ( - onPostDeleted(uri)} - onPostUpdate={(uri: string, content: string) => onPostUpdated(uri, content)} - /> + + onPostDeleted(uri)} + onPostUpdate={(uri: string, content: string) => onPostUpdated(uri, content)} + /> + ); + const anyParentPost = deletedParentCard || parentPostIfExists; + const mainPost = ( - ); - const replyPadding = parentPostDeleted || parentPostIfExists ? 5 : 0; - return ( ({ pt: 4, minHeight: "100%", pb: 16, backgroundColor: theme.vars.palette.background.level1 })}> @@ -103,39 +140,22 @@ export default function ProfilePostView({ currentUser: user, post, parentPost, p {/* }>View user profile */} - {deletedParentCard || parentPostIfExists} - {deletedParentCard || parentPostIfExists ? {mainPost} : mainPost} - - {/* - Comments ({replyCount ?? replies.length}) - */} - {user && - - - - } - - { - replies.length - ? replies.map((x) => ( - - - - )) - : <> - } - - - Come back later to see new comments! - - + {anyParentPost} + + {mainPost} + + + + Come back later to see new comments! + diff --git a/sites/app.campground.gg/src/util/RestError.ts b/sites/app.campground.gg/src/util/RestError.ts new file mode 100644 index 0000000..cf1884d --- /dev/null +++ b/sites/app.campground.gg/src/util/RestError.ts @@ -0,0 +1,13 @@ +export default class RestError extends Error { + #header: string | null; + status: number | null; + constructor(message: string, status: number | null = null, header: string | null = null) { + super(message); + this.#header = header; + this.status = status; + this.name = "RestError"; + } + get header() { + return this.#header ?? (this.status ? `Error ${this.status}` : null) ?? this.message; + } +} \ No newline at end of file diff --git a/sites/app.campground.gg/types/user.ts b/sites/app.campground.gg/types/user.ts index bab98a1..e73a1a2 100644 --- a/sites/app.campground.gg/types/user.ts +++ b/sites/app.campground.gg/types/user.ts @@ -22,10 +22,15 @@ export interface UserPost { updatedAt: string | null; author: User; }; +export interface UserPostWithParent extends UserPost { + parent: UserPostBasic | null; +} export interface UserPostBasic extends UserPost { replyCount: number; }; -export interface UserPostDetailed extends UserPost { +export interface UserPostParented extends UserPostWithParent, UserPostBasic { +}; +export interface UserPostDetailed extends UserPostWithParent { replies: UserPostBasic[]; }; export interface EitherUserPost extends UserPostBasic, UserPostDetailed {