fix: conflict

This commit is contained in:
Aslam H
2024-09-28 19:53:32 +07:00
205 changed files with 8806 additions and 2121 deletions

23
.gitignore vendored
View File

@@ -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/
# repos
private
docs
# other
past.*
x.*

6
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.encore
encore.gen.go
encore.gen.cue
/.encore
node_modules
/encore.gen

72
api/api/api.ts Normal file
View File

@@ -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<void> => {
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<any>
// 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<any>
// console.log(globalGroupId)
// console.log(worker)
// console.log("runs..")
const topicContent = {
links: []
}
return topicContent
}
)

40
api/api/links.ts Normal file
View File

@@ -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<void> => {
// 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"
}
}
)

BIN
api/bun.lockb Executable file

Binary file not shown.

4
api/encore.app Normal file
View File

@@ -0,0 +1,4 @@
{
"id": "encore-test-76k2",
"lang": "typescript"
}

20
api/package.json Normal file
View File

@@ -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"
}
}

1
api/readme.md Normal file
View File

@@ -0,0 +1 @@
Using [Encore](https://encore.dev).

31
api/tsconfig.json Normal file
View File

@@ -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
}
}

View File

@@ -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": {

BIN
bun.lockb

Binary file not shown.

5
cli/readme.md Normal file
View File

@@ -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).

View File

@@ -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
}
]

View File

@@ -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"
}

View File

@@ -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_)

88
scripts/past-seed.ts Normal file
View File

@@ -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 })
})

View File

@@ -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"]

View File

@@ -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
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

4
web/.gitignore vendored
View File

@@ -34,3 +34,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin
.ronin

2
web/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
[install.scopes]
ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }

View File

@@ -0,0 +1,5 @@
import { CommunityTopicRoute } from "@/components/routes/community/CommunityTopicRoute"
export default function CommunityTopicPage({ params }: { params: { topicName: string } }) {
return <CommunityTopicRoute topicName={params.topicName} />
}

View File

@@ -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 <JournalRoute />
}

View File

@@ -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 <PublicHomeRoute />
}
export const viewport: Viewport = {
width: "device-width, shrink-to-fit=no",
maximumScale: 1,
userScalable: false
}
export default function PageLayout({ children }: { children: React.ReactNode }) {
return (
<JazzClerkAuth>
<SignedInClient>
<JazzProvider>
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
<CommandPalette />
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
<LearnAnythingOnboarding />
<GlobalKeyboardHandler />
<CommandPalette />
<Shortcut />
<div className="flex min-w-0 flex-1 flex-col">
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
{children}
</main>
</div>
</div>
</JazzProvider>
</SignedInClient>
</JazzClerkAuth>
<div className="relative flex min-w-0 flex-1 flex-col">
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
{children}
</main>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { LinkRoute } from "@/components/routes/link/LinkRoute"
export default function HomePage() {
export default function LinksPage() {
return <LinkRoute />
}

View File

@@ -0,0 +1,5 @@
import OnboardingRoute from "@/components/routes/OnboardingRoute"
export default function EditProfilePage() {
return <OnboardingRoute />
}

View File

@@ -0,0 +1,5 @@
import { PageRoute } from "@/components/routes/page/PageRoute"
export default function Page() {
return <PageRoute />
}

View File

@@ -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<ProfileStatsProps> = ({ number, label }) => {
return (
<div className="text-center font-semibold text-black/60 dark:text-white">
@@ -31,37 +23,65 @@ const ProfileStats: React.FC<ProfileStatsProps> = ({ number, label }) => {
)
}
const ProfileLinks: React.FC<ProfileLinksProps> = ({ linklabel, link, topic }) => {
return (
<div className="flex flex-row items-center justify-between bg-[#121212] p-3 text-black dark:text-white">
<div className="flex flex-row items-center space-x-3">
<p className="text-base text-opacity-90">{linklabel || "Untitled"}</p>
<div className="flex cursor-pointer flex-row items-center gap-1">
<Icon name="Link" />
<p className="text-sm text-opacity-10">{link || "#"}</p>
</div>
</div>
<div className="text0opacity-50 bg-[#1a1a1a] p-2">{topic || "Uncategorized"}</div>
</div>
)
}
const ProfilePages: React.FC<ProfilePagesProps> = ({ topic }) => {
return (
<div className="flex flex-row items-center justify-between rounded-lg bg-[#121212] p-3 text-black dark:text-white">
<div className="rounded-lg bg-[#1a1a1a] p-2 text-opacity-50">{topic || "Uncategorized"}</div>
</div>
)
}
export const ProfileWrapper = () => {
const account = useAccount()
const params = useParams()
const username = params.username as string
const { user, isSignedIn } = useUser()
const avatarInputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const editAvatar = (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 = () => {
<p className="mb-5 text-center text-lg font-semibold">
The link you followed may be broken, or the page may have been removed. Go back to
<Link href="/">
<span className="">homepage</span>
<span className=""> homepage</span>
</Link>
.
</p>
@@ -86,32 +106,48 @@ export const ProfileWrapper = () => {
return (
<div className="flex flex-1 flex-col text-black dark:text-white">
<div className="flex items-center justify-between p-[20px]">
<p className="text-2xl font-semibold">Profile</p>
<Button
onClick={clickEdit}
className="shadow-outer ml-auto flex h-[34px] cursor-pointer flex-row space-x-2 rounded-lg bg-white px-3 text-black shadow-[1px_1px_1px_1px_rgba(0,0,0,0.3)] hover:bg-black/10 dark:bg-[#222222] dark:text-white dark:hover:opacity-60"
>
<LaIcon name="UserCog" className="text-foreground cursor-pointer" />
<span>Edit Profile</span>
</Button>
<p className="text-2xl font-semibold opacity-70">Profile</p>
</div>
<p className="text-2xl font-semibold">{username}</p>
<div className="flex flex-col items-center border-b border-neutral-900 bg-inherit pb-5">
<div className="flex w-full max-w-2xl align-top">
<div className="mr-3 h-[130px] w-[130px] rounded-md bg-[#222222]" />
<Button onClick={() => avatarInputRef.current?.click()} variant="ghost" className="p-0 hover:bg-transparent">
<Avatar className="size-20">
<AvatarImage src={account.me?.profile?.avatarUrl || user?.imageUrl} alt={user?.fullName || ""} />
</Avatar>
</Button>
<input type="file" ref={avatarInputRef} onChange={editAvatar} accept="image/*" style={{ display: "none" }} />
<div className="ml-6 flex-1">
<p className="mb-3 text-[25px] font-semibold">{account.me.profile.name}</p>
<div className="mb-1 flex flex-row items-center font-light text-[24]">
@<p className="pl-1">{account.me.root?.username}</p>
</div>
<a href={account.me.root?.website || "#"} className="mb-1 flex flex-row items-center text-sm font-light">
<Icon name="Link" />
<p className="pl-1">{account.me.root?.website}</p>
</a>
{isEditing ? (
<>
<Input
value={newName}
onChange={changeName}
className="border-result mb-3 mr-3 text-[25px] font-semibold"
/>
{error && <p className="text-red-500 text-opacity-70">{error}</p>}
</>
) : (
<p className="mb-3 text-[25px] font-semibold">{account.me?.profile?.name}</p>
)}
</div>
<button className="shadow-outer ml-auto flex h-[34px] cursor-pointer flex-row items-center justify-center space-x-2 rounded-lg bg-white px-3 text-center font-medium text-black shadow-[1px_1px_1px_1px_rgba(0,0,0,0.3)] hover:bg-black/10 dark:bg-[#222222] dark:text-white dark:hover:opacity-60">
Follow
</button>
{isEditing ? (
<div>
<Button onClick={saveProfile} className="mr-2">
Save
</Button>
<Button onClick={cancelEditing} variant="outline">
Cancel
</Button>
</div>
) : (
<Button
onClick={editProfileClicked}
className="shadow-outer ml-auto flex h-[34px] cursor-pointer flex-row items-center justify-center space-x-2 rounded-lg bg-white px-3 text-center font-medium text-black shadow-[1px_1px_1px_1px_rgba(0,0,0,0.3)] hover:bg-black/10 dark:bg-[#222222] dark:text-white dark:hover:opacity-60"
>
Edit profile
</Button>
)}
</div>
</div>
<div className="mt-10 flex justify-center">
@@ -121,17 +157,9 @@ export const ProfileWrapper = () => {
<ProfileStats number={account.me.root?.topicsLearned?.length || 0} label="Learned" />
</div>
</div>
{/* <div className="mx-auto mt-10 w-[50%] justify-center space-y-1">
<p className="pb-3 pl-2 text-base font-light text-white/50">Public Pages</p>
{account.me.root?.personalPages?.map((page, index) => <ProfileLinks topic={page.topic?.name} />)}
<div className="mx-auto py-20">
<p>Public profiles are coming soon</p>
</div>
<div className="mx-auto mt-10 w-[50%] justify-center space-y-1">
<p className="pb-3 pl-2 text-base font-light text-white/50">Public Links</p>
{account.me.root?.personalLinks?.map((link, index) => (
<ProfileLinks key={index} linklabel={link.title} link={link.url} topic={link.topic?.name} />
))}
</div> */}
</div>
)
}

View File

@@ -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 <TaskRoute />
}

View File

@@ -0,0 +1,5 @@
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
export default function Page() {
return <TopicRoute />
}

View File

@@ -0,0 +1,7 @@
export default function PublicLayout({
children
}: Readonly<{
children: React.ReactNode
}>) {
return <main className="h-full">{children}</main>
}

91
web/app/actions.ts Normal file
View File

@@ -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
}

127
web/app/command-palette.css Normal file
View File

@@ -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)];
}

11
web/app/custom.css Normal file
View File

@@ -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%);
}

7
web/app/fonts.ts Normal file
View File

@@ -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"] })

23
web/app/global-error.tsx Normal file
View File

@@ -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 (
<html>
<body>
{/* `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. */}
<NextError statusCode={0} />
</body>
</html>
)
}

