diff --git a/apps/backend/api/src/api.ts b/apps/backend/api/src/api.ts index 36cf662e..79c74c6a 100644 --- a/apps/backend/api/src/api.ts +++ b/apps/backend/api/src/api.ts @@ -4,7 +4,7 @@ import { createOpenApiNodeHttpHandler, CreateOpenApiNodeHttpHandlerOptions } from "trpc-openapi/dist/adapters/node-http/core"; -import corsPlugin from "@fastify/cors"; +import corsPlugin, { OriginFunction } from "@fastify/cors"; import { OpenApiRouter } from "trpc-openapi"; import { AnyRouter } from "@trpc/server"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; @@ -55,36 +55,38 @@ const fastifyTRPCOpenApiPlugin = ( done(); }; const apiService = publicPlugin(async (fastify) => { + const originCallback: OriginFunction = (origin, callback) => { + if (!origin || origin === "null") { + callback(null, true); + + return; + } + + const { hostname } = new URL(origin); + const appHostname = new URL(fastify.config.PUBLIC_APP_URL).hostname; + + if ( + hostname === "localhost" || + hostname.endsWith(appHostname) || + (fastify.config.VRITE_CLOUD && hostname.endsWith("swagger.io")) + ) { + callback(null, true); + + return; + } + + callback(new Error("Not allowed"), false); + }; + await fastify.register(rateLimitPlugin, { max: 500, timeWindow: "1 minute", redis: fastify.redis }); await fastify.register(corsPlugin, { + origin: true, credentials: true, - methods: ["GET", "DELETE", "PUT", "POST"], - origin(origin, callback) { - if (!origin || origin === "null") { - callback(null, true); - - return; - } - - const { hostname } = new URL(origin); - const appHostname = new URL(fastify.config.PUBLIC_APP_URL).hostname; - - if ( - hostname === "localhost" || - hostname.endsWith(appHostname) || - (fastify.config.VRITE_CLOUD && hostname.endsWith("swagger.io")) - ) { - callback(null, true); - - return; - } - - callback(new Error("Not allowed"), false); - } + methods: ["GET", "DELETE", "PUT", "POST"] }); await fastify.register(fastifyTRPCOpenApiPlugin, { basePath: "/", diff --git a/apps/backend/api/src/index.ts b/apps/backend/api/src/index.ts index e3dab613..48f0092e 100644 --- a/apps/backend/api/src/index.ts +++ b/apps/backend/api/src/index.ts @@ -3,7 +3,14 @@ import { generateOpenApiDocument } from "trpc-openapi"; import { createServer, appRouter } from "@vrite/backend"; (async () => { - const server = await createServer(); + const server = await createServer({ + database: true, + pubSub: true, + auth: true, + email: true, + gitSync: true, + search: true + }); await server.register(apiService); server.get("/swagger.json", (req, res) => { @@ -11,7 +18,7 @@ import { createServer, appRouter } from "@vrite/backend"; generateOpenApiDocument(appRouter, { baseUrl: server.config.PUBLIC_API_URL, title: "Vrite API", - version: "0.2.0" + version: "0.3.0" }) ); }); diff --git a/apps/backend/app/src/app.ts b/apps/backend/app/src/app.ts index 6f9a2906..eeb77175 100644 --- a/apps/backend/app/src/app.ts +++ b/apps/backend/app/src/app.ts @@ -1,11 +1,10 @@ -import { appRouter, errors, publicPlugin, trpcPlugin } from "@vrite/backend"; +import { errors, publicPlugin, trpcPlugin, processAuth } from "@vrite/backend"; import staticPlugin from "@fastify/static"; import websocketPlugin from "@fastify/websocket"; import axios from "axios"; import viewPlugin from "@fastify/view"; import handlebars from "handlebars"; import { FastifyReply } from "fastify"; -import { processAuth } from "@vrite/backend/src/lib/auth"; import { nanoid } from "nanoid"; import multipartPlugin from "@fastify/multipart"; import mime from "mime-types"; @@ -15,7 +14,7 @@ import path from "path"; const appService = publicPlugin(async (fastify) => { const renderPage = async (reply: FastifyReply): Promise => { - return reply.view("index.html", { + return reply.header("X-Frame-Options", "SAMEORIGIN").view("index.html", { PUBLIC_APP_URL: fastify.config.PUBLIC_APP_URL, PUBLIC_API_URL: fastify.config.PUBLIC_API_URL, PUBLIC_COLLAB_URL: fastify.config.PUBLIC_COLLAB_URL, @@ -51,57 +50,6 @@ const appService = publicPlugin(async (fastify) => { fastify.setNotFoundHandler(async (_request, reply) => { return renderPage(reply); }); - fastify.get<{ Querystring: { url: string } }>("/proxy*", async (request, reply) => { - const filterOutRegex = - /(localhost|\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?::\d{0,4})?\b)/; - - if (request.headers.origin) { - reply.header("Access-Control-Allow-Origin", fastify.config.PUBLIC_APP_URL); - reply.header("Access-Control-Allow-Methods", "GET"); - reply.header( - "Access-Control-Allow-Headers", - request.headers["access-control-request-headers"] - ); - } else if ( - fastify.config.NODE_ENV !== "development" && - !fastify.config.PUBLIC_APP_URL.includes("localhost") - ) { - // Prevent proxy abuse in production - return reply.status(400).send("Invalid Origin"); - } - - if ( - filterOutRegex.test(request.query.url) && - !request.query.url.includes(fastify.config.PUBLIC_ASSETS_URL) - ) { - return reply.status(400).send("Invalid URL"); - } - - if (request.method === "OPTIONS") { - // CORS Preflight - reply.send(); - } else { - const targetURL = request.query.url; - - try { - const response = await axios.get(targetURL, { - responseType: "arraybuffer" - }); - - if (!`${response.headers["content-type"]}`.includes("image")) { - return reply.status(400).send("Invalid Content-Type"); - } - - reply.header("content-type", response.headers["content-type"]); - reply.send(Buffer.from(response.data, "binary")); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - - return reply.status(500).send("Could not fetch"); - } - } - }); fastify.post<{ Body: Buffer; }>("/upload", async (req, res) => { diff --git a/apps/backend/app/src/index.ts b/apps/backend/app/src/index.ts index 7f400ee7..00b95399 100644 --- a/apps/backend/app/src/index.ts +++ b/apps/backend/app/src/index.ts @@ -2,7 +2,15 @@ import { appService } from "./app"; import { createServer } from "@vrite/backend"; (async () => { - const server = await createServer(); + const server = await createServer({ + database: true, + pubSub: true, + auth: true, + email: true, + gitSync: true, + search: true, + storage: true + }); await server.register(appService); server.listen({ host: server.config.HOST, port: server.config.PORT }, (err) => { diff --git a/apps/backend/assets/src/api.ts b/apps/backend/assets/src/assets.ts similarity index 94% rename from apps/backend/assets/src/api.ts rename to apps/backend/assets/src/assets.ts index 6ea413e8..9a877335 100644 --- a/apps/backend/assets/src/api.ts +++ b/apps/backend/assets/src/assets.ts @@ -46,7 +46,10 @@ const assetsService = publicPlugin(async (fastify) => { await reply.header("Content-Type", sourceContentType).send(sourceAsset); }; - reply.header("Access-Control-Allow-Origin", fastify.config.PUBLIC_APP_URL); + reply.header( + "Access-Control-Allow-Origin", + fastify.config.NODE_ENV === "development" ? "*" : fastify.config.PUBLIC_APP_URL + ); reply.header("Access-Control-Allow-Methods", "GET"); if (!sourceAsset) return reply.status(404).send(); diff --git a/apps/backend/assets/src/index.ts b/apps/backend/assets/src/index.ts index df04edc8..4e8e485d 100644 --- a/apps/backend/assets/src/index.ts +++ b/apps/backend/assets/src/index.ts @@ -1,8 +1,10 @@ -import { assetsService } from "./api"; +import { assetsService } from "./assets"; import { createServer } from "@vrite/backend"; (async () => { - const server = await createServer(); + const server = await createServer({ + storage: true + }); await server.register(assetsService); diff --git a/apps/backend/collaboration/src/extensions/git-sync.ts b/apps/backend/collaboration/src/extensions/git-sync.ts index d34338e5..bb3f844e 100644 --- a/apps/backend/collaboration/src/extensions/git-sync.ts +++ b/apps/backend/collaboration/src/extensions/git-sync.ts @@ -1,15 +1,14 @@ import { Extension, onChangePayload, onDisconnectPayload } from "@hocuspocus/server"; import { - GitData, - ObjectId, createGenericOutputContentProcessor, docToJSON, getContentPiecesCollection, getGitDataCollection, - jsonToBuffer + jsonToBuffer, + publishGitDataEvent } from "@vrite/backend"; -import { createEventPublisher } from "@vrite/backend/src/lib/pub-sub"; import { FastifyInstance } from "fastify"; +import { ObjectId } from "mongodb"; import crypto from "node:crypto"; interface Configuration { @@ -17,11 +16,6 @@ interface Configuration { debounceMaxWait: number; } -type GitDataEvent = { - action: "update"; - data: Partial; -}; - class GitSync implements Extension { private configuration: Configuration = { debounce: 5000, @@ -36,10 +30,6 @@ class GitSync implements Extension { private debounced: Map = new Map(); - private publishGitDataEvent = createEventPublisher((workspaceId) => { - return `gitData:${workspaceId}`; - }); - public constructor(fastify: FastifyInstance, configuration?: Partial) { this.fastify = fastify; this.configuration = { @@ -121,7 +111,7 @@ class GitSync implements Extension { } } ); - this.publishGitDataEvent({ fastify: this.fastify }, `${details.context.workspaceId}`, { + publishGitDataEvent({ fastify: this.fastify }, `${details.context.workspaceId}`, { action: "update", data: { records: gitData.records.map((record: any) => { diff --git a/apps/backend/collaboration/src/extensions/search-indexing.ts b/apps/backend/collaboration/src/extensions/search-indexing.ts index 1ac46b31..63043d8b 100644 --- a/apps/backend/collaboration/src/extensions/search-indexing.ts +++ b/apps/backend/collaboration/src/extensions/search-indexing.ts @@ -1,6 +1,7 @@ import { Extension, onChangePayload, onDisconnectPayload } from "@hocuspocus/server"; -import { ObjectId, docToBuffer, getContentPiecesCollection } from "@vrite/backend"; +import { docToBuffer, getContentPiecesCollection } from "@vrite/backend"; import { FastifyInstance } from "fastify"; +import { ObjectId } from "mongodb"; interface Configuration { debounce: number | false | null; diff --git a/apps/backend/collaboration/src/index.ts b/apps/backend/collaboration/src/index.ts index 77b0a3be..f8e7d444 100644 --- a/apps/backend/collaboration/src/index.ts +++ b/apps/backend/collaboration/src/index.ts @@ -2,7 +2,12 @@ import { writingPlugin } from "./writing"; import { createServer } from "@vrite/backend"; (async () => { - const server = await createServer(); + const server = await createServer({ + database: true, + auth: true, + pubSub: true, + search: true + }); await server.register(writingPlugin); })(); diff --git a/apps/backend/collaboration/src/writing.ts b/apps/backend/collaboration/src/writing.ts index fb4a3c87..405e9432 100644 --- a/apps/backend/collaboration/src/writing.ts +++ b/apps/backend/collaboration/src/writing.ts @@ -1,10 +1,14 @@ -import { publicPlugin, getContentsCollection, getContentVariantsCollection } from "@vrite/backend"; +import { + publicPlugin, + getContentsCollection, + getContentVariantsCollection, + errors, + SessionData +} from "@vrite/backend"; import { Server } from "@hocuspocus/server"; import { Database } from "@hocuspocus/extension-database"; import { Redis } from "@hocuspocus/extension-redis"; import { ObjectId, Binary } from "mongodb"; -import { SessionData } from "@vrite/backend/src/lib/session"; -import { unauthorized } from "@vrite/backend/src/lib/errors"; import { SearchIndexing } from "#extensions/search-indexing"; import { GitSync } from "#extensions/git-sync"; @@ -18,13 +22,13 @@ const writingPlugin = publicPlugin(async (fastify) => { const cookies = fastify.parseCookie(data.requestHeaders.cookie || ""); if (!cookies.accessToken) { - throw unauthorized(); + throw errors.unauthorized(); } const token = fastify.unsignCookie(cookies.accessToken || "")?.value || ""; if (!token) { - throw unauthorized(); + throw errors.unauthorized(); } const { sessionId } = fastify.jwt.verify<{ sessionId: string }>(token); diff --git a/apps/backend/extensions/package.json b/apps/backend/extensions/package.json index da50d825..5b8dc5ff 100644 --- a/apps/backend/extensions/package.json +++ b/apps/backend/extensions/package.json @@ -11,11 +11,16 @@ "dependencies": { "@fastify/cors": "^8.3.0", "@trpc/server": "^10.35.0", + "@types/mdast": "^4.0.1", "@vrite/backend": "workspace:*", "@vrite/sdk": "workspace:*", "fastify": "^4.20.0", "openai": "^4.0.0", - "trpc-openapi": "^1.2.0" + "remark": "^15.0.1", + "remark-mdx": "^2.3.0", + "remark-parse": "^11.0.0", + "trpc-openapi": "^1.2.0", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@vrite/scripts": "workspace:*" diff --git a/apps/backend/extensions/src/extensions.ts b/apps/backend/extensions/src/extensions.ts index 0496a24e..06c13ba9 100644 --- a/apps/backend/extensions/src/extensions.ts +++ b/apps/backend/extensions/src/extensions.ts @@ -4,10 +4,10 @@ import { createOpenApiNodeHttpHandler, CreateOpenApiNodeHttpHandlerOptions } from "trpc-openapi/dist/adapters/node-http/core"; -import corsPlugin from "@fastify/cors"; import { OpenApiRouter } from "trpc-openapi"; import { AnyRouter } from "@trpc/server"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import corsPlugin from "@fastify/cors"; type CreateOpenApiFastifyPluginOptions = CreateOpenApiNodeHttpHandlerOptions & { diff --git a/apps/backend/extensions/src/index.ts b/apps/backend/extensions/src/index.ts index 5ca3ad84..f91c7aba 100644 --- a/apps/backend/extensions/src/index.ts +++ b/apps/backend/extensions/src/index.ts @@ -2,7 +2,7 @@ import { extensionsService } from "./extensions"; import { createServer, z } from "@vrite/backend"; (async () => { - const server = await createServer(); + const server = await createServer({}); await server.register(extensionsService); diff --git a/apps/backend/extensions/src/routes/dev/transformer.ts b/apps/backend/extensions/src/routes/dev/transformer.ts index e3b7b63d..46773de8 100644 --- a/apps/backend/extensions/src/routes/dev/transformer.ts +++ b/apps/backend/extensions/src/routes/dev/transformer.ts @@ -111,7 +111,7 @@ const devOutputTransformer = createOutputTransformer((contentNode) => { }; const transformContentNode = ( nodeWalker: JSONContentNodeWalker< - JSONContentNode["listItem" | "blockquote" | "doc" | "wrapper"] + JSONContentNode["listItem" | "blockquote" | "doc" | "element"] > ): string => { return nodeWalker.children @@ -150,9 +150,9 @@ const devOutputTransformer = createOutputTransformer((contentNode) => { return `\n\`\`\`${child.node.attrs?.lang || ""}\n${transformTextNode( child as JSONContentNodeWalker )}\n\`\`\`\n`; - case "wrapper": + case "element": return `\n${transformContentNode( - child as JSONContentNodeWalker + child as JSONContentNodeWalker )}\n`; case "blockquote": return `\n${transformContentNode( diff --git a/apps/backend/extensions/src/routes/docusaurus/index.ts b/apps/backend/extensions/src/routes/docusaurus/index.ts new file mode 100644 index 00000000..3e97583f --- /dev/null +++ b/apps/backend/extensions/src/routes/docusaurus/index.ts @@ -0,0 +1,50 @@ +import { docusaurusInputTransformer } from "./input-transformer"; +import { procedure, router, z } from "@vrite/backend"; +import { OpenAI } from "openai"; + +// test +docusaurusInputTransformer(""); + +const basePath = "/docusaurus"; +const docusaurusRouter = router({ + prompt: procedure + .meta({ + openapi: { + method: "POST", + path: `${basePath}` + } + }) + .input(z.object({ prompt: z.string() })) + .output(z.void()) + .mutation(async ({ ctx, input }) => { + const openai = new OpenAI({ + apiKey: ctx.fastify.config.OPENAI_API_KEY, + organization: ctx.fastify.config.OPENAI_ORGANIZATION + }); + const responseStream = await openai.chat.completions.create({ + model: "gpt-3.5-turbo", + stream: true, + messages: [{ role: "user", content: input.prompt }] + }); + + ctx.res.raw.writeHead(200, { + ...ctx.res.getHeaders(), + "content-type": "text/event-stream", + "cache-control": "no-cache", + "connection": "keep-alive" + }); + + for await (const part of responseStream) { + const content = part.choices[0].delta.content || ""; + + if (content) { + ctx.res.raw.write(`data: ${encodeURIComponent(content)}`); + ctx.res.raw.write("\n\n"); + } + } + + ctx.res.raw.end(); + }) +}); + +export { docusaurusRouter }; diff --git a/apps/backend/extensions/src/routes/docusaurus/input-transformer.ts b/apps/backend/extensions/src/routes/docusaurus/input-transformer.ts new file mode 100644 index 00000000..804a2f63 --- /dev/null +++ b/apps/backend/extensions/src/routes/docusaurus/input-transformer.ts @@ -0,0 +1,23 @@ +import { createInputTransformer } from "@vrite/sdk/transformers"; +import { remark } from "remark"; +import remarkMdx from "remark-mdx"; + +const docusaurusInputTransformer = createInputTransformer(() => { + /* const myRemarkPlugin = () => { + return (tree: Root) => { + visit(tree, (node) => { + console.log(node); + // `node` can now be one of the nodes for JSX, expressions, or ESM. + }); + }; + };*/ + const file = remark().use(remarkMdx).processSync('import a from "b"\n\na c {1 + 1} d'); + + console.log(String(file)); + + return { + content: "" + }; +}); + +export { docusaurusInputTransformer }; diff --git a/apps/backend/extensions/src/routes/hashnode/transformer.ts b/apps/backend/extensions/src/routes/hashnode/transformer.ts index 87125bb4..736218db 100644 --- a/apps/backend/extensions/src/routes/hashnode/transformer.ts +++ b/apps/backend/extensions/src/routes/hashnode/transformer.ts @@ -111,7 +111,7 @@ const hashnodeOutputTransformer = createOutputTransformer((contentNode) }; const transformContentNode = ( nodeWalker: JSONContentNodeWalker< - JSONContentNode["listItem" | "blockquote" | "doc" | "wrapper"] + JSONContentNode["listItem" | "blockquote" | "doc" | "element"] > ): string => { return nodeWalker.children @@ -150,9 +150,9 @@ const hashnodeOutputTransformer = createOutputTransformer((contentNode) return `\n\`\`\`${child.node.attrs?.lang || ""}\n${transformTextNode( child as JSONContentNodeWalker )}\n\`\`\`\n`; - case "wrapper": + case "element": return `\n${transformContentNode( - child as JSONContentNodeWalker + child as JSONContentNodeWalker )}\n`; case "blockquote": return `\n${transformContentNode( diff --git a/apps/backend/extensions/src/routes/index.ts b/apps/backend/extensions/src/routes/index.ts index 749e6b27..10f9df3d 100644 --- a/apps/backend/extensions/src/routes/index.ts +++ b/apps/backend/extensions/src/routes/index.ts @@ -2,13 +2,15 @@ import { devRouter } from "./dev"; import { hashnodeRouter } from "./hashnode"; import { gptRouter } from "./gpt"; import { mediumRouter } from "./medium"; +import { docusaurusRouter } from "./docusaurus"; import { router } from "@vrite/backend"; const extensionsRouter = router({ dev: devRouter, hashnode: hashnodeRouter, medium: mediumRouter, - gpt: gptRouter + gpt: gptRouter, + docusaurus: docusaurusRouter }); type Router = typeof extensionsRouter; diff --git a/apps/backend/extensions/src/routes/medium/transformer.ts b/apps/backend/extensions/src/routes/medium/transformer.ts index 2c246802..838b3155 100644 --- a/apps/backend/extensions/src/routes/medium/transformer.ts +++ b/apps/backend/extensions/src/routes/medium/transformer.ts @@ -63,7 +63,7 @@ const mediumOutputTransformer = createOutputTransformer((contentNode) => }; const transformContentNode = ( nodeWalker: JSONContentNodeWalker< - JSONContentNode["listItem" | "blockquote" | "doc" | "wrapper"] + JSONContentNode["listItem" | "blockquote" | "doc" | "element"] > ): string => { return nodeWalker.children @@ -98,9 +98,9 @@ const mediumOutputTransformer = createOutputTransformer((contentNode) => return `\n\`\`\`${child.node.attrs?.lang || ""}\n${transformTextNode( child as JSONContentNodeWalker )}\n\`\`\`\n`; - case "wrapper": + case "element": return `\n${transformContentNode( - child as JSONContentNodeWalker + child as JSONContentNodeWalker )}\n`; case "blockquote": return `\n${transformContentNode( diff --git a/apps/docs/package.json b/apps/docs/package.json index f09d9cae..04f2d7f5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -14,6 +14,8 @@ "@astrojs/sitemap": "^3.0.0", "@astrojs/solid-js": "^3.0.1", "@mdi/js": "^7.2.96", + "@microsoft/fetch-event-source": "^2.0.1", + "@solid-primitives/scheduled": "^1.4.0", "@types/marked": "^5.0.1", "@unocss/reset": "^0.55.7", "@vrite/components": "workspace:*", @@ -24,8 +26,10 @@ "mini-svg-data-uri": "^1.4.4", "nanoid": "^5.0.1", "plausible-tracker": "^0.3.8", + "seamless-scroll-polyfill": "^2.3.4", "shiki": "^0.14.4", "solid-js": "^1.7.11", + "tinykeys": "^2.1.0", "typescript": "^5.2.2", "unocss": "^0.55.7", "vite": "^4.4.9" diff --git a/apps/docs/src/components/fragments/command-palette.tsx b/apps/docs/src/components/fragments/command-palette.tsx new file mode 100644 index 00000000..6ce4458f --- /dev/null +++ b/apps/docs/src/components/fragments/command-palette.tsx @@ -0,0 +1,637 @@ +import { + Accessor, + Component, + For, + Match, + ParentComponent, + Setter, + Show, + Switch, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + useContext +} from "solid-js"; +import { + mdiChevronRight, + mdiConsoleLine, + mdiCreationOutline, + mdiFileDocumentOutline, + mdiHeadSnowflakeOutline, + mdiInformationOutline, + mdiKeyboardEsc, + mdiKeyboardReturn, + mdiMagnify, + mdiSwapVertical +} from "@mdi/js"; +import clsx from "clsx"; +import { scrollIntoView } from "seamless-scroll-polyfill"; +import { createContext } from "solid-js"; +import { debounce } from "@solid-primitives/scheduled"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { marked } from "marked"; +import { Card, Icon, IconButton, Input, Loader, Overlay, Tooltip } from "#components/primitives"; + +interface CommandCategory { + label: string; + id: string; +} +interface Command { + name: string; + category: string; + icon: string; + action(): void; +} +interface CommandPaletteProps { + opened: boolean; + commands: Command[]; + setOpened(opened: boolean): void; +} +interface CommandPaletteContextData { + opened: Accessor; + setOpened: Setter; + registerCommand(command: Command | Command[]): void; +} + +const categories: CommandCategory[] = [ + { + label: "Navigate", + id: "navigate" + }, + { + label: "Dashboard", + id: "dashboard" + }, + { + label: "Editor", + id: "editor" + } +]; +const CommandPaletteContext = createContext(); +const CommandPalette: Component = (props) => { + const [inputRef, setInputRef] = createSignal(null); + const [abortControllerRef, setAbortControllerRef] = createSignal(null); + const [mode, setMode] = createSignal<"command" | "search" | "ask">("search"); + const [searchResults, setSearchResults] = createSignal< + Array<{ content: string; breadcrumb: string[]; contentPieceId: string }> + >([]); + const [answer, setAnswer] = createSignal(""); + const [loading, setLoading] = createSignal(false); + const [scrollableContainerRef, setScrollableContainerRef] = createSignal( + null + ); + const [mouseHoverEnabled, setMouseHoverEnabled] = createSignal(false); + const [selectedIndex, setSelectedIndex] = createSignal(0); + const [query, setQuery] = createSignal(""); + const ask = async (): Promise => { + let content = ""; + + await fetchEventSource(`http://localhost:4444/search/ask/?query=${query()}`, { + method: "GET", + headers: { + "Authorization": `Bearer Fq0U5T6vebJHtIqCPmyAt:jihNYSVwqNVgS9v8RqccR`, + "Content-Type": "application/json", + "Accept": "text/event-stream" + }, + signal: abortControllerRef()?.signal, + onerror(error) { + setLoading(false); + throw error; + }, + onmessage(event) { + const partOfContent = decodeURIComponent(event.data); + + content += partOfContent; + setAnswer(marked.parse(content, { gfm: true })); + }, + onclose() { + setLoading(false); + } + }); + setLoading(false); + }; + const search = debounce(async () => { + setSearchResults([]); + + if (!query()) { + setLoading(false); + + return; + } + + if (abortControllerRef()) abortControllerRef()?.abort(); + + setAbortControllerRef(new AbortController()); + + try { + const response = await fetch(`http://localhost:4444/search/?query=${query()}`, { + signal: abortControllerRef()?.signal, + credentials: "include", + headers: { + "Authorization": `Bearer Fq0U5T6vebJHtIqCPmyAt:jihNYSVwqNVgS9v8RqccR`, + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + const results = await response.json(); + + setSearchResults(results); + setLoading(false); + + return; + } catch (error) { + const trpcError = error as any; + const causeErrorName = trpcError.cause?.name?.toLowerCase() || ""; + + if (!causeErrorName.includes("aborterror")) { + setLoading(false); + } + } + }, 150); + const filteredCommands = createMemo(() => { + return props.commands.filter(({ name }) => { + return name.toLowerCase().includes(query().toLowerCase()); + }); + }); + const selectedCommand = (): Command | null => { + return filteredCommands()[selectedIndex()] || null; + }; + const scrollToSelectedCommand = (smooth?: boolean): void => { + const selectedCommandElement = document.querySelector("[data-selected=true]"); + + if (selectedCommandElement) { + scrollIntoView(selectedCommandElement, { + behavior: smooth ? "smooth" : "instant", + block: "center" + }); + } + }; + const goToContentPiece = (contentPieceId: string, breadcrumb?: string[]): void => { + // eslint-disable-next-line no-console + props.setOpened(false); + }; + + createEffect( + on( + query, + (query) => { + if (query === ">") { + setMode("command"); + setQuery(""); + + return; + } + + if (mode() === "search") { + search.clear(); + search(); + } + }, + { defer: true } + ) + ); + createEffect( + on(mode, () => { + setMouseHoverEnabled(false); + setSelectedIndex(0); + setLoading(false); + setAnswer(""); + setSearchResults([]); + setQuery(""); + }) + ); + createEffect(() => { + if (inputRef() && props.opened && mode()) { + setTimeout(() => { + inputRef()?.focus(); + }, 300); + } + }); + createEffect(() => { + import("tinykeys").then(({ createKeybindingsHandler }) => { + const keyShortcutHandler = createKeybindingsHandler({ + "$mod+KeyK": (event) => { + props.setOpened(!props.opened); + }, + "escape": (event) => { + if (!props.opened) return; + + props.setOpened(false); + }, + "ArrowUp": (event) => { + if (!props.opened) return; + + setMouseHoverEnabled(false); + event.preventDefault(); + event.stopPropagation(); + + if (selectedIndex() > 0) { + setSelectedIndex(selectedIndex() - 1); + scrollToSelectedCommand(); + } else if (mode() === "command") { + setSelectedIndex(filteredCommands().length - 1); + scrollToSelectedCommand(true); + } else if (mode() === "search") { + setSelectedIndex(searchResults().length - 1); + scrollToSelectedCommand(true); + } + }, + "ArrowDown": (event) => { + if (!props.opened) return; + + setMouseHoverEnabled(false); + event.preventDefault(); + event.stopPropagation(); + + if ( + (mode() === "command" && selectedIndex() < filteredCommands().length - 1) || + (mode() === "search" && selectedIndex() < searchResults().length - 1) + ) { + setSelectedIndex(selectedIndex() + 1); + scrollToSelectedCommand(); + } else { + setSelectedIndex(0); + scrollToSelectedCommand(true); + } + }, + "Enter": (event) => { + if (!props.opened) return; + + if (mode() === "command") { + const selectedCommand = filteredCommands()[selectedIndex()]; + + if (selectedCommand) { + selectedCommand.action(); + props.setOpened(false); + } + } else if (mode() === "search") { + goToContentPiece( + searchResults()[selectedIndex()].contentPieceId, + searchResults()[selectedIndex()].breadcrumb + ); + } + } + }); + + window.addEventListener("keydown", keyShortcutHandler); + onCleanup(() => { + window.removeEventListener("keydown", keyShortcutHandler); + }); + }); + }); + + const getIcon = (): string => { + switch (mode()) { + case "command": + return mdiConsoleLine; + case "search": + return mdiMagnify; + case "ask": + return mdiHeadSnowflakeOutline; + } + }; + const getLabel = (): string => { + switch (mode()) { + case "command": + return "Command"; + case "ask": + return "Just ask"; + case "search": + default: + return "Search"; + } + }; + + return ( + { + props.setOpened(false); + }} + > + { + setMouseHoverEnabled(true); + }} + > +
+ + { + if (mode() === "search") { + setLoading(true); + } + + setQuery(value); + }} + ref={setInputRef} + placeholder={getLabel()} + wrapperClass="flex-1 m-0" + class="m-0 bg-transparent" + onEnter={() => { + if (mode() === "ask") { + setLoading(true); + setAnswer(""); + ask(); + } + }} + onKeyDown={(event) => { + if (mode() === "command" && event.key === "Backspace" && !query()) { + setMode("search"); + } + }} + adornment={() => ( + + + { + setMode((mode) => (mode === "ask" ? "search" : "ask")); + }} + variant="text" + /> + + + )} + /> +
+
+
+ + + + +
+ } + > + + + No results + + + } + > + {(result, index) => { + return ( + { + goToContentPiece(result.contentPieceId, result.breadcrumb); + }} + onPointerEnter={() => { + if (!mouseHoverEnabled()) return; + + setSelectedIndex(index()); + }} + > +
+ +
+

+ + {(breadcrumb, index) => { + return ( + <> + + + + {breadcrumb} + + ); + }} + +

+

+ {result.content} +

+
+
+
+ ); + }} +
+ + + + + +
+ } + > + + What do you want to know? + + + } + > + +
+ + + + + + + + + No results + + + {({ id, label }) => { + const commands = (): Command[] => { + return filteredCommands().filter((command) => { + return ( + command.category === id && + command.name.toLowerCase().includes(query().toLowerCase()) + ); + }); + }; + + return ( + <> + + + {label} + + + + {(command) => { + return ( + { + command.action(); + props.setOpened(false); + }} + onPointerEnter={() => { + if (!mouseHoverEnabled()) return; + + setSelectedIndex(filteredCommands().indexOf(command)); + }} + color="base" + data-selected={command === selectedCommand()} + > + + {command.name} + + ); + }} + + + ); + }} + + + +
+ +
+ +
+ { + setMode((mode) => (mode === "command" ? "search" : "command")); + }} + /> +
+ + + ); +}; +const CommandPaletteProvider: ParentComponent = (props) => { + const [opened, setOpened] = createSignal(false); + const [commands, setCommands] = createSignal([]); + const registerCommand = (command: Command | Command[]): void => { + if (Array.isArray(command)) { + setCommands((commands) => [...commands, ...command]); + } else { + setCommands((commands) => [...commands, command]); + } + + onCleanup(() => { + setCommands((commands) => { + return commands.filter((filteredCommand) => { + if (Array.isArray(command)) { + return !command.includes(filteredCommand); + } + + return filteredCommand !== command; + }); + }); + }); + }; + + return ( + + {props.children} + + + ); +}; +const useCommandPalette = (): CommandPaletteContextData => { + return useContext(CommandPaletteContext)!; +}; + +export { CommandPaletteProvider, useCommandPalette }; diff --git a/apps/docs/src/components/fragments/header.tsx b/apps/docs/src/components/fragments/header.tsx index ced8faf0..9186d80c 100644 --- a/apps/docs/src/components/fragments/header.tsx +++ b/apps/docs/src/components/fragments/header.tsx @@ -1,7 +1,9 @@ -import { mdiAppleKeyboardCommand, mdiMagnify } from "@mdi/js"; -import type { Component } from "solid-js"; -import { Icon, IconButton } from "#components/primitives"; -import { logoIcon } from "#assets/icons"; +import { CommandPaletteProvider, useCommandPalette } from "./command-palette"; +import { mdiAppleKeyboardCommand, mdiGithub, mdiMagnify } from "@mdi/js"; +import { For, type Component } from "solid-js"; +import clsx from "clsx"; +import { Button, Icon, IconButton, Tooltip } from "#components/primitives"; +import { discordIcon } from "#assets/icons"; const isAppleDevice = (): boolean => { const platform = typeof navigator === "object" ? navigator.platform : ""; @@ -9,14 +11,53 @@ const isAppleDevice = (): boolean => { return appleDeviceRegex.test(platform); }; +const externalLinks = [ + { + label: "GitHub", + icon: mdiGithub, + href: "https://github.com/vriteio/vrite" + }, + { + label: "Discord", + icon: discordIcon, + href: "https://discord.gg/yYqDWyKnqE" + } +]; const Header: Component = () => { + const { opened, setOpened } = useCommandPalette(); + return ( -
+
+ + {(link) => { + return ( + + + + + + ); + }} +
); }; +const HeaderWrapper: Component = () => { + return ( + +
+ + ); +}; -export { Header }; +export { HeaderWrapper as Header }; diff --git a/apps/docs/src/components/fragments/index.tsx b/apps/docs/src/components/fragments/index.tsx index 6bfc94a2..75a30865 100644 --- a/apps/docs/src/components/fragments/index.tsx +++ b/apps/docs/src/components/fragments/index.tsx @@ -5,3 +5,4 @@ export * from "./side-bar"; export * from "./on-this-page"; export * from "./svg-defs"; export * from "./navigation"; +export * from "./command-palette"; diff --git a/apps/docs/src/components/fragments/on-this-page.tsx b/apps/docs/src/components/fragments/on-this-page.tsx index 8235ca69..720b181d 100644 --- a/apps/docs/src/components/fragments/on-this-page.tsx +++ b/apps/docs/src/components/fragments/on-this-page.tsx @@ -1,6 +1,7 @@ import { Component, For, onMount, onCleanup, createSignal, createMemo } from "solid-js"; import { mdiListBox } from "@mdi/js"; import clsx from "clsx"; +import { scroll } from "seamless-scroll-polyfill"; import type { MarkdownHeading } from "astro"; import { Button, IconButton } from "#components/primitives"; @@ -12,7 +13,7 @@ const OnThisPage: Component = (props) => { const [activeHeading, setActiveHeading] = createSignal(props.headings[0]?.slug || ""); const headings = createMemo(() => { return props.headings.filter((heading) => { - return heading.depth === 2; + return heading.depth === 2 || heading.depth === 3; }); }); @@ -69,7 +70,7 @@ const OnThisPage: Component = (props) => { <>