mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
fix: conflict
This commit is contained in:
23
.gitignore
vendored
23
.gitignore
vendored
@@ -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
6
api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.encore
|
||||
encore.gen.go
|
||||
encore.gen.cue
|
||||
/.encore
|
||||
node_modules
|
||||
/encore.gen
|
||||
72
api/api/api.ts
Normal file
72
api/api/api.ts
Normal 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
40
api/api/links.ts
Normal 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
BIN
api/bun.lockb
Executable file
Binary file not shown.
4
api/encore.app
Normal file
4
api/encore.app
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "encore-test-76k2",
|
||||
"lang": "typescript"
|
||||
}
|
||||
20
api/package.json
Normal file
20
api/package.json
Normal 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
1
api/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
Using [Encore](https://encore.dev).
|
||||
31
api/tsconfig.json
Normal file
31
api/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
5
cli/readme.md
Normal file
5
cli/readme.md
Normal 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).
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
68
package.json
68
package.json
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"name": "learn-anything",
|
||||
"scripts": {
|
||||
"dev": "bun web",
|
||||
"web": "cd web && bun dev",
|
||||
"web:build": "bun run --filter '*' build",
|
||||
"app": "tauri dev",
|
||||
"cli": "bun run --watch cli/run.ts",
|
||||
"seed": "bun --watch cli/seed.ts",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"workspaces": [
|
||||
"web"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0-rc.6",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0-rc",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
||||
"jazz-nodejs": "^0.7.34",
|
||||
"react-icons": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.26"
|
||||
},
|
||||
"prettier": {
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120,
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"license": "MIT"
|
||||
"name": "learn-anything",
|
||||
"scripts": {
|
||||
"dev": "bun web",
|
||||
"web": "cd web && bun dev",
|
||||
"web:build": "bun run --filter '*' build",
|
||||
"ts": "bun run --watch scripts/run.ts",
|
||||
"seed": "bun --watch scripts/seed.ts",
|
||||
"tauri": "tauri",
|
||||
"app": "tauri dev",
|
||||
"app:build": "bun tauri build -b dmg -v"
|
||||
},
|
||||
"workspaces": [
|
||||
"web"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0-rc.17",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0-rc.1",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
||||
"jazz-nodejs": "0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.29"
|
||||
},
|
||||
"prettier": {
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120,
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
43
readme.md
43
readme.md
@@ -1,14 +1,35 @@
|
||||
# [Learn-Anything.xyz](https://learn-anything.xyz)
|
||||
# [learn-anything.xyz](https://learn-anything.xyz)
|
||||
|
||||
> Organize world's knowledge, explore connections and curate learning paths
|
||||
|
||||
## Files
|
||||
|
||||
- [api](api) - http services (using TS/[Encore](https://encore.dev/))
|
||||
- [app](app) - desktop app (wrapping the [website](web) with desktop specific logic) (using [Tauri](https://v2.tauri.app/))
|
||||
- [cli](cli) - cli (using [Go](https://go.dev))
|
||||
- [docs](https://github.com/learn-anything/docs) - public docs hosted on [docs.learn-anything.xyz](https://docs.learn-anything.xyz/)
|
||||
- [lib](lib) - shared utility functions in TS
|
||||
- [nix](nix) - shared nix code
|
||||
- [scripts](scripts) - utility scripts in TS
|
||||
- [web](web) - website hosted on [learn-anything.xyz](https://learn-anything.xyz) (using [React](https://react.dev/)/[Next.js](https://nextjs.org/) + [Jazz](https://jazz.tools/) for local/global state)
|
||||
|
||||
## Setup
|
||||
|
||||
Using [Bun](https://bun.sh).
|
||||
> [!NOTE]
|
||||
> Project is currently in unstable state but actively improving. Reach out on [Discord](https://discord.gg/bxtD8x6aNF) for help.
|
||||
|
||||
Using [Bun](https://bun.sh):
|
||||
|
||||
```
|
||||
bun i
|
||||
```
|
||||
|
||||
[Jazz](https://jazz.tools/) is used for all global/local state management.
|
||||
> [!NOTE]
|
||||
> bun setup is not yet done but will be a command to fully bootstrap a local working env for the project, without it, running `bun web` is impossible yet
|
||||
|
||||
```
|
||||
bun setup
|
||||
```
|
||||
|
||||
## Run website
|
||||
|
||||
@@ -16,18 +37,18 @@ bun i
|
||||
bun web
|
||||
```
|
||||
|
||||
## Contribute
|
||||
|
||||
Currently things are unstable but will improve.
|
||||
## Contributing
|
||||
|
||||
If you want to help contribute to code, ask for help on [Discord](https://discord.gg/bxtD8x6aNF)'s `#dev` channel. You will be onboarded and unblocked fast.
|
||||
|
||||
Can see [existing issues](../../issues) for things being worked on. See [main issue](../../issues/110) for what's in focus right now.
|
||||
|
||||
Can [open new issue](../../issues/new/choose) (search existing ones for duplicates first) or start discussion on [GitHub](../../discussions) or [Discord](https://discord.gg/bxtD8x6aNF).
|
||||
|
||||
Can always submit draft PRs with good ideas/fixes. We will help along the way to make it merge ready.
|
||||
|
||||
## Chat
|
||||
## Join core team
|
||||
|
||||
Community chat in [Discord server](https://discord.gg/bxtD8x6aNF).
|
||||
We are a small team of core developers right now but are always looking to expand. We will reach out with offer to join us if you contribute to repo in form of PRs.
|
||||
|
||||
Internal dev chat in Telegram (can email `join@learn-anything.xyz` to join core team). We will reach out with offer to join if you contribute to repo in form of PRs too.
|
||||
|
||||
[](https://x.com/learnanything_)
|
||||
[](https://discord.com/invite/bxtD8x6aNF) [](https://x.com/learnanything_)
|
||||
|
||||
88
scripts/past-seed.ts
Normal file
88
scripts/past-seed.ts
Normal 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 })
|
||||
})
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
4
web/.gitignore
vendored
@@ -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
2
web/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
[install.scopes]
|
||||
ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }
|
||||
5
web/app/(pages)/community/[topicName]/page.tsx
Normal file
5
web/app/(pages)/community/[topicName]/page.tsx
Normal 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} />
|
||||
}
|
||||
15
web/app/(pages)/journal/page.tsx
Normal file
15
web/app/(pages)/journal/page.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LinkRoute } from "@/components/routes/link/LinkRoute"
|
||||
|
||||
export default function HomePage() {
|
||||
export default function LinksPage() {
|
||||
return <LinkRoute />
|
||||
}
|
||||
5
web/app/(pages)/onboarding/page.tsx
Normal file
5
web/app/(pages)/onboarding/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import OnboardingRoute from "@/components/routes/OnboardingRoute"
|
||||
|
||||
export default function EditProfilePage() {
|
||||
return <OnboardingRoute />
|
||||
}
|
||||
5
web/app/(pages)/pages/page.tsx
Normal file
5
web/app/(pages)/pages/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PageRoute } from "@/components/routes/page/PageRoute"
|
||||
|
||||
export default function Page() {
|
||||
return <PageRoute />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
15
web/app/(pages)/tasks/page.tsx
Normal file
15
web/app/(pages)/tasks/page.tsx
Normal 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 />
|
||||
}
|
||||
5
web/app/(pages)/topics/page.tsx
Normal file
5
web/app/(pages)/topics/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
|
||||
|
||||
export default function Page() {
|
||||
return <TopicRoute />
|
||||
}
|
||||
7
web/app/(public)/layout.tsx
Normal file
7
web/app/(public)/layout.tsx
Normal 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
91
web/app/actions.ts
Normal 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
127
web/app/command-palette.css
Normal 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
11
web/app/custom.css
Normal 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
7
web/app/fonts.ts
Normal 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
23
web/app/global-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
5
web/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute"
|
||||
|
||||
export default function HomePage() {
|
||||
return <PublicHomeRoute />
|
||||
}
|
||||
50
web/components/custom/GuideCommunityToggle.tsx
Normal file
50
web/components/custom/GuideCommunityToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
web/components/custom/QuestionList.tsx
Normal file
65
web/components/custom/QuestionList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
web/components/custom/QuestionThread.tsx
Normal file
167
web/components/custom/QuestionThread.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
web/components/custom/Shortcut/shortcut.tsx
Normal file
164
web/components/custom/Shortcut/shortcut.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
42
web/components/custom/column.tsx
Normal file
42
web/components/custom/column.tsx
Normal 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
|
||||
}
|
||||
135
web/components/custom/command-palette/command-data.ts
Normal file
135
web/components/custom/command-palette/command-data.ts
Normal 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")
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
57
web/components/custom/command-palette/command-items.tsx
Normal file
57
web/components/custom/command-palette/command-items.tsx
Normal 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"
|
||||
229
web/components/custom/command-palette/command-palette.tsx
Normal file
229
web/components/custom/command-palette/command-palette.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
23
web/components/custom/discordIcon.tsx
Normal file
23
web/components/custom/discordIcon.tsx
Normal 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>
|
||||
)
|
||||
130
web/components/custom/global-keyboard-handler.tsx
Normal file
130
web/components/custom/global-keyboard-handler.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
100
web/components/custom/learn-anything-onboarding.tsx
Normal file
100
web/components/custom/learn-anything-onboarding.tsx
Normal 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've been working hard on this new version, which
|
||||
addresses previous issues and offers more features. As an early customer, you're locked in at the{" "}
|
||||
<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'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
|
||||
@@ -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"
|
||||
|
||||
137
web/components/custom/sidebar/partial/feedback.tsx
Normal file
137
web/components/custom/sidebar/partial/feedback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
web/components/custom/sidebar/partial/journal-section.tsx
Normal file
113
web/components/custom/sidebar/partial/journal-section.tsx
Normal 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>
|
||||
)
|
||||
128
web/components/custom/sidebar/partial/link-section.tsx
Normal file
128
web/components/custom/sidebar/partial/link-section.tsx
Normal 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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
149
web/components/custom/sidebar/partial/task-section.tsx
Normal file
149
web/components/custom/sidebar/partial/task-section.tsx
Normal 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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
17
web/components/custom/spinner.tsx
Normal file
17
web/components/custom/spinner.tsx
Normal 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"
|
||||
26
web/components/custom/text-blur-transition.tsx
Normal file
26
web/components/custom/text-blur-transition.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
() => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
84
web/components/minimal-tiptap/components/section/five.tsx
Normal file
84
web/components/minimal-tiptap/components/section/five.tsx
Normal 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
|
||||
73
web/components/minimal-tiptap/components/section/four.tsx
Normal file
73
web/components/minimal-tiptap/components/section/four.tsx
Normal 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
|
||||
137
web/components/minimal-tiptap/components/section/one.tsx
Normal file
137
web/components/minimal-tiptap/components/section/one.tsx
Normal 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
|
||||
191
web/components/minimal-tiptap/components/section/three.tsx
Normal file
191
web/components/minimal-tiptap/components/section/three.tsx
Normal 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
|
||||
100
web/components/minimal-tiptap/components/section/two.tsx
Normal file
100
web/components/minimal-tiptap/components/section/two.tsx
Normal 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
|
||||
33
web/components/minimal-tiptap/components/shortcut-key.tsx
Normal file
33
web/components/minimal-tiptap/components/shortcut-key.tsx
Normal 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"
|
||||
38
web/components/minimal-tiptap/components/toolbar-button.tsx
Normal file
38
web/components/minimal-tiptap/components/toolbar-button.tsx
Normal 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
|
||||
112
web/components/minimal-tiptap/components/toolbar-section.tsx
Normal file
112
web/components/minimal-tiptap/components/toolbar-section.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './code-block-lowlight'
|
||||
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal file
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './color'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './horizontal-rule'
|
||||
@@ -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 }
|
||||
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal file
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './image'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user