View File

@@ -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;
}

View File

@@ -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 }) => (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ClerkProviderClient>
<DeepLinkProvider>
<JotaiProvider>
<TooltipProvider>
<ConfirmProvider>
<JazzAndAuth>{children}</JazzAndAuth>
</ConfirmProvider>
</TooltipProvider>
</JotaiProvider>
</DeepLinkProvider>
</ClerkProviderClient>
</ThemeProvider>
)
export default function RootLayout({
children
}: Readonly<{
@@ -27,20 +46,13 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="h-full w-full" suppressHydrationWarning>
<ClerkProviderClient>
<DeepLinkProvider>
<body className={cn("h-full w-full font-sans antialiased", fontSans.variable)}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<JotaiProvider>
<ConfirmProvider>
{children}
<Toaster expand={false} />
</ConfirmProvider>
</JotaiProvider>
</ThemeProvider>
</body>
</DeepLinkProvider>
</ClerkProviderClient>
<body className={cn("h-full w-full font-sans antialiased", GeistSans.variable, GeistMono.variable)}>
<Providers>
{children}
<Toaster expand={false} />
</Providers>
</body>
</html>
)
}

5
web/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute"
export default function HomePage() {
return <PublicHomeRoute />
}

View File

@@ -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<GuideCommunityToggleProps> = ({ 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 (
<div className="bg-accent/70 relative flex h-8 w-48 items-center rounded-md">
<div
className="absolute h-8 w-[calc(50%-4px)] rounded-md transition-all duration-300 ease-in-out"
style={{ left: view === "guide" ? "2px" : "calc(50% + 2px)" }}
/>
<button
className={cn(
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
view === "guide" ? "text-primary bg-accent" : "text-primary/50"
)}
onClick={() => handleToggle("guide")}
>
Guide
</button>
<button
className={cn(
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
view === "community" ? "text-primary bg-accent" : "text-primary/50"
)}
onClick={() => handleToggle("community")}
>
Community
</button>
</div>
)
}

View File

@@ -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<Question[]>([])
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 (
<div className="flex h-full flex-col">
<div className="scrollbar-hide flex-grow overflow-y-auto">
{questions.map(question => (
<div
key={question.id}
className={cn(
"flex cursor-pointer flex-col gap-2 rounded p-4",
selectedQuestionId === question.id && "bg-red-500"
)}
onClick={() => onSelectQuestion(question)}
>
<div className="flex flex-row justify-between opacity-50">
<div className="flex flex-row items-center space-x-2">
<div className="h-6 w-6 rounded-full bg-slate-500" />
<p className="text-sm font-medium">{question.author}</p>
</div>
<p>{question.timestamp}</p>
</div>
<h3 className="font-medium">{question.title}</h3>
</div>
))}
</div>
<div className="relative mt-4">
<Input className="bg-input py-5 pr-10 focus:outline-none focus:ring-0" placeholder="Ask new question..." />
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-60 hover:opacity-80">
<LaIcon name="Send" />
</button>
</div>
</div>
)
}

View File

@@ -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<Answer[]>([])
const [newAnswer, setNewAnswer] = useState("")
const [replyTo, setReplyTo] = useState<Answer | null>(null)
const [replyToAuthor, setReplyToAuthor] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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) => (
<div>
{answers.map(answer => (
<div key={answer.id} className={`flex-grow overflow-y-auto p-4 ${isReply ? "ml-3 border-l" : ""}`}>
<div className="flex items-center justify-between pb-1">
<div className="flex items-center">
<div className="bg-accent mr-2 h-6 w-6 rounded-full"></div>
<span className="text-sm">{answer.author}</span>
</div>
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="focus:outline-none">
<LaIcon name="Ellipsis" className="mr-2 size-4 shrink-0 opacity-30 hover:opacity-70" />
</button>
</DropdownMenuTrigger>
<div className="w-[15px]">
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => sendReply(answer)}>
<div className="mx-auto flex flex-row items-center gap-3">
<LaIcon name="Reply" className="size-4 shrink-0" />
Reply
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</div>
</DropdownMenu>
<span className="text-sm opacity-30">{answer.timestamp}</span>
</div>
</div>
<div className="flex items-end justify-between">
<p className="">{answer.content}</p>
<LaIcon name="ThumbsUp" className="ml-2 size-4 shrink-0 opacity-70" />
</div>
{answer.replies && renderAnswers(answer.replies, true)}
</div>
))}
</div>
)
return (
<div className="border-accent bg-background fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l">
<div className="border-accent flex w-full justify-between border-b p-4">
<div className="flex w-full flex-col">
<div className="mb-2 flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-accent h-8 w-8 rounded-full"></div>
<h2 className="opacity-70">{question.author}</h2>
</div>
<button className="bg-accent rounded-full p-1.5 opacity-50 hover:opacity-80" onClick={onClose}>
<LaIcon name="X" className="text-primary" />
</button>
</div>
<p className="text-md mb-1 font-semibold">{question.title}</p>
<p className="text-sm opacity-70">{question.timestamp}</p>
</div>
</div>
<div className="flex-grow overflow-y-auto">{renderAnswers(answers)}</div>
<div className="border-accent border-t p-4">
<form className="relative" onSubmit={sendAnswer}>
<div className="relative flex items-center">
<input
ref={inputRef}
type="text"
value={newAnswer}
onChange={changeInput}
placeholder="Answer the question..."
className="bg-input w-full rounded p-2 text-opacity-70 placeholder:text-opacity-50 focus:outline-none focus:ring-0"
/>
</div>
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-50 hover:opacity-90">
<LaIcon name="Send" />
</button>
</form>
</div>
</div>
)
}

View File

