diff --git a/.gitignore b/.gitignore index b97f0dd6..8b7e3161 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,28 @@ +# general .DS_Store .env .env*.local +output +dist +.idea + # ts node_modules package-lock.json pnpm-lock.yaml .vercel + +# rust +/target/ + # next .next-types .next -# other -private -past-* -output -dist -# rust -/target/ \ No newline at end of file +# repos +private +docs + +# other +past.* +x.* diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 00000000..30a613ef --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,6 @@ +.encore +encore.gen.go +encore.gen.cue +/.encore +node_modules +/encore.gen diff --git a/api/api/api.ts b/api/api/api.ts new file mode 100644 index 00000000..f3aacecb --- /dev/null +++ b/api/api/api.ts @@ -0,0 +1,72 @@ +import { api, APIError } from "encore.dev/api" +// import { startWorker } from "jazz-nodejs" +// import { ID } from "jazz-tools" +import { secret } from "encore.dev/config" +import log from "encore.dev/log" + +const jazzWorkerAccountId = secret("jazzWorkerAccountId") +const jazzWorkerSecret = secret("jazzWorkerSecret") +const jazzPublicGlobalGroup = secret("jazzPublicGlobalGroup") + +export const testRoute = api( + { expose: true, method: "GET", path: "/test" }, + async ({}: {}): Promise => { + console.log(jazzPublicGlobalGroup(), "group") + log.info("better logs from encore") + } +) + +// return all content for GlobalTopic +export const getTopic = api( + { expose: true, method: "GET", path: "/topic/:topic" }, + async ({ + topic + }: { + topic: string + // TODO: can return type be inferred like Elysia? + }): Promise<{ + links: { + label: string + url: string + }[] + }> => { + // const { worker } = await startWorker({ + // accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", + // accountSecret: JAZZ_WORKER_SECRET + // }) + + // TODO: how to get the import from outside this package? + // const globalGroupId = process.env.JAZZ_PUBLIC_GLOBAL_GROUP as ID + // const globalGroup = await PublicGlobalGroup.load(globalGroupId, worker, { + // root: { + // topics: [ + // { + // latestGlobalGuide: { + // sections: [ + // { + // links: [{}] + // } + // ] + // } + // } + // ], + // forceGraphs: [ + // { + // connections: [{}] + // } + // ] + // } + // }) + // if (!globalGroup) throw APIError.notFound("GlobalGroup not found") + + // const globalGroupId = process.env.JAZZ_PUBLIC_GLOBAL_GROUP as ID + // console.log(globalGroupId) + // console.log(worker) + // console.log("runs..") + + const topicContent = { + links: [] + } + return topicContent + } +) diff --git a/api/api/links.ts b/api/api/links.ts new file mode 100644 index 00000000..9c17de3d --- /dev/null +++ b/api/api/links.ts @@ -0,0 +1,40 @@ +// TODO: not sure if `links` should be separate service +// it is responsible for adding and getting links into LA from API + +import { api, APIError } from "encore.dev/api" +// import { startWorker } from "jazz-nodejs" +import { secret } from "encore.dev/config" + +const jazzWorkerSecret = secret("jazzWorkerSecret") + +export const addPersonalLink = api( + { expose: true, method: "POST", path: "/save-link" }, + async ({ url }: { url: string }): Promise => { + // const { worker } = await startWorker({ + // accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", + // accountSecret: JAZZ_WORKER_SECRET + // }) + } +) + +export const getLinkDetails = api( + { expose: true, method: "GET", path: "/global-link-details/:url" }, + async ({ + url + }: { + url: string + }): Promise<{ + title: string + summary?: string + }> => { + // const { worker } = await startWorker({ + // accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", + // accountSecret: JAZZ_WORKER_SECRET + // }) + + return { + title: "Jazz", + summary: "Jazz is local first framework for building web apps" + } + } +) diff --git a/api/bun.lockb b/api/bun.lockb new file mode 100755 index 00000000..6408494b Binary files /dev/null and b/api/bun.lockb differ diff --git a/api/encore.app b/api/encore.app new file mode 100644 index 00000000..a95572f9 --- /dev/null +++ b/api/encore.app @@ -0,0 +1,4 @@ +{ + "id": "encore-test-76k2", + "lang": "typescript" +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 00000000..217c0111 --- /dev/null +++ b/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "api", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "encore run" + }, + "devDependencies": { + "@types/node": "^22.5.4", + "typescript": "^5.6.2", + "vitest": "^2.0.5" + }, + "dependencies": { + "encore.dev": "^1.41.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.21.2" + } +} diff --git a/api/readme.md b/api/readme.md new file mode 100644 index 00000000..437b2b1c --- /dev/null +++ b/api/readme.md @@ -0,0 +1 @@ +Using [Encore](https://encore.dev). diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 00000000..ffe650c7 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + /* Basic Options */ + "lib": ["ES2022"], + "target": "ES2022", + "module": "ES2022", + "types": ["node"], + "paths": { + "~encore/*": ["./encore.gen/*"] + }, + + /* Workspace Settings */ + "composite": true, + + /* Strict Type-Checking Options */ + "strict": true, + + /* Module Resolution Options */ + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "sourceMap": true, + + "declaration": true, + + /* Advanced Options */ + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +} diff --git a/app/tauri.conf.json b/app/tauri.conf.json index bef4ac9d..0b255c5d 100644 --- a/app/tauri.conf.json +++ b/app/tauri.conf.json @@ -1,12 +1,11 @@ { - "productName": "learn-anything", + "productName": "Learn Anything", "version": "0.1.0", - "identifier": "xyz.learn-anything", + "identifier": "xyz.learnanything.desktop", "build": { - "frontendDist": "../out", + "frontendDist": "https://dev.learn-anything.xyz", "devUrl": "http://localhost:3000", - "beforeDevCommand": "bun dev", - "beforeBuildCommand": "bun build" + "beforeDevCommand": "bun dev" }, "app": { "windows": [ @@ -15,8 +14,7 @@ "width": 800, "height": 600, "resizable": true, - "fullscreen": false, - "url": "http://localhost:3000" + "fullscreen": false } ], "security": { diff --git a/bun.lockb b/bun.lockb index 1973c374..90599619 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli/readme.md b/cli/readme.md new file mode 100644 index 00000000..f4b786b3 --- /dev/null +++ b/cli/readme.md @@ -0,0 +1,5 @@ +# CLI + +> CLI for interfacing with LA + +Will be modelled after [Encore's Go CLI](https://github.com/encoredev/encore/tree/main/cli/cmd/encore). diff --git a/eslint.config.js b/eslint.config.js index 22c9826a..c74755ce 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,22 +3,22 @@ import js from "@eslint/js" const compat = new FlatCompat() +const typescriptConfig = compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:@typescript-eslint/stylistic-type-checked", + "prettier" +) + +const javascriptConfig = js.configs.recommended + export default [ { - ...compat - .extends( - "eslint:recommended", - "plugin:@typescript-eslint/recommended-type-checked", - "plugin:@typescript-eslint/stylistic-type-checked", - "prettier" - ) - .map(c => ({ - ...c, - files: ["**/*.{ts,tsx,mts}"] - })) + files: ["**/*.{ts,tsx,mts}"], + ...typescriptConfig }, { files: ["**/*.{js,jsx,cjs,mjs}"], - ...js.configs.recommended + ...javascriptConfig } ] diff --git a/package.json b/package.json index d275fe78..014346cc 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,36 @@ { - "name": "learn-anything", - "scripts": { - "dev": "bun web", - "web": "cd web && bun dev", - "web:build": "bun run --filter '*' build", - "app": "tauri dev", - "cli": "bun run --watch cli/run.ts", - "seed": "bun --watch cli/seed.ts", - "tauri": "tauri" - }, - "workspaces": [ - "web" - ], - "dependencies": { - "@tauri-apps/cli": "^2.0.0-rc.6", - "@tauri-apps/plugin-dialog": "^2.0.0-rc", - "@tauri-apps/plugin-fs": "^2.0.0-rc.2", - "jazz-nodejs": "^0.7.34", - "react-icons": "^5.3.0" - }, - "devDependencies": { - "bun-types": "^1.1.26" - }, - "prettier": { - "plugins": [ - "prettier-plugin-tailwindcss" - ], - "useTabs": true, - "semi": false, - "trailingComma": "none", - "printWidth": 120, - "arrowParens": "avoid" - }, - "license": "MIT" + "name": "learn-anything", + "scripts": { + "dev": "bun web", + "web": "cd web && bun dev", + "web:build": "bun run --filter '*' build", + "ts": "bun run --watch scripts/run.ts", + "seed": "bun --watch scripts/seed.ts", + "tauri": "tauri", + "app": "tauri dev", + "app:build": "bun tauri build -b dmg -v" + }, + "workspaces": [ + "web" + ], + "dependencies": { + "@tauri-apps/cli": "^2.0.0-rc.17", + "@tauri-apps/plugin-dialog": "^2.0.0-rc.1", + "@tauri-apps/plugin-fs": "^2.0.0-rc.2", + "jazz-nodejs": "0.8.0" + }, + "devDependencies": { + "bun-types": "^1.1.29" + }, + "prettier": { + "plugins": [ + "prettier-plugin-tailwindcss" + ], + "useTabs": true, + "semi": false, + "trailingComma": "none", + "printWidth": 120, + "arrowParens": "avoid" + }, + "license": "MIT" } diff --git a/readme.md b/readme.md index 04ee1085..56c3be6e 100644 --- a/readme.md +++ b/readme.md @@ -1,14 +1,35 @@ -# [Learn-Anything.xyz](https://learn-anything.xyz) +# [learn-anything.xyz](https://learn-anything.xyz) + +> Organize world's knowledge, explore connections and curate learning paths + +## Files + +- [api](api) - http services (using TS/[Encore](https://encore.dev/)) +- [app](app) - desktop app (wrapping the [website](web) with desktop specific logic) (using [Tauri](https://v2.tauri.app/)) +- [cli](cli) - cli (using [Go](https://go.dev)) +- [docs](https://github.com/learn-anything/docs) - public docs hosted on [docs.learn-anything.xyz](https://docs.learn-anything.xyz/) +- [lib](lib) - shared utility functions in TS +- [nix](nix) - shared nix code +- [scripts](scripts) - utility scripts in TS +- [web](web) - website hosted on [learn-anything.xyz](https://learn-anything.xyz) (using [React](https://react.dev/)/[Next.js](https://nextjs.org/) + [Jazz](https://jazz.tools/) for local/global state) ## Setup -Using [Bun](https://bun.sh). +> [!NOTE] +> Project is currently in unstable state but actively improving. Reach out on [Discord](https://discord.gg/bxtD8x6aNF) for help. + +Using [Bun](https://bun.sh): ``` bun i ``` -[Jazz](https://jazz.tools/) is used for all global/local state management. +> [!NOTE] +> bun setup is not yet done but will be a command to fully bootstrap a local working env for the project, without it, running `bun web` is impossible yet + +``` +bun setup +``` ## Run website @@ -16,18 +37,18 @@ bun i bun web ``` -## Contribute - -Currently things are unstable but will improve. +## Contributing If you want to help contribute to code, ask for help on [Discord](https://discord.gg/bxtD8x6aNF)'s `#dev` channel. You will be onboarded and unblocked fast. +Can see [existing issues](../../issues) for things being worked on. See [main issue](../../issues/110) for what's in focus right now. + +Can [open new issue](../../issues/new/choose) (search existing ones for duplicates first) or start discussion on [GitHub](../../discussions) or [Discord](https://discord.gg/bxtD8x6aNF). + Can always submit draft PRs with good ideas/fixes. We will help along the way to make it merge ready. -## Chat +## Join core team -Community chat in [Discord server](https://discord.gg/bxtD8x6aNF). +We are a small team of core developers right now but are always looking to expand. We will reach out with offer to join us if you contribute to repo in form of PRs. -Internal dev chat in Telegram (can email `join@learn-anything.xyz` to join core team). We will reach out with offer to join if you contribute to repo in form of PRs too. - -[![X](https://img.shields.io/badge/learnanything-100000?logo=X&color=black)](https://x.com/learnanything_) +[![Discord](https://img.shields.io/badge/Discord-100000?style=flat&logo=discord&logoColor=white&labelColor=black&color=black)](https://discord.com/invite/bxtD8x6aNF) [![X](https://img.shields.io/badge/learnanything-100000?logo=X&color=black)](https://x.com/learnanything_) diff --git a/scripts/past-seed.ts b/scripts/past-seed.ts new file mode 100644 index 00000000..d821546e --- /dev/null +++ b/scripts/past-seed.ts @@ -0,0 +1,88 @@ +// @ts-nocheck +async function devSeed() { + const { worker } = await startWorker({ + accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", + accountSecret: JAZZ_WORKER_SECRET + }) + const user = (await ( + await LaAccount.createAs(worker, { + creationProps: { name: "nikiv" } + }) + ).ensureLoaded({ root: { personalLinks: [], pages: [], todos: [] } }))! + const globalLinksGroup = Group.create({ owner: worker }) + globalLinksGroup.addMember("everyone", "reader") + const globalLink1 = GlobalLink.create({ url: "https://google.com" }, { owner: globalLinksGroup }) + const globalLink2 = GlobalLink.create({ url: "https://jazz.tools" }, { owner: globalLinksGroup }) + // TODO: make note: optional + const personalLink1 = PersonalLink.create( + { globalLink: globalLink1, type: "personalLink", note: "" }, + { owner: user } + ) + const personalLink2 = PersonalLink.create( + { globalLink: globalLink2, type: "personalLink", note: "Great framework" }, + { owner: user } + ) + user.root.personalLinks.push(personalLink1) + user.root.personalLinks.push(personalLink2) + const pageOneTitle = "Physics" + const pageTwoTitle = "Karabiner" + const page1 = PersonalPage.create( + { title: pageOneTitle, slug: generateUniqueSlug(pageOneTitle), content: "Physics is great" }, + { owner: user } + ) + const page2 = PersonalPage.create( + { title: pageTwoTitle, slug: generateUniqueSlug(pageTwoTitle), content: "Karabiner is great" }, + { owner: user } + ) + user.root.personalPages?.push(page1) + user.root.personalPages?.push(page2) + const page1 = Page.create({ title: "Physics", content: "Physics is great" }, { owner: user }) + const page2 = Page.create({ title: "Karabiner", content: "Karabiner is great" }, { owner: user }) + user.root.pages.push(page1) + user.root.pages.push(page2) + const credentials = { + accountID: user.id, + accountSecret: (user._raw as RawControlledAccount).agentSecret + } + await Bun.write( + "./web/.env", + `VITE_SEED_ACCOUNTS='${JSON.stringify({ + nikiv: credentials + })}'` + ) + await Bun.write( + "./.env", + `VITE_SEED_ACCOUNTS='${JSON.stringify({ + nikiv: credentials + })}'` + ) +} +const globalLink = GlobalLink.create( + { + url: "https://google.com", + urlTitle: "Google", + protocol: "https" + }, + { owner: globalGroup } +) +const user = (await ( + await LaAccount.createAs(worker, { + creationProps: { name: "nikiv" } + }) +).ensureLoaded({ root: { personalLinks: [], pages: [], todos: [] } }))! +console.log(process.env.JAZZ_GLOBAL_GROUP!, "group") +console.log(worker) +// TODO: type err +console.log(globalGroup, "group") +return +const currentFilePath = import.meta.path +const connectionsFilePath = `${currentFilePath.replace("seed.ts", "/seed/connections.json")}` +const file = Bun.file(connectionsFilePath) +const fileContent = await file.text() +const topicsWithConnections = JSON.parse(fileContent) +// let topicsWithConnections = JSON.stringify(obj, null, 2) +console.log(topicsWithConnections) +// TODO: type err +topicsWithConnections.map(topic => { + const globalTopic = GlobalTopic.create({ name: topic.name, description: topic.description }, { owner: globalGroup }) +}) diff --git a/cli/run.ts b/scripts/run.ts similarity index 100% rename from cli/run.ts rename to scripts/run.ts diff --git a/cli/seed.ts b/scripts/seed.ts similarity index 100% rename from cli/seed.ts rename to scripts/seed.ts diff --git a/tsconfig.json b/tsconfig.json index 1bb3943b..ba35efc4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,44 @@ { "compilerOptions": { + // Project Structure "rootDirs": [".", ".next-types"], - "target": "esnext", - "module": "esnext", - "moduleResolution": "bundler", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], "baseUrl": ".", "paths": { "@/*": ["./*"] }, - "types": ["bun-types"] + + // Module Settings + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "resolveJsonModule": true, + + // Compilation Behavior + "noEmit": true, + "incremental": true, + "isolatedModules": true, + + // Type Checking + "strict": true, + "skipLibCheck": true, + + // JavaScript Support + "allowJs": true, + + // React Support + "jsx": "preserve", + + // Libraries and Types + "lib": ["dom", "dom.iterable", "esnext"], + "types": ["bun-types"], + + // Plugins + "plugins": [ + { + "name": "next" + } + ] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "**/*.(mts|ts)"], "exclude": ["node_modules"] diff --git a/web/.env.example b/web/.env.example index c775b455..bae601e6 100644 --- a/web/.env.example +++ b/web/.env.example @@ -7,4 +7,14 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in -NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up \ No newline at end of file +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up + +NEXT_PUBLIC_JAZZ_PEER_URL="wss://" + +RONIN_TOKEN= + +NEXT_PUBLIC_SENTRY_DSN= +NEXT_PUBLIC_SENTRY_ORG= +NEXT_PUBLIC_SENTRY_PROJECT= + +# IGNORE_BUILD_ERRORS=true \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index fd3dbb57..d1dc7456 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -34,3 +34,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin +.ronin diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 00000000..2806c9c2 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1,2 @@ +[install.scopes] +ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" } \ No newline at end of file diff --git a/web/app/(pages)/community/[topicName]/page.tsx b/web/app/(pages)/community/[topicName]/page.tsx new file mode 100644 index 00000000..62e69371 --- /dev/null +++ b/web/app/(pages)/community/[topicName]/page.tsx @@ -0,0 +1,5 @@ +import { CommunityTopicRoute } from "@/components/routes/community/CommunityTopicRoute" + +export default function CommunityTopicPage({ params }: { params: { topicName: string } }) { + return +} diff --git a/web/app/(pages)/journal/page.tsx b/web/app/(pages)/journal/page.tsx new file mode 100644 index 00000000..0c2cd038 --- /dev/null +++ b/web/app/(pages)/journal/page.tsx @@ -0,0 +1,15 @@ +import { JournalRoute } from "@/components/routes/journal/JournalRoute" +import { currentUser } from "@clerk/nextjs/server" +import { notFound } from "next/navigation" +import { get } from "ronin" + +export default async function JournalPage() { + const user = await currentUser() + const flag = await get.featureFlag.with.name("JOURNAL") + + if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { + notFound() + } + + return +} diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 8cadcceb..6f3decf8 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -1,33 +1,30 @@ -import { SignedInClient } from "@/components/custom/clerk/signed-in-client" +import type { Viewport } from "next" import { Sidebar } from "@/components/custom/sidebar/sidebar" -import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute" -import { CommandPalette } from "@/components/ui/CommandPalette" -import { JazzClerkAuth, JazzProvider } from "@/lib/providers/jazz-provider" -import { currentUser } from "@clerk/nextjs/server" +import { CommandPalette } from "@/components/custom/command-palette/command-palette" +import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" +import { Shortcut } from "@/components/custom/Shortcut/shortcut" +import { GlobalKeyboardHandler } from "@/components/custom/global-keyboard-handler" -export default async function PageLayout({ children }: { children: React.ReactNode }) { - const user = await currentUser() - - if (!user) { - return - } +export const viewport: Viewport = { + width: "device-width, shrink-to-fit=no", + maximumScale: 1, + userScalable: false +} +export default function PageLayout({ children }: { children: React.ReactNode }) { return ( - - - -
- - +
+ + + + + -
-
- {children} -
-
-
- - - +
+
+ {children} +
+
+
) } diff --git a/web/app/(pages)/page.tsx b/web/app/(pages)/links/page.tsx similarity index 69% rename from web/app/(pages)/page.tsx rename to web/app/(pages)/links/page.tsx index daa5ee79..682f9c0a 100644 --- a/web/app/(pages)/page.tsx +++ b/web/app/(pages)/links/page.tsx @@ -1,5 +1,5 @@ import { LinkRoute } from "@/components/routes/link/LinkRoute" -export default function HomePage() { +export default function LinksPage() { return } diff --git a/web/app/(pages)/onboarding/page.tsx b/web/app/(pages)/onboarding/page.tsx new file mode 100644 index 00000000..b286035c --- /dev/null +++ b/web/app/(pages)/onboarding/page.tsx @@ -0,0 +1,5 @@ +import OnboardingRoute from "@/components/routes/OnboardingRoute" + +export default function EditProfilePage() { + return +} diff --git a/web/app/(pages)/pages/page.tsx b/web/app/(pages)/pages/page.tsx new file mode 100644 index 00000000..edc1ae2c --- /dev/null +++ b/web/app/(pages)/pages/page.tsx @@ -0,0 +1,5 @@ +import { PageRoute } from "@/components/routes/page/PageRoute" + +export default function Page() { + return +} diff --git a/web/app/(pages)/profile/_components/wrapper.tsx b/web/app/(pages)/profile/_components/wrapper.tsx index a8433c32..cc7257de 100644 --- a/web/app/(pages)/profile/_components/wrapper.tsx +++ b/web/app/(pages)/profile/_components/wrapper.tsx @@ -1,27 +1,19 @@ "use client" import { useAccount } from "@/lib/providers/jazz-provider" -import { useParams, useRouter } from "next/navigation" -import Link from "next/link" -import { LaIcon } from "@/components/custom/la-icon" -import { Icon } from "@/components/la-editor/components/ui/icon" +import { useUser } from "@clerk/nextjs" +import { useState, useRef, useCallback } from "react" +import { useParams } from "next/navigation" +import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" +import Link from "next/link" +import { Avatar, AvatarImage } from "@/components/ui/avatar" interface ProfileStatsProps { number: number label: string } -interface ProfileLinksProps { - linklabel?: string - link?: string - topic?: string -} - -interface ProfilePagesProps { - topic?: string -} - const ProfileStats: React.FC = ({ number, label }) => { return (
@@ -31,37 +23,65 @@ const ProfileStats: React.FC = ({ number, label }) => { ) } -const ProfileLinks: React.FC = ({ linklabel, link, topic }) => { - return ( -
-
-

{linklabel || "Untitled"}

-
- -

{link || "#"}

-
-
-
{topic || "Uncategorized"}
-
- ) -} - -const ProfilePages: React.FC = ({ topic }) => { - return ( -
-
{topic || "Uncategorized"}
-
- ) -} - export const ProfileWrapper = () => { const account = useAccount() const params = useParams() const username = params.username as string + const { user, isSignedIn } = useUser() + const avatarInputRef = useRef(null) - const router = useRouter() + const editAvatar = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + const imageUrl = URL.createObjectURL(file) + if (account.me && account.me.profile) { + account.me.profile.avatarUrl = imageUrl + } + } + } - const clickEdit = () => router.push("/edit-profile") + const [isEditing, setIsEditing] = useState(false) + const [newName, setNewName] = useState(account.me?.profile?.name || "") + const [error, setError] = useState("") + + const editProfileClicked = () => { + setIsEditing(true) + setError("") + } + + const changeName = (e: React.ChangeEvent) => { + setNewName(e.target.value) + setError("") + } + + const validateName = useCallback((name: string) => { + if (name.trim().length < 2) { + return "Name must be at least 2 characters long" + } + if (name.trim().length > 40) { + return "Name must not exceed 40 characters" + } + return "" + }, []) + + const saveProfile = () => { + const validationError = validateName(newName) + if (validationError) { + setError(validationError) + return + } + + if (account.me && account.me.profile) { + account.me.profile.name = newName.trim() + } + setIsEditing(false) + } + + const cancelEditing = () => { + setNewName(account.me?.profile?.name || "") + setIsEditing(false) + setError("") + } if (!account.me || !account.me.profile) { return ( @@ -74,7 +94,7 @@ export const ProfileWrapper = () => {

The link you followed may be broken, or the page may have been removed. Go back to - homepage + homepage .

@@ -86,32 +106,48 @@ export const ProfileWrapper = () => { return (
-

Profile

- +

Profile

{username}

-
+ +
-

{account.me.profile.name}

-
- @

{account.me.root?.username}

-
- - -

{account.me.root?.website}

-
+ {isEditing ? ( + <> + + {error &&

{error}

} + + ) : ( +

{account.me?.profile?.name}

+ )}
- + {isEditing ? ( +
+ + +
+ ) : ( + + )}
@@ -121,17 +157,9 @@ export const ProfileWrapper = () => {
- - {/*
-

Public Pages

- {account.me.root?.personalPages?.map((page, index) => )} +
+

Public profiles are coming soon

-
-

Public Links

- {account.me.root?.personalLinks?.map((link, index) => ( - - ))} -
*/}
) } diff --git a/web/app/(pages)/tasks/page.tsx b/web/app/(pages)/tasks/page.tsx new file mode 100644 index 00000000..cabad9b4 --- /dev/null +++ b/web/app/(pages)/tasks/page.tsx @@ -0,0 +1,15 @@ +import { TaskRoute } from "@/components/routes/task/TaskRoute" +import { currentUser } from "@clerk/nextjs/server" +import { notFound } from "next/navigation" +import { get } from "ronin" + +export default async function TaskPage() { + const user = await currentUser() + const flag = await get.featureFlag.with.name("TASK") + + if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { + notFound() + } + + return +} diff --git a/web/app/(pages)/topics/page.tsx b/web/app/(pages)/topics/page.tsx new file mode 100644 index 00000000..6251415e --- /dev/null +++ b/web/app/(pages)/topics/page.tsx @@ -0,0 +1,5 @@ +import { TopicRoute } from "@/components/routes/topics/TopicRoute" + +export default function Page() { + return +} diff --git a/web/app/(public)/layout.tsx b/web/app/(public)/layout.tsx new file mode 100644 index 00000000..95c7ee6d --- /dev/null +++ b/web/app/(public)/layout.tsx @@ -0,0 +1,7 @@ +export default function PublicLayout({ + children +}: Readonly<{ + children: React.ReactNode +}>) { + return
{children}
+} diff --git a/web/app/actions.ts b/web/app/actions.ts new file mode 100644 index 00000000..42f598f6 --- /dev/null +++ b/web/app/actions.ts @@ -0,0 +1,91 @@ +"use server" + +import { authedProcedure } from "@/lib/utils/auth-procedure" +import { currentUser } from "@clerk/nextjs/server" +import { get } from "ronin" +import { create } from "ronin" +import { z } from "zod" +import { ZSAError } from "zsa" + +const MAX_FILE_SIZE = 1 * 1024 * 1024 +const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] + +export const getFeatureFlag = authedProcedure + .input( + z.object({ + name: z.string() + }) + ) + .handler(async ({ input }) => { + const { name } = input + const flag = await get.featureFlag.with.name(name) + + return { flag } + }) + +export const sendFeedback = authedProcedure + .input( + z.object({ + content: z.string() + }) + ) + .handler(async ({ input, ctx }) => { + const { clerkUser } = ctx + const { content } = input + + try { + await create.feedback.with({ + message: content, + emailFrom: clerkUser?.emailAddresses[0].emailAddress + }) + } catch (error) { + console.error(error) + throw new ZSAError("ERROR", "Failed to send feedback") + } + }) + +export const storeImage = authedProcedure + .input( + z.object({ + file: z + .any() + .refine(file => file instanceof File, { + message: "Not a file" + }) + .refine(file => ALLOWED_FILE_TYPES.includes(file.type), { + message: "Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed." + }) + .refine(file => file.size <= MAX_FILE_SIZE, { + message: "File size exceeds the maximum limit of 1 MB." + }) + }), + { type: "formData" } + ) + .handler(async ({ ctx, input }) => { + const { file } = input + const { clerkUser } = ctx + + if (!clerkUser?.id) { + throw new ZSAError("NOT_AUTHORIZED", "You are not authorized to upload files") + } + + try { + const fileModel = await create.image.with({ + content: file, + name: file.name, + type: file.type, + size: file.size + }) + + return { fileModel } + } catch (error) { + console.error(error) + throw new ZSAError("ERROR", "Failed to store image") + } + }) + +export const isExistingUser = async () => { + const clerkUser = await currentUser() + const roninUser = await get.existingStripeSubscriber.with({ email: clerkUser?.emailAddresses[0].emailAddress }) + return clerkUser?.emailAddresses[0].emailAddress === roninUser?.email +} diff --git a/web/app/command-palette.css b/web/app/command-palette.css new file mode 100644 index 00000000..f4e67bc7 --- /dev/null +++ b/web/app/command-palette.css @@ -0,0 +1,127 @@ +@keyframes scaleIn { + 0% { + transform: scale(0.97) translateX(-50%); + opacity: 0; + } + to { + transform: scale(1) translateX(-50%); + opacity: 1; + } +} + +@keyframes scaleOut { + 0% { + transform: scale(1) translateX(-50%); + opacity: 1; + } + to { + transform: scale(0.97) translateX(-50%); + opacity: 0; + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + to { + opacity: 0.8; + } +} +@keyframes fadeOut { + 0% { + opacity: 0.8; + } + to { + opacity: 0; + } +} + +:root { + --cmdk-shadow: rgba(0, 0, 0, 0.12) 0px 4px 30px, rgba(0, 0, 0, 0.04) 0px 3px 17px, rgba(0, 0, 0, 0.04) 0px 2px 8px, + rgba(0, 0, 0, 0.04) 0px 1px 1px; + --cmdk-bg: rgb(255, 255, 255); + --cmdk-border-color: rgb(216, 216, 216); + + --cmdk-input-color: rgb(48, 48, 49); + --cmdk-input-placeholder: hsl(0, 0%, 56.1%); + + --cmdk-accent: rgb(243, 243, 243); +} + +.dark { + --cmdk-shadow: rgba(0, 0, 0, 0.15) 0px 4px 40px, rgba(0, 0, 0, 0.184) 0px 3px 20px, rgba(0, 0, 0, 0.184) 0px 3px 12px, + rgba(0, 0, 0, 0.184) 0px 2px 8px, rgba(0, 0, 0, 0.184) 0px 1px 1px; + --cmdk-bg: rgb(27, 28, 31); + --cmdk-border-color: rgb(56, 59, 65); + + --cmdk-input-color: rgb(228, 229, 233); + --cmdk-input-placeholder: hsl(0, 0%, 43.9%); + + --cmdk-accent: rgb(44, 48, 57); +} + +[la-overlay][cmdk-overlay] { + animation: fadeIn 0.2s ease; + @apply fixed inset-0 z-50 opacity-80; +} + +[la-dialog][cmdk-dialog] { + top: 15%; + transform: translateX(-50%); + max-width: 640px; + background: var(--cmdk-bg); + box-shadow: var(--cmdk-shadow); + transform-origin: left; + animation: scaleIn 0.2s ease; + transition: transform 0.1s ease; + border: 0.5px solid var(--cmdk-border-color); + @apply fixed left-1/2 z-50 w-full overflow-hidden rounded-lg outline-none; +} + +[la-dialog][cmdk-dialog][data-state="closed"] { + animation: scaleOut 0.2s ease; +} + +.la [cmdk-input-wrapper] { + border-bottom: 1px solid var(--cmdk-border-color); + height: 62px; + font-size: 1.125rem; + @apply relative; +} + +.la [cmdk-input] { + font-size: inherit; + height: 62px; + color: var(--cmdk-input-color); + caret-color: rgb(110, 94, 210); + @apply m-0 w-full appearance-none border-none bg-transparent p-5 outline-none; +} + +.la [cmdk-input]::placeholder { + color: var(--cmdk-input-placeholder); +} + +.la [cmdk-list] { + max-height: 400px; + overflow: auto; + overscroll-behavior: contain; + transition: 100ms ease; + transition-property: height; + @apply p-2; +} + +.la [cmdk-group-heading] { + font-size: 13px; + height: 30px; + @apply text-muted-foreground flex items-center px-2; +} + +.la [cmdk-empty] { + @apply text-muted-foreground flex h-16 items-center justify-center whitespace-pre-wrap text-sm; +} + +.la [cmdk-item] { + scroll-margin: 8px 0; + @apply flex min-h-10 cursor-pointer items-center gap-3 rounded-md px-2 text-sm aria-selected:bg-[var(--cmdk-accent)]; +} diff --git a/web/app/custom.css b/web/app/custom.css new file mode 100644 index 00000000..3d0b3660 --- /dev/null +++ b/web/app/custom.css @@ -0,0 +1,11 @@ +:root { + --link-background-muted: hsl(0, 0%, 97.3%); + --link-border-after: hsl(0, 0%, 91%); + --link-shadow: hsl(240, 5.6%, 82.5%); +} + +.dark { + --link-background-muted: hsl(220, 6.7%, 8.8%); + --link-border-after: hsl(230, 10%, 11.8%); + --link-shadow: hsl(234.9, 27.1%, 25.3%); +} diff --git a/web/app/fonts.ts b/web/app/fonts.ts new file mode 100644 index 00000000..bcdcb16f --- /dev/null +++ b/web/app/fonts.ts @@ -0,0 +1,7 @@ +import { Raleway } from "next/font/google" +export { GeistSans } from "geist/font/sans" +export { GeistMono } from "geist/font/mono" +// import { Inter } from "next/font/google" + +// export const inter = Inter({ subsets: ["latin"] }) +export const raleway = Raleway({ subsets: ["latin"] }) diff --git a/web/app/global-error.tsx b/web/app/global-error.tsx new file mode 100644 index 00000000..1d4c5617 --- /dev/null +++ b/web/app/global-error.tsx @@ -0,0 +1,23 @@ +"use client" + +import * as Sentry from "@sentry/nextjs" +import NextError from "next/error" +import { useEffect } from "react" + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error) + }, [error]) + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ) +} diff --git a/web/app/globals.css b/web/app/globals.css index 623eca9c..24faef84 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -71,3 +71,15 @@ @apply bg-background text-foreground; } } + +@import "./command-palette.css"; +@import "./custom.css"; + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 7a526ab8..871c2b75 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,25 +1,44 @@ -import type { Metadata } from "next" -import { Inter as FontSans } from "next/font/google" +import type { Metadata, Viewport } from "next" import { cn } from "@/lib/utils" import { ThemeProvider } from "@/lib/providers/theme-provider" import "./globals.css" - import { ClerkProviderClient } from "@/components/custom/clerk/clerk-provider-client" import { JotaiProvider } from "@/lib/providers/jotai-provider" import { Toaster } from "@/components/ui/sonner" import { ConfirmProvider } from "@/lib/providers/confirm-provider" import { DeepLinkProvider } from "@/lib/providers/deep-link-provider" +import { GeistMono, GeistSans } from "./fonts" +import { JazzAndAuth } from "@/lib/providers/jazz-provider" +import { TooltipProvider } from "@/components/ui/tooltip" -const fontSans = FontSans({ - subsets: ["latin"], - variable: "--font-sans" -}) +export const viewport: Viewport = { + width: "device-width", + height: "device-height", + initialScale: 1, + viewportFit: "cover" +} export const metadata: Metadata = { title: "Learn Anything", description: "Organize world's knowledge, explore connections and curate learning paths" } +const Providers = ({ children }: { children: React.ReactNode }) => ( + + + + + + + {children} + + + + + + +) + export default function RootLayout({ children }: Readonly<{ @@ -27,20 +46,13 @@ export default function RootLayout({ }>) { return ( - - - - - - - {children} - - - - - - - + + + {children} + + + + ) } diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 00000000..5cb37316 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,5 @@ +import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute" + +export default function HomePage() { + return +} diff --git a/web/components/custom/GuideCommunityToggle.tsx b/web/components/custom/GuideCommunityToggle.tsx new file mode 100644 index 00000000..70adc389 --- /dev/null +++ b/web/components/custom/GuideCommunityToggle.tsx @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react" +import { useRouter, usePathname } from "next/navigation" + +import { cn } from "@/lib/utils" + +interface GuideCommunityToggleProps { + topicName: string +} + +export const GuideCommunityToggle: React.FC = ({ topicName }) => { + const router = useRouter() + const pathname = usePathname() + const [view, setView] = useState<"guide" | "community">("guide") + + useEffect(() => { + setView(pathname.includes("/community/") ? "community" : "guide") + }, [pathname]) + + const handleToggle = (newView: "guide" | "community") => { + setView(newView) + router.push(newView === "community" ? `/community/${topicName}` : `/${topicName}`) + } + + return ( +
+
+ + +
+ ) +} diff --git a/web/components/custom/QuestionList.tsx b/web/components/custom/QuestionList.tsx new file mode 100644 index 00000000..979c8af3 --- /dev/null +++ b/web/components/custom/QuestionList.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from "react" +import { Input } from "../ui/input" +import { LaIcon } from "./la-icon" +import { cn } from "@/lib/utils" + +interface Question { + id: string + title: string + author: string + timestamp: string +} + +interface QuestionListProps { + topicName: string + onSelectQuestion: (question: Question) => void + selectedQuestionId?: string +} + +export function QuestionList({ topicName, onSelectQuestion, selectedQuestionId }: QuestionListProps) { + const [questions, setQuestions] = useState([]) + + useEffect(() => { + const mockQuestions: Question[] = Array(10) + .fill(null) + .map((_, index) => ({ + id: (index + 1).toString(), + title: "What can I do offline in Figma?", + author: "Ana", + timestamp: "13:35" + })) + setQuestions(mockQuestions) + }, [topicName]) + + return ( +
+
+ {questions.map(question => ( +
onSelectQuestion(question)} + > +
+
+
+

{question.author}

+
+

{question.timestamp}

+
+

{question.title}

+
+ ))} +
+
+ + +
+
+ ) +} diff --git a/web/components/custom/QuestionThread.tsx b/web/components/custom/QuestionThread.tsx new file mode 100644 index 00000000..69e13df0 --- /dev/null +++ b/web/components/custom/QuestionThread.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect, useRef } from "react" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { LaIcon } from "./la-icon" +interface Answer { + id: string + author: string + content: string + timestamp: string + replies?: Answer[] +} + +interface QuestionThreadProps { + question: { + id: string + title: string + author: string + timestamp: string + } + onClose: () => void +} + +export function QuestionThread({ question, onClose }: QuestionThreadProps) { + const [answers, setAnswers] = useState([]) + const [newAnswer, setNewAnswer] = useState("") + const [replyTo, setReplyTo] = useState(null) + const [replyToAuthor, setReplyToAuthor] = useState(null) + const inputRef = useRef(null) + + useEffect(() => { + const mockAnswers: Answer[] = [ + { + id: "1", + author: "Noone", + content: + "Just press Command + Just press Command + Just press Command + Just press Command + Just press Command +", + timestamp: "14:40" + } + ] + setAnswers(mockAnswers) + }, [question.id]) + + const sendReply = (answer: Answer) => { + setReplyTo(answer) + setReplyToAuthor(answer.author) + setNewAnswer(`@${answer.author} `) + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + const length = inputRef.current.value.length + inputRef.current.setSelectionRange(length, length) + } + }, 0) + } + + const changeInput = (e: React.ChangeEvent) => { + const newValue = e.target.value + setNewAnswer(newValue) + + if (replyToAuthor && !newValue.startsWith(`@${replyToAuthor}`)) { + setReplyTo(null) + setReplyToAuthor(null) + } + } + + const sendAnswer = (e: React.FormEvent) => { + e.preventDefault() + if (newAnswer.trim()) { + const newReply: Answer = { + id: Date.now().toString(), + author: "Me", + content: newAnswer, + timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + } + + if (replyTo) { + setAnswers(prevAnswers => + prevAnswers.map(answer => + answer.id === replyTo.id ? { ...answer, replies: [...(answer.replies || []), newReply] } : answer + ) + ) + } else { + setAnswers(prevAnswers => [...prevAnswers, newReply]) + } + setNewAnswer("") + setReplyTo(null) + setReplyToAuthor(null) + } + } + + const renderAnswers = (answers: Answer[], isReply = false) => ( +
+ {answers.map(answer => ( +
+
+
+
+ {answer.author} +
+
+ + + + +
+ + sendReply(answer)}> +
+ + Reply +
+
+
+
+
+ {answer.timestamp} +
+
+
+

{answer.content}

+ +
+ {answer.replies && renderAnswers(answer.replies, true)} +
+ ))} +
+ ) + + return ( +
+
+
+
+
+
+

{question.author}

+
+ +
+

{question.title}

+

{question.timestamp}

+
+
+
{renderAnswers(answers)}
+
+
+
+ +
+ +
+
+
+ ) +} diff --git a/web/components/custom/Shortcut/shortcut.tsx b/web/components/custom/Shortcut/shortcut.tsx new file mode 100644 index 00000000..070d446d --- /dev/null +++ b/web/components/custom/Shortcut/shortcut.tsx @@ -0,0 +1,164 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { atom, useAtom } from "jotai" +import { Sheet, SheetPortal, SheetOverlay, SheetTitle, sheetVariants, SheetDescription } from "@/components/ui/sheet" +import { LaIcon } from "../la-icon" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" + +export const showShortcutAtom = atom(false) + +type ShortcutItem = { + label: string + keys: string[] + then?: string[] +} + +type ShortcutSection = { + title: string + shortcuts: ShortcutItem[] +} + +const SHORTCUTS: ShortcutSection[] = [ + { + title: "General", + shortcuts: [ + { label: "Open command menu", keys: ["⌘", "k"] }, + { label: "Log out", keys: ["⌥", "⇧", "q"] } + ] + }, + { + title: "Navigation", + shortcuts: [ + { label: "Go to link", keys: ["G"], then: ["L"] }, + { label: "Go to page", keys: ["G"], then: ["P"] }, + { label: "Go to topic", keys: ["G"], then: ["T"] } + ] + }, + { + title: "Links", + shortcuts: [{ label: "Create new link", keys: ["c"] }] + }, + { + title: "Pages", + shortcuts: [{ label: "Create new page", keys: ["p"] }] + } +] + +const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => ( + +) + +const ShortcutItem: React.FC = ({ label, keys, then }) => ( +
+
+ {label} +
+
+ + + {keys.map((key, index) => ( + + ))} + {then && ( + <> + then + {then.map((key, index) => ( + + ))} + + )} + + +
+
+) + +const ShortcutSection: React.FC = ({ title, shortcuts }) => ( +
+

{title}

+
+ {shortcuts.map((shortcut, index) => ( + + ))} +
+
+) + +export function Shortcut() { + const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom) + const [searchQuery, setSearchQuery] = React.useState("") + + const { disableKeydown } = useKeyboardManager("shortcutSection") + + React.useEffect(() => { + disableKeydown(showShortcut) + }, [showShortcut, disableKeydown]) + + const filteredShortcuts = React.useMemo(() => { + if (!searchQuery) return SHORTCUTS + + return SHORTCUTS.map(section => ({ + ...section, + shortcuts: section.shortcuts.filter(shortcut => shortcut.label.toLowerCase().includes(searchQuery.toLowerCase())) + })).filter(section => section.shortcuts.length > 0) + }, [searchQuery]) + + return ( + + + + +
+ Keyboard Shortcuts + Quickly navigate around the app + +
+ + + + Close + +
+ +
+
+ + setSearchQuery(e.target.value)} + /> + +
+ +
+
+
+ {filteredShortcuts.map((section, index) => ( + + ))} +
+
+
+
+
+
+ ) +} diff --git a/web/components/custom/clerk/clerk-provider-client.tsx b/web/components/custom/clerk/clerk-provider-client.tsx index 6edee0ca..69a5acdc 100644 --- a/web/components/custom/clerk/clerk-provider-client.tsx +++ b/web/components/custom/clerk/clerk-provider-client.tsx @@ -1,7 +1,22 @@ "use client" import { ClerkProvider } from "@clerk/nextjs" +import { dark } from "@clerk/themes" +import { useTheme } from "next-themes" -export const ClerkProviderClient = ({ children }: { children: React.ReactNode }) => { - return {children} +interface ClerkProviderClientProps { + children: React.ReactNode +} + +export const ClerkProviderClient: React.FC = ({ children }) => { + const { theme, systemTheme } = useTheme() + + const isDarkTheme = theme === "dark" || (theme === "system" && systemTheme === "dark") + + const appearance = { + baseTheme: isDarkTheme ? dark : undefined, + variables: { colorPrimary: isDarkTheme ? "#dddddd" : "#2e2e2e" } + } + + return {children} } diff --git a/web/components/custom/clerk/sign-in-client.tsx b/web/components/custom/clerk/sign-in-client.tsx index a789546a..cd4f6223 100644 --- a/web/components/custom/clerk/sign-in-client.tsx +++ b/web/components/custom/clerk/sign-in-client.tsx @@ -1,7 +1,16 @@ -"use client" - import { SignIn } from "@clerk/nextjs" export const SignInClient = () => { - return + return ( +
+ +
+ ) } diff --git a/web/components/custom/column.tsx b/web/components/custom/column.tsx new file mode 100644 index 00000000..d1482f93 --- /dev/null +++ b/web/components/custom/column.tsx @@ -0,0 +1,42 @@ +import React from "react" +import { cn } from "@/lib/utils" + +interface ColumnWrapperProps extends React.HTMLAttributes { + style?: { [key: string]: string } +} + +interface ColumnTextProps extends React.HTMLAttributes {} + +const ColumnWrapper = React.forwardRef( + ({ children, className, style, ...props }, ref) => ( +
+ {children} +
+ ) +) + +ColumnWrapper.displayName = "ColumnWrapper" + +const ColumnText = React.forwardRef(({ children, className, ...props }, ref) => ( + + {children} + +)) + +ColumnText.displayName = "ColumnText" + +export const Column = { + Wrapper: ColumnWrapper, + Text: ColumnText +} diff --git a/web/components/custom/command-palette/command-data.ts b/web/components/custom/command-palette/command-data.ts new file mode 100644 index 00000000..01221ec1 --- /dev/null +++ b/web/components/custom/command-palette/command-data.ts @@ -0,0 +1,135 @@ +import { icons } from "lucide-react" +import { useCommandActions } from "./hooks/use-command-actions" +import { LaAccount } from "@/lib/schema" +import { HTMLLikeElement } from "@/lib/utils" + +export type CommandAction = string | (() => void) + +export interface CommandItemType { + id?: string + icon?: keyof typeof icons + value: string + label: HTMLLikeElement | string + action: CommandAction + payload?: any + shortcut?: string +} + +export type CommandGroupType = Array<{ + heading?: string + items: CommandItemType[] +}> + +const createNavigationItem = ( + icon: keyof typeof icons, + value: string, + path: string, + actions: ReturnType +): CommandItemType => ({ + icon, + value: `Go to ${value}`, + label: { + tag: "span", + children: ["Go to ", { tag: "span", attributes: { className: "font-semibold" }, children: [value] }] + }, + action: () => actions.navigateTo(path) +}) + +export const createCommandGroups = ( + actions: ReturnType, + me: LaAccount +): Record => ({ + home: [ + { + heading: "General", + items: [ + { + icon: "SunMoon", + value: "Change Theme...", + label: "Change Theme...", + action: "CHANGE_PAGE", + payload: "changeTheme" + }, + { + icon: "Copy", + value: "Copy Current URL", + label: "Copy Current URL", + action: actions.copyCurrentURL + } + ] + }, + { + heading: "Personal Links", + items: [ + { + icon: "TextSearch", + value: "Search Links...", + label: "Search Links...", + action: "CHANGE_PAGE", + payload: "searchLinks" + }, + { + icon: "Plus", + value: "Create New Link...", + label: "Create New Link...", + action: () => actions.navigateTo("/links?create=true") + } + ] + }, + { + heading: "Personal Pages", + items: [ + { + icon: "FileSearch", + value: "Search Pages...", + label: "Search Pages...", + action: "CHANGE_PAGE", + payload: "searchPages" + }, + { + icon: "Plus", + value: "Create New Page...", + label: "Create New Page...", + action: () => actions.createNewPage(me) + } + ] + }, + { + heading: "Navigation", + items: [ + createNavigationItem("ArrowRight", "Links", "/links", actions), + createNavigationItem("ArrowRight", "Pages", "/pages", actions), + createNavigationItem("ArrowRight", "Search", "/search", actions), + createNavigationItem("ArrowRight", "Profile", "/profile", actions), + createNavigationItem("ArrowRight", "Settings", "/settings", actions) + ] + } + ], + searchLinks: [], + searchPages: [], + topics: [], + changeTheme: [ + { + items: [ + { + icon: "Moon", + value: "Change Theme to Dark", + label: "Change Theme to Dark", + action: () => actions.changeTheme("dark") + }, + { + icon: "Sun", + value: "Change Theme to Light", + label: "Change Theme to Light", + action: () => actions.changeTheme("light") + }, + { + icon: "Monitor", + value: "Change Theme to System", + label: "Change Theme to System", + action: () => actions.changeTheme("system") + } + ] + } + ] +}) diff --git a/web/components/custom/command-palette/command-items.tsx b/web/components/custom/command-palette/command-items.tsx new file mode 100644 index 00000000..51f64d3a --- /dev/null +++ b/web/components/custom/command-palette/command-items.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Command } from "cmdk" +import { CommandSeparator, CommandShortcut } from "@/components/ui/command" +import { LaIcon } from "@/components/custom/la-icon" +import { CommandItemType, CommandAction } from "./command-data" +import { HTMLLikeElement, renderHTMLLikeElement } from "@/lib/utils" + +export interface CommandItemProps extends Omit { + action: CommandAction + handleAction: (action: CommandAction, payload?: any) => void +} + +const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = React.memo(({ content }) => { + return {renderHTMLLikeElement(content)} +}) + +HTMLLikeRenderer.displayName = "HTMLLikeRenderer" + +export const CommandItem: React.FC = React.memo( + ({ icon, label, action, payload, shortcut, handleAction, ...item }) => ( + handleAction(action, payload)}> + {icon && } + + {shortcut && {shortcut}} + + ) +) + +CommandItem.displayName = "CommandItem" + +export interface CommandGroupProps { + heading?: string + items: CommandItemType[] + handleAction: (action: CommandAction, payload?: any) => void + isLastGroup: boolean +} + +export const CommandGroup: React.FC = React.memo(({ heading, items, handleAction, isLastGroup }) => { + return ( + <> + {heading ? ( + + {items.map((item, index) => ( + + ))} + + ) : ( + items.map((item, index) => ( + + )) + )} + {!isLastGroup && } + + ) +}) + +CommandGroup.displayName = "CommandGroup" diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx new file mode 100644 index 00000000..db76b28b --- /dev/null +++ b/web/components/custom/command-palette/command-palette.tsx @@ -0,0 +1,229 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Command } from "cmdk" +import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" +import { CommandGroup } from "./command-items" +import { CommandAction, CommandItemType, createCommandGroups } from "./command-data" +import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { searchSafeRegExp } from "@/lib/utils" +import { GraphNode } from "@/components/routes/public/PublicHomeRoute" +import { useCommandActions } from "./hooks/use-command-actions" +import { atom, useAtom } from "jotai" + +const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default) + +const filterItems = (items: CommandItemType[], searchRegex: RegExp) => + items.filter(item => searchRegex.test(item.value)).slice(0, 10) + +export const commandPaletteOpenAtom = atom(false) + +export function CommandPalette() { + const { me } = useAccountOrGuest() + + if (me._type === "Anonymous") return null + + return +} + +export function RealCommandPalette() { + const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } }) + const dialogRef = React.useRef(null) + const [inputValue, setInputValue] = React.useState("") + const [activePage, setActivePage] = React.useState("home") + const [open, setOpen] = useAtom(commandPaletteOpenAtom) + + const actions = useCommandActions() + const commandGroups = React.useMemo(() => me && createCommandGroups(actions, me), [actions, me]) + + const raw_graph_data = React.use(graph_data_promise) as GraphNode[] + + const bounce = React.useCallback(() => { + if (dialogRef.current) { + dialogRef.current.style.transform = "scale(0.99) translateX(-50%)" + setTimeout(() => { + if (dialogRef.current) { + dialogRef.current.style.transform = "" + } + }, 100) + } + }, []) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + bounce() + } + + if (activePage !== "home" && !inputValue && e.key === "Backspace") { + e.preventDefault() + setActivePage("home") + setInputValue("") + bounce() + } + }, + [activePage, inputValue, bounce] + ) + + const topics = React.useMemo( + () => ({ + heading: "Topics", + items: raw_graph_data.map(topic => ({ + icon: "Circle" as const, + value: topic?.prettyName || "", + label: topic?.prettyName || "", + action: () => actions.navigateTo(`/${topic?.name}`) + })) + }), + [raw_graph_data, actions] + ) + + const personalLinks = React.useMemo( + () => ({ + heading: "Personal Links", + items: + me?.root.personalLinks?.map(link => ({ + id: link?.id, + icon: "Link" as const, + value: link?.title || "Untitled", + label: link?.title || "Untitled", + action: () => actions.openLinkInNewTab(link?.url || "#") + })) || [] + }), + [me?.root.personalLinks, actions] + ) + + const personalPages = React.useMemo( + () => ({ + heading: "Personal Pages", + items: + me?.root.personalPages?.map(page => ({ + id: page?.id, + icon: "FileText" as const, + value: page?.title || "Untitled", + label: page?.title || "Untitled", + action: () => actions.navigateTo(`/pages/${page?.id}`) + })) || [] + }), + [me?.root.personalPages, actions] + ) + + const getFilteredCommands = React.useCallback(() => { + if (!commandGroups) return [] + + const searchRegex = searchSafeRegExp(inputValue) + + if (activePage === "home") { + if (!inputValue) { + return commandGroups.home + } + + const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics] + + return allGroups + .map(group => ({ + heading: group.heading, + items: filterItems(group.items, searchRegex) + })) + .filter(group => group.items.length > 0) + } + + switch (activePage) { + case "searchLinks": + return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }] + case "searchPages": + return [...commandGroups.searchPages, { items: filterItems(personalPages.items, searchRegex) }] + default: + const pageCommands = commandGroups[activePage] + if (!inputValue) return pageCommands + return pageCommands + .map(group => ({ + heading: group.heading, + items: filterItems(group.items, searchRegex) + })) + .filter(group => group.items.length > 0) + } + }, [inputValue, activePage, commandGroups, personalLinks, personalPages, topics]) + + const handleAction = React.useCallback( + (action: CommandAction, payload?: any) => { + const closeDialog = () => { + setOpen(false) + } + + if (typeof action === "function") { + action() + closeDialog() + return + } + + switch (action) { + case "CHANGE_PAGE": + if (payload) { + setActivePage(payload) + setInputValue("") + bounce() + } else { + console.error(`Invalid page: ${payload}`) + } + break + default: + console.log(`Unhandled action: ${action}`) + closeDialog() + } + }, + [bounce, setOpen] + ) + + const filteredCommands = React.useMemo(() => getFilteredCommands(), [getFilteredCommands]) + + const commandKey = React.useMemo(() => { + return filteredCommands + .map(group => { + const itemsKey = group.items.map(item => `${item.label}-${item.value}`).join("|") + return `${group.heading}:${itemsKey}` + }) + .join("__") + }, [filteredCommands]) + + if (!me) return null + + return ( + + + + + + Command Palette + Search for commands and actions + + + +
+ +
+ + + No results found. + {filteredCommands.map((group, index, array) => ( + + ))} + +
+
+
+
+ ) +} diff --git a/web/components/custom/command-palette/hooks/use-command-actions.ts b/web/components/custom/command-palette/hooks/use-command-actions.ts new file mode 100644 index 00000000..589d95d6 --- /dev/null +++ b/web/components/custom/command-palette/hooks/use-command-actions.ts @@ -0,0 +1,53 @@ +import * as React from "react" +import { ensureUrlProtocol } from "@/lib/utils" +import { useTheme } from "next-themes" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { LaAccount } from "@/lib/schema" +import { usePageActions } from "@/components/routes/page/hooks/use-page-actions" + +export const useCommandActions = () => { + const { setTheme } = useTheme() + const router = useRouter() + const { newPage } = usePageActions() + + const changeTheme = React.useCallback( + (theme: string) => { + setTheme(theme) + toast.success(`Theme changed to ${theme}.`, { position: "bottom-right" }) + }, + [setTheme] + ) + + const navigateTo = React.useCallback( + (path: string) => { + router.push(path) + }, + [router] + ) + + const openLinkInNewTab = React.useCallback((url: string) => { + window.open(ensureUrlProtocol(url), "_blank") + }, []) + + const copyCurrentURL = React.useCallback(() => { + navigator.clipboard.writeText(window.location.href) + toast.success("URL copied to clipboard.", { position: "bottom-right" }) + }, []) + + const createNewPage = React.useCallback( + (me: LaAccount) => { + const page = newPage(me) + router.push(`/pages/${page.id}`) + }, + [router, newPage] + ) + + return { + changeTheme, + navigateTo, + openLinkInNewTab, + copyCurrentURL, + createNewPage + } +} diff --git a/web/components/custom/content-header.tsx b/web/components/custom/content-header.tsx index 500e0254..550f3459 100644 --- a/web/components/custom/content-header.tsx +++ b/web/components/custom/content-header.tsx @@ -1,12 +1,12 @@ "use client" -import React from "react" +import * as React from "react" import { Button } from "../ui/button" -import { PanelLeftIcon } from "lucide-react" import { useAtom } from "jotai" import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar" -import { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { cn } from "@/lib/utils" +import { LaIcon } from "./la-icon" type ContentHeaderProps = Omit, "title"> @@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef { className="text-primary/60" onClick={handleClick} > - +
) diff --git a/web/components/custom/discordIcon.tsx b/web/components/custom/discordIcon.tsx new file mode 100644 index 00000000..9d172b4b --- /dev/null +++ b/web/components/custom/discordIcon.tsx @@ -0,0 +1,23 @@ +export const DiscordIcon = () => ( + + + + + +) diff --git a/web/components/custom/global-keyboard-handler.tsx b/web/components/custom/global-keyboard-handler.tsx new file mode 100644 index 00000000..4c7aa6ba --- /dev/null +++ b/web/components/custom/global-keyboard-handler.tsx @@ -0,0 +1,130 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { useRouter } from "next/navigation" +import queryString from "query-string" +import { usePageActions } from "../routes/page/hooks/use-page-actions" +import { useAuth } from "@clerk/nextjs" +import { isModKey } from "@/lib/utils" +import { useAtom } from "jotai" +import { commandPaletteOpenAtom } from "./command-palette/command-palette" + +type RegisterKeyDownProps = { + trigger: KeyFilter + handler: (event: KeyboardEvent) => void + options?: Options +} + +function RegisterKeyDown({ trigger, handler, options }: RegisterKeyDownProps) { + useKeyDown(trigger, handler, options) + return null +} + +type Sequence = { + [key: string]: string +} + +const SEQUENCES: Sequence = { + GL: "/links", + GP: "/pages", + GT: "/topics" +} + +const MAX_SEQUENCE_TIME = 1000 + +export function GlobalKeyboardHandler() { + const [openCommandPalette, setOpenCommandPalette] = useAtom(commandPaletteOpenAtom) + const [sequence, setSequence] = useState([]) + const { signOut } = useAuth() + const router = useRouter() + const { me } = useAccountOrGuest() + const { newPage } = usePageActions() + + const resetSequence = useCallback(() => { + setSequence([]) + }, []) + + const checkSequence = useCallback(() => { + const sequenceStr = sequence.join("") + const route = SEQUENCES[sequenceStr] + + if (route) { + console.log(`Navigating to ${route}...`) + router.push(route) + resetSequence() + } + }, [sequence, router, resetSequence]) + + const goToNewLink = useCallback( + (event: KeyboardEvent) => { + if (event.metaKey || event.altKey) { + return + } + + router.push(`/links?${queryString.stringify({ create: true })}`) + }, + [router] + ) + + const goToNewPage = useCallback( + (event: KeyboardEvent) => { + if (event.metaKey || event.altKey) { + return + } + + if (!me || me._type === "Anonymous") { + return + } + + const page = newPage(me) + + router.push(`/pages/${page.id}`) + }, + [me, newPage, router] + ) + + useKeyDown( + e => e.altKey && e.shiftKey && e.code === "KeyQ", + () => { + signOut() + } + ) + + useKeyDown( + () => true, + e => { + const key = e.key.toUpperCase() + setSequence(prev => [...prev, key]) + } + ) + + useKeyDown( + e => isModKey(e) && e.code === "KeyK", + e => { + e.preventDefault() + setOpenCommandPalette(prev => !prev) + } + ) + + useEffect(() => { + checkSequence() + + const timeoutId = setTimeout(() => { + resetSequence() + }, MAX_SEQUENCE_TIME) + + return () => clearTimeout(timeoutId) + }, [sequence, checkSequence, resetSequence]) + + return ( + me && + me._type !== "Anonymous" && ( + <> + + + + ) + ) +} diff --git a/web/components/custom/learn-anything-onboarding.tsx b/web/components/custom/learn-anything-onboarding.tsx new file mode 100644 index 00000000..405f0e41 --- /dev/null +++ b/web/components/custom/learn-anything-onboarding.tsx @@ -0,0 +1,100 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { atom, useAtom } from "jotai" +import { atomWithStorage } from "jotai/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from "@/components/ui/alert-dialog" +import { isExistingUser } from "@/app/actions" +import { usePathname } from "next/navigation" + +const hasVisitedAtom = atomWithStorage("hasVisitedLearnAnything", false) +const isDialogOpenAtom = atom(true) + +export function LearnAnythingOnboarding() { + const pathname = usePathname() + const [hasVisited, setHasVisited] = useAtom(hasVisitedAtom) + const [isOpen, setIsOpen] = useAtom(isDialogOpenAtom) + const [isFetching, setIsFetching] = useState(true) + const [isExisting, setIsExisting] = useState(false) + + useEffect(() => { + const loadUser = async () => { + try { + const existingUser = await isExistingUser() + setIsExisting(existingUser) + setIsOpen(true) + } catch (error) { + console.error("Error loading user:", error) + } finally { + setIsFetching(false) + } + } + + if (!hasVisited && pathname !== "/") { + loadUser() + } + }, [hasVisited, pathname, setIsOpen]) + + const handleClose = () => { + setIsOpen(false) + setHasVisited(true) + } + + if (hasVisited || isFetching) return null + + return ( + + + + +

