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..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, @@ -27,7 +28,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/index.ts b/packages/components/theme/index.ts index 056d781..dd5dc7d 100644 --- a/packages/components/theme/index.ts +++ b/packages/components/theme/index.ts @@ -30,7 +30,35 @@ const theme = extendTheme({ transition: "background 0.3s", } }, - } + }, + JoyCard: { + styleOverrides: { + root: ({ theme }) => ({ + 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/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..c7a045f 100644 --- a/sites/app.campground.gg/api/RESTClient.ts +++ b/sites/app.campground.gg/api/RESTClient.ts @@ -1,8 +1,9 @@ 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, UserPostParented } 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,18 @@ 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 }); + } + 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 }); } @@ -152,4 +167,83 @@ export default class RESTClient { }, }); } + + fetchPosts(actor: string, replies: boolean = false, offset: number = 0) { + return this.get<{ posts: UserPostParented[] }>({ + route: `gg.campground.profile.getPosts`, + queries: { + actor: actor, + limit: "50", + offset: offset.toString(), + replies: replies.toString() + }, + }); + } + + fetchPostReplies(actor: string, post_tid: string, offset: number = 0) { + return this.get<{ posts: UserPostBasic[] }>({ + route: `gg.campground.profile.getReplies`, + queries: { + uri: `at://${actor}/gg.campground.profile.post/${post_tid}`, + limit: "50", + offset: offset.toString(), + }, + }); + } + + fetchPost(actor: string, post_tid: string) { + return this.get({ + route: `gg.campground.profile.getPost`, + queries: { + uri: `at://${actor}/gg.campground.profile.post/${post_tid}`, + }, + }); + } + + 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, + collection: "gg.campground.profile.post", + rkey: "", + record: { + ...record, + "$type": "gg.campground.profile.post", + }, + }); + } + + 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], + }) + .then((a) => + this.unindexPost(uri) + .then(() => a) + ); + } } \ 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 04b70a2..47c35d1 100644 --- a/sites/app.campground.gg/package-lock.json +++ b/sites/app.campground.gg/package-lock.json @@ -18,12 +18,14 @@ "@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", + "@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-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", @@ -32,6 +34,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 +1255,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 +1483,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 +2118,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 +2437,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 +2534,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 +2543,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 +2575,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 +2587,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 +2999,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 +3446,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 +3526,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 +3644,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 +3704,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 +4114,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 +4668,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 +4844,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 +4871,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 +5022,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 +5082,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 +5383,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 +6331,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 +6639,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 +6662,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 +7041,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 +7096,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 +7294,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 +7616,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 +7820,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 +7965,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 +8264,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..29beb8a 100644 --- a/sites/app.campground.gg/package.json +++ b/sites/app.campground.gg/package.json @@ -20,12 +20,14 @@ "@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", + "@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-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", @@ -34,6 +36,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/Datestamp.tsx b/sites/app.campground.gg/src/components/Datestamp.tsx index 63cef8b..d97834e 100644 --- a/sites/app.campground.gg/src/components/Datestamp.tsx +++ b/sites/app.campground.gg/src/components/Datestamp.tsx @@ -3,23 +3,20 @@ 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) { - const isInvalid = Number.isNaN(date.getSeconds()); +export default function Datestamp({ noAgo, displayDate, date, long }: Props) { + const isInvalid = !date || Number.isNaN(date.getSeconds()); if (isInvalid) return ( @@ -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/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 5a9c0ac..f816010 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 | "xl"; + 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/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/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..b2fab22 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BasicPostEditor.tsx @@ -0,0 +1,36 @@ +import { Link, Stack } 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 new file mode 100644 index 0000000..39f4eb4 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BlockNodeInsert.tsx @@ -0,0 +1,29 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { EditorBlockElementType, EditorItemElementType } from "../../editor/editor"; + +type Props = { + format: EditorBlockElementType | EditorItemElementType; + 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/BlockNodeMenuItem.tsx b/sites/app.campground.gg/src/components/editor/BlockNodeMenuItem.tsx new file mode 100644 index 0000000..eb28ebe --- /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 { EditorBlockElementType } from "../../editor/editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: EditorBlockElementType; + 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 new file mode 100644 index 0000000..2cba810 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BlockNodeToggle.tsx @@ -0,0 +1,30 @@ +import { IconButton } from "@mui/joy"; +import { ReactNode } from "react"; +import { useSlate } from "slate-react"; +import type { RichEditor, EditorBlockElementType } from "../../editor/editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: T; + children: ReactNode[] | ReactNode; + onClick?: (editor: RichEditor, format: T) => void; +}; + +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(); + toggleFormattingFn(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..7faeb4f --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/BlockTextEditor.tsx @@ -0,0 +1,95 @@ +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 type { RichEditor } from "../../editor/editor"; +import EditorLeaf from "./EditorLeaf"; +import EditorElement from "./EditorElement"; +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, { + 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({ defaultValue, editor, sx, placeholder }: Props) { + const blockDecorate = useBlockDecorate(); + + return ( + + + + + + + + + + + + + + { + 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..1f35579 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/CampgroundEditor.tsx @@ -0,0 +1,245 @@ +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) { + 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 getSelectedNodes(editor: RichEditor, type?: EditorElementType): 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) && (!type || n.type === type), + }) + ); + } + static isTextFormatted(editor: RichEditor, type: keyof EditorTextFormatting) { + const marks = Editor.marks(editor); + return (marks?.[type] as boolean | null) ?? false; + } + static toggleBlockFormatting(editor: RichEditor, type: EditorBlockElementType, additionalProps?: any) { + 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; + + Transforms.setNodes(editor, { + type: active ? `paragraph` : type, + ...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, { + type: type, + ...additionalProps, + }); + } + static toggleInlineFormatting(editor: RichEditor, type: EditorInlineElementType) { + const active = CampgroundEditor.isNodeFormatted(editor, type); + + if (active) + return Transforms.unwrapNodes(editor, { + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + (EditorInlineElementType as readonly string[]).includes(n.type) + }); + + Transforms.wrapNodes( + editor, + { + type: "link", + url: "#", + children: [] + }, + { + match: (n) => + !Editor.isEditor(n) && + ((Element.isElement(n) && (EditorInlineElementType as readonly string[]).includes(n.type)) || Text.isText(n)) + } + ); + } + static toggleListFormatting(editor: RichEditor, type: EditorBlockElementType, itemType: EditorItemElementType) { + const active = CampgroundEditor.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: EditorBlockElementType, itemType: EditorItemElementType) { + const active = CampgroundEditor.isNodeFormatted(editor, type); + const activeElems = CampgroundEditor.getSelectedNodes(editor); + + console.log(activeElems); + + // Simple type change + Transforms.setNodes( + editor, + { + type: active ? `paragraph` : itemType, + }, + { + match: n => Element.isElement(n), + split: true, + } + ); + + if (active) + Transforms.unwrapNodes( + editor, + { + match: n => Element.isElement(n) && n.type === type + } + ) + else + Transforms.wrapNodes( + editor, + { + type, + children: [], + lang: "js", + }, + { + match: n => Element.isElement(n) && n.type === itemType + } + ); + } + 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/CodeBlockEditorHeader.tsx b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx new file mode 100644 index 0000000..5bce0ea --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/CodeBlockEditorHeader.tsx @@ -0,0 +1,45 @@ +import { Option, Select } from "@mui/joy"; +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: EditorCodeBlock; +}; + +export default function CodeBlockEditorHeader({ element }: Props) { + const editor = useSlateStatic(); + + const onValueSelected = (_: unknown, language: string | null) => { + Transforms.setNodes( + editor, + { + lang: 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..ea4e3ac --- /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 { EditorBlockElementType, EditorItemElementType } from "../../editor/editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: EditorBlockElementType; + itemFormat: EditorItemElementType; + 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..2ff443c --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/EditorElement.tsx @@ -0,0 +1,154 @@ +import { type RenderElementProps } from "slate-react"; +import type { EditorElementType, EditorCodeBlock, EditorCodeLine, EditorHeading, EditorLink } from "../../editor/editor"; +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 }) { + return

{children}

+ }, + divider({ attributes }) { + return
; + }, + heading({ attributes, children, element }) { + const Tag = `h${(element as EditorHeading).depth ?? 1}` as "h1"; + return ( + + {children} + + ); + }, + link({ children, element }) { + const link = element as EditorLink; + + return ( + + {children} + + ); + }, + ["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} +
  • + ); + }, + ["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 ( + // + // {children} + // + // ); + // }, +} + +export default function EditorElement({ attributes, children, element }: RenderElementProps) { + const nodeType = element.type; + const Renderer = typeToRenderer[nodeType]; + + 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..f02bed7 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/EditorLeaf.tsx @@ -0,0 +1,40 @@ +import { styled, Typography } from "@mui/joy"; +import type { RenderLeafProps } from "slate-react"; + +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", + }, + "&.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..7e7fc3d --- /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 { EditorInlineElementType } from "../../editor/editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: EditorInlineElementType; + 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..e20d193 --- /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 { EditorBlockElementType, EditorItemElementType } from "../../editor/editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: EditorBlockElementType; + itemFormat: EditorItemElementType; + 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..070bf3e --- /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 { EditorTextFormatting } from "../../editor/editor"; +import CampgroundEditor from "./CampgroundEditor"; + +type Props = { + format: keyof EditorTextFormatting; + 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/editor/RichEditorToolbar.tsx b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx new file mode 100644 index 0000000..eddd514 --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/RichEditorToolbar.tsx @@ -0,0 +1,154 @@ +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, 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; +} + +export function RichEditorToolbarInlineFormatting() { + return ( + + + + + + + + + + + + + + + + + {/* + + */} + + ); +} + +export function RichEditorToolbarBlockFormatting() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +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 ( + + + + + + + + + + + + + + + + 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} + + ) +} \ No newline at end of file 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/codeEditorContext.tsx b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx new file mode 100644 index 0000000..cf0c34e --- /dev/null +++ b/sites/app.campground.gg/src/components/editor/codeEditorContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react"; +import type { EditorCodeLine } from "../../editor/editor"; + +export const CodeEditorContext = createContext([]); +export const useCodeEditorContext = () => useContext(CodeEditorContext); + +export function CodeEditorContextProvider({ codeLines, children }: React.PropsWithChildren & { codeLines: EditorCodeLine[] }) { + 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/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/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 6d46b07..ef4575b 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%", @@ -26,16 +28,39 @@ 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, + }, + "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, 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/block-decorate.tsx b/sites/app.campground.gg/src/editor/block-decorate.tsx new file mode 100644 index 0000000..9b23723 --- /dev/null +++ b/sites/app.campground.gg/src/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 { EditorElementType, EditorCodeBlock } from "./editor"; +import CodeBlock, { linefyTokens } from "../components/markdown/CodeBlock"; + +const decorators: Partial DecoratedRange[]>> = { + ["code-block"]([node, path]) { + const content = Node.string(node); + + const { lang: language } = node as unknown as EditorCodeBlock; + + 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/editor/editor.ts b/sites/app.campground.gg/src/editor/editor.ts new file mode 100644 index 0000000..aab5dda --- /dev/null +++ b/sites/app.campground.gg/src/editor/editor.ts @@ -0,0 +1,58 @@ +import type { BaseEditor, BasePoint, BaseRange, Element, Range } from "slate"; +import type { HistoryEditor } from "slate-history"; +import type { ReactEditor } from "slate-react"; +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 & + { + nodeToDecorations?: Map + }; + +export interface EditorPoint extends BasePoint { + lineOffset?: number; +} + +declare module 'slate' { + interface CustomTypes { + Editor: RichEditor; + Element: EditorElement; + Text: EditorText; + Point: EditorPoint; + Range: BaseRange & { + [key: string]: unknown + } + } +} \ No newline at end of file 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..09ea380 --- /dev/null +++ b/sites/app.campground.gg/src/editor/element.ts @@ -0,0 +1,112 @@ +import type { Text } from "slate"; +import type { EditorText, EditorTextUnformatted } from "./text"; + +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. +*/ +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", "table-row", "table-cell"] 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", + "table-cell": "table-row", + "table-row": "table", +}; + +// 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 BlockAlignment = "left" | "center" | "right"; +export interface EditorTable extends EditorBlockElementBase<"table", EditorTableRow> { + align?: BlockAlignment[] | undefined | null; +} + +export type EditorBlockElement = + EditorParagraph | + EditorHeading | + EditorBlockQuote | + EditorDivider | + EditorCodeBlock | + EditorUnorderedList | + 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 | + 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/keyboard-logic.ts b/sites/app.campground.gg/src/editor/keyboard-logic.ts new file mode 100644 index 0000000..abea0d7 --- /dev/null +++ b/sites/app.campground.gg/src/editor/keyboard-logic.ts @@ -0,0 +1,141 @@ +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 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); + + if (!isElement) + return; + + const elementString = Node.string(elementAbove); + const newlines = getNewlineIndexes(elementString).map((x) => x + 1); + + const currentSelection = editor.selection; + const selectionOffset = currentSelection?.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 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 + const abovePath = above![1]; + const aboveParent = getParentPath(abovePath); + + const sequentialItemSettings = { at: abovePath, match: (node: Node) => !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 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; + + // 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( + { + type: "paragraph", + children: [], + }, + { + at: newNodePath, + }, + ); + return selectWithOptionalShift( + editor, + currentSelection!, + shift, + { + path: [...newNodePath, 0], + offset: 0, + lineOffset: 0 + } + ); +} + +export const editorKeyboardLogic: Record) => void> = { + ArrowUp(editor: RichEditor, event: React.KeyboardEvent) { + return ArrowVertical(editor, true, event.shiftKey); + }, + ArrowDown(editor: RichEditor, event: React.KeyboardEvent) { + return ArrowVertical(editor, false, event.shiftKey); + }, + Enter(editor: RichEditor, event: React.KeyboardEvent) { + const above = editor.above(); + + // Override others for code blocks and list + if (above && Element.isElement(above[0]) && EditorItemElementType.includes(above[0].type as EditorItemElementType) && (!event.shiftKey || above[0].type === "code-line")) { + editor.insertNode({ type: above[0].type as EditorItemElementType, 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/editor/mdast/editor.ts b/sites/app.campground.gg/src/editor/mdast/editor.ts new file mode 100644 index 0000000..fb89d2c --- /dev/null +++ b/sites/app.campground.gg/src/editor/mdast/editor.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..8d46d47 --- /dev/null +++ b/sites/app.campground.gg/src/editor/mdast/element.ts @@ -0,0 +1,242 @@ +import { Element, Node } from "slate"; +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] | TableRow | TableCell> = { + 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"), }; + }, + 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[] }; + }, + ["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[] }; + } +}; + +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/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/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 new file mode 100644 index 0000000..1e14e26 --- /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); +} \ 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 new file mode 100644 index 0000000..346037c --- /dev/null +++ b/sites/app.campground.gg/src/editor/mdast/text.ts @@ -0,0 +1,42 @@ +import { Text as SlateText } from "slate"; +import type { PhrasingContentMap, RootContentMap } from "mdast"; +import type { EditorText, EditorTextFormatting } from "../text"; + +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 + ); +} + +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; + + 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 new file mode 100644 index 0000000..2e421ec --- /dev/null +++ b/sites/app.campground.gg/src/editor/utils.ts @@ -0,0 +1,26 @@ +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)]; + +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); + +export const paragraph: () => EditorBlockElementBase<"paragraph", Text> = () => ({ + type: "paragraph", + children: [ + { + text: "", + }, + ], +}); \ No newline at end of file diff --git a/sites/app.campground.gg/src/editor/withCgMarkdown.tsx b/sites/app.campground.gg/src/editor/withCgMarkdown.tsx new file mode 100644 index 0000000..970eeeb --- /dev/null +++ b/sites/app.campground.gg/src/editor/withCgMarkdown.tsx @@ -0,0 +1,138 @@ +import { Editor, Element, Point, Range, Transforms } from "slate"; +import { type RichEditor, EditorBlockElementType, EditorItemElementType } 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 +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 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); + + + const elem = nodePrefixes[beforeText + text]; + + if (!elem) + return false; + + Transforms.select(editor, range); + + if (!Range.isCollapsed(range)) + Transforms.delete(editor) + + const props: Partial = { + type: elem.type, + children: [] + }; + + Transforms.setNodes( + editor, + props, + { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + } + ); + + 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; + + if (!selection || Range.isCollapsed(selection)) + return false; + + const match = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + }); + + 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/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..dc061eb 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeed.tsx @@ -1,35 +1,107 @@ -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 { 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 { 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; }; -export default class ProfileFeed extends React.Component { - render(): React.ReactNode { - const { user } = this.props; - const posts = examplePosts.map((x) => ({ ...x, author: user, profileUser: user })) +export default function ProfileFeed({ user, isSelf }: Props) { + const session = useSession(); + const [isLoading, setIsLoading] = useState(true); + const [fetchReplies, setFetchReplies] = useState(false); + const [postList, setPostList] = useState([]); + const [error, setError] = useState(null); + + if (error) + throw error; - return ( - - Feed - - {posts.map((x) => - - )} - - - This user has no more posts to be found! Come back later! - - - ) + 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 = { + content, + tags: [], + createdAt: new Date().toISOString(), + 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(), parent: null } satisfies UserPostParented, ...postList])) + .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 */} + 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/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 04ca171..cf66d58 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileFeedPost.tsx @@ -1,63 +1,52 @@ -import { Card, CardContent, Chip, Stack, Typography } from "@mui/joy"; -import React from "react"; -import UserDisplay from "~/components/UserDisplay"; -import { IconMessage, IconShare } from "@tabler/icons-react"; -import Datestamp from "~/components/Datestamp"; -import type { UserPost } from "types/user"; -import Link from "~/components/Link"; -import MarkdownWrapper from "~/components/markdown/MarkdownWrapper"; -import { LargeContentMarkdown } from "~/components/markdown/Markdown"; +import { Alert, Box } from "@mui/joy"; +import { useState } from "react"; +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 = { - post: UserPost; - linkTitle?: boolean; + appear?: boolean; + post: UserPostParented; + isOwnPost?: boolean; + opacity?: number; + onPostDelete: (uri: string) => void | Promise; + onPostUpdate: (uri: string, content: string) => void | Promise; }; -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} - ); +export default function ProfileFeedPost(props: Props) { + const { appear } = props; + const { parent, parentUri } = props.post as EitherUserPost; + const [parentPost, setParentPost] = useState(parent); + if (parentUri) return ( - - - - - {linkTitle - ? {titleNode} - : titleNode - } - - {tags.map((tag, i) => {tag})} - - - - {content} - - {/* {content} */} - - - - - {/* {ms(Date.now() - createdAt, { long: true })} ago */} - - - - }> - {comments}{" "} - Comments - - }> - Share - - - - - - + + + {parent + ? setParentPost({ ...parentPost!, content })} + onPostDelete={() => setParentPost(null)} + /> + : }>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 867366d..4ea448b 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileLayout.tsx @@ -27,8 +27,8 @@ 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/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 new file mode 100644 index 0000000..43f282d --- /dev/null +++ b/sites/app.campground.gg/src/layout/profile/ProfilePostCreator.tsx @@ -0,0 +1,39 @@ +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 } from "components"; +import BasicPostEditor from "~/components/editor/BasicPostEditor"; +import { IconPencil } from "@tabler/icons-react"; + +type Props = { + user: User; + content?: string; + placeholder?: string; + sx?: SxProps; + onPost: (content: string) => void | Promise; +}; + +export default function ProfilePostCreator({ user, placeholder, onPost, sx }: Props) { + const [open, setOpen] = useState(false); + const finalPlaceholder = placeholder ?? "What are you thinking?"; + + return ( + + + {open + ? + + (setOpen(false), onPost(content))} onCancel={() => setOpen(false)} sx={{ flex: 1 }} placeholder={finalPlaceholder} /> + + : setOpen(!open)} gap={1.5} color="neutral" startDecorator={}> + }> + {finalPlaceholder} + + + } + + + ); +} \ No newline at end of file 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 610bc51..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 { User, UserPost, UserPostComment } 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"; - -type Props = { - user: User; - post: UserPost; - comments: UserPostComment[]; -}; - -export default class ProfilePostView extends React.Component { - render(): React.ReactNode { - const { user, post, comments } = this.props; - - return ( - - - - - - - }>Go back to the profile - - - - - Comments ({post.comments}) - - - { - comments.length - ? comments.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 ede2371..aded3d2 100644 --- a/sites/app.campground.gg/src/layout/profile/ProfileView.tsx +++ b/sites/app.campground.gg/src/layout/profile/ProfileView.tsx @@ -1,33 +1,33 @@ 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 ProfileLayout from "./ProfileLayout"; import ProfileGames from "./ProfileGames"; +import ProfileLayout from "./ProfileLayout"; +import ErrorBoundary from "~/components/ErrorBoundary"; type Props = { user: User; + isSelf: boolean; }; -export default class ProfileView extends React.Component { - render(): React.ReactNode { - const { user } = 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/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/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/_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 (
    ({ 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..b163218 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" }, ]; } @@ -38,15 +38,18 @@ 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, }; } 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, 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 deleted file mode 100644 index 24eeb0c..0000000 --- a/sites/app.campground.gg/src/routes/profile.($id).posts.$postId.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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) { - return [ - { title: "Campground — Camp" }, - { name: "description", content: "Gather around the fire, friends" }, - ]; -} - -export const clientMiddleware: Route.ClientMiddlewareFunction[] = [ - authMiddleware, - loginRequiredMiddleware, -]; - - -export async function clientLoader({ context, params: { id, postId } }: Route.ClientLoaderArgs) { - if (!id) - throw redirect("/profile"); - - const session = context.get(sessionRouterContext); - - // Can't fetch - if (!session.restClient) - return { - id, - status: 401, - }; - - const userRequest = await session.restClient.fetchProfile(id); - console.log(userRequest); - - const post = examplePosts.find((x) => x.id === postId); - - const { errorDescription, errorHeader, content, ok, status } = userRequest; - - return { - id, - status, - errorHeader, - errorDescription, - ok, - user: content, - post, - comments: exampleComments - }; -} -clientLoader.hydrate = true as const; - -export default function ProfilePosts_Id({ loaderData: { error, user, post, comments } }: Route.ComponentProps) { - return ( - error == null - ? ({ author: user!, ...x }))} /> - : Error {error} - ); -} \ 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..4fc6674 --- /dev/null +++ b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/ProfilePostView.tsx @@ -0,0 +1,168 @@ +import { Alert, Box, Stack, } from "@mui/joy"; +import { useState } from "react"; +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 { ThreadLineItem, ThreadLineWrapper } from "../../components/ThreadLine"; +import type { Session } from "~/session/types"; + +type Props = { + currentUser: User | null; + parentPost?: UserPost | undefined | null; + parentPostDeleted: boolean; + 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); + 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 anyParentPost = deletedParentCard || parentPostIfExists; + + 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 }))} + /> + ); + + return ( + + ({ pt: 4, minHeight: "100%", pb: 16, backgroundColor: theme.vars.palette.background.level1 })}> + + + + + {/* + }>View user profile + */} + {anyParentPost} + + {mainPost} + + + + 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/route.tsx b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/route.tsx new file mode 100644 index 0000000..319ec2a --- /dev/null +++ b/sites/app.campground.gg/src/routes/profile.($id).posts.$postId/route.tsx @@ -0,0 +1,64 @@ +import type { Route } from "./+types/route"; +import { Typography } from "@mui/joy"; +import { redirect } from "react-router"; +import ProfilePostView from "~/routes/profile.($id).posts.$postId/ProfilePostView"; +import { authGetUserMiddleware, authMiddleware } from "~/middleware/auth"; +import { loginRequiredMiddleware } from "~/middleware/login"; +import { sessionRouterContext, sessionUserRouterContext } from "~/session"; + +export function meta({ loaderData }: Route.MetaArgs) { + return [ + { 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, +]; + + +export async function clientLoader({ context, params: { id, postId } }: Route.ClientLoaderArgs) { + if (!id) + throw redirect("/profile"); + + const session = context.get(sessionRouterContext); + const user = context.get(sessionUserRouterContext); + + // Can't fetch + if (!session.restClient) + return { + id, + status: 401, + }; + + // 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, + status, + errorHeader, + errorDescription, + ok, + currentUser: user, + post: content, + parentPostDeleted: parentPostRequest?.status === 404, + parentPost: parentPostRequest?.content, + }; +} +clientLoader.hydrate = true as const; + +export default function ProfilePosts_Id({ loaderData: { ok, errorHeader, status, currentUser, post, parentPostDeleted, parentPost } }: Route.ComponentProps) { + return ( + ok + ? + : Error {errorHeader ?? 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/src/session/SessionMiddleware.ts b/sites/app.campground.gg/src/session/SessionMiddleware.ts index d01a236..f52de95 100644 --- a/sites/app.campground.gg/src/session/SessionMiddleware.ts +++ b/sites/app.campground.gg/src/session/SessionMiddleware.ts @@ -33,6 +33,10 @@ 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; + } + + 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/src/session/provider.tsx b/sites/app.campground.gg/src/session/provider.tsx index a609d9e..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, }; @@ -38,10 +38,11 @@ export function SessionProvider({ children }: React.PropsWithChildren) { const login = async (details: AuthCredentials) => { const data = await RESTClient.login(details); + if (data.ok) { - setAuth(data.content); - setRestClient(new RESTClient({ auth: data.content.accessJwt, refreshAuth: data.content.refreshJwt }, refreshLogin)) + setAuth({ authenticated: true, user: data.content }); + 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/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/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 diff --git a/sites/app.campground.gg/types/user.ts b/sites/app.campground.gg/types/user.ts index 014f161..e73a1a2 100644 --- a/sites/app.campground.gg/types/user.ts +++ b/sites/app.campground.gg/types/user.ts @@ -13,18 +13,26 @@ export interface User { createdAt: string; } export interface UserPost { - id: string; - title: string; + uri: string; + parentUri?: string | null; 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 UserPostWithParent extends UserPost { + parent: UserPostBasic | null; +} +export interface UserPostBasic extends UserPost { + replyCount: number; +}; +export interface UserPostParented extends UserPostWithParent, UserPostBasic { +}; +export interface UserPostDetailed extends UserPostWithParent { + replies: UserPostBasic[]; +}; +export interface EitherUserPost extends UserPostBasic, UserPostDetailed { + +} \ No newline at end of file