@@ -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 }) => (
<kbd
aria-hidden="true"
className="inline-flex size-5 items-center justify-center rounded border font-sans text-xs capitalize"
>
{keyChar}
</kbd>
)
const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
<div className="flex flex-row items-center gap-2">
<dt className="flex grow items-center">
<span className="text-muted-foreground text-left text-sm">{label}</span>
</dt>
<dd className="flex items-end">
<span className="text-left">
<span
aria-label={keys.join(" ") + (then ? ` then ${then.join(" ")}` : "")}
className="inline-flex items-center gap-1"
>
{keys.map((key, index) => (
<ShortcutKey key={index} keyChar={key} />
))}
{then && (
<>
<span className="text-muted-foreground text-xs">then</span>
{then.map((key, index) => (
<ShortcutKey key={`then-${index}`} keyChar={key} />
))}
</>
)}
</span>
</span>
</dd>
</div>
)
const ShortcutSection: React.FC<ShortcutSection> = ({ title, shortcuts }) => (
<section className="flex flex-col gap-2">
<h2 className="inline-flex gap-1.5 text-sm">{title}</h2>
<dl className="m-0 flex flex-col gap-2">
{shortcuts.map((shortcut, index) => (
<ShortcutItem key={index} {...shortcut} />
))}
</dl>
</section>
)
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 (
<Sheet open={showShortcut} onOpenChange={setShowShortcut}>
<SheetPortal>
<SheetOverlay className="bg-black/10" />
<SheetPrimitive.Content
className={cn(sheetVariants({ side: "right" }), "m-3 h-[calc(100vh-24px)] rounded-md p-0")}
>
<header className="flex flex-[0_0_auto] items-center gap-3 px-5 pb-4 pt-5">
<SheetTitle className="text-base font-medium">Keyboard Shortcuts</SheetTitle>
<SheetDescription className="sr-only">Quickly navigate around the app</SheetDescription>
<div className="flex-auto"></div>
<SheetPrimitive.Close className={cn(buttonVariants({ size: "icon", variant: "ghost" }), "size-6 p-0")}>
<LaIcon name="X" className="text-muted-foreground size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</header>
<div className="flex flex-col gap-1 px-5 pb-6">
<form className="relative flex items-center">
<LaIcon name="Search" className="text-muted-foreground absolute left-3 size-4" />
<Input
autoFocus
placeholder="Search shortcuts"
className="border-muted-foreground/50 focus-visible:border-muted-foreground h-10 pl-10 focus-visible:ring-0"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</form>
</div>
<main className="flex-auto overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
<div className="px-5 pb-5">
<div role="region" aria-live="polite" className="flex flex-col gap-7">
{filteredShortcuts.map((section, index) => (
<ShortcutSection key={index} {...section} />
))}
</div>
</div>
</main>
</SheetPrimitive.Content>
</SheetPortal>
</Sheet>
)
}

View File

@@ -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 <ClerkProvider>{children}</ClerkProvider>
interface ClerkProviderClientProps {
children: React.ReactNode
}
export const ClerkProviderClient: React.FC<ClerkProviderClientProps> = ({ 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 <ClerkProvider appearance={appearance}>{children}</ClerkProvider>
}

View File

@@ -1,7 +1,16 @@
"use client"
import { SignIn } from "@clerk/nextjs"
export const SignInClient = () => {
return <SignIn />
return (
<div className="flex justify-center">
<SignIn
appearance={{
elements: {
formButtonPrimary: "bg-primary text-primary-foreground",
card: "shadow-none"
}
}}
/>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import React from "react"
import { cn } from "@/lib/utils"
interface ColumnWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
style?: { [key: string]: string }
}
interface ColumnTextProps extends React.HTMLAttributes<HTMLSpanElement> {}
const ColumnWrapper = React.forwardRef<HTMLDivElement, ColumnWrapperProps>(
({ children, className, style, ...props }, ref) => (
<div
className={cn("flex grow flex-row items-center justify-start", className)}
style={{
width: "var(--width)",
minWidth: "var(--min-width, min-content)",
maxWidth: "min(var(--width), var(--max-width))",
flexBasis: "var(--width)",
...style
}}
ref={ref}
{...props}
>
{children}
</div>
)
)
ColumnWrapper.displayName = "ColumnWrapper"
const ColumnText = React.forwardRef<HTMLSpanElement, ColumnTextProps>(({ children, className, ...props }, ref) => (
<span className={cn("text-left text-xs", className)} ref={ref} {...props}>
{children}
</span>
))
ColumnText.displayName = "ColumnText"
export const Column = {
Wrapper: ColumnWrapper,
Text: ColumnText
}

View File

@@ -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<typeof useCommandActions>
): 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<typeof useCommandActions>,
me: LaAccount
): Record<string, CommandGroupType> => ({
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")
}
]
}
]
})

View File

@@ -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<CommandItemType, "action"> {
action: CommandAction
handleAction: (action: CommandAction, payload?: any) => void
}
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = React.memo(({ content }) => {
return <span className="line-clamp-1">{renderHTMLLikeElement(content)}</span>
})
HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
export const CommandItem: React.FC<CommandItemProps> = React.memo(
({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
<Command.Item value={`${item.id}-${item.value}`} onSelect={() => handleAction(action, payload)}>
{icon && <LaIcon name={icon} />}
<HTMLLikeRenderer content={label} />
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
</Command.Item>
)
)
CommandItem.displayName = "CommandItem"
export interface CommandGroupProps {
heading?: string
items: CommandItemType[]
handleAction: (action: CommandAction, payload?: any) => void
isLastGroup: boolean
}
export const CommandGroup: React.FC<CommandGroupProps> = React.memo(({ heading, items, handleAction, isLastGroup }) => {
return (
<>
{heading ? (
<Command.Group heading={heading}>
{items.map((item, index) => (
<CommandItem key={`${heading}-${item.label}-${index}`} {...item} handleAction={handleAction} />
))}
</Command.Group>
) : (
items.map((item, index) => (
<CommandItem key={`item-${item.label}-${index}`} {...item} handleAction={handleAction} />
))
)}
{!isLastGroup && <CommandSeparator className="my-1.5" />}
</>
)
})
CommandGroup.displayName = "CommandGroup"

View File

@@ -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 <RealCommandPalette />
}
export function RealCommandPalette() {
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
const dialogRef = React.useRef<HTMLDivElement | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogPortal>
<DialogPrimitive.Overlay la-overlay="" cmdk-overlay="" />
<DialogPrimitive.Content la-dialog="" cmdk-dialog="" className="la" ref={dialogRef}>
<DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle>
<DialogDescription>Search for commands and actions</DialogDescription>
</DialogHeader>
<Command key={commandKey} onKeyDown={handleKeyDown}>
<div cmdk-input-wrapper="">
<Command.Input
autoFocus
placeholder="Type a command or search..."
value={inputValue}
onValueChange={setInputValue}
/>
</div>
<Command.List>
<Command.Empty>No results found.</Command.Empty>
{filteredCommands.map((group, index, array) => (
<CommandGroup
key={`${group.heading}-${index}`}
heading={group.heading}
items={group.items}
handleAction={handleAction}
isLastGroup={index === array.length - 1}
/>
))}
</Command.List>
</Command>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
)
}

View File

@@ -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
}
}

View File

@@ -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<React.HTMLAttributes<HTMLDivElement>, "title">
@@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
return (
<header
className={cn(
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
"flex min-h-10 min-w-0 shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
className
)}
ref={ref}
@@ -52,7 +52,7 @@ export const SidebarToggleButton: React.FC = () => {
className="text-primary/60"
onClick={handleClick}
>
<PanelLeftIcon size={16} />
<LaIcon name="PanelLeft" />
</Button>
</div>
)

View File

@@ -0,0 +1,23 @@
export const DiscordIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.9143 7.38378L4.93679 14.6174C4.82454 15.448 5.24219 16.2606 5.983 16.6528L8.99995 18.25L9.99995 15.75C9.99995 15.75 10.6562 16.25 11.9999 16.25C13.3437 16.25 13.9999 15.75 13.9999 15.75L14.9999 18.25L18.0169 16.6528C18.7577 16.2606 19.1754 15.448 19.0631 14.6174L18.0856 7.38378C18.0334 6.99739 17.7613 6.67658 17.3887 6.56192L14.7499 5.75003V6.25003C14.7499 6.80232 14.3022 7.25003 13.7499 7.25003H10.2499C9.69766 7.25003 9.24995 6.80232 9.24995 6.25003V5.75003L6.61122 6.56192C6.23855 6.67658 5.96652 6.99739 5.9143 7.38378Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M10.5 12C10.5 12.2761 10.2761 12.5 10 12.5C9.72386 12.5 9.5 12.2761 9.5 12C9.5 11.7239 9.72386 11.5 10 11.5C10.2761 11.5 10.5 11.7239 10.5 12Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M14.5 12C14.5 12.2761 14.2761 12.5 14 12.5C13.7239 12.5 13.5 12.2761 13.5 12C13.5 11.7239 13.7239 11.5 14 11.5C14.2761 11.5 14.5 11.7239 14.5 12Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
)

View File

@@ -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<string[]>([])
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" && (
<>
<RegisterKeyDown trigger="c" handler={goToNewLink} />
<RegisterKeyDown trigger="p" handler={goToNewPage} />
</>
)
)
}

View File