Welcome to Learn Anything!

+
+
+ + + {isExisting && ( + <> +

Existing Customer Notice

+

+ We noticed you are an existing Learn Anything customer. We sincerely apologize for any broken experience + you may have encountered on the old website. We've been working hard on this new version, which + addresses previous issues and offers more features. As an early customer, you're locked in at the{" "} + $3 price for our upcoming pro version. Thank you for your support! +

+ + )} +

+ Learn Anything is a learning platform that organizes knowledge in a social way. You can create pages, add + links, track learning status of any topic, and more things in the future. +

+

Try do these quick onboarding steps to get a feel for the product:

+
    +
  • Create your first page
  • +
  • Add a link to a resource
  • +
  • Update your learning status on a topic
  • +
+

+ If you have any questions, don't hesitate to reach out. Click on question mark button in the bottom + right corner and enter your message. +

+
+ + + Close + Get Started + +
+
+ ) +} + +export default LearnAnythingOnboarding diff --git a/web/components/custom/learning-state-selector.tsx b/web/components/custom/learning-state-selector.tsx index c1b2e4a9..984f8456 100644 --- a/web/components/custom/learning-state-selector.tsx +++ b/web/components/custom/learning-state-selector.tsx @@ -8,6 +8,7 @@ import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { linkLearningStateSelectorAtom } from "@/store/link" import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" import { ScrollArea } from "@/components/ui/scroll-area" +import { icons } from "lucide-react" interface LearningStateSelectorProps { showSearch?: boolean @@ -16,15 +17,17 @@ interface LearningStateSelectorProps { value?: string onChange: (value: LearningStateValue) => void className?: string + defaultIcon?: keyof typeof icons } export const LearningStateSelector: React.FC = ({ showSearch = true, - defaultLabel = "Select state", + defaultLabel = "State", searchPlaceholder = "Search state...", value, onChange, - className + className, + defaultIcon }) => { const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value]) @@ -34,6 +37,9 @@ export const LearningStateSelector: React.FC = ({ setIsLearningStateSelectorOpen(false) } + const iconName = selectedLearningState?.icon || defaultIcon + const labelText = selectedLearningState?.label || defaultLabel + return ( @@ -44,21 +50,12 @@ export const LearningStateSelector: React.FC = ({ variant="secondary" className={cn("gap-x-2 text-sm", className)} > - {selectedLearningState?.icon && ( - - )} - - {selectedLearningState?.label || defaultLabel} - + {iconName && } + {labelText && {labelText}} - e.preventDefault()} - > + {LEARNING_STATES.map(ls => ( - + {ls.icon && } {ls.label} (null) + const { isPending, execute } = useServerAction(sendFeedback) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + content: "" + } + }) + + async function onSubmit(values: z.infer) { + const [, err] = await execute(values) + + if (err) { + toast.error("Failed to send feedback") + console.error(err) + return + } + + form.reset({ content: "" }) + editorRef.current?.editor?.commands.clearContent() + setOpen(false) + toast.success("Feedback sent") + } + + return ( + + + + + + + + +
+ + + Share feedback + + Your feedback helps us improve. Please share your thoughts, ideas, and suggestions + + + + ( + + Content + + + + + + )} + /> + + + Cancel + + + + +
+
+
+ ) +} diff --git a/web/components/custom/sidebar/partial/journal-section.tsx b/web/components/custom/sidebar/partial/journal-section.tsx new file mode 100644 index 00000000..19879f84 --- /dev/null +++ b/web/components/custom/sidebar/partial/journal-section.tsx @@ -0,0 +1,113 @@ +import Link from "next/link" +import { usePathname } from "next/navigation" +import { useAccount } from "@/lib/providers/jazz-provider" +import { LaIcon } from "../../la-icon" +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" +import { useAuth, useUser } from "@clerk/nextjs" +import { getFeatureFlag } from "@/app/actions" + +export const JournalSection: React.FC = () => { + const { me } = useAccount() + const journalEntries = me?.root?.journalEntries + const pathname = usePathname() + const isActive = pathname === "/journal" + + const [isFetching, setIsFetching] = useState(false) + const [isFeatureActive, setIsFeatureActive] = useState(false) + const { isLoaded, isSignedIn } = useAuth() + const { user } = useUser() + + useEffect(() => { + async function checkFeatureFlag() { + setIsFetching(true) + + if (isLoaded && isSignedIn) { + const [data, err] = await getFeatureFlag({ name: "JOURNAL" }) + + if (err) { + console.error(err) + setIsFetching(false) + return + } + + if (user?.emailAddresses.some(email => data.flag?.emails.includes(email.emailAddress))) { + setIsFeatureActive(true) + } + setIsFetching(false) + } + } + + checkFeatureFlag() + }, [isLoaded, isSignedIn, user]) + + if (!isLoaded || !isSignedIn) { + return
Loading...
+ } + + if (!me) return null + + if (!isFeatureActive) { + return null + } + + return ( +
+ + {journalEntries && journalEntries.length > 0 && } +
+ ) +} + +interface JournalHeaderProps { + entriesCount: number + isActive: boolean +} + +const JournalSectionHeader: React.FC = ({ entriesCount, isActive }) => ( +
+ +

+ Journal + {entriesCount > 0 && ({entriesCount})} +

+ +
+) + +interface JournalEntryListProps { + entries: any[] +} + +const JournalEntryList: React.FC = ({ entries }) => { + return ( +
+ {entries.map((entry, index) => ( + + ))} +
+ ) +} + +interface JournalEntryItemProps { + entry: any +} + +const JournalEntryItem: React.FC = ({ entry }) => ( + +
+
+ +

{entry.title}

+
+
+ +) diff --git a/web/components/custom/sidebar/partial/link-section.tsx b/web/components/custom/sidebar/partial/link-section.tsx new file mode 100644 index 00000000..44e94eaf --- /dev/null +++ b/web/components/custom/sidebar/partial/link-section.tsx @@ -0,0 +1,128 @@ +import React from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { useAccount } from "@/lib/providers/jazz-provider" +import { cn } from "@/lib/utils" +import { PersonalLinkLists } from "@/lib/schema/personal-link" +import { useQueryState, parseAsStringLiteral } from "nuqs" +import { LEARNING_STATES } from "@/lib/constants" + +const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES] +const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value) + +interface LinkSectionProps { + pathname: string +} + +export const LinkSection: React.FC = ({ pathname }) => { + const { me } = useAccount({ + root: { + personalLinks: [] + } + }) + + if (!me) return null + + const linkCount = me.root.personalLinks?.length || 0 + const isActive = pathname === "/links" + + return ( +
+ + +
+ ) +} + +interface LinkSectionHeaderProps { + linkCount: number + isActive: boolean +} + +const LinkSectionHeader: React.FC = ({ linkCount }) => { + const pathname = usePathname() + const [state] = useQueryState("state", parseAsStringLiteral(ALL_STATES_STRING)) + const isLinksActive = pathname.startsWith("/links") && (!state || state === "all") + + return ( +
+ +

+ Links + {linkCount > 0 && {linkCount}} +

+ +
+ ) +} + +interface ListProps { + personalLinks: PersonalLinkLists +} + +const List: React.FC = ({ personalLinks }) => { + const pathname = usePathname() + const [state] = useQueryState("state", parseAsStringLiteral(LEARNING_STATES.map(ls => ls.value))) + + const linkCounts = { + wantToLearn: personalLinks.filter(link => link?.learningState === "wantToLearn").length, + learning: personalLinks.filter(link => link?.learningState === "learning").length, + learned: personalLinks.filter(link => link?.learningState === "learned").length + } + + const isActive = (checkState: string) => pathname === "/links" && state === checkState + + return ( +
+ + + +
+ ) +} + +interface ListItemProps { + label: string + href: string + count: number + isActive: boolean +} + +const ListItem: React.FC = ({ label, href, count, isActive }) => ( +
+
+ +
+

{label}

+
+ + {count > 0 && ( + {count} + )} +
+
+) diff --git a/web/components/custom/sidebar/partial/page-section.tsx b/web/components/custom/sidebar/partial/page-section.tsx index b5064c0f..652aaf31 100644 --- a/web/components/custom/sidebar/partial/page-section.tsx +++ b/web/components/custom/sidebar/partial/page-section.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useMemo } from "react" import { useAtom } from "jotai" import { usePathname, useRouter } from "next/navigation" import { useAccount } from "@/lib/providers/jazz-provider" @@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils" import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page" import { Button } from "@/components/ui/button" import { LaIcon } from "@/components/custom/la-icon" -import { toast } from "sonner" import Link from "next/link" import { DropdownMenu, @@ -21,6 +20,7 @@ import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { icons } from "lucide-react" +import { usePageActions } from "@/components/routes/page/hooks/use-page-actions" type SortOption = "title" | "recent" type ShowOption = 5 | 10 | 15 | 20 | 0 @@ -46,36 +46,49 @@ const SHOWS: Option[] = [ const pageSortAtom = atomWithStorage("pageSort", "title") const pageShowAtom = atomWithStorage("pageShow", 5) -export const PageSection: React.FC = () => { - const { me } = useAccount({ root: { personalPages: [] } }) - const pageCount = me?.root.personalPages?.length || 0 +export const PageSection: React.FC<{ pathname?: string }> = ({ pathname }) => { + const { me } = useAccount({ + root: { + personalPages: [] + } + }) + + const [sort] = useAtom(pageSortAtom) + const [show] = useAtom(pageShowAtom) + + if (!me) return null + + const pageCount = me.root.personalPages?.length || 0 + const isActive = pathname === "/pages" return (
- - {me?.root.personalPages && } + +
) } interface PageSectionHeaderProps { pageCount: number + isActive: boolean } -const PageSectionHeader: React.FC = ({ pageCount }) => ( +const PageSectionHeader: React.FC = ({ pageCount, isActive }) => (
- -
+ + +
@@ -85,20 +98,13 @@ const PageSectionHeader: React.FC = ({ pageCount }) => ( const NewPageButton: React.FC = () => { const { me } = useAccount() const router = useRouter() + const { newPage } = usePageActions() if (!me) return null const handleClick = () => { - try { - const newPersonalPage = PersonalPage.create( - { public: false, createdAt: new Date(), updatedAt: new Date() }, - { owner: me._owner } - ) - me.root?.personalPages?.push(newPersonalPage) - router.push(`/pages/${newPersonalPage.id}`) - } catch (error) { - toast.error("Failed to create page") - } + const page = newPage(me) + router.push(`/pages/${page.id}`) } return ( @@ -121,26 +127,23 @@ const NewPageButton: React.FC = () => { interface PageListProps { personalPages: PersonalPageLists + sort: SortOption + show: ShowOption } -const PageList: React.FC = ({ personalPages }) => { +const PageList: React.FC = ({ personalPages, sort, show }) => { const pathname = usePathname() - const [sortCriteria] = useAtom(pageSortAtom) - const [showCount] = useAtom(pageShowAtom) - - const sortedPages = [...personalPages] - .sort((a, b) => { - switch (sortCriteria) { - case "title": + const sortedPages = useMemo(() => { + return [...personalPages] + .sort((a, b) => { + if (sort === "title") { return (a?.title ?? "").localeCompare(b?.title ?? "") - case "recent": - return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0) - default: - return 0 - } - }) - .slice(0, showCount === 0 ? personalPages.length : showCount) + } + return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0) + }) + .slice(0, show === 0 ? personalPages.length : show) + }, [personalPages, sort, show]) return (
@@ -162,11 +165,11 @@ const PageListItem: React.FC = ({ page, isActive }) => ( -
+

{page.title || "Untitled"}

@@ -250,4 +253,4 @@ const ShowAllForm: React.FC = () => { ) -} \ No newline at end of file +} diff --git a/web/components/custom/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx index df626468..09c73624 100644 --- a/web/components/custom/sidebar/partial/profile-section.tsx +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -1,7 +1,14 @@ -import { LaIcon } from "../../la-icon" -import { useState } from "react" -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" +"use client" + +import * as React from "react" +import { SignInButton, useAuth, useUser } from "@clerk/nextjs" +import { useAtom } from "jotai" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { icons } from "lucide-react" + +import { LaIcon } from "@/components/custom/la-icon" +import { DiscordIcon } from "@/components/custom/discordIcon" import { DropdownMenu, DropdownMenuContent, @@ -9,105 +16,141 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { useAccount } from "@/lib/providers/jazz-provider" -import Link from "next/link" -import { useAuth } from "@clerk/nextjs" +import { Avatar, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { Feedback } from "./feedback" +import { showShortcutAtom } from "@/components/custom/Shortcut/shortcut" +import { ShortcutKey } from "@/components/minimal-tiptap/components/shortcut-key" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" -const MenuItem = ({ - icon, - text, - href, - onClick, - onClose -}: { - icon: string - text: string - href?: string - onClick?: () => void - onClose: () => void -}) => { - const handleClick = () => { - onClose() - if (onClick) { - onClick() - } +export const ProfileSection: React.FC = () => { + const { user, isSignedIn } = useUser() + const { signOut } = useAuth() + const [menuOpen, setMenuOpen] = React.useState(false) + const pathname = usePathname() + const [, setShowShortcut] = useAtom(showShortcutAtom) + + const { disableKeydown } = useKeyboardManager("profileSection") + + React.useEffect(() => { + disableKeydown(menuOpen) + }, [menuOpen, disableKeydown]) + + if (!isSignedIn) { + return ( +
+ + + +
+ ) } return ( -
- - {href ? ( - - {text} - - ) : ( - - {text} - - )} -
- ) -} -export const ProfileSection: React.FC = () => { - const { me } = useAccount({ - profile: true - }) - const { signOut } = useAuth() - const [menuOpen, setMenuOpen] = useState(false) - - const closeMenu = () => setMenuOpen(false) - - return ( -
+
-
- - - - - - - - - - - - - - - - - - - - - -
- {/*
-
- - - - -
*/} + +
) } + +interface ProfileDropdownProps { + user: any + menuOpen: boolean + setMenuOpen: (open: boolean) => void + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const ProfileDropdown: React.FC = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => ( +
+ + + + + + + + +
+) + +interface DropdownMenuItemsProps { + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const DropdownMenuItems: React.FC = ({ signOut, setShowShortcut }) => ( + <> + + setShowShortcut(true)}> + + Shortcut + + + + + + + + +
+ + Log out +
+ +
+
+
+ +) + +interface MenuLinkProps { + href: string + icon: keyof typeof icons | React.FC + text: string + iconClass?: string +} + +const MenuLink: React.FC = ({ href, icon, text, iconClass = "" }) => { + const IconComponent = typeof icon === "string" ? icons[icon] : icon + return ( + + +
+ + {text} +
+ +
+ ) +} + +export default ProfileSection diff --git a/web/components/custom/sidebar/partial/task-section.tsx b/web/components/custom/sidebar/partial/task-section.tsx new file mode 100644 index 00000000..203100f7 --- /dev/null +++ b/web/components/custom/sidebar/partial/task-section.tsx @@ -0,0 +1,149 @@ +import Link from "next/link" +import { usePathname } from "next/navigation" +import { cn } from "@/lib/utils" +import { ListOfTasks } from "@/lib/schema/tasks" +import { LaIcon } from "../../la-icon" +import { useEffect, useState } from "react" +import { useAuth, useUser } from "@clerk/nextjs" +import { getFeatureFlag } from "@/app/actions" + +export const TaskSection: React.FC<{ pathname: string }> = ({ pathname }) => { + const me = { root: { tasks: [{ id: "1", title: "Test Task" }] } } + + const taskCount = me?.root.tasks?.length || 0 + const isActive = pathname === "/tasks" + + const [isFetching, setIsFetching] = useState(false) + const [isFeatureActive, setIsFeatureActive] = useState(false) + const { isLoaded, isSignedIn } = useAuth() + const { user } = useUser() + + useEffect(() => { + async function checkFeatureFlag() { + setIsFetching(true) + + if (isLoaded && isSignedIn) { + const [data, err] = await getFeatureFlag({ name: "TASK" }) + + if (err) { + console.error(err) + setIsFetching(false) + return + } + + if (user?.emailAddresses.some(email => data.flag?.emails.includes(email.emailAddress))) { + setIsFeatureActive(true) + } + setIsFetching(false) + } + } + + checkFeatureFlag() + }, [isLoaded, isSignedIn, user]) + + if (!isLoaded || !isSignedIn) { + return
Loading...
+ } + + if (!me) return null + + if (!isFeatureActive) { + return null + } + + return ( +
+ + {isFetching ? ( +
Fetching tasks...
+ ) : ( + + )} +
+ ) +} + +interface TaskSectionHeaderProps { + taskCount: number + isActive: boolean +} + +const TaskSectionHeader: React.FC = ({ taskCount, isActive }) => ( +
+ +

+ Tasks + {taskCount > 0 && {taskCount}} +

+ +
+ //
+ // + //
+) + +interface ListProps { + tasks: ListOfTasks +} + +const List: React.FC = ({ tasks }) => { + const pathname = usePathname() + + return ( +
+ +
+ ) +} + +interface ListItemProps { + label: string + href: string + count: number + isActive: boolean +} + +const ListItem: React.FC = ({ label, href, count, isActive }) => ( +
+
+ +
+ +

{label}

+
+ + {count > 0 && ( + {count} + )} +
+
+) diff --git a/web/components/custom/sidebar/partial/topic-section.tsx b/web/components/custom/sidebar/partial/topic-section.tsx index e1aaeb9b..d814dff5 100644 --- a/web/components/custom/sidebar/partial/topic-section.tsx +++ b/web/components/custom/sidebar/partial/topic-section.tsx @@ -3,12 +3,11 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { useAccount } from "@/lib/providers/jazz-provider" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" import { LaIcon } from "@/components/custom/la-icon" import { ListOfTopics } from "@/lib/schema" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" -export const TopicSection: React.FC = () => { +export const TopicSection: React.FC<{ pathname: string }> = ({ pathname }) => { const { me } = useAccount({ root: { topicsWantToLearn: [], @@ -22,11 +21,13 @@ export const TopicSection: React.FC = () => { (me?.root.topicsLearning?.length || 0) + (me?.root.topicsLearned?.length || 0) + const isActive = pathname.startsWith("/topics") + if (!me) return null return ( -
- +
+ { interface TopicSectionHeaderProps { topicCount: number + isActive: boolean } -const TopicSectionHeader: React.FC = ({ topicCount }) => ( +const TopicSectionHeader: React.FC = ({ topicCount, isActive }) => (
- +
) @@ -72,7 +74,7 @@ const List: React.FC = ({ topicsWantToLearn, topicsLearning, topicsLe count={topicsWantToLearn.length} label="To Learn" value="wantToLearn" - href="/me/wantToLearn" + href="#" isActive={pathname === "/me/wantToLearn"} /> = ({ topicsWantToLearn, topicsLearning, topicsLe label="Learning" value="learning" count={topicsLearning.length} - href="/me/learning" + href="#" isActive={pathname === "/me/learning"} /> = ({ topicsWantToLearn, topicsLearning, topicsLe label="Learned" value="learned" count={topicsLearned.length} - href="/me/learned" + href="#" isActive={pathname === "/me/learned"} />
@@ -114,7 +116,7 @@ const ListItem: React.FC = ({ label, value, href, count, isActive = ({ label, value, href, count, isActive
) -} \ No newline at end of file +} diff --git a/web/components/custom/sidebar/sidebar.tsx b/web/components/custom/sidebar/sidebar.tsx index bfccd464..dabc3e3a 100644 --- a/web/components/custom/sidebar/sidebar.tsx +++ b/web/components/custom/sidebar/sidebar.tsx @@ -3,16 +3,20 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { useAtom } from "jotai" -import { SearchIcon } from "lucide-react" import { Logo } from "@/components/custom/logo" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { isCollapseAtom } from "@/store/sidebar" +import { LinkSection } from "./partial/link-section" import { PageSection } from "./partial/page-section" import { TopicSection } from "./partial/topic-section" import { ProfileSection } from "./partial/profile-section" +import { TaskSection } from "./partial/task-section" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { LaIcon } from "../la-icon" +import { JournalSection } from "./partial/journal-section" interface SidebarContextType { isCollapsed: boolean @@ -96,7 +100,7 @@ const LogoAndSearch: React.FC = React.memo(() => { type="button" className="text-primary/60 flex w-20 items-center justify-start py-4 pl-2" > - + )} @@ -108,20 +112,25 @@ const LogoAndSearch: React.FC = React.memo(() => { LogoAndSearch.displayName = "LogoAndSearch" const SidebarContent: React.FC = React.memo(() => { + const { me } = useAccountOrGuest() + const pathname = usePathname() + return ( - <> - + ) }) diff --git a/web/components/custom/spinner.tsx b/web/components/custom/spinner.tsx new file mode 100644 index 00000000..cf164b81 --- /dev/null +++ b/web/components/custom/spinner.tsx @@ -0,0 +1,17 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +interface SpinnerProps extends React.SVGAttributes {} + +export const Spinner = React.forwardRef(({ className, ...props }, ref) => ( + + + + +)) + +Spinner.displayName = "Spinner" diff --git a/web/components/custom/text-blur-transition.tsx b/web/components/custom/text-blur-transition.tsx new file mode 100644 index 00000000..477bd706 --- /dev/null +++ b/web/components/custom/text-blur-transition.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils" +import { motion } from "framer-motion" + +export default function TextBlurTransition(props: { children: string; className?: string }) { + const words = props.children.split(" ") + + return ( + + {words.map((word, index) => { + return ( + + {word} + + ) + })} + + ) +} diff --git a/web/components/custom/topic-selector.tsx b/web/components/custom/topic-selector.tsx index 8a1f6691..2807b1fc 100644 --- a/web/components/custom/topic-selector.tsx +++ b/web/components/custom/topic-selector.tsx @@ -79,12 +79,7 @@ export const TopicSelector = forwardRef( - e.preventDefault()} - > + {group?.root.topics && ( { ariaLabel: string @@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef(({ class {...props} ref={ref} > - {getShortcutKey(shortcut)} + {getShortcutKey(shortcut).symbol} ) }) diff --git a/web/components/la-editor/components/ui/toolbar-button.tsx b/web/components/la-editor/components/ui/toolbar-button.tsx index 8556a35c..7a4371d7 100644 --- a/web/components/la-editor/components/ui/toolbar-button.tsx +++ b/web/components/la-editor/components/ui/toolbar-button.tsx @@ -1,4 +1,4 @@ -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Toggle } from "@/components/ui/toggle" import * as React from "react" @@ -16,31 +16,29 @@ const ToolbarButton = React.forwardRef(fu ref ) { return ( - - - - - {children} - - - {tooltip && ( - -
{tooltip}
-
- )} -
-
+ + + + {children} + + + {tooltip && ( + +
{tooltip}
+
+ )} +
) }) diff --git a/web/components/la-editor/extensions/link/link.ts b/web/components/la-editor/extensions/link/link.ts index 26eef782..b737c2ec 100644 --- a/web/components/la-editor/extensions/link/link.ts +++ b/web/components/la-editor/extensions/link/link.ts @@ -49,7 +49,6 @@ export const Link = TiptapLink.extend({ * This will move the cursor to the end of the link. */ if (event.key === "Escape" && selection.empty !== true) { - console.log("Link handleKeyDown") editor.commands.focus(selection.to, { scrollIntoView: false }) } diff --git a/web/components/la-editor/extensions/slash-command/menu-list.tsx b/web/components/la-editor/extensions/slash-command/menu-list.tsx index 10377d18..ebf92265 100644 --- a/web/components/la-editor/extensions/slash-command/menu-list.tsx +++ b/web/components/la-editor/extensions/slash-command/menu-list.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Command, MenuListProps } from "./types" -import { getShortcutKeys } from "../../lib/utils" +import { getShortcutKeys } from "@/lib/utils" import { Icon } from "../../components/ui/icon" import { PopoverWrapper } from "../../components/ui/popover-wrapper" import { Shortcut } from "../../components/ui/shortcut" @@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => { {command.label}
- + shortcut.readable) + .join(" + ")} + > {command.shortcuts.map(shortcut => ( ))} diff --git a/web/components/la-editor/la-editor.tsx b/web/components/la-editor/la-editor.tsx index 147a02b5..7b718d88 100644 --- a/web/components/la-editor/la-editor.tsx +++ b/web/components/la-editor/la-editor.tsx @@ -1,13 +1,13 @@ import * as React from "react" import { EditorContent, useEditor } from "@tiptap/react" import { Editor, Content } from "@tiptap/core" -import { useThrottleFn } from "react-use" import { BubbleMenu } from "./components/bubble-menu" import { createExtensions } from "./extensions" import "./styles/index.css" import { cn } from "@/lib/utils" import { getOutput } from "./lib/utils" -import { EditorView } from "@tiptap/pm/view" +import type { EditorView } from "@tiptap/pm/view" +import { useThrottle } from "@/hooks/use-throttle" export interface LAEditorProps extends Omit, "value"> { output?: "html" | "json" | "text" @@ -25,10 +25,6 @@ export interface LAEditorRef { editor: Editor | null } -interface CustomEditor extends Editor { - previousBlockCount?: number -} - export const LAEditor = React.forwardRef( ( { @@ -46,32 +42,13 @@ export const LAEditor = React.forwardRef( }, ref ) => { - const [content, setContent] = React.useState(value) - const throttledContent = useThrottleFn(defaultContent => defaultContent, throttleDelay, [content]) - const [lastThrottledContent, setLastThrottledContent] = React.useState(throttledContent) + const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay) const handleUpdate = React.useCallback( (editor: Editor) => { - const newContent = getOutput(editor, output) - setContent(newContent) - - const customEditor = editor as CustomEditor - const json = customEditor.getJSON() - - if (json.content && Array.isArray(json.content)) { - const currentBlockCount = json.content.length - - if ( - typeof customEditor.previousBlockCount === "number" && - currentBlockCount > customEditor.previousBlockCount - ) { - onNewBlock?.(newContent) - } - - customEditor.previousBlockCount = currentBlockCount - } + throttledSetValue(getOutput(editor, output)) }, - [output, onNewBlock] + [output, throttledSetValue] ) const editor = useEditor({ @@ -96,13 +73,6 @@ export const LAEditor = React.forwardRef( } }) - React.useEffect(() => { - if (lastThrottledContent !== throttledContent) { - setLastThrottledContent(throttledContent) - onUpdate?.(throttledContent!) - } - }, [throttledContent, lastThrottledContent, onUpdate]) - React.useImperativeHandle( ref, () => ({ diff --git a/web/components/la-editor/lib/utils/index.ts b/web/components/la-editor/lib/utils/index.ts index 885f4fa4..60123d5b 100644 --- a/web/components/la-editor/lib/utils/index.ts +++ b/web/components/la-editor/lib/utils/index.ts @@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) { return "" } -export * from "./keyboard" -export * from "./platform" export * from "./isCustomNodeSelected" export * from "./isTextSelected" diff --git a/web/components/la-editor/lib/utils/keyboard.ts b/web/components/la-editor/lib/utils/keyboard.ts deleted file mode 100644 index 09739fc2..00000000 --- a/web/components/la-editor/lib/utils/keyboard.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isMacOS } from "./platform" - -export const getShortcutKey = (key: string) => { - const lowercaseKey = key.toLowerCase() - const macOS = isMacOS() - - switch (lowercaseKey) { - case "mod": - return macOS ? "⌘" : "Ctrl" - case "alt": - return macOS ? "⌥" : "Alt" - case "shift": - return macOS ? "⇧" : "Shift" - default: - return key - } -} - -export const getShortcutKeys = (keys: string | string[], separator: string = "") => { - const keyArray = Array.isArray(keys) ? keys : keys.split(/\s+/) - const shortcutKeys = keyArray.map(getShortcutKey) - return shortcutKeys.join(separator) -} - -export default { getShortcutKey, getShortcutKeys } diff --git a/web/components/la-editor/lib/utils/platform.ts b/web/components/la-editor/lib/utils/platform.ts deleted file mode 100644 index 2dffa98a..00000000 --- a/web/components/la-editor/lib/utils/platform.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface NavigatorWithUserAgentData extends Navigator { - userAgentData?: { - brands: { brand: string; version: string }[] - mobile: boolean - platform: string - getHighEntropyValues: (hints: string[]) => Promise<{ - platform: string - platformVersion: string - uaFullVersion: string - }> - } -} - -let isMac: boolean | undefined - -const getPlatform = () => { - const nav = navigator as NavigatorWithUserAgentData - if (nav.userAgentData) { - if (nav.userAgentData.platform) { - return nav.userAgentData.platform - } - - nav.userAgentData - .getHighEntropyValues(["platform"]) - .then(highEntropyValues => { - if (highEntropyValues.platform) { - return highEntropyValues.platform - } - }) - .catch(() => { - return navigator.platform || "" - }) - } - - return navigator.platform || "" -} - -export const isMacOS = () => { - if (isMac === undefined) { - isMac = getPlatform().toLowerCase().includes("mac") - } - - return isMac -} - -export default isMacOS diff --git a/web/components/minimal-tiptap/components/bubble-menu/image-bubble-menu.tsx b/web/components/minimal-tiptap/components/bubble-menu/image-bubble-menu.tsx new file mode 100644 index 00000000..cae3243e --- /dev/null +++ b/web/components/minimal-tiptap/components/bubble-menu/image-bubble-menu.tsx @@ -0,0 +1,39 @@ +import type { Editor } from '@tiptap/react' +import { BubbleMenu } from '@tiptap/react' +import { ImagePopoverBlock } from '../image/image-popover-block' +import { ShouldShowProps } from '../../types' + +const ImageBubbleMenu = ({ editor }: { editor: Editor }) => { + const shouldShow = ({ editor, from, to }: ShouldShowProps) => { + if (from === to) { + return false + } + + const img = editor.getAttributes('image') + + if (img.src) { + return true + } + + return false + } + + const unSetImage = () => { + editor.commands.deleteSelection() + } + + return ( + + + + ) +} + +export { ImageBubbleMenu } diff --git a/web/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx b/web/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx new file mode 100644 index 00000000..53aa107b --- /dev/null +++ b/web/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx @@ -0,0 +1,106 @@ +import React, { useState, useCallback } from 'react' +import { Editor } from '@tiptap/react' +import { BubbleMenu } from '@tiptap/react' +import { LinkEditBlock } from '../link/link-edit-block' +import { LinkPopoverBlock } from '../link/link-popover-block' +import { ShouldShowProps } from '../../types' + +interface LinkBubbleMenuProps { + editor: Editor +} + +interface LinkAttributes { + href: string + target: string +} + +export const LinkBubbleMenu: React.FC = ({ editor }) => { + const [showEdit, setShowEdit] = useState(false) + const [linkAttrs, setLinkAttrs] = useState({ href: '', target: '' }) + const [selectedText, setSelectedText] = useState('') + + const updateLinkState = useCallback(() => { + const { from, to } = editor.state.selection + const { href, target } = editor.getAttributes('link') + const text = editor.state.doc.textBetween(from, to, ' ') + + setLinkAttrs({ href, target }) + setSelectedText(text) + }, [editor]) + + const shouldShow = useCallback( + ({ editor, from, to }: ShouldShowProps) => { + if (from === to) { + return false + } + const { href } = editor.getAttributes('link') + + if (href) { + updateLinkState() + return true + } + return false + }, + [updateLinkState] + ) + + const handleEdit = useCallback(() => { + setShowEdit(true) + }, []) + + const onSetLink = useCallback( + (url: string, text?: string, openInNewTab?: boolean) => { + editor + .chain() + .focus() + .extendMarkRange('link') + .insertContent({ + type: 'text', + text: text || url, + marks: [ + { + type: 'link', + attrs: { + href: url, + target: openInNewTab ? '_blank' : '' + } + } + ] + }) + .setLink({ href: url, target: openInNewTab ? '_blank' : '' }) + .run() + setShowEdit(false) + updateLinkState() + }, + [editor, updateLinkState] + ) + + const onUnsetLink = useCallback(() => { + editor.chain().focus().extendMarkRange('link').unsetLink().run() + setShowEdit(false) + updateLinkState() + }, [editor, updateLinkState]) + + return ( + setShowEdit(false) + }} + > + {showEdit ? ( + + ) : ( + + )} + + ) +} diff --git a/web/components/minimal-tiptap/components/image/image-edit-block.tsx b/web/components/minimal-tiptap/components/image/image-edit-block.tsx new file mode 100644 index 00000000..da4dd8be --- /dev/null +++ b/web/components/minimal-tiptap/components/image/image-edit-block.tsx @@ -0,0 +1,102 @@ +import type { Editor } from "@tiptap/react" +import React, { useRef, useState } from "react" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" + +import { storeImage } from "@/app/actions" + +interface ImageEditBlockProps extends React.HTMLAttributes { + editor: Editor + close: () => void +} + +const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockProps) => { + const fileInputRef = useRef(null) + const [link, setLink] = useState("") + const [isUploading, setIsUploading] = useState(false) + const [error, setError] = useState(null) + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + fileInputRef.current?.click() + } + + const handleLink = () => { + editor.chain().focus().setImage({ src: link }).run() + close() + } + + const handleFile = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + setIsUploading(true) + setError(null) + + const formData = new FormData() + formData.append("file", files[0]) + + try { + const [response, err] = await storeImage(formData) + + if (err) { + throw new Error(err.fieldErrors?.file?.join(", ")) + } + + if (response?.fileModel) { + editor.chain().setImage({ src: response.fileModel.content.src }).focus().run() + close() + } else { + throw new Error("Failed to upload image") + } + } catch (error) { + setError(error instanceof Error ? error.message : "An unknown error occurred") + } finally { + setIsUploading(false) + } + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + handleLink() + } + + return ( +
+
+
+ +
+ setLink(e.target.value)} + /> + +
+
+ + + {error &&
{error}
} +
+
+ ) +} + +export { ImageEditBlock } diff --git a/web/components/minimal-tiptap/components/image/image-edit-dialog.tsx b/web/components/minimal-tiptap/components/image/image-edit-dialog.tsx new file mode 100644 index 00000000..68b10e07 --- /dev/null +++ b/web/components/minimal-tiptap/components/image/image-edit-dialog.tsx @@ -0,0 +1,48 @@ +import type { Editor } from '@tiptap/react' +import { useState } from 'react' +import { ImageIcon } from '@radix-ui/react-icons' +import { ToolbarButton } from '../toolbar-button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogDescription, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { ImageEditBlock } from './image-edit-block' +import type { VariantProps } from 'class-variance-authority' +import type { toggleVariants } from '@/components/ui/toggle' + +interface ImageEditDialogProps extends VariantProps { + editor: Editor +} + +const ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => { + const [open, setOpen] = useState(false) + + return ( + + + + + + + + + Select image + Upload an image from your computer + + setOpen(false)} /> + + + ) +} + +export { ImageEditDialog } diff --git a/web/components/minimal-tiptap/components/image/image-popover-block.tsx b/web/components/minimal-tiptap/components/image/image-popover-block.tsx new file mode 100644 index 00000000..28fef8d7 --- /dev/null +++ b/web/components/minimal-tiptap/components/image/image-popover-block.tsx @@ -0,0 +1,21 @@ +import { ToolbarButton } from '../toolbar-button' +import { TrashIcon } from '@radix-ui/react-icons' + +const ImagePopoverBlock = ({ onRemove }: { onRemove: (e: React.MouseEvent) => void }) => { + const handleRemove = (e: React.MouseEvent) => { + e.preventDefault() + onRemove(e) + } + + return ( +
+
+ + + +
+
+ ) +} + +export { ImagePopoverBlock } diff --git a/web/components/minimal-tiptap/components/link/link-edit-block.tsx b/web/components/minimal-tiptap/components/link/link-edit-block.tsx new file mode 100644 index 00000000..d42bc8c1 --- /dev/null +++ b/web/components/minimal-tiptap/components/link/link-edit-block.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +export interface LinkEditorProps extends React.HTMLAttributes { + defaultUrl?: string + defaultText?: string + defaultIsNewTab?: boolean + onSave: (url: string, text?: string, isNewTab?: boolean) => void +} + +export const LinkEditBlock = React.forwardRef( + ({ onSave, defaultIsNewTab, defaultUrl, defaultText, className }, ref) => { + const formRef = React.useRef(null) + const [url, setUrl] = React.useState(defaultUrl || '') + const [text, setText] = React.useState(defaultText || '') + const [isNewTab, setIsNewTab] = React.useState(defaultIsNewTab || false) + + const handleSave = React.useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (formRef.current) { + const isValid = Array.from(formRef.current.querySelectorAll('input')).every(input => input.checkValidity()) + + if (isValid) { + onSave(url, text, isNewTab) + } else { + formRef.current.querySelectorAll('input').forEach(input => { + if (!input.checkValidity()) { + input.reportValidity() + } + }) + } + } + }, + [onSave, url, text, isNewTab] + ) + + React.useImperativeHandle(ref, () => formRef.current as HTMLDivElement) + + return ( +
+
+
+ + setUrl(e.target.value)} /> +
+ +
+ + setText(e.target.value)} /> +
+ +
+ + +
+ +
+ +
+
+
+ ) + } +) + +LinkEditBlock.displayName = 'LinkEditBlock' + +export default LinkEditBlock diff --git a/web/components/minimal-tiptap/components/link/link-edit-popover.tsx b/web/components/minimal-tiptap/components/link/link-edit-popover.tsx new file mode 100644 index 00000000..90fc0467 --- /dev/null +++ b/web/components/minimal-tiptap/components/link/link-edit-popover.tsx @@ -0,0 +1,68 @@ +import type { Editor } from '@tiptap/react' +import * as React from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Link2Icon } from '@radix-ui/react-icons' +import { ToolbarButton } from '../toolbar-button' +import { LinkEditBlock } from './link-edit-block' +import type { VariantProps } from 'class-variance-authority' +import type { toggleVariants } from '@/components/ui/toggle' + +interface LinkEditPopoverProps extends VariantProps { + editor: Editor +} + +const LinkEditPopover = ({ editor, size, variant }: LinkEditPopoverProps) => { + const [open, setOpen] = React.useState(false) + + const { from, to } = editor.state.selection + const text = editor.state.doc.textBetween(from, to, ' ') + + const onSetLink = React.useCallback( + (url: string, text?: string, openInNewTab?: boolean) => { + editor + .chain() + .focus() + .extendMarkRange('link') + .insertContent({ + type: 'text', + text: text || url, + marks: [ + { + type: 'link', + attrs: { + href: url, + target: openInNewTab ? '_blank' : '' + } + } + ] + }) + .setLink({ href: url }) + .run() + + editor.commands.enter() + }, + [editor] + ) + + return ( + + + + + + + + + + + ) +} + +export { LinkEditPopover } diff --git a/web/components/minimal-tiptap/components/link/link-popover-block.tsx b/web/components/minimal-tiptap/components/link/link-popover-block.tsx new file mode 100644 index 00000000..7d495439 --- /dev/null +++ b/web/components/minimal-tiptap/components/link/link-popover-block.tsx @@ -0,0 +1,62 @@ +import React, { useState, useCallback } from 'react' +import { Separator } from '@/components/ui/separator' +import { ToolbarButton } from '../toolbar-button' +import { CopyIcon, ExternalLinkIcon, LinkBreak2Icon } from '@radix-ui/react-icons' + +interface LinkPopoverBlockProps { + url: string + onClear: () => void + onEdit: (e: React.MouseEvent) => void +} + +export const LinkPopoverBlock: React.FC = ({ url, onClear, onEdit }) => { + const [copyTitle, setCopyTitle] = useState('Copy') + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + navigator.clipboard + .writeText(url) + .then(() => { + setCopyTitle('Copied!') + setTimeout(() => setCopyTitle('Copy'), 1000) + }) + .catch(console.error) + }, + [url] + ) + + const handleOpenLink = useCallback(() => { + window.open(url, '_blank', 'noopener,noreferrer') + }, [url]) + + return ( +
+
+ + Edit link + + + + + + + + + + + { + if (e.target === e.currentTarget) e.preventDefault() + } + }} + > + + +
+
+ ) +} diff --git a/web/components/minimal-tiptap/components/section/five.tsx b/web/components/minimal-tiptap/components/section/five.tsx new file mode 100644 index 00000000..606485c0 --- /dev/null +++ b/web/components/minimal-tiptap/components/section/five.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import type { Editor } from '@tiptap/react' +import { CaretDownIcon, CodeIcon, DividerHorizontalIcon, PlusIcon, QuoteIcon } from '@radix-ui/react-icons' +import { LinkEditPopover } from '../link/link-edit-popover' +import { ImageEditDialog } from '../image/image-edit-dialog' +import type { FormatAction } from '../../types' +import { ToolbarSection } from '../toolbar-section' +import type { toggleVariants } from '@/components/ui/toggle' +import type { VariantProps } from 'class-variance-authority' + +type InsertElementAction = 'codeBlock' | 'blockquote' | 'horizontalRule' +interface InsertElement extends FormatAction { + value: InsertElementAction +} + +const formatActions: InsertElement[] = [ + { + value: 'codeBlock', + label: 'Code block', + icon: , + action: editor => editor.chain().focus().toggleCodeBlock().run(), + isActive: editor => editor.isActive('codeBlock'), + canExecute: editor => editor.can().chain().focus().toggleCodeBlock().run(), + shortcuts: ['mod', 'alt', 'C'] + }, + { + value: 'blockquote', + label: 'Blockquote', + icon: , + action: editor => editor.chain().focus().toggleBlockquote().run(), + isActive: editor => editor.isActive('blockquote'), + canExecute: editor => editor.can().chain().focus().toggleBlockquote().run(), + shortcuts: ['mod', 'shift', 'B'] + }, + { + value: 'horizontalRule', + label: 'Divider', + icon: , + action: editor => editor.chain().focus().setHorizontalRule().run(), + isActive: () => false, + canExecute: editor => editor.can().chain().focus().setHorizontalRule().run(), + shortcuts: ['mod', 'alt', '-'] + } +] + +interface SectionFiveProps extends VariantProps { + editor: Editor + activeActions?: InsertElementAction[] + mainActionCount?: number +} + +export const SectionFive: React.FC = ({ + editor, + activeActions = formatActions.map(action => action.value), + mainActionCount = 0, + size, + variant +}) => { + return ( + <> + + + + + + + } + dropdownTooltip="Insert elements" + size={size} + variant={variant} + /> + + ) +} + +SectionFive.displayName = 'SectionFive' + +export default SectionFive diff --git a/web/components/minimal-tiptap/components/section/four.tsx b/web/components/minimal-tiptap/components/section/four.tsx new file mode 100644 index 00000000..2686c635 --- /dev/null +++ b/web/components/minimal-tiptap/components/section/four.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' +import type { Editor } from '@tiptap/react' +import { CaretDownIcon, ListBulletIcon } from '@radix-ui/react-icons' +import type { FormatAction } from '../../types' +import { ToolbarSection } from '../toolbar-section' +import type { toggleVariants } from '@/components/ui/toggle' +import type { VariantProps } from 'class-variance-authority' + +type ListItemAction = 'orderedList' | 'bulletList' +interface ListItem extends FormatAction { + value: ListItemAction +} + +const formatActions: ListItem[] = [ + { + value: 'orderedList', + label: 'Numbered list', + icon: ( + + + + ), + isActive: editor => editor.isActive('orderedList'), + action: editor => editor.chain().focus().toggleOrderedList().run(), + canExecute: editor => editor.can().chain().focus().toggleOrderedList().run(), + shortcuts: ['mod', 'shift', '7'] + }, + { + value: 'bulletList', + label: 'Bullet list', + icon: , + isActive: editor => editor.isActive('bulletList'), + action: editor => editor.chain().focus().toggleBulletList().run(), + canExecute: editor => editor.can().chain().focus().toggleBulletList().run(), + shortcuts: ['mod', 'shift', '8'] + } +] + +interface SectionFourProps extends VariantProps { + editor: Editor + activeActions?: ListItemAction[] + mainActionCount?: number +} + +export const SectionFour: React.FC = ({ + editor, + activeActions = formatActions.map(action => action.value), + mainActionCount = 0, + size, + variant +}) => { + return ( + + + + + } + dropdownTooltip="Lists" + size={size} + variant={variant} + /> + ) +} + +SectionFour.displayName = 'SectionFour' + +export default SectionFour diff --git a/web/components/minimal-tiptap/components/section/one.tsx b/web/components/minimal-tiptap/components/section/one.tsx new file mode 100644 index 00000000..85c41d8a --- /dev/null +++ b/web/components/minimal-tiptap/components/section/one.tsx @@ -0,0 +1,137 @@ +import type { Editor } from '@tiptap/react' +import type { Level } from '@tiptap/extension-heading' +import { cn } from '@/lib/utils' +import { CaretDownIcon, LetterCaseCapitalizeIcon } from '@radix-ui/react-icons' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { ToolbarButton } from '../toolbar-button' +import { ShortcutKey } from '../shortcut-key' +import React, { useCallback, useMemo } from 'react' +import type { FormatAction } from '../../types' +import type { VariantProps } from 'class-variance-authority' +import type { toggleVariants } from '@/components/ui/toggle' + +interface TextStyle extends Omit { + element: keyof JSX.IntrinsicElements + level?: Level + className: string +} + +const formatActions: TextStyle[] = [ + { + label: 'Normal Text', + element: 'span', + className: 'grow', + shortcuts: ['mod', 'alt', '0'] + }, + { + label: 'Heading 1', + element: 'h1', + level: 1, + className: 'm-0 grow text-3xl font-extrabold', + shortcuts: ['mod', 'alt', '1'] + }, + { + label: 'Heading 2', + element: 'h2', + level: 2, + className: 'm-0 grow text-xl font-bold', + shortcuts: ['mod', 'alt', '2'] + }, + { + label: 'Heading 3', + element: 'h3', + level: 3, + className: 'm-0 grow text-lg font-semibold', + shortcuts: ['mod', 'alt', '3'] + }, + { + label: 'Heading 4', + element: 'h4', + level: 4, + className: 'm-0 grow text-base font-semibold', + shortcuts: ['mod', 'alt', '4'] + }, + { + label: 'Heading 5', + element: 'h5', + level: 5, + className: 'm-0 grow text-sm font-normal', + shortcuts: ['mod', 'alt', '5'] + }, + { + label: 'Heading 6', + element: 'h6', + level: 6, + className: 'm-0 grow text-sm font-normal', + shortcuts: ['mod', 'alt', '6'] + } +] + +interface SectionOneProps extends VariantProps { + editor: Editor + activeLevels?: Level[] +} + +export const SectionOne: React.FC = React.memo( + ({ editor, activeLevels = [1, 2, 3, 4, 5, 6], size, variant }) => { + const filteredActions = useMemo( + () => formatActions.filter(action => !action.level || activeLevels.includes(action.level)), + [activeLevels] + ) + + const handleStyleChange = useCallback( + (level?: Level) => { + if (level) { + editor.chain().focus().toggleHeading({ level }).run() + } else { + editor.chain().focus().setParagraph().run() + } + }, + [editor] + ) + + const renderMenuItem = useCallback( + ({ label, element: Element, level, className, shortcuts }: TextStyle) => ( + handleStyleChange(level)} + className={cn('flex flex-row items-center justify-between gap-4', { + 'bg-accent': level ? editor.isActive('heading', { level }) : editor.isActive('paragraph') + })} + aria-label={label} + > + {label} + + + ), + [editor, handleStyleChange] + ) + + return ( + + + + + + + + + {filteredActions.map(renderMenuItem)} + + + ) + } +) + +SectionOne.displayName = 'SectionOne' + +export default SectionOne diff --git a/web/components/minimal-tiptap/components/section/three.tsx b/web/components/minimal-tiptap/components/section/three.tsx new file mode 100644 index 00000000..3b724569 --- /dev/null +++ b/web/components/minimal-tiptap/components/section/three.tsx @@ -0,0 +1,191 @@ +import * as React from 'react' +import type { Editor } from '@tiptap/react' +import { CaretDownIcon, CheckIcon } from '@radix-ui/react-icons' +import { ToolbarButton } from '../toolbar-button' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useTheme } from '../../hooks/use-theme' +import type { toggleVariants } from '@/components/ui/toggle' +import type { VariantProps } from 'class-variance-authority' + +interface ColorItem { + cssVar: string + label: string + darkLabel?: string +} + +interface ColorPalette { + label: string + colors: ColorItem[] + inverse: string +} + +const COLORS: ColorPalette[] = [ + { + label: 'Palette 1', + inverse: 'hsl(var(--background))', + colors: [ + { cssVar: 'hsl(var(--foreground))', label: 'Default' }, + { cssVar: 'var(--mt-accent-bold-blue)', label: 'Bold blue' }, + { cssVar: 'var(--mt-accent-bold-teal)', label: 'Bold teal' }, + { cssVar: 'var(--mt-accent-bold-green)', label: 'Bold green' }, + { cssVar: 'var(--mt-accent-bold-orange)', label: 'Bold orange' }, + { cssVar: 'var(--mt-accent-bold-red)', label: 'Bold red' }, + { cssVar: 'var(--mt-accent-bold-purple)', label: 'Bold purple' } + ] + }, + { + label: 'Palette 2', + inverse: 'hsl(var(--background))', + colors: [ + { cssVar: 'var(--mt-accent-gray)', label: 'Gray' }, + { cssVar: 'var(--mt-accent-blue)', label: 'Blue' }, + { cssVar: 'var(--mt-accent-teal)', label: 'Teal' }, + { cssVar: 'var(--mt-accent-green)', label: 'Green' }, + { cssVar: 'var(--mt-accent-orange)', label: 'Orange' }, + { cssVar: 'var(--mt-accent-red)', label: 'Red' }, + { cssVar: 'var(--mt-accent-purple)', label: 'Purple' } + ] + }, + { + label: 'Palette 3', + inverse: 'hsl(var(--foreground))', + colors: [ + { cssVar: 'hsl(var(--background))', label: 'White', darkLabel: 'Black' }, + { cssVar: 'var(--mt-accent-blue-subtler)', label: 'Blue subtle' }, + { cssVar: 'var(--mt-accent-teal-subtler)', label: 'Teal subtle' }, + { cssVar: 'var(--mt-accent-green-subtler)', label: 'Green subtle' }, + { cssVar: 'var(--mt-accent-yellow-subtler)', label: 'Yellow subtle' }, + { cssVar: 'var(--mt-accent-red-subtler)', label: 'Red subtle' }, + { cssVar: 'var(--mt-accent-purple-subtler)', label: 'Purple subtle' } + ] + } +] + +const MemoizedColorButton = React.memo<{ + color: ColorItem + isSelected: boolean + inverse: string + onClick: (value: string) => void +}>(({ color, isSelected, inverse, onClick }) => { + const isDarkMode = useTheme() + const label = isDarkMode && color.darkLabel ? color.darkLabel : color.label + + return ( + + + ) => { + e.preventDefault() + onClick(color.cssVar) + }} + > + {isSelected && } + + + +

{label}

+
+
+ ) +}) + +MemoizedColorButton.displayName = 'MemoizedColorButton' + +const MemoizedColorPicker = React.memo<{ + palette: ColorPalette + selectedColor: string + inverse: string + onColorChange: (value: string) => void +}>(({ palette, selectedColor, inverse, onColorChange }) => ( + { + if (value) onColorChange(value) + }} + className="gap-1.5" + > + {palette.colors.map((color, index) => ( + + ))} + +)) + +MemoizedColorPicker.displayName = 'MemoizedColorPicker' + +interface SectionThreeProps extends VariantProps { + editor: Editor +} + +export const SectionThree: React.FC = ({ editor, size, variant }) => { + const color = editor.getAttributes('textStyle')?.color || 'hsl(var(--foreground))' + const [selectedColor, setSelectedColor] = React.useState(color) + + const handleColorChange = React.useCallback( + (value: string) => { + setSelectedColor(value) + editor.chain().setColor(value).run() + }, + [editor] + ) + + React.useEffect(() => { + setSelectedColor(color) + }, [color]) + + return ( + + + + + + + + + + + + +
+ {COLORS.map((palette, index) => ( + + ))} +
+
+
+ ) +} + +SectionThree.displayName = 'SectionThree' + +export default SectionThree diff --git a/web/components/minimal-tiptap/components/section/two.tsx b/web/components/minimal-tiptap/components/section/two.tsx new file mode 100644 index 00000000..3515e630 --- /dev/null +++ b/web/components/minimal-tiptap/components/section/two.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' +import type { Editor } from '@tiptap/react' +import { + CodeIcon, + DotsHorizontalIcon, + FontBoldIcon, + FontItalicIcon, + StrikethroughIcon, + TextNoneIcon +} from '@radix-ui/react-icons' +import type { FormatAction } from '../../types' +import { ToolbarSection } from '../toolbar-section' +import type { toggleVariants } from '@/components/ui/toggle' +import type { VariantProps } from 'class-variance-authority' + +type TextStyleAction = 'bold' | 'italic' | 'strikethrough' | 'code' | 'clearFormatting' + +interface TextStyle extends FormatAction { + value: TextStyleAction +} + +const formatActions: TextStyle[] = [ + { + value: 'bold', + label: 'Bold', + icon: , + action: editor => editor.chain().focus().toggleBold().run(), + isActive: editor => editor.isActive('bold'), + canExecute: editor => editor.can().chain().focus().toggleBold().run() && !editor.isActive('codeBlock'), + shortcuts: ['mod', 'B'] + }, + { + value: 'italic', + label: 'Italic', + icon: , + action: editor => editor.chain().focus().toggleItalic().run(), + isActive: editor => editor.isActive('italic'), + canExecute: editor => editor.can().chain().focus().toggleItalic().run() && !editor.isActive('codeBlock'), + shortcuts: ['mod', 'I'] + }, + { + value: 'strikethrough', + label: 'Strikethrough', + icon: , + action: editor => editor.chain().focus().toggleStrike().run(), + isActive: editor => editor.isActive('strike'), + canExecute: editor => editor.can().chain().focus().toggleStrike().run() && !editor.isActive('codeBlock'), + shortcuts: ['mod', 'shift', 'S'] + }, + { + value: 'code', + label: 'Code', + icon: , + action: editor => editor.chain().focus().toggleCode().run(), + isActive: editor => editor.isActive('code'), + canExecute: editor => editor.can().chain().focus().toggleCode().run() && !editor.isActive('codeBlock'), + shortcuts: ['mod', 'E'] + }, + { + value: 'clearFormatting', + label: 'Clear formatting', + icon: , + action: editor => editor.chain().focus().unsetAllMarks().run(), + isActive: () => false, + canExecute: editor => editor.can().chain().focus().unsetAllMarks().run() && !editor.isActive('codeBlock'), + shortcuts: ['mod', '\\'] + } +] + +interface SectionTwoProps extends VariantProps { + editor: Editor + activeActions?: TextStyleAction[] + mainActionCount?: number +} + +export const SectionTwo: React.FC = ({ + editor, + activeActions = formatActions.map(action => action.value), + mainActionCount = 2, + size, + variant +}) => { + return ( + } + dropdownTooltip="More formatting" + dropdownClassName="w-8" + size={size} + variant={variant} + /> + ) +} + +SectionTwo.displayName = 'SectionTwo' + +export default SectionTwo diff --git a/web/components/minimal-tiptap/components/shortcut-key.tsx b/web/components/minimal-tiptap/components/shortcut-key.tsx new file mode 100644 index 00000000..e81bdd88 --- /dev/null +++ b/web/components/minimal-tiptap/components/shortcut-key.tsx @@ -0,0 +1,33 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { getShortcutKey } from "@/lib/utils" + +export interface ShortcutKeyProps extends React.HTMLAttributes { + keys: string[] +} + +export const ShortcutKey = React.forwardRef(({ className, keys, ...props }, ref) => { + const modifiedKeys = keys.map(key => getShortcutKey(key)) + const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ") + + return ( + + {modifiedKeys.map(shortcut => ( + + {shortcut.symbol} + + ))} + + ) +}) + +ShortcutKey.displayName = "ShortcutKey" diff --git a/web/components/minimal-tiptap/components/toolbar-button.tsx b/web/components/minimal-tiptap/components/toolbar-button.tsx new file mode 100644 index 00000000..5fd9cf40 --- /dev/null +++ b/web/components/minimal-tiptap/components/toolbar-button.tsx @@ -0,0 +1,38 @@ +import * as React from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Toggle } from '@/components/ui/toggle' +import { cn } from '@/lib/utils' +import type { TooltipContentProps } from '@radix-ui/react-tooltip' + +interface ToolbarButtonProps extends React.ComponentPropsWithoutRef { + isActive?: boolean + tooltip?: string + tooltipOptions?: TooltipContentProps +} + +export const ToolbarButton = React.forwardRef( + ({ isActive, children, tooltip, className, tooltipOptions, ...props }, ref) => { + const toggleButton = ( + + {children} + + ) + + if (!tooltip) { + return toggleButton + } + + return ( + + {toggleButton} + +
{tooltip}
+
+
+ ) + } +) + +ToolbarButton.displayName = 'ToolbarButton' + +export default ToolbarButton diff --git a/web/components/minimal-tiptap/components/toolbar-section.tsx b/web/components/minimal-tiptap/components/toolbar-section.tsx new file mode 100644 index 00000000..9aacc01b --- /dev/null +++ b/web/components/minimal-tiptap/components/toolbar-section.tsx @@ -0,0 +1,112 @@ +import * as React from "react" +import type { Editor } from "@tiptap/react" +import { cn } from "@/lib/utils" +import { CaretDownIcon } from "@radix-ui/react-icons" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { ToolbarButton } from "./toolbar-button" +import { ShortcutKey } from "./shortcut-key" +import { getShortcutKey } from "@/lib/utils" +import type { FormatAction } from "../types" +import type { VariantProps } from "class-variance-authority" +import type { toggleVariants } from "@/components/ui/toggle" + +interface ToolbarSectionProps extends VariantProps { + editor: Editor + actions: FormatAction[] + activeActions?: string[] + mainActionCount?: number + dropdownIcon?: React.ReactNode + dropdownTooltip?: string + dropdownClassName?: string +} + +export const ToolbarSection: React.FC = ({ + editor, + actions, + activeActions = actions.map(action => action.value), + mainActionCount = 0, + dropdownIcon, + dropdownTooltip = "More options", + dropdownClassName = "w-12", + size, + variant +}) => { + const { mainActions, dropdownActions } = React.useMemo(() => { + const sortedActions = actions + .filter(action => activeActions.includes(action.value)) + .sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value)) + + return { + mainActions: sortedActions.slice(0, mainActionCount), + dropdownActions: sortedActions.slice(mainActionCount) + } + }, [actions, activeActions, mainActionCount]) + + const renderToolbarButton = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + isActive={action.isActive(editor)} + tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`} + aria-label={action.label} + size={size} + variant={variant} + > + {action.icon} + + ), + [editor, size, variant] + ) + + const renderDropdownMenuItem = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + className={cn("flex flex-row items-center justify-between gap-4", { + "bg-accent": action.isActive(editor) + })} + aria-label={action.label} + > + {action.label} + + + ), + [editor] + ) + + const isDropdownActive = React.useMemo( + () => dropdownActions.some(action => action.isActive(editor)), + [dropdownActions, editor] + ) + + return ( + <> + {mainActions.map(renderToolbarButton)} + {dropdownActions.length > 0 && ( + + + + {dropdownIcon || } + + + + {dropdownActions.map(renderDropdownMenuItem)} + + + )} + + ) +} + +export default ToolbarSection diff --git a/web/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts b/web/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts new file mode 100644 index 00000000..54523668 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts @@ -0,0 +1,17 @@ +import { CodeBlockLowlight as TiptapCodeBlockLowlight } from '@tiptap/extension-code-block-lowlight' +import { common, createLowlight } from 'lowlight' + +export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({ + addOptions() { + return { + ...this.parent?.(), + lowlight: createLowlight(common), + defaultLanguage: null, + HTMLAttributes: { + class: 'block-node' + } + } + } +}) + +export default CodeBlockLowlight diff --git a/web/components/minimal-tiptap/extensions/code-block-lowlight/index.ts b/web/components/minimal-tiptap/extensions/code-block-lowlight/index.ts new file mode 100644 index 00000000..9ded0403 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/code-block-lowlight/index.ts @@ -0,0 +1 @@ +export * from './code-block-lowlight' diff --git a/web/components/minimal-tiptap/extensions/color/color.ts b/web/components/minimal-tiptap/extensions/color/color.ts new file mode 100644 index 00000000..9582d38b --- /dev/null +++ b/web/components/minimal-tiptap/extensions/color/color.ts @@ -0,0 +1,20 @@ +import { Color as TiptapColor } from '@tiptap/extension-color' +import { Plugin } from '@tiptap/pm/state' + +export const Color = TiptapColor.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() || []), + new Plugin({ + props: { + handleKeyDown: (_, event) => { + if (event.key === 'Enter') { + this.editor.commands.unsetColor() + } + return false + } + } + }) + ] + } +}) diff --git a/web/components/minimal-tiptap/extensions/color/index.ts b/web/components/minimal-tiptap/extensions/color/index.ts new file mode 100644 index 00000000..1315dbcf --- /dev/null +++ b/web/components/minimal-tiptap/extensions/color/index.ts @@ -0,0 +1 @@ +export * from './color' diff --git a/web/components/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts b/web/components/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts new file mode 100644 index 00000000..c530d4f5 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts @@ -0,0 +1,18 @@ +/* + * Wrap the horizontal rule in a div element. + * Also add a keyboard shortcut to insert a horizontal rule. + */ +import { HorizontalRule as TiptapHorizontalRule } from '@tiptap/extension-horizontal-rule' + +export const HorizontalRule = TiptapHorizontalRule.extend({ + addKeyboardShortcuts() { + return { + 'Mod-Alt--': () => + this.editor.commands.insertContent({ + type: this.name + }) + } + } +}) + +export default HorizontalRule diff --git a/web/components/minimal-tiptap/extensions/horizontal-rule/index.ts b/web/components/minimal-tiptap/extensions/horizontal-rule/index.ts new file mode 100644 index 00000000..e6cb8015 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/horizontal-rule/index.ts @@ -0,0 +1 @@ +export * from './horizontal-rule' diff --git a/web/components/minimal-tiptap/extensions/image/components/image-view-block.tsx b/web/components/minimal-tiptap/extensions/image/components/image-view-block.tsx new file mode 100644 index 00000000..522569b4 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/image/components/image-view-block.tsx @@ -0,0 +1,45 @@ +import { isNumber, NodeViewProps, NodeViewWrapper } from '@tiptap/react' +import { useMemo } from 'react' +import { useImageLoad } from '../../../hooks/use-image-load' +import { cn } from '@/lib/utils' + +const ImageViewBlock = ({ editor, node, getPos }: NodeViewProps) => { + const imgSize = useImageLoad(node.attrs.src) + + const paddingBottom = useMemo(() => { + if (!imgSize.width || !imgSize.height) { + return 0 + } + + return (imgSize.height / imgSize.width) * 100 + }, [imgSize.width, imgSize.height]) + + return ( + +
+
+
+
+
+
+ {node.attrs.alt} +
+
+
+
+
+
+
+ ) +} + +export { ImageViewBlock } diff --git a/web/components/minimal-tiptap/extensions/image/image.ts b/web/components/minimal-tiptap/extensions/image/image.ts new file mode 100644 index 00000000..12784fb5 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/image/image.ts @@ -0,0 +1,9 @@ +import { Image as TiptapImage } from '@tiptap/extension-image' +import { ReactNodeViewRenderer } from '@tiptap/react' +import { ImageViewBlock } from './components/image-view-block' + +export const Image = TiptapImage.extend({ + addNodeView() { + return ReactNodeViewRenderer(ImageViewBlock) + } +}) diff --git a/web/components/minimal-tiptap/extensions/image/index.ts b/web/components/minimal-tiptap/extensions/image/index.ts new file mode 100644 index 00000000..556dbfdf --- /dev/null +++ b/web/components/minimal-tiptap/extensions/image/index.ts @@ -0,0 +1 @@ +export * from './image' diff --git a/web/components/minimal-tiptap/extensions/index.ts b/web/components/minimal-tiptap/extensions/index.ts new file mode 100644 index 00000000..63f21fa7 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/index.ts @@ -0,0 +1,8 @@ +export * from './code-block-lowlight' +export * from './color' +export * from './horizontal-rule' +export * from './image' +export * from './link' +export * from './selection' +export * from './unset-all-marks' +export * from './reset-marks-on-enter' diff --git a/web/components/minimal-tiptap/extensions/link/index.ts b/web/components/minimal-tiptap/extensions/link/index.ts new file mode 100644 index 00000000..6bbafd20 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/link/index.ts @@ -0,0 +1 @@ +export * from './link' diff --git a/web/components/minimal-tiptap/extensions/link/link.ts b/web/components/minimal-tiptap/extensions/link/link.ts new file mode 100644 index 00000000..1db71b95 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/link/link.ts @@ -0,0 +1,89 @@ +import { mergeAttributes } from '@tiptap/core' +import TiptapLink from '@tiptap/extension-link' +import { EditorView } from '@tiptap/pm/view' +import { getMarkRange } from '@tiptap/core' +import { Plugin, TextSelection } from '@tiptap/pm/state' + +export const Link = TiptapLink.extend({ + /* + * Determines whether typing next to a link automatically becomes part of the link. + * In this case, we dont want any characters to be included as part of the link. + */ + inclusive: false, + + /* + * Match all elements that have an href attribute, except for: + * - elements with a data-type attribute set to button + * - elements with an href attribute that contains 'javascript:' + */ + parseHTML() { + return [{ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addOptions() { + return { + ...this.parent?.(), + openOnClick: false, + HTMLAttributes: { + class: 'link' + } + } + }, + + addProseMirrorPlugins() { + const { editor } = this + + return [ + ...(this.parent?.() || []), + new Plugin({ + props: { + handleKeyDown: (_: EditorView, event: KeyboardEvent) => { + const { selection } = editor.state + + /* + * Handles the 'Escape' key press when there's a selection within the link. + * This will move the cursor to the end of the link. + */ + if (event.key === 'Escape' && selection.empty !== true) { + editor.commands.focus(selection.to, { scrollIntoView: false }) + } + + return false + }, + handleClick(view, pos) { + /* + * Marks the entire link when the user clicks on it. + */ + + const { schema, doc, tr } = view.state + const range = getMarkRange(doc.resolve(pos), schema.marks.link) + + if (!range) { + return + } + + const { from, to } = range + const start = Math.min(from, to) + const end = Math.max(from, to) + + if (pos < start || pos > end) { + return + } + + const $start = doc.resolve(start) + const $end = doc.resolve(end) + const transaction = tr.setSelection(new TextSelection($start, $end)) + + view.dispatch(transaction) + } + } + }) + ] + } +}) + +export default Link diff --git a/web/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts new file mode 100644 index 00000000..f514cdd1 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts @@ -0,0 +1 @@ +export * from './reset-marks-on-enter' diff --git a/web/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts new file mode 100644 index 00000000..e9770710 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts @@ -0,0 +1,25 @@ +import { Extension } from '@tiptap/core' + +export const ResetMarksOnEnter = Extension.create({ + name: 'resetMarksOnEnter', + + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + if ( + editor.isActive('bold') || + editor.isActive('italic') || + editor.isActive('strike') || + editor.isActive('underline') || + editor.isActive('code') + ) { + editor.commands.splitBlock({ keepMarks: false }) + + return true + } + + return false + } + } + } +}) diff --git a/web/components/minimal-tiptap/extensions/selection/index.ts b/web/components/minimal-tiptap/extensions/selection/index.ts new file mode 100644 index 00000000..75df11a6 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/selection/index.ts @@ -0,0 +1 @@ +export * from './selection' diff --git a/web/components/minimal-tiptap/extensions/selection/selection.ts b/web/components/minimal-tiptap/extensions/selection/selection.ts new file mode 100644 index 00000000..7e28ac2f --- /dev/null +++ b/web/components/minimal-tiptap/extensions/selection/selection.ts @@ -0,0 +1,36 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +export const Selection = Extension.create({ + name: 'selection', + + addProseMirrorPlugins() { + const { editor } = this + + return [ + new Plugin({ + key: new PluginKey('selection'), + props: { + decorations(state) { + if (state.selection.empty) { + return null + } + + if (editor.isFocused === true) { + return null + } + + return DecorationSet.create(state.doc, [ + Decoration.inline(state.selection.from, state.selection.to, { + class: 'selection' + }) + ]) + } + } + }) + ] + } +}) + +export default Selection diff --git a/web/components/minimal-tiptap/extensions/unset-all-marks/index.ts b/web/components/minimal-tiptap/extensions/unset-all-marks/index.ts new file mode 100644 index 00000000..50d26fbc --- /dev/null +++ b/web/components/minimal-tiptap/extensions/unset-all-marks/index.ts @@ -0,0 +1 @@ +export * from './unset-all-marks' diff --git a/web/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts b/web/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts new file mode 100644 index 00000000..6aa64469 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts @@ -0,0 +1,9 @@ +import { Extension } from '@tiptap/core' + +export const UnsetAllMarks = Extension.create({ + addKeyboardShortcuts() { + return { + 'Mod-\\': () => this.editor.commands.unsetAllMarks() + } + } +}) diff --git a/web/components/minimal-tiptap/hooks/use-image-load.ts b/web/components/minimal-tiptap/hooks/use-image-load.ts new file mode 100644 index 00000000..463efb1e --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-image-load.ts @@ -0,0 +1,15 @@ +import * as React from 'react' + +export const useImageLoad = (src: string) => { + const [imgSize, setImgSize] = React.useState({ width: 0, height: 0 }) + + React.useEffect(() => { + const img = new Image() + img.src = src + img.onload = () => { + setImgSize({ width: img.width, height: img.height }) + } + }, [src]) + + return imgSize +} diff --git a/web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts b/web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts new file mode 100644 index 00000000..ed89b675 --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts @@ -0,0 +1,107 @@ +import * as React from "react" +import { StarterKit } from "@tiptap/starter-kit" +import type { Content, UseEditorOptions } from "@tiptap/react" +import { useEditor } from "@tiptap/react" +import type { Editor } from "@tiptap/core" +import { Typography } from "@tiptap/extension-typography" +import { Placeholder } from "@tiptap/extension-placeholder" +import { TextStyle } from "@tiptap/extension-text-style" +import { + Link, + Image, + HorizontalRule, + CodeBlockLowlight, + Selection, + Color, + UnsetAllMarks, + ResetMarksOnEnter +} from "../extensions" +import { cn } from "@/lib/utils" +import { getOutput } from "../utils" +import { useThrottle } from "../hooks/use-throttle" + +export interface UseMinimalTiptapEditorProps extends UseEditorOptions { + value?: Content + output?: "html" | "json" | "text" + placeholder?: string + editorClassName?: string + throttleDelay?: number + onUpdate?: (content: Content) => void + onBlur?: (content: Content) => void +} + +const createExtensions = (placeholder: string) => [ + StarterKit.configure({ + horizontalRule: false, + codeBlock: false, + paragraph: { HTMLAttributes: { class: "text-node" } }, + heading: { HTMLAttributes: { class: "heading-node" } }, + blockquote: { HTMLAttributes: { class: "block-node" } }, + bulletList: { HTMLAttributes: { class: "list-node" } }, + orderedList: { HTMLAttributes: { class: "list-node" } }, + code: { HTMLAttributes: { class: "inline", spellcheck: "false" } }, + dropcursor: { width: 2, class: "ProseMirror-dropcursor border" } + }), + Link, + Image, + Color, + TextStyle, + Selection, + Typography, + UnsetAllMarks, + HorizontalRule, + ResetMarksOnEnter, + CodeBlockLowlight, + Placeholder.configure({ placeholder: () => placeholder }) +] + +export const useMinimalTiptapEditor = ({ + value, + output = "html", + placeholder = "", + editorClassName, + throttleDelay = 1000, + onUpdate, + onBlur, + ...props +}: UseMinimalTiptapEditorProps) => { + const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay) + + const handleUpdate = React.useCallback( + (editor: Editor) => { + throttledSetValue(getOutput(editor, output)) + }, + [output, throttledSetValue] + ) + + const handleCreate = React.useCallback( + (editor: Editor) => { + if (value && editor.isEmpty) { + editor.commands.setContent(value) + } + }, + [value] + ) + + const handleBlur = React.useCallback((editor: Editor) => onBlur?.(getOutput(editor, output)), [output, onBlur]) + + const editor = useEditor({ + extensions: createExtensions(placeholder!), + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + class: cn("focus:outline-none", editorClassName) + } + }, + onUpdate: ({ editor }) => handleUpdate(editor), + onCreate: ({ editor }) => handleCreate(editor), + onBlur: ({ editor }) => handleBlur(editor), + ...props + }) + + return editor +} + +export default useMinimalTiptapEditor diff --git a/web/components/minimal-tiptap/hooks/use-theme.ts b/web/components/minimal-tiptap/hooks/use-theme.ts new file mode 100644 index 00000000..9bb816b6 --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-theme.ts @@ -0,0 +1,25 @@ +import * as React from 'react' + +export const useTheme = () => { + const [isDarkMode, setIsDarkMode] = React.useState(false) + + React.useEffect(() => { + const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + setIsDarkMode(darkModeMediaQuery.matches) + + const handleChange = (e: MediaQueryListEvent) => { + const newDarkMode = e.matches + setIsDarkMode(newDarkMode) + } + + darkModeMediaQuery.addEventListener('change', handleChange) + + return () => { + darkModeMediaQuery.removeEventListener('change', handleChange) + } + }, []) + + return isDarkMode +} + +export default useTheme diff --git a/web/components/minimal-tiptap/hooks/use-throttle.ts b/web/components/minimal-tiptap/hooks/use-throttle.ts new file mode 100644 index 00000000..f3f88fba --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-throttle.ts @@ -0,0 +1,34 @@ +import { useRef, useCallback } from 'react' + +export function useThrottle void>( + callback: T, + delay: number +): (...args: Parameters) => void { + const lastRan = useRef(Date.now()) + const timeoutRef = useRef(null) + + return useCallback( + (...args: Parameters) => { + const handler = () => { + if (Date.now() - lastRan.current >= delay) { + callback(...args) + lastRan.current = Date.now() + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + timeoutRef.current = setTimeout( + () => { + callback(...args) + lastRan.current = Date.now() + }, + delay - (Date.now() - lastRan.current) + ) + } + } + + handler() + }, + [callback, delay] + ) +} diff --git a/web/components/minimal-tiptap/index.ts b/web/components/minimal-tiptap/index.ts new file mode 100644 index 00000000..1532aab8 --- /dev/null +++ b/web/components/minimal-tiptap/index.ts @@ -0,0 +1 @@ +export * from './minimal-tiptap' diff --git a/web/components/minimal-tiptap/minimal-tiptap.tsx b/web/components/minimal-tiptap/minimal-tiptap.tsx new file mode 100644 index 00000000..668dde79 --- /dev/null +++ b/web/components/minimal-tiptap/minimal-tiptap.tsx @@ -0,0 +1,95 @@ +import * as React from "react" +import "./styles/index.css" + +import { EditorContent } from "@tiptap/react" +import type { Content, Editor } from "@tiptap/react" +import { Separator } from "@/components/ui/separator" +import { cn } from "@/lib/utils" +import { SectionOne } from "./components/section/one" +import { SectionTwo } from "./components/section/two" +import { SectionThree } from "./components/section/three" +import { SectionFour } from "./components/section/four" +import { SectionFive } from "./components/section/five" +import { LinkBubbleMenu } from "./components/bubble-menu/link-bubble-menu" +import { ImageBubbleMenu } from "./components/bubble-menu/image-bubble-menu" +import type { UseMinimalTiptapEditorProps } from "./hooks/use-minimal-tiptap" +import { useMinimalTiptapEditor } from "./hooks/use-minimal-tiptap" + +export interface MinimalTiptapProps extends Omit { + value?: Content + onChange?: (value: Content) => void + className?: string + editorContentClassName?: string +} + +const Toolbar = ({ editor }: { editor: Editor }) => ( +
+
+ + + + + + + + + + + + + + + + + +
+
+) + +export type MinimalTiptapEditorRef = { + editor: Editor | null +} + +export const MinimalTiptapEditor = React.forwardRef( + ({ value, onChange, className, editorContentClassName, ...props }, ref) => { + const editor = useMinimalTiptapEditor({ + value, + onUpdate: onChange, + ...props + }) + + React.useImperativeHandle( + ref, + () => ({ + editor: editor || null + }), + [editor] + ) + + if (!editor) { + return null + } + + return ( +
+ + + + +
+ ) + } +) + +MinimalTiptapEditor.displayName = "MinimalTiptapEditor" + +export default MinimalTiptapEditor diff --git a/web/components/minimal-tiptap/styles/index.css b/web/components/minimal-tiptap/styles/index.css new file mode 100644 index 00000000..7121c31a --- /dev/null +++ b/web/components/minimal-tiptap/styles/index.css @@ -0,0 +1,182 @@ +@import './partials/code.css'; +@import './partials/placeholder.css'; +@import './partials/lists.css'; +@import './partials/typography.css'; + +:root { + --mt-font-size-regular: 0.9375rem; + + --mt-code-background: #082b781f; + --mt-code-color: #d4d4d4; + --mt-secondary: #9d9d9f; + --mt-pre-background: #ececec; + --mt-pre-border: #e0e0e0; + --mt-pre-color: #2f2f31; + --mt-hr: #dcdcdc; + --mt-drag-handle-hover: #5c5c5e; + + --mt-accent-bold-blue: #05c; + --mt-accent-bold-teal: #206a83; + --mt-accent-bold-green: #216e4e; + --mt-accent-bold-orange: #a54800; + --mt-accent-bold-red: #ae2e24; + --mt-accent-bold-purple: #5e4db2; + + --mt-accent-gray: #758195; + --mt-accent-blue: #1d7afc; + --mt-accent-teal: #2898bd; + --mt-accent-green: #22a06b; + --mt-accent-orange: #fea362; + --mt-accent-red: #c9372c; + --mt-accent-purple: #8270db; + + --mt-accent-blue-subtler: #cce0ff; + --mt-accent-teal-subtler: #c6edfb; + --mt-accent-green-subtler: #baf3db; + --mt-accent-yellow-subtler: #f8e6a0; + --mt-accent-red-subtler: #ffd5d2; + --mt-accent-purple-subtler: #dfd8fd; + + --hljs-string: #aa430f; + --hljs-title: #b08836; + --hljs-comment: #999999; + --hljs-keyword: #0c5eb1; + --hljs-attr: #3a92bc; + --hljs-literal: #c82b0f; + --hljs-name: #259792; + --hljs-selector-tag: #c8500f; + --hljs-number: #3da067; +} + +.dark { + --mt-font-size-regular: 0.9375rem; + + --mt-code-background: #ffffff13; + --mt-code-color: #2c2e33; + --mt-secondary: #595a5c; + --mt-pre-background: #080808; + --mt-pre-border: #23252a; + --mt-pre-color: #e3e4e6; + --mt-hr: #26282d; + --mt-drag-handle-hover: #969799; + + --mt-accent-bold-blue: #85b8ff; + --mt-accent-bold-teal: #9dd9ee; + --mt-accent-bold-green: #7ee2b8; + --mt-accent-bold-orange: #fec195; + --mt-accent-bold-red: #fd9891; + --mt-accent-bold-purple: #b8acf6; + + --mt-accent-gray: #738496; + --mt-accent-blue: #388bff; + --mt-accent-teal: #42b2d7; + --mt-accent-green: #2abb7f; + --mt-accent-orange: #a54800; + --mt-accent-red: #e2483d; + --mt-accent-purple: #8f7ee7; + + --mt-accent-blue-subtler: #09326c; + --mt-accent-teal-subtler: #164555; + --mt-accent-green-subtler: #164b35; + --mt-accent-yellow-subtler: #533f04; + --mt-accent-red-subtler: #5d1f1a; + --mt-accent-purple-subtler: #352c63; + + --hljs-string: #da936b; + --hljs-title: #f1d59d; + --hljs-comment: #aaaaaa; + --hljs-keyword: #6699cc; + --hljs-attr: #90cae8; + --hljs-literal: #f2777a; + --hljs-name: #5fc0a0; + --hljs-selector-tag: #e8c785; + --hljs-number: #b6e7b6; +} + +.minimal-tiptap-editor .ProseMirror { + @apply flex max-w-full flex-1 cursor-text flex-col; + @apply z-0 outline-0; +} + +.minimal-tiptap-editor .ProseMirror > div.editor { + @apply block flex-1 whitespace-pre-wrap; +} + +.minimal-tiptap-editor .ProseMirror .block-node:not(:last-child), +.minimal-tiptap-editor .ProseMirror .list-node:not(:last-child), +.minimal-tiptap-editor .ProseMirror .text-node:not(:last-child) { + @apply mb-2.5; +} + +.minimal-tiptap-editor .ProseMirror ol, +.minimal-tiptap-editor .ProseMirror ul { + @apply pl-6; +} + +.minimal-tiptap-editor .ProseMirror blockquote, +.minimal-tiptap-editor .ProseMirror dl, +.minimal-tiptap-editor .ProseMirror ol, +.minimal-tiptap-editor .ProseMirror p, +.minimal-tiptap-editor .ProseMirror pre, +.minimal-tiptap-editor .ProseMirror ul { + @apply m-0; +} + +.minimal-tiptap-editor .ProseMirror li { + @apply leading-7; +} + +.minimal-tiptap-editor .ProseMirror p { + @apply break-words; +} + +.minimal-tiptap-editor .ProseMirror li .text-node:has(+ .list-node), +.minimal-tiptap-editor .ProseMirror li > .list-node, +.minimal-tiptap-editor .ProseMirror li > .text-node, +.minimal-tiptap-editor .ProseMirror li p { + @apply mb-0; +} + +.minimal-tiptap-editor .ProseMirror blockquote { + @apply relative pl-3.5; +} + +.minimal-tiptap-editor .ProseMirror blockquote::before, +.minimal-tiptap-editor .ProseMirror blockquote.is-empty::before { + @apply absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm bg-accent-foreground/15 content-['']; +} + +.minimal-tiptap-editor .ProseMirror hr { + @apply my-3 h-0.5 w-full border-none bg-[var(--mt-hr)]; +} + +.minimal-tiptap-editor .ProseMirror-focused hr.ProseMirror-selectednode { + @apply rounded-full outline outline-2 outline-offset-1 outline-muted-foreground; +} + +.minimal-tiptap-editor .ProseMirror .ProseMirror-gapcursor { + @apply pointer-events-none absolute hidden; +} + +.minimal-tiptap-editor .ProseMirror .ProseMirror-hideselection { + @apply caret-transparent; +} + +.minimal-tiptap-editor .ProseMirror.resize-cursor { + @apply cursor-col-resize; +} + +.minimal-tiptap-editor .ProseMirror .selection { + @apply inline-block; +} + +.minimal-tiptap-editor .ProseMirror .selection, +.minimal-tiptap-editor .ProseMirror *::selection, +::selection { + @apply bg-primary/25; +} + +/* Override native selection when custom selection is present */ +.minimal-tiptap-editor .ProseMirror .selection::selection { + background: transparent; +} diff --git a/web/components/minimal-tiptap/styles/partials/code.css b/web/components/minimal-tiptap/styles/partials/code.css new file mode 100644 index 00000000..b1d03ea7 --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/code.css @@ -0,0 +1,86 @@ +.minimal-tiptap-editor .ProseMirror code.inline { + @apply rounded border border-[var(--mt-code-color)] bg-[var(--mt-code-background)] px-1 py-0.5 text-sm; +} + +.minimal-tiptap-editor .ProseMirror pre { + @apply relative overflow-auto rounded border font-mono text-sm; + @apply border-[var(--mt-pre-border)] bg-[var(--mt-pre-background)] text-[var(--mt-pre-color)]; + @apply hyphens-none whitespace-pre text-left; +} + +.minimal-tiptap-editor .ProseMirror code { + @apply break-words leading-[1.7em]; +} + +.minimal-tiptap-editor .ProseMirror pre code { + @apply block overflow-x-auto p-3.5; +} + +.minimal-tiptap-editor .ProseMirror pre { + .hljs-keyword, + .hljs-operator, + .hljs-function, + .hljs-built_in, + .hljs-builtin-name { + color: var(--hljs-keyword); + } + + .hljs-attr, + .hljs-symbol, + .hljs-property, + .hljs-attribute, + .hljs-variable, + .hljs-template-variable, + .hljs-params { + color: var(--hljs-attr); + } + + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-type, + .hljs-addition { + color: var(--hljs-name); + } + + .hljs-string, + .hljs-bullet { + color: var(--hljs-string); + } + + .hljs-title, + .hljs-subst, + .hljs-section { + color: var(--hljs-title); + } + + .hljs-literal, + .hljs-type, + .hljs-deletion { + color: var(--hljs-literal); + } + + .hljs-selector-tag, + .hljs-selector-id, + .hljs-selector-class { + color: var(--hljs-selector-tag); + } + + .hljs-number { + color: var(--hljs-number); + } + + .hljs-comment, + .hljs-meta, + .hljs-quote { + color: var(--hljs-comment); + } + + .hljs-emphasis { + @apply italic; + } + + .hljs-strong { + @apply font-bold; + } +} diff --git a/web/components/minimal-tiptap/styles/partials/lists.css b/web/components/minimal-tiptap/styles/partials/lists.css new file mode 100644 index 00000000..f4d75a68 --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/lists.css @@ -0,0 +1,82 @@ +.minimal-tiptap-editor div.tiptap p { + @apply text-[var(--mt-font-size-regular)]; +} + +.minimal-tiptap-editor .ProseMirror ol { + @apply list-decimal; +} + +.minimal-tiptap-editor .ProseMirror ol ol { + list-style: lower-alpha; +} + +.minimal-tiptap-editor .ProseMirror ol ol ol { + list-style: lower-roman; +} + +.minimal-tiptap-editor .ProseMirror ul { + list-style: disc; +} + +.minimal-tiptap-editor .ProseMirror ul ul { + list-style: circle; +} + +.minimal-tiptap-editor .ProseMirror ul ul ul { + list-style: square; +} + +.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] { + @apply list-none pl-1; +} + +.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] p { + @apply m-0; +} + +.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] li > label { + @apply mr-2 mt-0.5 flex-none select-none; +} + +.minimal-tiptap-editor .ProseMirror li[data-type='taskItem'] { + @apply flex flex-row items-start; +} + +.minimal-tiptap-editor .ProseMirror li[data-type='taskItem'] .taskItem-checkbox-container { + @apply relative pr-2; +} + +.minimal-tiptap-editor .ProseMirror .taskItem-drag-handle { + @apply absolute -left-5 top-1.5 h-[18px] w-[18px] cursor-move pl-0.5 text-[var(--mt-secondary)] opacity-0; +} + +.minimal-tiptap-editor + .ProseMirror + li[data-type='taskItem']:hover:not(:has(li:hover)) + > .taskItem-checkbox-container + > .taskItem-drag-handle { + @apply opacity-100; +} + +.minimal-tiptap-editor .ProseMirror .taskItem-drag-handle:hover { + @apply text-[var(--mt-drag-handle-hover)]; +} + +.minimal-tiptap-editor .ProseMirror .taskItem-checkbox { + fill-opacity: 0; + @apply h-3.5 w-3.5 flex-shrink-0 cursor-pointer select-none appearance-none rounded border border-solid border-[var(--mt-secondary)] bg-transparent bg-[1px_2px] p-0.5 align-middle transition-colors duration-75 ease-out; +} + +.minimal-tiptap-editor .ProseMirror .taskItem-checkbox:checked { + @apply border-primary bg-primary bg-no-repeat; + background-image: url('data:image/svg+xml;utf8,%3Csvg%20width=%2210%22%20height=%229%22%20viewBox=%220%200%2010%208%22%20xmlns=%22http://www.w3.org/2000/svg%22%20fill=%22%23fbfbfb%22%3E%3Cpath%20d=%22M3.46975%205.70757L1.88358%204.1225C1.65832%203.8974%201.29423%203.8974%201.06897%204.1225C0.843675%204.34765%200.843675%204.7116%201.06897%204.93674L3.0648%206.93117C3.29006%207.15628%203.65414%207.15628%203.8794%206.93117L8.93103%201.88306C9.15633%201.65792%209.15633%201.29397%208.93103%201.06883C8.70578%200.843736%208.34172%200.843724%208.11646%201.06879C8.11645%201.0688%208.11643%201.06882%208.11642%201.06883L3.46975%205.70757Z%22%20stroke-width=%220.2%22%20/%3E%3C/svg%3E'); +} + +.minimal-tiptap-editor .ProseMirror .taskItem-content { + @apply min-w-0 flex-1; +} + +.minimal-tiptap-editor .ProseMirror li[data-checked='true'] .taskItem-content > :not([data-type='taskList']), +.minimal-tiptap-editor .ProseMirror li[data-checked='true'] .taskItem-content .taskItem-checkbox { + @apply opacity-75; +} diff --git a/web/components/minimal-tiptap/styles/partials/placeholder.css b/web/components/minimal-tiptap/styles/partials/placeholder.css new file mode 100644 index 00000000..04bcfdf0 --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/placeholder.css @@ -0,0 +1,4 @@ +.minimal-tiptap-editor .ProseMirror > p.is-editor-empty::before { + content: attr(data-placeholder); + @apply pointer-events-none float-left h-0 text-[var(--mt-secondary)]; +} diff --git a/web/components/minimal-tiptap/styles/partials/typography.css b/web/components/minimal-tiptap/styles/partials/typography.css new file mode 100644 index 00000000..a1f753b7 --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/typography.css @@ -0,0 +1,27 @@ +.minimal-tiptap-editor .ProseMirror .heading-node { + @apply relative font-semibold; +} + +.minimal-tiptap-editor .ProseMirror .heading-node:first-child { + @apply mt-0; +} + +.minimal-tiptap-editor .ProseMirror h1 { + @apply mb-4 mt-[46px] text-[1.375rem] leading-7 tracking-[-0.004375rem]; +} + +.minimal-tiptap-editor .ProseMirror h2 { + @apply mb-3.5 mt-8 text-[1.1875rem] leading-7 tracking-[0.003125rem]; +} + +.minimal-tiptap-editor .ProseMirror h3 { + @apply mb-3 mt-6 text-[1.0625rem] leading-6 tracking-[0.00625rem]; +} + +.minimal-tiptap-editor .ProseMirror a.link { + @apply cursor-pointer text-primary; +} + +.minimal-tiptap-editor .ProseMirror a.link:hover { + @apply underline; +} diff --git a/web/components/minimal-tiptap/types.ts b/web/components/minimal-tiptap/types.ts new file mode 100644 index 00000000..fe06c8e0 --- /dev/null +++ b/web/components/minimal-tiptap/types.ts @@ -0,0 +1,28 @@ +import type { Editor } from '@tiptap/core' +import type { EditorView } from '@tiptap/pm/view' +import type { EditorState } from '@tiptap/pm/state' + +export interface LinkProps { + url: string + text?: string + openInNewTab?: boolean +} + +export interface ShouldShowProps { + editor: Editor + view: EditorView + state: EditorState + oldState?: EditorState + from: number + to: number +} + +export interface FormatAction { + label: string + icon?: React.ReactNode + action: (editor: Editor) => void + isActive: (editor: Editor) => boolean + canExecute: (editor: Editor) => boolean + shortcuts: string[] + value: string +} diff --git a/web/components/minimal-tiptap/utils.ts b/web/components/minimal-tiptap/utils.ts new file mode 100644 index 00000000..d749a3cc --- /dev/null +++ b/web/components/minimal-tiptap/utils.ts @@ -0,0 +1,14 @@ +import type { Editor } from "@tiptap/core" +import type { MinimalTiptapProps } from "./minimal-tiptap" + +export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) { + if (format === "json") { + return editor.getJSON() + } + + if (format === "html") { + return editor.getText() ? editor.getHTML() : "" + } + + return editor.getText() +} diff --git a/web/components/routes/OnboardingRoute.tsx b/web/components/routes/OnboardingRoute.tsx new file mode 100644 index 00000000..359bd610 --- /dev/null +++ b/web/components/routes/OnboardingRoute.tsx @@ -0,0 +1,141 @@ +"use client" + +import React, { useEffect } from "react" +import { atomWithStorage } from "jotai/utils" +import { LaIcon } from "../custom/la-icon" +import { useAccount } from "@/lib/providers/jazz-provider" +import { useAtom } from "jotai" + +const isCreateLinkDoneAtom = atomWithStorage("isCreateLinkDone", false) +const isCreatePageDoneAtom = atomWithStorage("isCreatePageDone", false) +const isStartTrackingDoneAtom = atomWithStorage("isStartTrackingDone", false) +const isAddLinkDoneAtom = atomWithStorage("isAddLinkDone", false) + +const steps = [ + { + number: 1, + title: "Create Link", + description: + "Links are essentially bookmarks of things from internet. You can create a link by pressing Links button in left sidebar. Then pressing + button on the bottom.", + task: "create any Link with any title or description (for example, you can add https://learn-anything.xyz as link)" + }, + { + number: 2, + title: "Create Page", + description: + "Pages are things with content inside (images, text, anything). You can think of them as Notion pages. To create page, press the + button next to pages, then create title and put some content.", + task: "create any Page with any content inside" + }, + { + number: 3, + title: "Start tracking Learning status of some Topic", + description: + "What makes Learn Anything different from Notion and other tools is notion of topics. A topic is anything after learn-anything.xyz/, for example learn-anything.xyz/typescript. You can go to the page, then on top right corner where it says add to my profile, press it and change the state of the topic to I want to learn, Learning or Learned.", + task: "go to any Topic, and mark it as I want to learn" + }, + { + number: 4, + title: "Add a Link from a Topic into personal link collection", + description: + "If you noticed, there are links attached to topics as a list. This is the topic's study guide. It will be improved greatly in future and we will allow any user to edit these study guides too (Reddit style). You can click on the circle to left of the links and add a link to your personal collection with learning status too.", + task: "add any Link from topic typescript into your personal collection" + } +] + +const StepItem = ({ + number, + title, + description, + task, + done +}: { + number: number + title: string + description: string + task: string + done: boolean +}) => ( +
+
+ {number} +
+
+

{title}

+

{description}

+
+ +

{task}

+
+
+
+) + +export default function OnboardingRoute() { + const { me } = useAccount({ + root: { + personalPages: [], + personalLinks: [], + topicsWantToLearn: [] + } + }) + + const [isCreateLinkDone, setIsCreateLinkDone] = useAtom(isCreateLinkDoneAtom) + const [isCreatePageDone, setIsCreatePageDone] = useAtom(isCreatePageDoneAtom) + const [isStartTrackingDone, setIsStartTrackingDone] = useAtom(isStartTrackingDoneAtom) + const [isAddLinkDone, setIsAddLinkDone] = useAtom(isAddLinkDoneAtom) + + useEffect(() => { + if (!me) return + + if (me.root.personalLinks.length > 0 && !isCreateLinkDone) { + setIsCreateLinkDone(true) + } + + if (me.root.personalPages.length > 0 && !isCreatePageDone) { + setIsCreatePageDone(true) + } + + if (me.root.topicsWantToLearn.length > 0 && !isStartTrackingDone) { + setIsStartTrackingDone(true) + } + + if (me.root.personalLinks.some(link => link?.topic?.name === "typescript") && !isAddLinkDone) { + setIsAddLinkDone(true) + } + }, [ + me, + isCreateLinkDone, + isCreatePageDone, + setIsCreateLinkDone, + setIsCreatePageDone, + isAddLinkDone, + setIsAddLinkDone, + isStartTrackingDone, + setIsStartTrackingDone + ]) + + const completedSteps = [isCreateLinkDone, isCreatePageDone, isStartTrackingDone, isAddLinkDone].filter(Boolean).length + + return ( +
+
+

Onboarding

+
+
+

Complete the steps below to get started

+

+ Completed {completedSteps} out of {steps.length} steps +

+
+ {steps.map((step, index) => ( + + ))} +
+
+
+ ) +} diff --git a/web/components/routes/SettingsRoute.tsx b/web/components/routes/SettingsRoute.tsx index 26a3f9e2..ccefbf2e 100644 --- a/web/components/routes/SettingsRoute.tsx +++ b/web/components/routes/SettingsRoute.tsx @@ -97,7 +97,6 @@ export const SettingsRoute = () => { const [topInboxHotkey, setTopInboxHotkey] = useState("") const saveSettings = () => { - console.log("Saving settings:", { inboxHotkey, topInboxHotkey }) toast.success("Settings saved", { description: "Your hotkey settings have been updated." }) diff --git a/web/components/routes/community/CommunityTopicRoute.tsx b/web/components/routes/community/CommunityTopicRoute.tsx new file mode 100644 index 00000000..cc9fa28b --- /dev/null +++ b/web/components/routes/community/CommunityTopicRoute.tsx @@ -0,0 +1,74 @@ +"use client" + +import { useMemo, useState } from "react" +import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider" +import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" +import { GuideCommunityToggle } from "@/components/custom/GuideCommunityToggle" +import { QuestionList } from "@/components/custom/QuestionList" +import { QuestionThread } from "@/components/custom/QuestionThread" +import { Topic } from "@/lib/schema" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" + +interface CommunityTopicRouteProps { + topicName: string +} + +interface Question { + id: string + title: string + author: string + timestamp: string +} + +export function CommunityTopicRoute({ topicName }: CommunityTopicRouteProps) { + const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) + const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } }) + + const [selectedQuestion, setSelectedQuestion] = useState(null) + + if (!topic) { + return null + } + + return ( +
+ +
+ +
+

Topic

+ {topic.prettyName} +
+
+
+ + +
+
+ setSelectedQuestion(question)} + /> +
+ {selectedQuestion && ( +
+ setSelectedQuestion(null)} + /> +
+ )} +
+
+ ) +} diff --git a/web/components/routes/journal/JournalRoute.tsx b/web/components/routes/journal/JournalRoute.tsx new file mode 100644 index 00000000..4dcb644e --- /dev/null +++ b/web/components/routes/journal/JournalRoute.tsx @@ -0,0 +1,114 @@ +"use client" + +import { useState, useEffect } from "react" +import { JournalEntry, JournalEntryLists } from "@/lib/schema/journal" +import { useAccount } from "@/lib/providers/jazz-provider" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { calendarFormatDate } from "@/lib/utils" +import { Calendar } from "@/components/ui/calendar" + +export function JournalRoute() { + const [date, setDate] = useState(new Date()) + const { me } = useAccount({ root: { journalEntries: [] } }) + const [newNote, setNewNote] = useState(null) + + const notes = me?.root?.journalEntries || (me ? JournalEntryLists.create([], { owner: me }) : []) + + useEffect(() => { + console.log("me:", me) + }, [me]) + + const selectDate = (selectedDate: Date | undefined) => { + if (selectedDate) { + setDate(selectedDate) + } + } + + const createNewNote = () => { + if (me) { + const newEntry = JournalEntry.create( + { + title: "", + content: "", + date: date, + createdAt: new Date(), + updatedAt: new Date() + }, + { owner: me._owner } + ) + setNewNote(newEntry) + } + } + + const handleNewNoteChange = (field: keyof JournalEntry, value: string) => { + if (newNote) { + setNewNote(prevNote => { + if (prevNote) { + return JournalEntry.create({ ...prevNote, [field]: value }, { owner: me!._owner }) + } + return prevNote + }) + } + } + + const saveNewNote = () => { + if (newNote && me?.root?.journalEntries) { + me.root.journalEntries.push(newNote) + setNewNote(null) + } + } + + return ( +
+
+
+ {newNote ? ( +
+ handleNewNoteChange("title", e.target.value)} + className="mb-2 w-full text-xl font-semibold" + /> +