@@ -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 (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogContent className="max-w-xl">
<AlertDialogHeader>
<AlertDialogTitle>
<h1 className="text-2xl font-bold">Welcome to Learn Anything!</h1>
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription className="text-foreground/70 space-y-4 text-base leading-5">
{isExisting && (
<>
<p className="font-medium">Existing Customer Notice</p>
<p>
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&apos;ve been working hard on this new version, which
addresses previous issues and offers more features. As an early customer, you&apos;re locked in at the{" "}
<strong>$3</strong> price for our upcoming pro version. Thank you for your support!
</p>
</>
)}
<p>
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.
</p>
<p>Try do these quick onboarding steps to get a feel for the product:</p>
<ul className="list-inside list-disc">
<li>Create your first page</li>
<li>Add a link to a resource</li>
<li>Update your learning status on a topic</li>
</ul>
<p>
If you have any questions, don&apos;t hesitate to reach out. Click on question mark button in the bottom
right corner and enter your message.
</p>
</AlertDialogDescription>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel onClick={handleClose}>Close</AlertDialogCancel>
<AlertDialogAction onClick={handleClose}>Get Started</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export default LearnAnythingOnboarding

View File

@@ -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<LearningStateSelectorProps> = ({
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<LearningStateSelectorProps> = ({
setIsLearningStateSelectorOpen(false)
}
const iconName = selectedLearningState?.icon || defaultIcon
const labelText = selectedLearningState?.label || defaultLabel
return (
<Popover open={isLearningStateSelectorOpen} onOpenChange={setIsLearningStateSelectorOpen}>
<PopoverTrigger asChild>
@@ -44,21 +50,12 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
variant="secondary"
className={cn("gap-x-2 text-sm", className)}
>
{selectedLearningState?.icon && (
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
)}
<span className={cn("truncate", selectedLearningState?.className || "")}>
{selectedLearningState?.label || defaultLabel}
</span>
{iconName && <LaIcon name={iconName} className={cn(selectedLearningState?.className)} />}
{labelText && <span className={cn("truncate", selectedLearningState?.className || "")}>{labelText}</span>}
<LaIcon name="ChevronDown" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="end"
onCloseAutoFocus={e => e.preventDefault()}
>
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="end">
<LearningStateSelectorContent
showSearch={showSearch}
searchPlaceholder={searchPlaceholder}
@@ -91,7 +88,7 @@ export const LearningStateSelectorContent: React.FC<LearningStateSelectorContent
<CommandGroup>
{LEARNING_STATES.map(ls => (
<CommandItem key={ls.value} value={ls.value} onSelect={onSelect}>
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
{ls.icon && <LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />}
<span className={ls.className}>{ls.label}</span>
<LaIcon
name="Check"

View File

@@ -0,0 +1,137 @@
"use client"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogPrimitive
} from "@/components/ui/dialog"
import { LaIcon } from "@/components/custom/la-icon"
import { MinimalTiptapEditor, MinimalTiptapEditorRef } from "@/components/minimal-tiptap"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { sendFeedback } from "@/app/actions"
import { useServerAction } from "zsa-react"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Spinner } from "@/components/custom/spinner"
const formSchema = z.object({
content: z.string().min(1, {
message: "Feedback cannot be empty"
})
})
export function Feedback() {
const [open, setOpen] = useState(false)
const editorRef = useRef<MinimalTiptapEditorRef>(null)
const { isPending, execute } = useServerAction(sendFeedback)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
content: ""
}
})
async function onSubmit(values: z.infer<typeof formSchema>) {
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="icon" className="shrink-0" variant="ghost">
<LaIcon name="CircleHelp" />
</Button>
</DialogTrigger>
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
"flex flex-col p-4 sm:max-w-2xl"
)}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader className="mb-5">
<DialogTitle>Share feedback</DialogTitle>
<DialogDescription className="sr-only">
Your feedback helps us improve. Please share your thoughts, ideas, and suggestions
</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">Content</FormLabel>
<FormControl>
<MinimalTiptapEditor
{...field}
ref={editorRef}
throttleDelay={500}
className={cn(
"border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg",
{
"border-destructive focus-within:border-destructive": form.formState.errors.content
}
)}
editorContentClassName="p-4 overflow-auto flex grow"
output="html"
placeholder="Your feedback helps us improve. Please share your thoughts, ideas, and suggestions."
autofocus={true}
immediatelyRender={true}
editable={true}
injectCSS={true}
editorClassName="focus:outline-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<DialogPrimitive.Close className={buttonVariants({ variant: "outline" })}>Cancel</DialogPrimitive.Close>
<Button type="submit">
{isPending ? (
<>
<Spinner className="mr-2" />
<span>Sending feedback...</span>
</>
) : (
"Send feedback"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
)
}

View File

@@ -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 <div className="py-2 text-center text-gray-500">Loading...</div>
}
if (!me) return null
if (!isFeatureActive) {
return null
}
return (
<div className="group/journal flex flex-col gap-px py-2">
<JournalSectionHeader entriesCount={journalEntries?.length || 0} isActive={isActive} />
{journalEntries && journalEntries.length > 0 && <JournalEntryList entries={journalEntries} />}
</div>
)
}
interface JournalHeaderProps {
entriesCount: number
isActive: boolean
}
const JournalSectionHeader: React.FC<JournalHeaderProps> = ({ entriesCount, isActive }) => (
<div
className={cn(
"flex min-h-[30px] items-center gap-px rounded-md",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)}
>
<Link
href="/journal"
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
>
<p className="text-xs">
Journal
{entriesCount > 0 && <span className="text-muted-foreground ml-1">({entriesCount})</span>}
</p>
</Link>
</div>
)
interface JournalEntryListProps {
entries: any[]
}
const JournalEntryList: React.FC<JournalEntryListProps> = ({ entries }) => {
return (
<div className="flex flex-col gap-px">
{entries.map((entry, index) => (
<JournalEntryItem key={index} entry={entry} />
))}
</div>
)
}
interface JournalEntryItemProps {
entry: any
}
const JournalEntryItem: React.FC<JournalEntryItemProps> = ({ entry }) => (
<Link href={`/journal/${entry.id}`} className="group/journal-entry relative flex min-w-0 flex-1">
<div className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium">
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
<LaIcon name="FileText" className="opacity-60" />
<p className={cn("truncate opacity-95 group-hover/journal-entry:opacity-100")}>{entry.title}</p>
</div>
</div>
</Link>
)

View File

@@ -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<LinkSectionProps> = ({ pathname }) => {
const { me } = useAccount({
root: {
personalLinks: []
}
})
if (!me) return null
const linkCount = me.root.personalLinks?.length || 0
const isActive = pathname === "/links"
return (
<div className="group/pages flex flex-col gap-px py-2">
<LinkSectionHeader linkCount={linkCount} isActive={isActive} />
<List personalLinks={me.root.personalLinks} />
</div>
)
}
interface LinkSectionHeaderProps {
linkCount: number
isActive: boolean
}
const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => {
const pathname = usePathname()
const [state] = useQueryState("state", parseAsStringLiteral(ALL_STATES_STRING))
const isLinksActive = pathname.startsWith("/links") && (!state || state === "all")
return (
<div
className={cn(
"flex h-9 items-center gap-px rounded-md sm:h-[30px]",
isLinksActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)}
>
<Link
href="/links"
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
>
<p className="flex w-full items-center text-sm font-medium sm:text-xs">
Links
{linkCount > 0 && <span className="text-muted-foreground ml-1">{linkCount}</span>}
</p>
</Link>
</div>
)
}
interface ListProps {
personalLinks: PersonalLinkLists
}
const List: React.FC<ListProps> = ({ 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 (
<div className="flex flex-col gap-px">
<ListItem
label="To Learn"
href="/links?state=wantToLearn"
count={linkCounts.wantToLearn}
isActive={isActive("wantToLearn")}
/>
<ListItem
label="Learning"
href="/links?state=learning"
count={linkCounts.learning}
isActive={isActive("learning")}
/>
<ListItem label="Learned" href="/links?state=learned" count={linkCounts.learned} isActive={isActive("learned")} />
</div>
)
}
interface ListItemProps {
label: string
href: string
count: number
isActive: boolean
}
const ListItem: React.FC<ListItemProps> = ({ label, href, count, isActive }) => (
<div className="group/reorder-page relative">
<div className="group/topic-link relative flex min-w-0 flex-1">
<Link
href={href}
className={cn(
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)}
>
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
<p className={cn("truncate opacity-95 group-hover/topic-link:opacity-100")}>{label}</p>
</div>
</Link>
{count > 0 && (
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">{count}</span>
)}
</div>
</div>
)

View File

@@ -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<ShowOption>[] = [
const pageSortAtom = atomWithStorage<SortOption>("pageSort", "title")
const pageShowAtom = atomWithStorage<ShowOption>("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 (
<div className="group/pages flex flex-col gap-px py-2">
<PageSectionHeader pageCount={pageCount} />
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} />}
<PageSectionHeader pageCount={pageCount} isActive={isActive} />
<PageList personalPages={me.root.personalPages} sort={sort} show={show} />
</div>
)
}
interface PageSectionHeaderProps {
pageCount: number
isActive: boolean
}
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActive }) => (
<div
className={cn("flex min-h-[30px] items-center gap-px rounded-md", "hover:bg-accent hover:text-accent-foreground")}
className={cn(
"flex h-9 items-center gap-px rounded-md sm:h-[30px]",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)}
>
<Button
variant="ghost"
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
>
<p className="flex items-center text-xs font-medium">
<Link href="/pages" className="flex flex-1 items-center justify-start rounded-md px-2 py-1">
<p className="text-sm sm:text-xs">
Pages
{pageCount > 0 && <span className="text-muted-foreground ml-1">{pageCount}</span>}
</p>
</Button>
<div className={cn("flex items-center gap-px pr-2")}>
</Link>
<div className="flex items-center gap-px pr-2">
<ShowAllForm />
<NewPageButton />
</div>
@@ -85,20 +98,13 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ 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<PageListProps> = ({ personalPages }) => {
const PageList: React.FC<PageListProps> = ({ 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 (
<div className="flex flex-col gap-px">
@@ -162,11 +165,11 @@ const PageListItem: React.FC<PageListItemProps> = ({ page, isActive }) => (
<Link
href={`/pages/${page.id}`}
className={cn(
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
{ "bg-accent text-accent-foreground": isActive }
)}
>
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
<div className="flex max-w-[calc(100%-1rem)] flex-1 items-center gap-1.5 truncate text-sm">
<LaIcon name="FileText" className="flex-shrink-0 opacity-60" />
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100">{page.title || "Untitled"}</p>
</div>
@@ -250,4 +253,4 @@ const ShowAllForm: React.FC = () => {
</DropdownMenuContent>
</DropdownMenu>
)
}
}

View File

@@ -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 (
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
<SignInButton mode="modal" forceRedirectUrl={pathname}>
<Button variant="outline" className="flex w-full items-center gap-2">
<LaIcon name="LogIn" />
Sign in
</Button>
</SignInButton>
</div>
)
}
return (
<div className="relative flex flex-1 items-center gap-2">
<LaIcon name={icon as any} />
{href ? (
<Link href={href} onClick={onClose}>
<span className="line-clamp-1 flex-1">{text}</span>
</Link>
) : (
<span className="line-clamp-1 flex-1" onClick={handleClick}>
{text}
</span>
)}
</div>
)
}
export const ProfileSection: React.FC = () => {
const { me } = useAccount({
profile: true
})
const { signOut } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
const closeMenu = () => setMenuOpen(false)
return (
<div className="visible absolute inset-x-0 bottom-0 z-10 flex gap-8 p-2.5">
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
<div className="flex h-10 min-w-full items-center">
<div className="flex min-w-0">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
aria-label="Profile"
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex items-center gap-1.5 truncate rounded pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
>
<Avatar className="size-6">
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
{/* <AvatarFallback>CN</AvatarFallback> */}
</Avatar>
<span className="truncate text-left text-sm font-medium -tracking-wider">{me?.profile?.name}</span>
<LaIcon
name="ChevronDown"
className={`size-4 shrink-0 transition-transform duration-300 ${menuOpen ? "rotate-180" : ""}`}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start" side="top">
<DropdownMenuItem>
<MenuItem icon="CircleUser" text="My profile" href="/profile" onClose={closeMenu} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<MenuItem icon="Settings" text="Settings" href="/settings" onClose={closeMenu} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<MenuItem icon="LogOut" text="Log out" onClick={signOut} onClose={closeMenu} />
</DropdownMenuItem>
<DropdownMenuItem>
<MenuItem icon="CircleUser" text="Tauri" href="/tauri" onClose={closeMenu} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* <div className="flex min-w-2 grow flex-row"></div>
<div className="flex flex-row items-center gap-2">
<Button size="icon" variant="ghost" aria-label="Settings" className="size-7 p-0">
<LaIcon name="Settings" />
</Button>
<Link href="/">
<Button size="icon" variant="ghost" aria-label="Settings" className="size-7 p-0">
<LaIcon name="House" />
</Button>
</Link>
</div> */}
<ProfileDropdown
user={user}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
signOut={signOut}
setShowShortcut={setShowShortcut}
/>
<Feedback />
</div>
</div>
)
}
interface ProfileDropdownProps {
user: any
menuOpen: boolean
setMenuOpen: (open: boolean) => void
signOut: () => void
setShowShortcut: (show: boolean) => void
}
const ProfileDropdown: React.FC<ProfileDropdownProps> = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => (
<div className="flex min-w-0">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
aria-label="Profile"
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
>
<Avatar className="size-6">
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
</Avatar>
<span className="truncate text-left text-sm font-medium -tracking-wider">{user.fullName}</span>
<LaIcon
name="ChevronDown"
className={cn("size-4 shrink-0 transition-transform duration-300", {
"rotate-180": menuOpen
})}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start" side="top">
<DropdownMenuItems signOut={signOut} setShowShortcut={setShowShortcut} />
</DropdownMenuContent>
</DropdownMenu>
</div>
)
interface DropdownMenuItemsProps {
signOut: () => void
setShowShortcut: (show: boolean) => void
}
const DropdownMenuItems: React.FC<DropdownMenuItemsProps> = ({ signOut, setShowShortcut }) => (
<>
<MenuLink href="/profile" icon="CircleUser" text="My profile" />
<DropdownMenuItem className="gap-2" onClick={() => setShowShortcut(true)}>
<LaIcon name="Keyboard" />
<span>Shortcut</span>
</DropdownMenuItem>
<MenuLink href="/onboarding" icon="LayoutList" text="Onboarding" />
<DropdownMenuSeparator />
<MenuLink href="https://docs.learn-anything.xyz/" icon="Sticker" text="Docs" />
<MenuLink href="https://github.com/learn-anything/learn-anything" icon="Github" text="GitHub" />
<MenuLink href="https://discord.com/invite/bxtD8x6aNF" icon={DiscordIcon} text="Discord" iconClass="-ml-1" />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={signOut}>
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
<LaIcon name="LogOut" />
<span>Log out</span>
<div className="absolute right-0">
<ShortcutKey keys={["alt", "shift", "q"]} />
</div>
</div>
</DropdownMenuItem>
</>
)
interface MenuLinkProps {
href: string
icon: keyof typeof icons | React.FC
text: string
iconClass?: string
}
const MenuLink: React.FC<MenuLinkProps> = ({ href, icon, text, iconClass = "" }) => {
const IconComponent = typeof icon === "string" ? icons[icon] : icon
return (
<DropdownMenuItem asChild>
<Link className="cursor-pointer" href={href}>
<div className={cn("relative flex flex-1 items-center gap-2", iconClass)}>
<IconComponent className="size-4" />
<span className="line-clamp-1 flex-1">{text}</span>
</div>
</Link>
</DropdownMenuItem>
)
}
export default ProfileSection

View File

@@ -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 <div className="py-2 text-center text-gray-500">Loading...</div>
}
if (!me) return null
if (!isFeatureActive) {
return null
}
return (
<div className="group/tasks flex flex-col gap-px py-2">
<TaskSectionHeader taskCount={taskCount} isActive={isActive} />
{isFetching ? (
<div className="py-2 text-center text-gray-500">Fetching tasks...</div>
) : (
<List tasks={me.root.tasks as ListOfTasks} />
)}
</div>
)
}
interface TaskSectionHeaderProps {
taskCount: number
isActive: boolean
}
const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({ taskCount, isActive }) => (
<div
className={cn(
"flex min-h-[30px] items-center gap-px rounded-md",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)}
>
<Link
href="/tasks"
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
>
<p className="text-xs">
Tasks
{taskCount > 0 && <span className="text-muted-foreground ml-1">{taskCount}</span>}
</p>
</Link>
</div>
// <div
// className={cn(
// "flex min-h-[30px] items-center gap-px rounded-md",
// isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
// )}
// >
// <Button
// variant="ghost"
// className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
// >
// <p className="flex items-center text-xs font-medium">
// Tasks
// {taskCount > 0 && <span className="text-muted-foreground ml-1">{taskCount}</span>}
// </p>
// </Button>
// </div>
)
interface ListProps {
tasks: ListOfTasks
}
const List: React.FC<ListProps> = ({ tasks }) => {
const pathname = usePathname()
return (
<div className="flex flex-col gap-px">
<ListItem label="All Tasks" href="/tasks" count={tasks.length} isActive={pathname === "/tasks"} />
</div>
)
}
interface ListItemProps {
label: string
href: string
count: number
isActive: boolean
}
const ListItem: React.FC<ListItemProps> = ({ label, href, count, isActive }) => (
<div className="group/reorder-task relative">
<div className="group/task-link relative flex min-w-0 flex-1">
<Link
// TODO: update links
href="/tasks"
className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium"
// className={cn(
// "relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
// isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
// )}
>
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
<LaIcon name="BookCheck" className="opacity-60" />
<p className={cn("truncate opacity-95 group-hover/task-link:opacity-100")}>{label}</p>
</div>
</Link>
{count > 0 && (
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">{count}</span>
)}
</div>
</div>
)

View File

@@ -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 (
<div className="group/pages flex flex-col gap-px py-2">
<TopicSectionHeader topicCount={topicCount} />
<div className="group/topics flex flex-col gap-px py-2">
<TopicSectionHeader topicCount={topicCount} isActive={isActive} />
<List
topicsWantToLearn={me.root.topicsWantToLearn}
topicsLearning={me.root.topicsLearning}
@@ -38,21 +39,22 @@ export const TopicSection: React.FC = () => {
interface TopicSectionHeaderProps {
topicCount: number
isActive: boolean
}
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({ topicCount }) => (
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({ topicCount, isActive }) => (
<div
className={cn("flex min-h-[30px] items-center gap-px rounded-md", "hover:bg-accent hover:text-accent-foreground")}
className={cn(
"flex h-9 items-center gap-px rounded-md sm:h-[30px]",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)}
>
<Button
variant="ghost"
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
>
<p className="flex items-center text-xs font-medium">
<Link href="/topics" className="flex flex-1 items-center justify-start rounded-md px-2 py-1">
<p className="text-sm sm:text-xs">
Topics
{topicCount > 0 && <span className="text-muted-foreground ml-1">{topicCount}</span>}
</p>
</Button>
</Link>
</div>
)
@@ -72,7 +74,7 @@ const List: React.FC<ListProps> = ({ topicsWantToLearn, topicsLearning, topicsLe
count={topicsWantToLearn.length}
label="To Learn"
value="wantToLearn"
href="/me/wantToLearn"
href="#"
isActive={pathname === "/me/wantToLearn"}
/>
<ListItem
@@ -80,7 +82,7 @@ const List: React.FC<ListProps> = ({ topicsWantToLearn, topicsLearning, topicsLe
label="Learning"
value="learning"
count={topicsLearning.length}
href="/me/learning"
href="#"
isActive={pathname === "/me/learning"}
/>
<ListItem
@@ -88,7 +90,7 @@ const List: React.FC<ListProps> = ({ topicsWantToLearn, topicsLearning, topicsLe
label="Learned"
value="learned"
count={topicsLearned.length}
href="/me/learned"
href="#"
isActive={pathname === "/me/learned"}
/>
</div>
@@ -114,7 +116,7 @@ const ListItem: React.FC<ListItemProps> = ({ label, value, href, count, isActive
<Link
href={href}
className={cn(
"group-hover/topic-link:bg-accent relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
"group-hover/topic-link:bg-accent relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
{ "bg-accent text-accent-foreground": isActive },
le.className
)}
@@ -131,4 +133,4 @@ const ListItem: React.FC<ListItemProps> = ({ label, value, href, count, isActive
</div>
</div>
)
}
}

View File

@@ -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"
>
<SearchIcon size={16} className="mr-2" />
<LaIcon name="Search" className="mr-2" />
</Button>
</Link>
)}
@@ -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 (
<>
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
<div>
<LogoAndSearch />
</div>
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3">
<div className="h-2 shrink-0" />
<PageSection />
<TopicSection />
</div>
</nav>
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
<div>
<LogoAndSearch />
</div>
<div className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3 outline-none">
<div className="h-2 shrink-0" />
{me._type === "Account" && <LinkSection pathname={pathname} />}
{me._type === "Account" && <TopicSection pathname={pathname} />}
{me._type === "Account" && <JournalSection />}
{me._type === "Account" && <TaskSection pathname={pathname} />}
{me._type === "Account" && <PageSection pathname={pathname} />}
</div>
<ProfileSection />
</>
</nav>
)
})

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SpinnerProps extends React.SVGAttributes<SVGElement> {}
export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(({ className, ...props }, ref) => (
<svg ref={ref} className={cn("h-4 w-4 animate-spin", className)} viewBox="0 0 24 24" {...props}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
))
Spinner.displayName = "Spinner"

View File

@@ -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 (
<motion.div className={cn("flex w-full justify-center gap-3 transition-all", props.className)}>
{words.map((word, index) => {
return (
<motion.div
key={index}
initial={{ filter: "blur(8px)", translateY: "18px", opacity: 0 }}
animate={{ filter: "blur(0px)", translateY: "0px", opacity: 1 }}
transition={{
duration: index * 0.4 + 0.7,
easings: "cubic-bezier(.77, 0, .175, 1)"
}}
>
{word}
</motion.div>
)
})}
</motion.div>
)
}

View File

@@ -79,12 +79,7 @@ export const TopicSelector = forwardRef<HTMLButtonElement, TopicSelectorProps>(
<LaIcon name="ChevronDown" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side={side}
align={align}
onCloseAutoFocus={e => e.preventDefault()}
>
<PopoverContent className="w-52 rounded-lg p-0" side={side} align={align}>
{group?.root.topics && (
<TopicSelectorContent
showSearch={showSearch}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { getShortcutKey } from "../../lib/utils"
import { getShortcutKey } from "@/lib/utils"
export interface ShortcutKeyWrapperProps extends React.HTMLAttributes<HTMLSpanElement> {
ariaLabel: string
@@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ class
{...props}
ref={ref}
>
{getShortcutKey(shortcut)}
{getShortcutKey(shortcut).symbol}
</kbd>
)
})

View File

@@ -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<HTMLButtonElement, ToolbarButtonProps>(fu
ref
) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
size="sm"
ref={ref}
className={cn(
"size-7 rounded-md p-0",
{
"bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary": isActive
},
className
)}
{...props}
>
{children}
</Toggle>
</TooltipTrigger>
{tooltip && (
<TooltipContent {...tooltipOptions}>
<div className="flex flex-col items-center text-center">{tooltip}</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
size="sm"
ref={ref}
className={cn(
"size-7 rounded-md p-0",
{
"bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary": isActive
},
className
)}
{...props}
>
{children}
</Toggle>
</TooltipTrigger>
{tooltip && (
<TooltipContent {...tooltipOptions}>
<div className="flex flex-col items-center text-center">{tooltip}</div>
</TooltipContent>
)}
</Tooltip>
)
})

View File

@@ -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 })
}

View File

@@ -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) => {
<Icon name={command.iconName} />
<span className="truncate text-sm">{command.label}</span>
<div className="flex flex-auto flex-row"></div>
<Shortcut.Wrapper ariaLabel={getShortcutKeys(command.shortcuts)}>
<Shortcut.Wrapper
ariaLabel={getShortcutKeys(command.shortcuts)
.map(shortcut => shortcut.readable)
.join(" + ")}
>
{command.shortcuts.map(shortcut => (
<Shortcut.Key shortcut={shortcut} key={shortcut} />
))}

View File

@@ -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<React.HTMLProps<HTMLDivElement>, "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<LAEditorRef, LAEditorProps>(
(
{
@@ -46,32 +42,13 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
},
ref
) => {
const [content, setContent] = React.useState<Content | undefined>(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<LAEditorRef, LAEditorProps>(
}
})
React.useEffect(() => {
if (lastThrottledContent !== throttledContent) {
setLastThrottledContent(throttledContent)
onUpdate?.(throttledContent!)
}
}, [throttledContent, lastThrottledContent, onUpdate])
React.useImperativeHandle(
ref,
() => ({

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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

View File

@@ -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 (
<BubbleMenu
editor={editor}
shouldShow={shouldShow}
tippyOptions={{
placement: 'bottom',
offset: [0, 8]
}}
>
<ImagePopoverBlock onRemove={unSetImage} />
</BubbleMenu>
)
}
export { ImageBubbleMenu }

View File

@@ -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<LinkBubbleMenuProps> = ({ editor }) => {
const [showEdit, setShowEdit] = useState(false)
const [linkAttrs, setLinkAttrs] = useState<LinkAttributes>({ 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 (
<BubbleMenu
editor={editor}
shouldShow={shouldShow}
tippyOptions={{
placement: 'bottom-start',
onHidden: () => setShowEdit(false)
}}
>
{showEdit ? (
<LinkEditBlock
defaultUrl={linkAttrs.href}
defaultText={selectedText}
defaultIsNewTab={linkAttrs.target === '_blank'}
onSave={onSetLink}
className="w-full min-w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none"
/>
) : (
<LinkPopoverBlock onClear={onUnsetLink} url={linkAttrs.href} onEdit={handleEdit} />
)}
</BubbleMenu>
)
}

View File

@@ -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<HTMLDivElement> {
editor: Editor
close: () => void
}
const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockProps) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [link, setLink] = useState<string>("")
const [isUploading, setIsUploading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<form onSubmit={handleSubmit}>
<div className={cn("space-y-5", className)} {...props}>
<div className="space-y-1">
<Label>Attach an image link</Label>
<div className="flex">
<Input
type="url"
required
placeholder="https://example.com"
value={link}
className="grow"
onChange={e => setLink(e.target.value)}
/>
<Button type="submit" className="ml-2 inline-block">
Submit
</Button>
</div>
</div>
<Button className="w-full" onClick={handleClick} disabled={isUploading}>
{isUploading ? "Uploading..." : "Upload from your computer"}
</Button>
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
ref={fileInputRef}
className="hidden"
onChange={handleFile}
/>
{error && <div className="text-destructive text-sm">{error}</div>}
</div>
</form>
)
}
export { ImageEditBlock }

View File

@@ -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<typeof toggleVariants> {
editor: Editor
}
const ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<ToolbarButton
isActive={editor.isActive('image')}
tooltip="Image"
aria-label="Image"
size={size}
variant={variant}
>
<ImageIcon className="size-5" />
</ToolbarButton>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Select image</DialogTitle>
<DialogDescription className="sr-only">Upload an image from your computer</DialogDescription>
</DialogHeader>
<ImageEditBlock editor={editor} close={() => setOpen(false)} />
</DialogContent>
</Dialog>
)
}
export { ImageEditDialog }

View File

@@ -0,0 +1,21 @@
import { ToolbarButton } from '../toolbar-button'
import { TrashIcon } from '@radix-ui/react-icons'
const ImagePopoverBlock = ({ onRemove }: { onRemove: (e: React.MouseEvent<HTMLButtonElement>) => void }) => {
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
onRemove(e)
}
return (
<div className="flex h-10 overflow-hidden rounded bg-background p-2 shadow-lg">
<div className="inline-flex items-center gap-1">
<ToolbarButton tooltip="Remove" onClick={handleRemove}>
<TrashIcon className="size-4" />
</ToolbarButton>
</div>
</div>
)
}
export { ImagePopoverBlock }

View File

@@ -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<HTMLDivElement> {
defaultUrl?: string
defaultText?: string
defaultIsNewTab?: boolean
onSave: (url: string, text?: string, isNewTab?: boolean) => void
}
export const LinkEditBlock = React.forwardRef<HTMLDivElement, LinkEditorProps>(
({ onSave, defaultIsNewTab, defaultUrl, defaultText, className }, ref) => {
const formRef = React.useRef<HTMLDivElement>(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 (
<div ref={formRef}>
<div className={cn('space-y-4', className)}>
<div className="space-y-1">
<Label>URL</Label>
<Input type="url" required placeholder="Enter URL" value={url} onChange={e => setUrl(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Display Text (optional)</Label>
<Input type="text" placeholder="Enter display text" value={text} onChange={e => setText(e.target.value)} />
</div>
<div className="flex items-center space-x-2">
<Label>Open in New Tab</Label>
<Switch checked={isNewTab} onCheckedChange={setIsNewTab} />
</div>
<div className="flex justify-end space-x-2">
<Button type="button" onClick={handleSave}>
Save
</Button>
</div>
</div>
</div>
)
}
)
LinkEditBlock.displayName = 'LinkEditBlock'
export default LinkEditBlock

View File

@@ -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<typeof toggleVariants> {
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<ToolbarButton
isActive={editor.isActive('link')}
tooltip="Link"
aria-label="Insert link"
disabled={editor.isActive('codeBlock')}
size={size}
variant={variant}
>
<Link2Icon className="size-5" />
</ToolbarButton>
</PopoverTrigger>
<PopoverContent className="w-full min-w-80" align="start" side="bottom">
<LinkEditBlock onSave={onSetLink} defaultText={text} />
</PopoverContent>
</Popover>
)
}
export { LinkEditPopover }

View File

@@ -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<HTMLButtonElement>) => void
}
export const LinkPopoverBlock: React.FC<LinkPopoverBlockProps> = ({ url, onClear, onEdit }) => {
const [copyTitle, setCopyTitle] = useState<string>('Copy')
const handleCopy = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
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 (
<div className="flex h-10 overflow-hidden rounded bg-background p-2 shadow-lg">
<div className="inline-flex items-center gap-1">
<ToolbarButton tooltip="Edit link" onClick={onEdit} className="w-auto px-2">
Edit link
</ToolbarButton>
<Separator orientation="vertical" />
<ToolbarButton tooltip="Open link in a new tab" onClick={handleOpenLink}>
<ExternalLinkIcon className="size-4" />
</ToolbarButton>
<Separator orientation="vertical" />
<ToolbarButton tooltip="Clear link" onClick={onClear}>
<LinkBreak2Icon className="size-4" />
</ToolbarButton>
<Separator orientation="vertical" />
<ToolbarButton
tooltip={copyTitle}
onClick={handleCopy}
tooltipOptions={{
onPointerDownOutside: e => {
if (e.target === e.currentTarget) e.preventDefault()
}
}}
>
<CopyIcon className="size-4" />
</ToolbarButton>
</div>
</div>
)
}

View File

@@ -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: <CodeIcon className="size-5" />,
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: <QuoteIcon className="size-5" />,
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: <DividerHorizontalIcon className="size-5" />,
action: editor => editor.chain().focus().setHorizontalRule().run(),
isActive: () => false,
canExecute: editor => editor.can().chain().focus().setHorizontalRule().run(),
shortcuts: ['mod', 'alt', '-']
}
]
interface SectionFiveProps extends VariantProps<typeof toggleVariants> {
editor: Editor
activeActions?: InsertElementAction[]
mainActionCount?: number
}
export const SectionFive: React.FC<SectionFiveProps> = ({
editor,
activeActions = formatActions.map(action => action.value),
mainActionCount = 0,
size,
variant
}) => {
return (
<>
<LinkEditPopover editor={editor} size={size} variant={variant} />
<ImageEditDialog editor={editor} size={size} variant={variant} />
<ToolbarSection
editor={editor}
actions={formatActions}
activeActions={activeActions}
mainActionCount={mainActionCount}
dropdownIcon={
<>
<PlusIcon className="size-5" />
<CaretDownIcon className="size-5" />
</>
}
dropdownTooltip="Insert elements"
size={size}
variant={variant}
/>
</>
)
}
SectionFive.displayName = 'SectionFive'
export default SectionFive

View File

@@ -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: (
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor">
<path d="M144-144v-48h96v-24h-48v-48h48v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9 10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9H144Zm0-240v-96q0-10.2 6.9-17.1 6.9-6.9 17.1-6.9h72v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v72q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9h-72v24h96v48H144Zm48-240v-144h-48v-48h96v192h-48Zm168 384v-72h456v72H360Zm0-204v-72h456v72H360Zm0-204v-72h456v72H360Z" />
</svg>
),
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: <ListBulletIcon className="size-5" />,
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<typeof toggleVariants> {
editor: Editor
activeActions?: ListItemAction[]
mainActionCount?: number
}
export const SectionFour: React.FC<SectionFourProps> = ({
editor,
activeActions = formatActions.map(action => action.value),
mainActionCount = 0,
size,
variant
}) => {
return (
<ToolbarSection
editor={editor}
actions={formatActions}
activeActions={activeActions}
mainActionCount={mainActionCount}
dropdownIcon={
<>
<ListBulletIcon className="size-5" />
<CaretDownIcon className="size-5" />
</>
}
dropdownTooltip="Lists"
size={size}
variant={variant}
/>
)
}
SectionFour.displayName = 'SectionFour'
export default SectionFour

View File

@@ -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<FormatAction, 'value' | 'icon' | 'action' | 'isActive' | 'canExecute'> {
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<typeof toggleVariants> {
editor: Editor
activeLevels?: Level[]
}
export const SectionOne: React.FC<SectionOneProps> = 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) => (
<DropdownMenuItem
key={label}
onClick={() => 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}
>
<Element className={className}>{label}</Element>
<ShortcutKey keys={shortcuts} />
</DropdownMenuItem>
),
[editor, handleStyleChange]
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={editor.isActive('heading')}
tooltip="Text styles"
aria-label="Text styles"
pressed={editor.isActive('heading')}
className="w-12"
disabled={editor.isActive('codeBlock')}
size={size}
variant={variant}
>
<LetterCaseCapitalizeIcon className="size-5" />
<CaretDownIcon className="size-5" />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{filteredActions.map(renderMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)
}
)
SectionOne.displayName = 'SectionOne'
export default SectionOne

View File

@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
className="relative size-7 rounded-md p-0"
value={color.cssVar}
aria-label={label}
style={{ backgroundColor: color.cssVar }}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
onClick(color.cssVar)
}}
>
{isSelected && <CheckIcon className="absolute inset-0 m-auto size-6" style={{ color: inverse }} />}
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{label}</p>
</TooltipContent>
</Tooltip>
)
})
MemoizedColorButton.displayName = 'MemoizedColorButton'
const MemoizedColorPicker = React.memo<{
palette: ColorPalette
selectedColor: string
inverse: string
onColorChange: (value: string) => void
}>(({ palette, selectedColor, inverse, onColorChange }) => (
<ToggleGroup
type="single"
value={selectedColor}
onValueChange={(value: string) => {
if (value) onColorChange(value)
}}
className="gap-1.5"
>
{palette.colors.map((color, index) => (
<MemoizedColorButton
key={index}
inverse={inverse}
color={color}
isSelected={selectedColor === color.cssVar}
onClick={onColorChange}
/>
))}
</ToggleGroup>
))
MemoizedColorPicker.displayName = 'MemoizedColorPicker'
interface SectionThreeProps extends VariantProps<typeof toggleVariants> {
editor: Editor
}
export const SectionThree: React.FC<SectionThreeProps> = ({ 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 (
<Popover>
<PopoverTrigger asChild>
<ToolbarButton tooltip="Text color" aria-label="Text color" className="w-12" size={size} variant={variant}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-5"
style={{ color: selectedColor }}
>
<path d="M4 20h16" />
<path d="m6 16 6-12 6 12" />
<path d="M8 12h8" />
</svg>
<CaretDownIcon className="size-5" />
</ToolbarButton>
</PopoverTrigger>
<PopoverContent align="start" className="w-full">
<div className="space-y-1.5">
{COLORS.map((palette, index) => (
<MemoizedColorPicker
key={index}
palette={palette}
inverse={palette.inverse}
selectedColor={selectedColor}
onColorChange={handleColorChange}
/>
))}
</div>
</PopoverContent>
</Popover>
)
}
SectionThree.displayName = 'SectionThree'
export default SectionThree

View File

@@ -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: <FontBoldIcon className="size-5" />,
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: <FontItalicIcon className="size-5" />,
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: <StrikethroughIcon className="size-5" />,
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: <CodeIcon className="size-5" />,
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: <TextNoneIcon className="size-5" />,
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<typeof toggleVariants> {
editor: Editor
activeActions?: TextStyleAction[]
mainActionCount?: number
}
export const SectionTwo: React.FC<SectionTwoProps> = ({
editor,
activeActions = formatActions.map(action => action.value),
mainActionCount = 2,
size,
variant
}) => {
return (
<ToolbarSection
editor={editor}
actions={formatActions}
activeActions={activeActions}
mainActionCount={mainActionCount}
dropdownIcon={<DotsHorizontalIcon className="size-5" />}
dropdownTooltip="More formatting"
dropdownClassName="w-8"
size={size}
variant={variant}
/>
)
}
SectionTwo.displayName = 'SectionTwo'
export default SectionTwo

View File

@@ -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<HTMLSpanElement> {
keys: string[]
}
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
const modifiedKeys = keys.map(key => getShortcutKey(key))
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ")
return (
<span aria-label={ariaLabel} className={cn("inline-flex items-center gap-0.5", className)} {...props} ref={ref}>
{modifiedKeys.map(shortcut => (
<kbd
key={shortcut.symbol}
className={cn(
"inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]",
className
)}
{...props}
ref={ref}
>
{shortcut.symbol}
</kbd>
))}
</span>
)
})
ShortcutKey.displayName = "ShortcutKey"

View File

@@ -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<typeof Toggle> {
isActive?: boolean
tooltip?: string
tooltipOptions?: TooltipContentProps
}
export const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
({ isActive, children, tooltip, className, tooltipOptions, ...props }, ref) => {
const toggleButton = (
<Toggle size="sm" ref={ref} className={cn('size-8 p-0', { 'bg-accent': isActive }, className)} {...props}>
{children}
</Toggle>
)
if (!tooltip) {
return toggleButton
}
return (
<Tooltip>
<TooltipTrigger asChild>{toggleButton}</TooltipTrigger>
<TooltipContent {...tooltipOptions}>
<div className="flex flex-col items-center text-center">{tooltip}</div>
</TooltipContent>
</Tooltip>
)
}
)
ToolbarButton.displayName = 'ToolbarButton'
export default ToolbarButton

View File

@@ -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<typeof toggleVariants> {
editor: Editor
actions: FormatAction[]
activeActions?: string[]
mainActionCount?: number
dropdownIcon?: React.ReactNode
dropdownTooltip?: string
dropdownClassName?: string
}
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
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) => (
<ToolbarButton
key={action.label}
onClick={() => 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}
</ToolbarButton>
),
[editor, size, variant]
)
const renderDropdownMenuItem = React.useCallback(
(action: FormatAction) => (
<DropdownMenuItem
key={action.label}
onClick={() => 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}
>
<span className="grow">{action.label}</span>
<ShortcutKey keys={action.shortcuts} />
</DropdownMenuItem>
),
[editor]
)
const isDropdownActive = React.useMemo(
() => dropdownActions.some(action => action.isActive(editor)),
[dropdownActions, editor]
)
return (
<>
{mainActions.map(renderToolbarButton)}
{dropdownActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={isDropdownActive}
tooltip={dropdownTooltip}
aria-label={dropdownTooltip}
className={cn(dropdownClassName)}
size={size}
variant={variant}
>
{dropdownIcon || <CaretDownIcon className="size-5" />}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{dropdownActions.map(renderDropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
}
export default ToolbarSection

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from './code-block-lowlight'

View File

@@ -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
}
}
})
]
}
})

View File

@@ -0,0 +1 @@
export * from './color'

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from './horizontal-rule'

View File

@@ -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 (
<NodeViewWrapper>
<div draggable data-drag-handle>
<figure>
<div className="relative w-full" style={{ paddingBottom: `${isNumber(paddingBottom) ? paddingBottom : 0}%` }}>
<div className="absolute h-full w-full">
<div
className={cn('relative h-full max-h-full w-full max-w-full rounded transition-all')}
style={{
boxShadow: editor.state.selection.from === getPos() ? '0 0 0 1px hsl(var(--primary))' : 'none'
}}
>
<div className="relative flex h-full max-h-full w-full max-w-full overflow-hidden">
<img
alt={node.attrs.alt}
src={node.attrs.src}
className="absolute left-2/4 top-2/4 m-0 h-full max-w-full -translate-x-2/4 -translate-y-2/4 transform object-contain"
/>
</div>
</div>
</div>
</div>
</figure>
</div>
</NodeViewWrapper>
)
}
export { ImageViewBlock }

View File

@@ -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)
}
})

View File

@@ -0,0 +1 @@
export * from './image'

Some files were not shown because too many files have changed in this diff Show More