fix: link (#115)

* start

* .

* seeding connections

* .

* wip

* wip: learning state

* wip: notes section

* wip: many

* topics

* chore: update schema

* update package

* update sidebar

* update page section

* feat: profile

* fix: remove z index

* fix: wrong type

* add avatar

* add avatar

* wip

* .

* store page section key

* remove atom page section

* fix rerender

* fix rerender

* fix rerender

* fix rerender

* fix link

* search light/dark mode

* bubble menu ui

* .

* fix: remove unecessary code

* chore: mark as old for old schema

* chore: adapt new schema

* fix: add topic schema but null for now

* fix: add icon on personal link

* fix: list item

* fix: set url fetched when editing

* fix: remove image

* feat: add icon to link

* feat: custom url zod validation

* fix: metadata test

* chore: update utils

* fix: link

* fix: url fetcher

* .

* .

* fix: add link, section

* chore: seeder

* .

* .

* .

* .

* fix: change checkbox to learning state

* fix: click outside editing form

* feat: constant

* chore: move to master folder

* chore: adapt new schema

* chore: cli for new schema

* fix: new schema for dev seed

* fix: seeding

* update package

* chore: forcegraph seed

* bottombar

* if isEdit delete icon

* showCreate X button

* .

* options

* chore: implement topic from public global group

* chore: update learning state

* fix: change implementation for outside click

* chore: implement new form param

* chore: update env example

* feat: link form refs exception

* new page button layout, link topic search fixed

* chore: enable topic

* chore: update seed

* profile

* chore: move framer motion package from root to web and add nuqs

* chore: add LearningStateValue

* chore: implement active state

* profile

* chore: use fancy switch and update const

* feat: filter implementation

* dropdown menu

* .

* sidebar topics

* topic selected color

* feat: topic detail

* fix: collapsible page

* pages - sorted by, layout, visible mode

* .

* .

* .

* topic status sidebar

* topic button and count

* fix: topic

* page delete/topic buttons

* search ui

* selected topic for page

* selected topic status sidebar

* removed footer

* update package

* .

---------

Co-authored-by: Nikita <github@nikiv.dev>
Co-authored-by: marshennikovaolga <marshennikova@gmail.com>
Co-authored-by: Kisuyo <ig.intr3st@gmail.com>
This commit is contained in:
Aslam
2024-08-26 19:35:00 +07:00
committed by GitHub
parent 7cbfcc705b
commit 2d270706a5
77 changed files with 3002 additions and 1327 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,3 @@
# base
.DS_Store
.env
.env*.local
@@ -13,3 +12,4 @@ pnpm-lock.yaml
# other
private
past-*
output

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,12 +1,89 @@
import { getEnvOrThrow } from "@/lib/utils"
import { PublicGlobalGroup } from "@/web/lib/schema/master/public-group"
import { startWorker } from "jazz-nodejs"
import { ID } from "jazz-tools"
const JAZZ_WORKER_SECRET = getEnvOrThrow("JAZZ_WORKER_SECRET")
async function run() {
try {
const OPENAI_API_KEY = getEnvOrThrow("OPENAI_API_KEY")
console.log(OPENAI_API_KEY)
await readJazz()
} catch (err) {
console.log(err, "err")
}
}
async function readJazz() {
const { worker } = await startWorker({
accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9",
accountSecret: JAZZ_WORKER_SECRET
})
const globalGroupId = process.env.JAZZ_PUBLIC_GLOBAL_GROUP as ID<PublicGlobalGroup>
const globalGroup = await PublicGlobalGroup.load(globalGroupId, worker, {
root: {
topics: [
{
latestGlobalGuide: {
sections: [
{
links: [{}]
}
]
}
}
],
forceGraphs: [
{
connections: [{}]
}
]
}
})
if (!globalGroup) return // TODO: err
// wait 10 seconds
await new Promise(resolve => setTimeout(resolve, 10000))
/*
* Log forceGraphs
*/
const asJsonForceGraphs = globalGroup.root.forceGraphs.map(node => {
console.log({ node }, "node")
return {
name: node.name,
prettyName: node.prettyName,
connections: node.connections?.map(connection => {
return {
name: connection?.name
}
})
}
})
const asJson = globalGroup.root.topics?.map(node => {
return {
name: node.name,
prettyName: node.prettyName,
latestGlobalGuide: {
sections: node.latestGlobalGuide.sections.map(section => {
return {
title: section?.title,
links: section?.links?.map(link => {
return {
title: link?.title,
url: link?.url
}
})
}
})
}
}
})
console.log({ asJsonForceGraphs }, "asJsonForceGraphs")
console.log({ asJson }, "asJson")
}
await run()

View File

@@ -1,11 +1,466 @@
import { getEnvOrThrow } from "@/lib/utils"
import { LaAccount } from "@/web/lib/schema"
import { Connection, ForceGraph, ListOfConnections, ListOfForceGraphs } from "@/web/lib/schema/master/force-graph"
import { PublicGlobalGroup, PublicGlobalGroupRoot } from "@/web/lib/schema/master/public-group"
import {
LatestGlobalGuide,
Link,
ListOfLinks,
ListOfSections,
ListOfTopics,
Section,
Topic
} from "@/web/lib/schema/master/topic"
import fs from "fs/promises"
import { startWorker } from "jazz-nodejs"
import { Group, ID } from "jazz-tools"
import { ID } from "jazz-tools"
import { appendFile } from "node:fs/promises"
import path from "path"
// Define interfaces for JSON data structures
interface LinkJson {
id?: ID<Link>
title: string
url: string
}
interface SectionJson {
title: string
links: LinkJson[]
}
interface TopicJson {
name: string
prettyName: string
latestGlobalGuide: {
sections: SectionJson[]
} | null
}
// Get the Jazz worker secret from environment variables
const JAZZ_WORKER_SECRET = getEnvOrThrow("JAZZ_WORKER_SECRET")
/**
* Manages links, handling deduplication and tracking duplicates.
*/
class LinkManager {
private links: Map<string, LinkJson> = new Map()
private duplicateCount: number = 0
/**
* Adds a link to the manager, tracking duplicates.
* @param link - The link to add.
*/
addLink(link: LinkJson) {
const key = link.url
if (this.links.has(key)) {
this.duplicateCount++
} else {
this.links.set(key, link)
}
}
/**
* Gets all unique links.
* @returns An array of unique links.
*/
getAllLinks() {
return Array.from(this.links.values())
}
/**
* Gets the count of duplicate links.
* @returns The number of duplicate links.
*/
getDuplicateCount() {
return this.duplicateCount
}
}
/**
* Starts a Jazz worker.
* @returns A Promise that resolves to the started worker.
*/
async function startJazzWorker() {
const { worker } = await startWorker({
accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9",
accountSecret: JAZZ_WORKER_SECRET
})
return worker
}
/**
* Sets up the global and admin groups.
*/
async function setup() {
console.log("Starting setup")
const worker = await startJazzWorker()
/*
* Create global group
*/
const publicGlobalGroup = PublicGlobalGroup.create({ owner: worker })
publicGlobalGroup.root = PublicGlobalGroupRoot.create(
{
topics: ListOfTopics.create([], { owner: publicGlobalGroup }),
forceGraphs: ListOfForceGraphs.create([], { owner: publicGlobalGroup })
},
{ owner: publicGlobalGroup }
)
publicGlobalGroup.addMember("everyone", "reader")
await appendFile("./.env", `\nJAZZ_PUBLIC_GLOBAL_GROUP=${JSON.stringify(publicGlobalGroup.id)}`)
/*
* Create admin group
*/
// const user = (await await LaAccount.createAs(worker, {
// creationProps: { name: "nikiv" }
// }))!
// const adminGlobalGroup = Group.create({ owner: worker })
// adminGlobalGroup.addMember(user, "admin")
// await appendFile("./.env", `\nJAZZ_ADMIN_GLOBAL_GROUP=${JSON.stringify(adminGlobalGroup.id)}`)
console.log("Setup completed successfully", publicGlobalGroup.id)
}
/**
* Loads the global group.
* @returns A Promise that resolves to the loaded global group.
* @throws Error if the global group fails to load.
*/
async function loadGlobalGroup() {
const worker = await startJazzWorker()
const globalGroupId = getEnvOrThrow("JAZZ_PUBLIC_GLOBAL_GROUP") as ID<PublicGlobalGroup>
const globalGroup = await PublicGlobalGroup.load(globalGroupId, worker, {
root: {
topics: [{ latestGlobalGuide: { sections: [] } }],
forceGraphs: [{ connections: [] }]
}
})
if (!globalGroup) throw new Error("Failed to load global group")
return globalGroup
}
/**
* Processes JSON files to extract link and topic data.
* @returns A Promise that resolves to a tuple containing a LinkManager and an array of TopicJson.
*/
async function processJsonFiles(): Promise<[LinkManager, TopicJson[]]> {
const directory = path.join(__dirname, "..", "private", "data", "edgedb", "topics")
const linkManager = new LinkManager()
const processedData: TopicJson[] = []
let files = await fs.readdir(directory)
files.sort((a, b) => a.localeCompare(b)) // sort files alphabetically
files = files.slice(0, 1) // get only 1 file for testing
for (const file of files) {
if (path.extname(file) === ".json") {
const filePath = path.join(directory, file)
try {
const data = JSON.parse(await fs.readFile(filePath, "utf-8")) as TopicJson
if (data.latestGlobalGuide) {
for (const section of data.latestGlobalGuide.sections) {
for (const link of section.links) {
linkManager.addLink(link)
}
}
}
processedData.push(data)
} catch (error) {
console.error(`Error processing file ${file}:`, error)
}
}
}
return [linkManager, processedData]
}
/**
* Creates a simple progress bar string.
* @param progress - Current progress (0-100).
* @param total - Total width of the progress bar.
* @returns A string representing the progress bar.
*/
function createProgressBar(progress: number, total: number = 30): string {
const filledWidth = Math.round((progress / 100) * total)
const emptyWidth = total - filledWidth
return `[${"=".repeat(filledWidth)}${" ".repeat(emptyWidth)}]`
}
/**
* Updates the progress display in the terminal.
* @param message - The message to display.
* @param current - Current progress value.
* @param total - Total progress value.
*/
function updateProgress(message: string, current: number, total: number) {
const percentage = Math.round((current / total) * 100)
const progressBar = createProgressBar(percentage)
process.stdout.write(`\r${message} ${progressBar} ${percentage}% (${current}/${total})`)
}
async function insertLinksInBatch(links: LinkJson[], chunkSize: number = 100) {
const globalGroup = await loadGlobalGroup()
const allCreatedLinks: Link[] = []
const totalLinks = links.length
for (let i = 0; i < totalLinks; i += chunkSize) {
const chunk = links.slice(i, i + chunkSize)
const rows = chunk.map(link =>
Link.create(
{
title: link.title,
url: link.url
},
{ owner: globalGroup }
)
)
allCreatedLinks.push(...rows)
updateProgress("Processing links:", i + chunk.length, totalLinks)
// Add a small delay between chunks to avoid overwhelming the system
await new Promise(resolve => setTimeout(resolve, 1000))
}
console.log("\nFinished processing links")
return allCreatedLinks
}
async function saveProcessedData(linkLists: Link[], topics: TopicJson[], chunkSize: number = 10) {
const globalGroup = await loadGlobalGroup()
const totalTopics = topics.length
for (let i = 0; i < totalTopics; i += chunkSize) {
const topicChunk = topics.slice(i, i + chunkSize)
topicChunk.forEach(topic => {
const topicModel = Topic.create(
{
name: topic.name,
prettyName: topic.prettyName,
latestGlobalGuide: LatestGlobalGuide.create(
{
sections: ListOfSections.create([], { owner: globalGroup })
},
{ owner: globalGroup }
)
},
{ owner: globalGroup }
)
if (!topic.latestGlobalGuide) {
console.error("No sections found in", topic.name)
return
}
topic.latestGlobalGuide.sections.map(section => {
const sectionModel = Section.create(
{
title: section.title,
links: ListOfLinks.create([], { owner: globalGroup })
},
{ owner: globalGroup }
)
section.links.map(link => {
const linkModel = linkLists.find(l => l.url === link.url)
if (linkModel) {
sectionModel.links?.push(linkModel)
}
})
topicModel.latestGlobalGuide?.sections?.push(sectionModel)
})
globalGroup.root.topics?.push(topicModel)
})
updateProgress("Processing topics:", i + topicChunk.length, totalTopics)
// Add a small delay between chunks to avoid overwhelming the system
await new Promise(resolve => setTimeout(resolve, 1000))
}
console.log("\nFinished processing topics")
}
/**
* Seeds production data.
*/
async function prodSeed() {
console.log("Starting to seed data")
const [linkManager, processedData] = await processJsonFiles()
console.log(`Collected ${linkManager.getAllLinks().length} unique links.`)
console.log(`Found ${linkManager.getDuplicateCount()} duplicate links.`)
console.log("\nInserting links:")
const insertedLinks = await insertLinksInBatch(linkManager.getAllLinks(), 100)
console.log("\nSaving processed data:")
await saveProcessedData(insertedLinks, processedData, 10)
console.log("\nFinished seeding data")
}
interface ForceGraphJson {
name: string
prettyName: string
connections: string[]
}
/**
* Manages links, handling deduplication and tracking duplicates.
*/
class ConnectionManager {
private connections: Map<string, string> = new Map()
private duplicateCount: number = 0
/**
* Adds a connection to the manager, tracking duplicates.
* @param connection - The connection to add.
*/
addConnection(connection: string) {
if (this.connections.has(connection)) {
this.duplicateCount++
} else {
this.connections.set(connection, connection)
}
}
/**
* Gets all unique connections.
* @returns An array of unique connections.
*/
getAllConnections() {
return Array.from(this.connections.values())
}
/**
* Gets the count of duplicate connections.
* @returns The number of duplicate connections.
*/
getDuplicateCount() {
return this.duplicateCount
}
}
/**
* Inserts connections in batch.
* @param connections - An array of string objects to insert.
* @returns A Promise that resolves to an array of created Connection models.
*/
async function insertConnectionsInBatch(connections: string[]) {
const globalGroup = await loadGlobalGroup()
const rows = []
for (const connection of connections) {
const connectionModel = Connection.create(
{
name: connection
},
{ owner: globalGroup }
)
rows.push(connectionModel)
}
return rows
}
/**
* Saves force graph data to the global group.
* @param connectionLists - An array of Connection models.
* @param forceGraphs - An array of ForceGraphJson objects.
*/
async function saveForceGraph(connectionLists: Connection[], forceGraphs: ForceGraphJson[]) {
const globalGroup = await loadGlobalGroup()
forceGraphs.map(forceGraph => {
const forceGraphModel = ForceGraph.create(
{
name: forceGraph.name,
prettyName: forceGraph.prettyName,
connections: ListOfConnections.create([], { owner: globalGroup })
},
{ owner: globalGroup }
)
forceGraph.connections.map(connection => {
const connectionModel = connectionLists.find(c => c.name === connection)
if (connectionModel) {
forceGraphModel.connections?.push(connectionModel)
}
})
globalGroup.root.forceGraphs?.push(forceGraphModel)
})
}
async function forceGraphSeed() {
console.log("Starting to seed force graph data")
const directory = path.join(__dirname, "..", "private", "data", "edgedb")
const connectionManager = new ConnectionManager()
const processedData: ForceGraphJson[] = []
const files = await fs.readdir(directory)
const file = files.find(file => file === "force-graphs.json")
if (!file) {
console.error("No force-graphs.json file found")
return
}
const filePath = path.join(directory, file)
try {
const forceGraphs = JSON.parse(await fs.readFile(filePath, "utf-8")) as ForceGraphJson[]
for (const forceGraph of forceGraphs) {
if (forceGraph.connections.length) {
for (const connection of forceGraph.connections) {
connectionManager.addConnection(connection)
}
}
processedData.push(forceGraph)
}
} catch (error) {
console.error(`Error processing file ${file}:`, error)
}
console.log(`Collected ${connectionManager.getAllConnections().length} unique connections.`)
console.log(`Found ${connectionManager.getDuplicateCount()} duplicate connections.`)
const insertedConnections = await insertConnectionsInBatch(connectionManager.getAllConnections())
await saveForceGraph(insertedConnections, processedData)
// wait 3 seconds before finishing
await new Promise(resolve => setTimeout(resolve, 3000))
console.log("Finished seeding force graph data")
}
/**
* Performs a full production rebuild.
*/
async function fullProdRebuild() {
await prodSeed()
await forceGraphSeed()
}
/**
* Main seed function to handle different commands.
*/
async function seed() {
const args = Bun.argv
const command = args[2]
@@ -20,40 +475,19 @@ async function seed() {
case "prod":
await prodSeed()
break
case "fullProdRebuild":
await fullProdRebuild()
break
case "forceGraph":
await forceGraphSeed()
break
default:
console.log("Unknown command")
break
}
console.log("done")
} catch (err) {
console.error("Error occurred:", err)
}
}
// sets up jazz global group and writes it to .env
async function setup() {
const { worker } = await startWorker({
accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9",
accountSecret: JAZZ_WORKER_SECRET
})
const user = (await await LaAccount.createAs(worker, {
creationProps: { name: "nikiv" }
}))!
const publicGlobalGroup = Group.create({ owner: worker })
publicGlobalGroup.addMember("everyone", "reader")
await appendFile("./.env", `\nJAZZ_PUBLIC_GLOBAL_GROUP=${JSON.stringify(publicGlobalGroup.id)}`)
const adminGlobalGroup = Group.create({ owner: worker })
adminGlobalGroup.addMember(user, "admin")
await appendFile("./.env", `\nJAZZ_ADMIN_GLOBAL_GROUP=${JSON.stringify(adminGlobalGroup.id)}`)
}
async function prodSeed() {
const { worker } = await startWorker({
accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9",
accountSecret: JAZZ_WORKER_SECRET
})
const globalGroup = await Group.load(process.env.JAZZ_PUBLIC_GLOBAL_GROUP as ID<Group>, worker, {})
if (!globalGroup) return // TODO: err
// TODO: complete full seed (connections, topics from old LA)
}
await seed()

View File

@@ -1,31 +1,31 @@
{
"name": "learn-anything",
"scripts": {
"dev": "bun web",
"web": "cd web && bun dev",
"web:build": "bun run --filter '*' build",
"cli": "bun run --watch cli/run.ts",
"seed": "bun --watch cli/seed.ts"
},
"workspaces": [
"web"
],
"dependencies": {
"jazz-nodejs": "^0.7.23",
"react-icons": "^5.2.1"
},
"devDependencies": {
"bun-types": "^1.1.21"
},
"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",
"cli": "bun run --watch cli/run.ts",
"seed": "bun --watch cli/seed.ts"
},
"workspaces": [
"web"
],
"dependencies": {
"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"
}

View File

@@ -1,2 +1,4 @@
NEXT_PUBLIC_APP_NAME="Learn Anything"
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_JAZZ_GLOBAL_GROUP=""

View File

@@ -0,0 +1,5 @@
import { TopicDetailRoute } from "@/components/routes/topics/detail/TopicDetailRoute"
export default function DetailTopicPage({ params }: { params: { name: string } }) {
return <TopicDetailRoute topicName={params.name} />
}

View File

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

View File

@@ -1,15 +1,22 @@
import { Sidebar } from "@/components/custom/sidebar/sidebar"
import PublicHomeRoute from "@/components/routes/PublicHomeRoute"
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
// TODO: get it from jazz/clerk
const loggedIn = true
<div className="flex min-w-0 flex-1 flex-col">
<main className="bg-card relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
{children}
</main>
if (loggedIn) {
return (
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
<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>
</div>
)
)
}
return <PublicHomeRoute />
}

View File

@@ -1,5 +0,0 @@
import { LinkWrapper } from "@/components/routes/link/wrapper"
export default function LinkPage() {
return <LinkWrapper />
}

5
web/app/(pages)/page.tsx Normal file
View File

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

View File

@@ -1,14 +1,137 @@
"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 { Button } from "@/components/ui/button"
export const ProfileWrapper = () => {
const account = useAccount()
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>
<h2>{account.me.profile?.name}</h2>
<p>Profile Page</p>
<div className="text-center font-semibold text-black/60 dark:text-white">
<p className="text-4xl">{number}</p>
<p className="text-[#878787]">{label}</p>
</div>
)
}
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 router = useRouter()
const clickEdit = () => router.push("/edit-profile")
if (!account.me || !account.me.profile) {
return (
<div className="flex h-screen flex-col py-3 text-black dark:text-white">
<div className="flex flex-1 flex-col rounded-3xl border border-neutral-800">
<p className="my-10 h-[74px] border-b border-neutral-900 text-center text-2xl font-semibold">
Oops! This account doesn't exist.
</p>
<p className="mb-5 text-center text-lg font-semibold">Try searching for another.</p>
<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>
</Link>
.
</p>
</div>
</div>
)
}
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="cursor-pointer text-neutral-200" />
<span>Edit Profile</span>
</Button>
</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]" />
<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>
</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>
</div>
</div>
<div className="mt-10 flex justify-center">
<div className="flex flex-row gap-20">
<ProfileStats number={account.me.root?.topicsLearning?.length || 0} label="Learning" />
<ProfileStats number={account.me.root?.topicsWantToLearn?.length || 0} label="To Learn" />
<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>
<div className="mx-auto mt-10 w-[50%] justify-center space-y-1">
<p className="pb-3 pl-2 text-base font-light text-white/50">Public Links</p>
{account.me.root?.personalLinks?.map((link, index) => (
<ProfileLinks key={index} linklabel={link.title} link={link.url} topic={link.topic?.name} />
))}
</div> */}
</div>
)
}

View File

@@ -1,14 +0,0 @@
import { Sidebar } from "@/components/custom/sidebar/sidebar"
export default function TopicsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<main className="bg-card relative flex flex-auto flex-col place-items-stretch overflow-auto rounded-md border lg:my-2 lg:mr-2">
{children}
</main>
</div>
</div>
)
}

View File

@@ -1,5 +0,0 @@
import GlobalTopic from "@/components/routes/globalTopic/globalTopic"
export default function GlobalTopicPage({ params }: { params: { topic: string } }) {
return <GlobalTopic topic={params.topic} />
}

View File

@@ -3,7 +3,7 @@
*/
import { NextRequest } from "next/server"
import axios from "axios"
import { GET } from "./route"
import { DEFAULT_VALUES, GET } from "./route"
jest.mock("axios")
const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -19,7 +19,7 @@ describe("Metadata Fetcher", () => {
<head>
<title>Test Title</title>
<meta name="description" content="Test Description">
<link rel="icon" href="/favicon.ico">
<link rel="icon" href="/icon.ico">
</head>
</html>
`
@@ -37,7 +37,7 @@ describe("Metadata Fetcher", () => {
expect(data).toEqual({
title: "Test Title",
description: "Test Description",
favicon: "https://example.com/favicon.ico",
icon: "https://example.com/icon.ico",
url: "https://example.com"
})
})
@@ -66,9 +66,9 @@ describe("Metadata Fetcher", () => {
expect(response.status).toBe(200)
expect(data).toEqual({
title: "No title available",
description: "No description available",
favicon: null,
title: DEFAULT_VALUES.TITLE,
description: DEFAULT_VALUES.DESCRIPTION,
icon: null,
url: "https://example.com"
})
})
@@ -92,9 +92,9 @@ describe("Metadata Fetcher", () => {
expect(response.status).toBe(200)
expect(data).toEqual({
title: "No title available",
description: "No description available",
favicon: null,
title: DEFAULT_VALUES.TITLE,
description: DEFAULT_VALUES.DESCRIPTION,
icon: null,
url: "https://example.com"
})
})

View File

@@ -1,29 +1,39 @@
import { NextRequest, NextResponse } from "next/server"
import axios from "axios"
import * as cheerio from "cheerio"
import { ensureUrlProtocol } from "@/lib/utils"
import { urlSchema } from "@/lib/utils/schema"
interface Metadata {
title: string
description: string
favicon: string | null
icon: string | null
url: string
}
const DEFAULT_VALUES = {
TITLE: "No title available",
DESCRIPTION: "No description available",
IMAGE: null,
export const DEFAULT_VALUES = {
TITLE: "",
DESCRIPTION: "",
FAVICON: null
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const url = searchParams.get("url")
let url = searchParams.get("url")
await new Promise(resolve => setTimeout(resolve, 1000))
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 })
}
const result = urlSchema.safeParse(url)
if (!result.success) {
throw new Error(result.error.issues.map(issue => issue.message).join(", "))
}
url = ensureUrlProtocol(url)
try {
const { data } = await axios.get(url, {
timeout: 5000,
@@ -41,13 +51,12 @@ export async function GET(request: NextRequest) {
$('meta[name="description"]').attr("content") ||
$('meta[property="og:description"]').attr("content") ||
DEFAULT_VALUES.DESCRIPTION,
favicon:
$('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON,
icon: $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON,
url: url
}
if (metadata.favicon && !metadata.favicon.startsWith("http")) {
metadata.favicon = new URL(metadata.favicon, url).toString()
if (metadata.icon && !metadata.icon.startsWith("http")) {
metadata.icon = new URL(metadata.icon, url).toString()
}
return NextResponse.json(metadata)
@@ -55,7 +64,7 @@ export async function GET(request: NextRequest) {
const defaultMetadata: Metadata = {
title: DEFAULT_VALUES.TITLE,
description: DEFAULT_VALUES.DESCRIPTION,
favicon: DEFAULT_VALUES.FAVICON,
icon: DEFAULT_VALUES.FAVICON,
url: url
}
return NextResponse.json(defaultMetadata)

View File

@@ -1,26 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
body {
font-family: "Inter", sans-serif;
}
@layer base {
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
min-height: 100vh;
line-height: 1.5;
}
}
@layer base {
:root {
@@ -42,17 +22,19 @@ body {
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--result: 240 5.9% 96%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--boxShadow: rgba(0, 0, 0, 0.05);
}
.dark {
--background: 240 10% 3.9%;
--background: 240 10% 4.5%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
@@ -69,13 +51,15 @@ body {
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--input: 220 9% 10%;
--result: 0 0% 7%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--boxShadow: rgba(255, 255, 255, 0.04);
}
}

View File

@@ -1,12 +0,0 @@
import { Button } from "@/components/ui/button"
import Link from "next/link"
export default function HomePage() {
return (
<div className="flex min-h-full items-center justify-center">
<Link href="/links">
<Button>Go to main page</Button>
</Link>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { LaIcon } from "@/components/custom/la-icon"
export default function LinkOptions() {
const buttonClass =
"block w-full flex flex-row items-center px-4 py-2 rounded-lg text-left text-sm hover:bg-gray-700/20"
return (
<div className="absolute bottom-full left-0 mb-2 w-48 rounded-md bg-neutral-800/40 text-white shadow-lg dark:bg-neutral-800">
<div>
<button className={buttonClass}>
<LaIcon name="Repeat2" className="mr-2" />
Repeat
</button>
<button className={buttonClass}>
<LaIcon name="Layers2" className="mr-2" />
Duplicate
</button>
<button className={buttonClass}>
<LaIcon name="Share" className="mr-2" />
Share
</button>
</div>
</div>
)
}

View File

@@ -71,14 +71,14 @@ const AiSearch: React.FC<AiSearchProps> = (props: { searchQuery: string }) => {
return (
<div className="mx-auto flex max-w-3xl flex-col items-center">
<div className="w-full rounded-lg bg-inherit p-6 text-white">
<div className="mb-6 rounded-lg bg-blue-700 p-4">
<div className="w-full rounded-lg bg-inherit p-6 text-black dark:text-white">
<div className="mb-6 rounded-lg bg-blue-700 p-4 text-white">
<h2 className="text-lg font-medium"> This is what I have found:</h2>
</div>
<div className="rounded-xl bg-[#121212] p-4" ref={root_el}></div>
<div className="rounded-xl bg-neutral-100 p-4 dark:bg-[#121212]" ref={root_el}></div>
</div>
<p className="text-md pb-5 font-semibold opacity-50">{error}</p>
<button className="text-md rounded-2xl bg-neutral-800 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-700/50 transition-colors hover:bg-neutral-700">
<button className="text-md rounded-2xl bg-neutral-300 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-400/50 transition-colors hover:bg-neutral-700 dark:bg-neutral-800 dark:shadow-neutral-700/50">
Ask Community
</button>
</div>

View File

@@ -16,7 +16,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
return (
<header
className={cn(
"flex min-h-20 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:pl-4 max-lg:pr-5",
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:pl-4 max-lg:pr-5",
className
)}
ref={ref}
@@ -50,7 +50,7 @@ export const SidebarToggleButton: React.FC = () => {
size="icon"
variant="ghost"
aria-label="Menu"
className="text-primary/60 z-50"
className="text-primary/60"
onClick={handleClick}
>
<PanelLeftIcon size={16} />

View File

@@ -0,0 +1,37 @@
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
interface DeleteModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
}
export default function DeletePageModal({ isOpen, onClose, onConfirm, title }: DeleteModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete "{title}"?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" className="bg-red-700" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { icons } from "lucide-react"
export type IconProps = {
name: keyof typeof icons
className?: string
strokeWidth?: number
[key: string]: any
}
export const LaIcon = React.memo(({ name, className, size, strokeWidth, ...props }: IconProps) => {
const IconComponent = icons[name]
if (!IconComponent) {
return null
}
return <IconComponent className={cn(!size ? "size-4" : size, className)} strokeWidth={strokeWidth || 2} {...props} />
})
LaIcon.displayName = "LaIcon"

View File

@@ -0,0 +1,14 @@
export const PageLoader = () => {
return (
<div className="relative top-[-60px] flex h-full flex-col justify-center">
<div className="mx-auto w-full max-w-[220px] py-6">
<div className="text-center">
<div className="mb-4 text-base font-medium">Preparing application</div>
<div className="bg-muted relative flex h-1 w-full appearance-none overflow-hidden rounded leading-3">
<div className="progress-bar-indeterminate bg-primary flex h-full flex-col justify-center overflow-hidden whitespace-nowrap text-center text-white"></div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,19 +1,23 @@
import { SidebarItem } from "../sidebar"
import { z } from "zod"
import { useAtom } from "jotai"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { usePathname, useRouter } from "next/navigation"
import { useAccount } from "@/lib/providers/jazz-provider"
import { Input } from "@/components/ui/input"
import { cn, generateUniqueSlug } from "@/lib/utils"
import { atomWithStorage } from "jotai/utils"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
import { PlusIcon } from "lucide-react"
import { generateUniqueSlug } from "@/lib/utils"
import { PersonalPage } from "@/lib/schema/personal-page"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { useForm } from "react-hook-form"
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState, useEffect, useCallback } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { LaIcon } from "../../la-icon"
import { toast } from "sonner"
import Link from "next/link"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
const pageSortAtom = atomWithStorage("pageSort", "title")
const createPageSchema = z.object({
title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" })
})
@@ -21,47 +25,114 @@ const createPageSchema = z.object({
type PageFormValues = z.infer<typeof createPageSchema>
export const PageSection: React.FC = () => {
const { me } = useAccount()
const [personalPages, setPersonalPages] = useState<PersonalPage[]>([])
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
useEffect(() => {
if (me.root?.personalPages) {
setPersonalPages(prevPages => {
const newPages = Array.from(me.root?.personalPages ?? []).filter((page): page is PersonalPage => page !== null)
return [...prevPages, ...newPages.filter(newPage => !prevPages.some(prevPage => prevPage.id === newPage.id))]
})
}
}, [me.root?.personalPages])
const { me } = useAccount({
root: { personalPages: [] }
})
const onPageCreated = useCallback((newPage: PersonalPage) => {
setPersonalPages(prevPages => [...prevPages, newPage])
}, [])
const pageCount = me?.root.personalPages?.length || 0
const sortedPages = (filter: string) => {
setPagesSorted(filter)
}
return (
<div className="-ml-2">
<div className="group mb-0.5 ml-2 mt-2 flex flex-row items-center justify-between rounded-md">
<div
role="button"
tabIndex={0}
className="text-muted-foreground hover:bg-muted/50 flex h-6 grow cursor-default items-center justify-between gap-x-0.5 self-start rounded-md px-1 text-xs font-medium"
<div className="flex flex-col gap-px py-2">
<div className="hover:bg-accent group/pages flex items-center gap-px rounded-md">
<Button
variant="ghost"
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus:outline-0 focus:ring-0"
>
<span className="group-hover:text-muted-foreground">Pages</span>
<CreatePageForm onPageCreated={onPageCreated} />
<p className="flex items-center text-xs font-medium">
Pages <span className="text-muted-foreground ml-1">{pageCount}</span>
</p>
</Button>
<div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/pages:opacity-100">
<ShowAllForm filteredPages={sortedPages} />
<CreatePageForm />
</div>
</div>
<div className="relative shrink-0">
<div aria-hidden="false" className="ml-2 flex shrink-0 flex-col space-y-1 pb-2">
{personalPages.map(page => (
<SidebarItem key={page.id} url={`/pages/${page.id}`} label={page.title} />
))}
</div>
</div>
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} sortBy={pagesSorted} />}
</div>
)
}
const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }> = ({ onPageCreated }) => {
const PageList: React.FC<{ personalPages: PersonalPageLists; sortBy: string }> = ({ personalPages, sortBy }) => {
const pathname = usePathname()
const sortedPages = [...personalPages]
.sort((a, b) => {
if (sortBy === "title") {
return (a?.title || "").localeCompare(b?.title || "")
} else if (sortBy === "latest") {
return ((b as any)?.createdAt?.getTime?.() ?? 0) - ((a as any)?.createdAt?.getTime?.() ?? 0)
}
return 0
})
.slice(0, 6)
return (
<div className="flex flex-col gap-1">
{sortedPages.map(
page =>
page?.id && (
<div key={page.id} className="group/reorder-page relative">
<div className="group/sidebar-link relative flex min-w-0 flex-1">
<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",
{ "bg-accent text-accent-foreground": pathname === `/pages/${page.id}` }
)}
>
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
<LaIcon name="FileText" className="size-3 flex-shrink-0 opacity-60" />
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100">{page.title}</p>
</div>
</Link>
</div>
</div>
)
)}
</div>
)
}
interface ShowAllFormProps {
filteredPages: (filter: string) => void
}
const ShowAllForm: React.FC<ShowAllFormProps> = ({ filteredPages }) => {
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
const handleSort = (newSort: string) => {
setPagesSorted(newSort.toLowerCase())
filteredPages(newSort.toLowerCase())
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs font-medium">
<LaIcon name="Ellipsis" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[100px]">
<DropdownMenuItem onClick={() => handleSort("title")}>
Title
{pagesSorted === "title" && <LaIcon name="Check" className="ml-auto h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSort("manual")}>
Manual
{pagesSorted === "manual" && <LaIcon name="Check" className="ml-auto h-4 w-4" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const CreatePageForm: React.FC = () => {
const [open, setOpen] = useState(false)
const { me } = useAccount()
const route = useRouter()
@@ -88,7 +159,6 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }>
)
me.root?.personalPages?.push(newPersonalPage)
onPageCreated(newPersonalPage)
form.reset()
setOpen(false)
@@ -103,9 +173,16 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }>
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" size="icon" variant="ghost" aria-label="New Page" className="size-6">
<PlusIcon size={16} />
</Button>
<button
type="button"
aria-label="New Page"
className={cn(
"flex size-6 cursor-pointer items-center justify-center rounded-lg bg-inherit p-0.5 shadow-none focus:outline-0 focus:ring-0",
'opacity-0 transition-opacity duration-200 group-hover/pages:opacity-100 data-[state="open"]:opacity-100'
)}
>
<LaIcon name="Plus" className="text-black dark:text-white" />
</button>
</PopoverTrigger>
<PopoverContent align="start">
<Form {...form}>

View File

@@ -0,0 +1,108 @@
import { LaIcon } from "../../la-icon"
import { useState } from "react"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useAccount } from "@/lib/providers/jazz-provider"
import Link from "next/link"
const MenuItem = ({
icon,
text,
href,
onClick,
onClose
}: {
icon: string
text: string
href?: string
onClick?: () => void
onClose: () => void
}) => {
const handleClick = () => {
onClose()
if (onClick) {
onClick()
}
}
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, logOut } = useAccount({
profile: true
})
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 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={logOut} 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> */}
</div>
</div>
)
}

View File

@@ -1,100 +1,66 @@
import { useState, useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { ChevronDown, BookOpen, Bookmark, GraduationCap, Check } from "lucide-react"
import { LaIcon } from "@/components/custom/la-icon"
import { SidebarItem } from "../sidebar"
const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"]
// const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"]
export const TopicSection = () => {
const [showOptions, setShowOptions] = useState(false)
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const learningOptions = [
{ text: "To Learn", icon: <Bookmark size={16} />, color: "text-white/70" },
{
text: "Learning",
icon: <GraduationCap size={16} />,
color: "text-[#D29752]"
text: "To Learn",
icon: <LaIcon name="NotebookPen" className="size-3 flex-shrink-0" />,
color: "text-black dark:text-white"
},
{
text: "Learned",
icon: <Check size={16} />,
color: "text-[#708F51]"
}
text: "Learning",
icon: <LaIcon name="GraduationCap" className="size-4 flex-shrink-0" />,
color: "text-[#D29752]"
},
{ text: "Learned", icon: <LaIcon name="Check" className="size-4 flex-shrink-0" />, color: "text-[#708F51]" }
]
const statusSelect = (status: string) => {
setSelectedStatus(status === "Show All" ? null : status)
setShowOptions(false)
setSelectedStatus(prevStatus => (prevStatus === status ? null : status))
}
useEffect(() => {
const overlayClick = (event: MouseEvent) => {
if (sectionRef.current && !sectionRef.current.contains(event.target as Node)) {
setShowOptions(false)
}
const topicCounts = {
"To Learn": 2,
Learning: 5,
Learned: 3,
get total() {
return this["To Learn"] + this.Learning + this.Learned
}
document.addEventListener("mousedown", overlayClick)
return () => {
document.removeEventListener("mousedown", overlayClick)
}
}, [])
const availableOptions = selectedStatus
? [
{
text: "Show All",
icon: <BookOpen size={16} />,
color: "text-white"
},
...learningOptions.filter(option => option.text !== selectedStatus)
]
: learningOptions
// const topicClick = (topic: string) => {
// router.push(`/${topic.toLowerCase()}`)
// }
}
return (
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
<Button
onClick={() => setShowOptions(!showOptions)}
className="bg-accent text-foreground hover:bg-accent/50 flex w-full items-center justify-between rounded-md px-3 py-2 text-sm font-medium"
>
<span>{selectedStatus ? `Topics: ${selectedStatus}` : "Topics"}</span>
<ChevronDown
size={16}
className={`transform transition-transform duration-200 ease-in-out ${
showOptions ? "rotate-0" : "rotate-[-90deg]"
}`}
/>
</Button>
{showOptions && (
<div className="rounded-md bg-neutral-800">
{availableOptions.map(option => (
<Button
key={option.text}
onClick={() => statusSelect(option.text)}
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
>
<div className="text-foreground group/topics hover:bg-accent flex w-full items-center justify-between rounded-md px-2 py-2 text-xs font-medium">
<span className="text-black dark:text-white">Topics {topicCounts.total}</span>
<button className="opacity-0 transition-opacity duration-200 group-hover/topics:opacity-100">
<LaIcon name="Ellipsis" className="size-4 flex-shrink-0" />
</button>
</div>
<div>
{learningOptions.map(option => (
<Button
key={option.text}
onClick={() => statusSelect(option.text)}
className={`flex w-full items-center justify-between rounded-md py-1 pl-1 text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-100/20 ${option.color} ${
selectedStatus === option.text ? "bg-accent" : "bg-inherit"
} shadow-none`}
>
<div className="flex items-center gap-2">
{option.icon && <span className={option.color}>{option.icon}</span>}
<span>{option.text}</span>
</Button>
))}
</div>
)}
<div className="scrollbar-hide space-y-1 overflow-y-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
{TOPICS.map(topic => (
<SidebarItem key={topic} label={topic} url={`/${topic}`} />
</div>
<span className={`${option.color} mr-2`}>{topicCounts[option.text as keyof typeof topicCounts]}</span>
</Button>
))}
</div>
</div>
)
}
export default TopicSection

View File

@@ -5,14 +5,14 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import { useMedia } from "react-use"
import { useAtom } from "jotai"
import { LinkIcon, SearchIcon } from "lucide-react"
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 { PageSection } from "./partial/page-section"
import { TopicSection } from "./partial/topic-section"
import { ProfileSection } from "./partial/profile-section"
interface SidebarContextType {
isCollapsed: boolean
@@ -73,14 +73,14 @@ export const SidebarItem: React.FC<SidebarItemProps> = React.memo(({ label, url,
const LogoAndSearch: React.FC = React.memo(() => {
const pathname = usePathname()
return (
<div className="px-3.5">
<div className="mb-1 mt-2 flex h-10 max-w-full items-center">
<Link href="/links" className="px-2">
<div className="px-3">
<div className="mt-2 flex h-10 max-w-full items-center">
<Link href="/" className="px-2">
<Logo className="size-7" />
</Link>
<div className="flex min-w-2 grow flex-row" />
{pathname === "/search" ? (
<Link href="/links">
<Link href="/">
<Button size="sm" variant="secondary" type="button" className="text-md text-primary/60 font-medium">
Back
</Button>
@@ -104,21 +104,20 @@ const LogoAndSearch: React.FC = React.memo(() => {
})
const SidebarContent: React.FC = React.memo(() => {
const { isCollapsed } = React.useContext(SidebarContext)
const isTablet = useMedia("(max-width: 1024px)")
return (
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
<div className={cn({ "pt-12": !isCollapsed && isTablet })}>
<LogoAndSearch />
</div>
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3.5">
<SidebarItem url="/links" label="Links" icon={<LinkIcon size={16} />} />
<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 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>
<ProfileSection />
</>
)
})
@@ -132,7 +131,7 @@ export const Sidebar: React.FC = () => {
)
const sidebarInnerClasses = cn(
"h-full w-auto min-w-56 transition-transform duration-300 ease-in-out",
"h-full w-56 min-w-56 transition-transform duration-300 ease-in-out",
isCollapsed ? "-translate-x-full" : "translate-x-0"
)

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import BaseTextareaAutosize from "react-textarea-autosize"
import { TextareaAutosizeProps as BaseTextareaAutosizeProps } from "react-textarea-autosize"
import { cn } from "@/lib/utils"
export interface TextareaProps extends Omit<BaseTextareaAutosizeProps, "ref"> {}
const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, style, ...props }, ref) => {
return (
<BaseTextareaAutosize
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
TextareaAutosize.displayName = "TextareaAutosize"
export { TextareaAutosize }

View File

@@ -5,6 +5,7 @@ import { BubbleMenu as TiptapBubbleMenu, Editor } from "@tiptap/react"
import { ToolbarButton } from "../ui/toolbar-button"
import { Icon } from "../ui/icon"
import * as React from "react"
import { Keybind } from "@/components/ui/Keybind"
export type BubbleMenuProps = {
editor: Editor
@@ -14,38 +15,93 @@ export const BubbleMenu = ({ editor }: BubbleMenuProps) => {
const commands = useTextmenuCommands(editor)
const states = useTextmenuStates(editor)
const toolbarButtonClassname =
"hover:opacity-100 transition-all dark:border-slate-500/10 border-gray-400 hover:border-b-2 active:translate-y-0 hover:translate-y-[-1.5px] hover:bg-zinc-300 dark:hover:bg-neutral-800 shadow-md rounded-[10px]"
return (
<TiptapBubbleMenu
tippyOptions={{
// duration: [0, 999999],
popperOptions: { placement: "top-start" }
}}
className="flex h-[40px] min-h-[40px] items-center rounded-[14px] shadow-md"
editor={editor}
pluginKey="textMenu"
shouldShow={states.shouldShow}
updateDelay={100}
>
<PopoverWrapper className="flex items-center overflow-x-auto p-1">
<div className="space-x-1">
<ToolbarButton value="bold" aria-label="Bold" onPressedChange={commands.onBold} isActive={states.isBold}>
<Icon name="Bold" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="italic" aria-label="Italic" onClick={commands.onItalic}>
<Icon name="Italic" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="strikethrough" aria-label="Strikethrough" onClick={commands.onStrike}>
<Icon name="Strikethrough" strokeWidth={2.5} />
</ToolbarButton>
<PopoverWrapper
className="flex items-center rounded-[14px] border border-slate-400/10 bg-gray-100 p-[4px] dark:bg-[#121212]"
style={{
boxShadow: "inset 0px 0px 5px 3px var(--boxShadow)"
}}
>
<div className="flex space-x-1">
<Keybind keys={["Ctrl", "I"]}>
<ToolbarButton
className={toolbarButtonClassname}
value="bold"
aria-label="Bold"
onPressedChange={commands.onBold}
isActive={states.isBold}
>
<Icon name="Bold" strokeWidth={2.5} />
</ToolbarButton>
</Keybind>
<Keybind keys={["Ctrl", "U"]}>
<ToolbarButton
className={toolbarButtonClassname}
value="italic"
aria-label="Italic"
onClick={commands.onItalic}
isActive={states.isItalic}
>
<Icon name="Italic" strokeWidth={2.5} />
</ToolbarButton>
</Keybind>
<Keybind keys={["Ctrl", "S"]}>
<ToolbarButton
className={toolbarButtonClassname}
value="strikethrough"
aria-label="Strikethrough"
onClick={commands.onStrike}
isActive={states.isStrike}
>
<Icon name="Strikethrough" strokeWidth={2.5} />
</ToolbarButton>
</Keybind>
{/* <ToolbarButton value="link" aria-label="Link">
<Icon name="Link" strokeWidth={2.5} />
</ToolbarButton> */}
<ToolbarButton value="quote" aria-label="Quote" onClick={commands.onCode}>
<Icon name="Quote" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="inline code" aria-label="Inline code" onClick={commands.onCode}>
<Icon name="Braces" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="code block" aria-label="Code block" onClick={commands.onCodeBlock}>
<Keybind keys={["cmd", "K"]}>
<ToolbarButton
className={toolbarButtonClassname}
value="quote"
aria-label="Quote"
onClick={commands.onCode}
isActive={states.isCode}
>
<Icon name="Quote" strokeWidth={2.5} />
</ToolbarButton>
</Keybind>
<Keybind keys={["Ctrl", "O"]}>
<ToolbarButton
className={toolbarButtonClassname}
value="inline code"
aria-label="Inline code"
onClick={commands.onCode}
isActive={states.isCode}
>
<Icon name="Braces" strokeWidth={2.5} />
</ToolbarButton>
</Keybind>
<ToolbarButton
className={toolbarButtonClassname}
value="code block"
aria-label="Code block"
onClick={commands.onCodeBlock}
>
<Icon name="Code" strokeWidth={2.5} />
</ToolbarButton>
{/* <ToolbarButton value="list" aria-label="List">

View File

@@ -0,0 +1,19 @@
"use client"
import { LinkHeader } from "@/components/routes/link/header"
import { LinkList } from "@/components/routes/link/list"
import { LinkManage } from "@/components/routes/link/form/manage"
import { useAtom } from "jotai"
import { linkEditIdAtom } from "@/store/link"
export default function AuthHomeRoute() {
const [editId] = useAtom(linkEditIdAtom)
return (
<div className="relative z-[1] flex h-full flex-auto flex-col overflow-hidden">
<LinkHeader />
<LinkManage />
<LinkList key={editId} />
</div>
)
}

View File

@@ -0,0 +1,45 @@
"use client"
import { useAccount } from "@/lib/providers/jazz-provider"
export default function EditProfileRoute() {
const account = useAccount()
return (
<div className="flex flex-1 flex-col">
<p className="h-[74px] p-[20px] text-2xl font-semibold text-white/30">Profile</p>
<div className="flex flex-col items-center border-b border-neutral-900 bg-inherit pb-5 text-white">
<div className="flex w-full max-w-2xl align-top">
<button className="mr-3 h-[130px] w-[130px] flex-col items-center justify-center rounded-xl border border-dashed border-white/10 bg-neutral-100 text-white/50 dark:bg-neutral-900">
<p className="text-sm tracking-wide">Photo</p>
</button>
<div className="ml-6 flex-1 space-y-4 font-light">
<input
type="text"
placeholder="Your name"
className="w-full rounded-md bg-[#121212] p-3 font-light tracking-wide text-white/70 placeholder-white/20 outline-none"
/>
<input
type="text"
placeholder="Username"
className="w-full rounded-md bg-[#121212] p-3 tracking-wide text-white/70 placeholder-white/20 outline-none"
/>
<p className="text-white/30">learn-anything.xyz/@</p>
<input
type="text"
placeholder="Website"
className="w-full rounded-md bg-[#121212] p-3 tracking-wide text-white/30 placeholder-white/20 outline-none"
/>
<textarea
placeholder="Bio"
className="h-[120px] w-full rounded-md bg-[#121212] p-3 text-left font-light tracking-wide text-white/30 placeholder-white/20 outline-none"
/>
<button className="mt-4 w-[120px] rounded-md bg-[#222222] px-3 py-2 font-light tracking-wide text-white/70 outline-none hover:opacity-60">
Save
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
"use client"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/global-topic-graph"
import { glob } from "fs"
import { ID } from "jazz-tools"
import { useMemo } from "react"
export default function PublicHomeRoute() {
// const globalGroup = useCoState(PublicGlobalGroup, "co_z6Tmg1sZTfwkPd4pV6qBV9T5SFU" as ID<PublicGlobalGroup>, {
// root: { topicGraph: [{ connectedTopics: [{}] }] }
// })
// const graph = useMemo(() => {
// return globalGroup?.root.topicGraph?.map(
// topic =>
// ({
// name: topic.name,
// prettyName: topic.prettyName,
// connectedTopics: topic.connectedTopics.map(connected => connected?.name)
// }) || []
// )
// }, [globalGroup?.root.topicGraph])
// const [{}]
// console.log(globalGroup, "graph")
return (
<>
<h1>I want to learn</h1>
<input type="text" />
</>
)
}

View File

@@ -0,0 +1,26 @@
"use client"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/global-topic-graph"
import { glob } from "fs"
import { ID } from "jazz-tools"
import { useMemo } from "react"
export default function ForceGraph() {
const globalGroup = useCoState(PublicGlobalGroup, "co_z6Tmg1sZTfwkPd4pV6qBV9T5SFU" as ID<PublicGlobalGroup>, {
root: { topicGraph: [{ connectedTopics: [{}] }] }
})
const graph = useMemo(() => {
return globalGroup?.root.topicGraph?.map(
topic =>
({
name: topic.name,
prettyName: topic.prettyName,
connectedTopics: topic.connectedTopics.map(connected => connected?.name)
}) || []
)
}, [globalGroup?.root.topicGraph])
// const [{}]
console.log(globalGroup, "graph")
return <>{JSON.stringify(graph)}</>
}

View File

@@ -1,174 +0,0 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { ContentHeader } from "@/components/custom/content-header"
import { PiLinkSimple } from "react-icons/pi"
import { Bookmark, GraduationCap, Check } from "lucide-react"
interface LinkProps {
title: string
url: string
}
const links = [
{ title: "JavaScript", url: "https://justjavascript.com" },
{ title: "TypeScript", url: "https://www.typescriptlang.org/" },
{ title: "React", url: "https://reactjs.org/" }
]
const LinkItem: React.FC<LinkProps> = ({ title, url }) => (
<div className="mb-1 flex flex-row items-center justify-between rounded-xl bg-[#121212] px-2 py-4 hover:cursor-pointer">
<div className="flex items-center space-x-4">
<p>{title}</p>
<span className="text-md flex flex-row items-center space-x-1 font-medium tracking-wide text-white/20 hover:opacity-50">
<PiLinkSimple size={20} className="text-white/20" />
<a href={url} target="_blank" rel="noopener noreferrer">
{new URL(url).hostname}
</a>
</span>
</div>
</div>
)
interface ButtonProps {
children: React.ReactNode
onClick: () => void
className?: string
color?: string
icon?: React.ReactNode
fullWidth?: boolean
}
const Button: React.FC<ButtonProps> = ({ children, onClick, className = "", color = "", icon, fullWidth = false }) => {
return (
<button
className={`flex items-center justify-start rounded px-3 py-1 text-sm font-medium ${
fullWidth ? "w-full" : ""
} ${className} ${color}`}
onClick={onClick}
>
{icon && <span className="mr-2 flex items-center">{icon}</span>}
<span>{children}</span>
</button>
)
}
export default function GlobalTopic({ topic }: { topic: string }) {
const [showOptions, setShowOptions] = useState(false)
const [selectedOption, setSelectedOption] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState("Guide")
const decodedTopic = decodeURIComponent(topic)
const learningStatusRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (learningStatusRef.current && !learningStatusRef.current.contains(event.target as Node)) {
setShowOptions(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const learningOptions = [
{ text: "To Learn", icon: <Bookmark size={18} /> },
{ text: "Learning", icon: <GraduationCap size={18} /> },
{ text: "Learned", icon: <Check size={18} /> }
]
const learningStatusColor = (option: string) => {
switch (option) {
case "To Learn":
return "text-white/70"
case "Learning":
return "text-[#D29752]"
case "Learned":
return "text-[#708F51]"
default:
return "text-white/70"
}
}
const selectedStatus = (option: string) => {
setSelectedOption(option)
setShowOptions(false)
}
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<ContentHeader>
<div className="flex w-full items-center justify-between">
<h1 className="text-2xl font-bold">{decodedTopic}</h1>
<div className="flex items-center space-x-4">
<div className="flex rounded-lg bg-neutral-800 bg-opacity-60">
<button
onClick={() => setActiveTab("Guide")}
className={`px-4 py-2 text-[16px] font-semibold transition-colors ${
activeTab === "Guide"
? "rounded-lg bg-neutral-800 shadow-inner shadow-neutral-700/70"
: "text-white/70"
}`}
>
Guide
</button>
<button
onClick={() => setActiveTab("All links")}
className={`px-4 py-2 text-[16px] font-semibold transition-colors ${
activeTab === "All links"
? "rounded-lg bg-neutral-800 shadow-inner shadow-neutral-700/70"
: "text-white/70"
}`}
>
All links
</button>
</div>
</div>
</div>
<div className="relative">
<Button
onClick={() => setShowOptions(!showOptions)}
className="w-[150px] whitespace-nowrap rounded-[7px] bg-neutral-800 px-4 py-2 text-[17px] font-semibold shadow-inner shadow-neutral-700/50 transition-colors hover:bg-neutral-700"
color={learningStatusColor(selectedOption || "")}
icon={selectedOption && learningOptions.find(opt => opt.text === selectedOption)?.icon}
>
{selectedOption || "Add to my profile"}
</Button>
{showOptions && (
<div
ref={learningStatusRef}
className="absolute left-1/2 mt-1 w-40 -translate-x-1/2 rounded-lg bg-neutral-800 shadow-lg"
>
{learningOptions.map(option => (
<Button
key={option.text}
onClick={() => selectedStatus(option.text)}
className="space-x-1 px-2 py-2 text-left text-[14px] font-semibold hover:bg-neutral-700"
color={learningStatusColor(option.text)}
icon={option.icon}
fullWidth
>
{option.text}
</Button>
))}
</div>
)}
</div>
</ContentHeader>
<div className="px-5 py-3">
<h2 className="mb-3 text-white/60">Intro</h2>
{links.map((link, index) => (
<LinkItem key={index} title={link.title} url={link.url} />
))}
</div>
<div className="px-5 py-3">
<h2 className="mb-3 text-opacity-60">Other</h2>
{links.map((link, index) => (
<LinkItem key={index} title={link.title} url={link.url} />
))}
</div>
<div className="flex-1 overflow-auto p-4"></div>
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { LinkManage } from "@/components/routes/link/form/manage"
import { useAtom } from "jotai"
import { linkEditIdAtom } from "@/store/link"
export function LinkWrapper() {
export function AuthHomeRoute() {
const [editId] = useAtom(linkEditIdAtom)
return (

View File

@@ -0,0 +1 @@
export * from "./manage"

View File

@@ -0,0 +1,253 @@
import * as React from "react"
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
import { PersonalLink, Topic } from "@/lib/schema"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { createLinkSchema, LinkFormValues } from "./schema"
import { cn, generateUniqueSlug } from "@/lib/utils"
import { Form } from "@/components/ui/form"
import { Button } from "@/components/ui/button"
import { UrlInput } from "./partial/url-input"
import { UrlBadge } from "./partial/url-badge"
import { TitleInput } from "./partial/title-input"
import { NotesSection } from "./partial/notes-section"
import { TopicSelector } from "./partial/topic-selector"
import { DescriptionInput } from "./partial/description-input"
import { LearningStateSelector } from "./partial/learning-state-selector"
import { atom, useAtom } from "jotai"
import { linkLearningStateSelectorAtom, linkTopicSelectorAtom } from "@/store/link"
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
onClose?: () => void
onSuccess?: () => void
onFail?: () => void
personalLink?: PersonalLink
exceptionsRefs?: React.RefObject<HTMLElement>[]
}
const defaultValues: Partial<LinkFormValues> = {
url: "",
icon: "",
title: "",
description: "",
completed: false,
notes: "",
learningState: "wantToLearn",
topic: null
}
export const LinkForm: React.FC<LinkFormProps> = ({
onSuccess,
onFail,
personalLink,
onClose,
exceptionsRefs = []
}) => {
const [selectedTopic, setSelectedTopic] = React.useState<Topic | null>(null)
const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
const formRef = React.useRef<HTMLFormElement>(null)
const [isFetching, setIsFetching] = React.useState(false)
const [urlFetched, setUrlFetched] = React.useState<string | null>(null)
const { me } = useAccount()
const selectedLink = useCoState(PersonalLink, personalLink?.id)
const form = useForm<LinkFormValues>({
resolver: zodResolver(createLinkSchema),
defaultValues,
mode: "all"
})
const allExceptionRefs = React.useMemo(
() => [...exceptionsRefs, ...globalExceptionRefs],
[exceptionsRefs, globalExceptionRefs]
)
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const isClickInsideForm = formRef.current && formRef.current.contains(event.target as Node)
const isClickInsideExceptions = allExceptionRefs.some((ref, index) => {
const isInside = ref.current && ref.current.contains(event.target as Node)
return isInside
})
if (!isClickInsideForm && !istopicSelectorOpen && !islearningStateSelectorOpen && !isClickInsideExceptions) {
onClose?.()
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose])
React.useEffect(() => {
if (selectedLink) {
setUrlFetched(selectedLink.url)
form.reset({
url: selectedLink.url,
icon: selectedLink.icon,
title: selectedLink.title,
description: selectedLink.description,
completed: selectedLink.completed,
notes: selectedLink.notes,
learningState: selectedLink.learningState
})
}
}, [selectedLink, form])
const fetchMetadata = async (url: string) => {
setIsFetching(true)
try {
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-cache" })
const data = await res.json()
setUrlFetched(data.url)
form.setValue("url", data.url, {
shouldValidate: true
})
form.setValue("icon", data.icon ?? "", {
shouldValidate: true
})
form.setValue("title", data.title, {
shouldValidate: true
})
if (!form.getValues("description"))
form.setValue("description", data.description, {
shouldValidate: true
})
form.setFocus("title")
console.log(form.formState.isValid, "form state after....")
} catch (err) {
console.error("Failed to fetch metadata", err)
} finally {
setIsFetching(false)
}
}
const onSubmit = (values: LinkFormValues) => {
if (isFetching) return
try {
const personalLinks = me.root?.personalLinks?.toJSON() || []
const slug = generateUniqueSlug(personalLinks, values.title)
if (selectedLink) {
selectedLink.applyDiff({ ...values, slug, topic: selectedTopic })
} else {
const newPersonalLink = PersonalLink.create(
{
...values,
slug,
topic: selectedTopic,
sequence: me.root?.personalLinks?.length || 1,
createdAt: new Date(),
updatedAt: new Date()
},
{ owner: me._owner }
)
me.root?.personalLinks?.push(newPersonalLink)
}
form.reset(defaultValues)
onSuccess?.()
} catch (error) {
onFail?.()
console.error("Failed to create/update link", error)
toast.error(personalLink ? "Failed to update link" : "Failed to create link")
}
}
const handleCancel = () => {
form.reset(defaultValues)
onClose?.()
}
const handleResetUrl = () => {
setUrlFetched(null)
form.setFocus("url")
form.reset({ url: "", title: "", icon: "", description: "" })
}
const canSubmit = form.formState.isValid && !form.formState.isSubmitting
return (
<div className="p-3 transition-all">
<div className={cn("bg-muted/30 relative rounded-md border", isFetching && "opacity-50")}>
<Form {...form}>
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1">
{isFetching && <div className="absolute inset-0 z-10 bg-transparent" aria-hidden="true" />}
<div className="flex flex-col gap-1.5 p-3">
<div className="flex flex-row items-start justify-between">
<UrlInput urlFetched={urlFetched} fetchMetadata={fetchMetadata} isFetchingUrlMetadata={isFetching} />
{urlFetched && <TitleInput urlFetched={urlFetched} />}
<div className="flex flex-row items-center gap-2">
<LearningStateSelector />
<TopicSelector onSelect={topic => setSelectedTopic(topic)} />
</div>
</div>
<DescriptionInput />
<UrlBadge urlFetched={urlFetched} handleResetUrl={handleResetUrl} />
</div>
<div
className="flex flex-row items-center justify-between gap-2 rounded-b-md border-t px-3 py-2"
onClick={e => {
if (!(e.target as HTMLElement).closest("button")) {
const notesInput = e.currentTarget.querySelector("input")
if (notesInput) {
notesInput.focus()
}
}
}}
>
<NotesSection />
{isFetching ? (
<div className="flex w-auto items-center justify-end gap-x-2">
<span className="text-muted-foreground flex items-center text-sm">
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
<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>
Fetching metadata...
</span>
</div>
) : (
<div className="flex w-auto items-center justify-end gap-x-2">
<Button size="sm" type="button" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" type="submit" disabled={!canSubmit}>
Save
</Button>
</div>
)}
</div>
</form>
</Form>
</div>
</div>
)
}
LinkForm.displayName = "LinkForm"

View File

@@ -1,446 +1,100 @@
"use client"
import React, { useState, useEffect, useRef } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useDebounce } from "react-use"
import { toast } from "sonner"
import Image from "next/image"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Form, FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { BoxIcon, PlusIcon, Trash2Icon, PieChartIcon, Bookmark, GraduationCap, Check } from "lucide-react"
import { cn, ensureUrlProtocol, generateUniqueSlug, isUrl as LibIsUrl } from "@/lib/utils"
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
import { LinkMetadata, PersonalLink } from "@/lib/schema/personal-link"
import { createLinkSchema } from "./schema"
import { TopicSelector } from "./partial/topic-section"
import { useAtom } from "jotai"
import { linkEditIdAtom, linkShowCreateAtom } from "@/store/link"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useAtom } from "jotai"
import React, { useEffect, useRef, useState } from "react"
import { useKey } from "react-use"
export type LinkFormValues = z.infer<typeof createLinkSchema>
const DEFAULT_FORM_VALUES: Partial<LinkFormValues> = {
title: "",
description: "",
topic: "",
isLink: false,
meta: null
}
import { globalLinkFormExceptionRefsAtom, LinkForm } from "./link-form"
import { LaIcon } from "@/components/custom/la-icon"
import LinkOptions from "@/components/LinkOptions"
// import { FloatingButton } from "./partial/floating-button"
const LinkManage: React.FC = () => {
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
const [, setEditId] = useAtom(linkEditIdAtom)
const formRef = useRef<HTMLFormElement>(null)
const [editId, setEditId] = useAtom(linkEditIdAtom)
const [, setGlobalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
const [showOptions, setShowOptions] = useState(false)
const optionsRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const toggleForm = (event: React.MouseEvent) => {
event.stopPropagation()
if (showCreate) return
setShowCreate(prev => !prev)
}
useEffect(() => {
const clickOptionsButton = (e: React.MouseEvent) => {
e.preventDefault()
setShowOptions(prev => !prev)
}
const handleFormClose = () => {
setShowCreate(false)
}
const handleFormFail = () => {}
// wipes the data from the form when the form is closed
React.useEffect(() => {
if (!showCreate) {
formRef.current?.reset()
setEditId(null)
}
}, [showCreate, setEditId])
useKey("Escape", handleFormClose)
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (
formRef.current &&
!formRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setShowCreate(false)
const handleClickOutside = (event: MouseEvent) => {
if (optionsRef.current && !optionsRef.current.contains(event.target as Node)) {
setShowOptions(false)
}
}
if (showCreate) {
document.addEventListener("mousedown", handleOutsideClick)
if (showOptions) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => {
document.removeEventListener("mousedown", handleOutsideClick)
document.removeEventListener("mousedown", handleClickOutside)
}
}, [showCreate, setShowCreate])
}, [showOptions])
useKey("Escape", () => {
setShowCreate(false)
})
/*
* This code means that when link form is opened, these refs will be added as an exception to the click outside handler
*/
React.useEffect(() => {
setGlobalExceptionRefs([optionsRef, buttonRef])
}, [setGlobalExceptionRefs])
return (
<>
{showCreate && (
<div className="z-50">
<LinkForm ref={formRef} onSuccess={() => setShowCreate(false)} onCancel={() => setShowCreate(false)} />
{showCreate && <LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />}
<div className="absolute bottom-0 m-0 flex w-full list-none bg-inherit p-2.5 text-center align-middle font-semibold leading-[13px] no-underline">
<div className="mx-auto flex flex-row items-center justify-center gap-2">
<Button
variant="ghost"
onClick={toggleForm}
className={editId || showCreate ? "text-red-500 hover:bg-red-500/50 hover:text-white" : ""}
>
<LaIcon name={showCreate ? "X" : editId ? "Trash" : "Plus"} />
</Button>
<div className="relative" ref={optionsRef}>
{showOptions && <LinkOptions />}
<Button ref={buttonRef} variant="ghost" onClick={clickOptionsButton}>
<LaIcon name="Ellipsis" />
</Button>
</div>
</div>
)}
<CreateButton ref={buttonRef} onClick={toggleForm} isOpen={showCreate} />
</div>
</>
)
}
const CreateButton = React.forwardRef<
HTMLButtonElement,
{
onClick: (event: React.MouseEvent) => void
isOpen: boolean
}
>(({ onClick, isOpen }, ref) => (
<Button
ref={ref}
className={cn(
"absolute bottom-4 right-4 size-12 rounded-full bg-[#274079] p-0 text-white transition-transform hover:bg-[#274079]/90",
{ "rotate-45 transform": isOpen }
)}
onClick={onClick}
>
<PlusIcon className="size-6" />
</Button>
))
CreateButton.displayName = "CreateButton"
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
onSuccess?: () => void
onCancel?: () => void
personalLink?: PersonalLink
}
const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess, onCancel, personalLink }, ref) => {
const [isFetching, setIsFetching] = useState(false)
const { me } = useAccount()
const form = useForm<LinkFormValues>({
resolver: zodResolver(createLinkSchema),
defaultValues: {
...DEFAULT_FORM_VALUES,
isLink: true
}
})
const selectedLink = useCoState(PersonalLink, personalLink?.id)
const title = form.watch("title")
const [inputValue, setInputValue] = useState("")
const [originalLink, setOriginalLink] = useState<string>("")
const [linkValidation, setLinkValidation] = useState<string | null>(null)
const [invalidLink, setInvalidLink] = useState(false)
const [showLink, setShowLink] = useState(false)
const [debouncedText, setDebouncedText] = useState<string>("")
useDebounce(() => setDebouncedText(title), 300, [title])
const [showStatusOptions, setShowStatusOptions] = useState(false)
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
const statusOptions = [
{
text: "To Learn",
icon: <Bookmark size={16} />,
color: "text-white/70"
},
{
text: "Learning",
icon: <GraduationCap size={16} />,
color: "text-[#D29752]"
},
{ text: "Learned", icon: <Check size={16} />, color: "text-[#708F51]" }
]
const statusSelect = (status: string) => {
setSelectedStatus(status === selectedStatus ? null : status)
setShowStatusOptions(false)
}
useEffect(() => {
if (selectedLink) {
form.setValue("title", selectedLink.title)
form.setValue("description", selectedLink.description ?? "")
form.setValue("isLink", selectedLink.isLink)
form.setValue("meta", selectedLink.meta)
}
}, [selectedLink, form])
const changeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
form.setValue("title", value)
}
const pressEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !showLink) {
e.preventDefault()
const trimmedValue = inputValue.trim().toLowerCase()
if (LibIsUrl(trimmedValue)) {
setShowLink(true)
setInvalidLink(false)
setLinkValidation(trimmedValue)
setInputValue(trimmedValue)
form.setValue("title", trimmedValue)
} else {
setInvalidLink(true)
setShowLink(true)
setLinkValidation(null)
}
}
}
useEffect(() => {
const fetchMetadata = async (url: string) => {
setIsFetching(true)
try {
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "force-cache" })
if (!res.ok) throw new Error("Failed to fetch metadata")
const data = await res.json()
form.setValue("isLink", true)
form.setValue("meta", data)
form.setValue("title", data.title)
form.setValue("description", data.description)
setOriginalLink(url)
} catch (err) {
form.setValue("isLink", false)
form.setValue("meta", null)
form.setValue("title", debouncedText)
form.setValue("description", "")
setOriginalLink("")
} finally {
setIsFetching(false)
}
}
if (showLink && !invalidLink && LibIsUrl(form.getValues("title").toLowerCase())) {
fetchMetadata(ensureUrlProtocol(form.getValues("title").toLowerCase()))
}
}, [showLink, invalidLink, form])
const onSubmit = (values: LinkFormValues) => {
if (isFetching) return
try {
let linkMetadata: LinkMetadata | undefined
const personalLinks = me.root?.personalLinks?.toJSON() || []
const slug = generateUniqueSlug(personalLinks, values.title)
if (values.isLink && values.meta) {
linkMetadata = LinkMetadata.create(values.meta, { owner: me._owner })
}
if (selectedLink) {
selectedLink.title = values.title
selectedLink.slug = slug
selectedLink.description = values.description ?? ""
selectedLink.isLink = values.isLink
if (values.isLink && values.meta) {
linkMetadata = LinkMetadata.create(values.meta, { owner: me._owner })
}
} else {
const newPersonalLink = PersonalLink.create(
{
title: values.title,
slug,
description: values.description,
sequence: me.root?.personalLinks?.length || 1,
completed: false,
isLink: values.isLink,
meta: linkMetadata
// topic: values.topic
},
{ owner: me._owner }
)
me.root?.personalLinks?.push(newPersonalLink)
}
form.reset(DEFAULT_FORM_VALUES)
onSuccess?.()
} catch (error) {
console.error("Failed to create/update link", error)
toast.error(personalLink ? "Failed to update link" : "Failed to create link")
}
}
const undoEditing: () => void = () => {
form.reset(DEFAULT_FORM_VALUES)
onCancel?.()
}
return (
<div className="p-3 transition-all">
<div className="rounded-md border bg-muted/50">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1" ref={ref}>
<div className="flex flex-row p-3">
<div className="flex flex-auto flex-col gap-1.5">
<div className="flex flex-row items-start justify-between">
<div className="flex grow flex-row items-center gap-1.5">
{/* <Button
type="button"
variant="secondary"
size="icon"
aria-label="Choose icon"
className="size-7 text-primary/60"
>
{form.watch("isLink") ? (
<Image
src={form.watch("meta")?.favicon || ""}
alt={form.watch("meta")?.title || ""}
className="size-5 rounded-md"
width={16}
height={16}
/>
) : (
<BoxIcon size={16} />
)}
</Button> */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Text</FormLabel>
<FormControl>
<Input
{...field}
value={inputValue}
autoComplete="off"
maxLength={100}
autoFocus
placeholder="Paste a link or write a link"
className={cn(
"h-6 border-none p-1.5 font-medium placeholder:text-primary/40 focus-visible:outline-none focus-visible:ring-0",
invalidLink ? "text-red-500" : ""
)}
onKeyDown={pressEnter}
onChange={changeInput}
/>
</FormControl>
</FormItem>
)}
/>
{showLink && (
<span className={cn("mr-5 max-w-[200px] truncate text-xs", invalidLink ? "text-red-500" : "")}>
{invalidLink ? "Only links are allowed" : linkValidation || originalLink || ""}
</span>
)}
</div>
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
<DropdownMenu>
<DropdownMenuTrigger asChild></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem className="group">
<Trash2Icon size={16} className="mr-2 text-destructive group-hover:text-red-500" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="relative">
<Button
size="icon"
type="button"
variant="ghost"
className="size-7 gap-x-2 text-sm"
onClick={() => setShowStatusOptions(!showStatusOptions)}
>
{selectedStatus ? (
(() => {
const option = statusOptions.find(opt => opt.text === selectedStatus)
return option
? React.cloneElement(option.icon, {
size: 16,
className: option.color
})
: null
})()
) : (
<PieChartIcon size={16} className="text-primary/60" />
)}
</Button>
{showStatusOptions && (
<div className="absolute right-0 mt-1 w-40 rounded-md bg-neutral-800 shadow-lg">
{statusOptions.map(option => (
<Button
key={option.text}
onClick={() => statusSelect(option.text)}
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
>
{React.cloneElement(option.icon, {
size: 16,
className: option.color
})}
<span>{option.text}</span>
</Button>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-row items-center gap-1.5 pl-8">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Description</FormLabel>
<FormControl>
<Textarea
{...field}
autoComplete="off"
placeholder="Description (optional)"
className="min-h-[24px] resize-none overflow-y-auto border-none p-1.5 text-xs font-medium shadow-none placeholder:text-primary/40 focus-visible:outline-none focus-visible:ring-0"
onInput={e => {
const target = e.target as HTMLTextAreaElement
target.style.height = "auto"
target.style.height = `${target.scrollHeight}px`
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</div>
<div className="flex flex-auto flex-row items-center justify-between gap-2 rounded-b-md border border-t px-3 py-2">
<div className="flex flex-row items-center gap-0.5">
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
<TopicSelector />
</div>
</div>
<div className="flex w-auto items-center justify-end">
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row gap-x-2">
<Button size="sm" type="button" variant="ghost" onClick={undoEditing}>
Cancel
</Button>
<Button size="sm" type="submit" disabled={isFetching}>
Save
</Button>
</div>
</div>
</div>
</form>
</Form>
</div>
</div>
)
})
LinkManage.displayName = "LinkManage"
LinkForm.displayName = "LinkForm"
export { LinkManage, LinkForm }
export { LinkManage }
/* <FloatingButton ref={buttonRef} onClick={toggleForm} isOpen={showCreate} /> */

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import { useFormContext } from "react-hook-form"
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
import { TextareaAutosize } from "@/components/custom/textarea-autosize"
import { LinkFormValues } from "../schema"
interface DescriptionInputProps {}
export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
const form = useFormContext<LinkFormValues>()
return (
<div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Description</FormLabel>
<FormControl>
<TextareaAutosize
{...field}
autoComplete="off"
placeholder="Description (optional)"
className="placeholder:text-muted-foreground/70 resize-none overflow-y-auto border-none p-1.5 text-[13px] font-medium shadow-none focus-visible:ring-0"
/>
</FormControl>
</FormItem>
)}
/>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import React, { forwardRef } from "react"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { Button, ButtonProps } from "@/components/ui/button"
interface FloatingButtonProps extends ButtonProps {
isOpen: boolean
}
export const FloatingButton = forwardRef<HTMLButtonElement, FloatingButtonProps>(
({ isOpen, className, ...props }, ref) => (
<Button
ref={ref}
className={cn(
"absolute bottom-4 right-4 h-12 w-12 rounded-full bg-[#274079] p-0 text-white transition-transform hover:bg-[#274079]/90",
{ "rotate-45 transform": isOpen },
className
)}
{...props}
>
<LaIcon name="Plus" className="h-6 w-6" />
</Button>
)
)
FloatingButton.displayName = "FloatingButton"
export default FloatingButton

View File

@@ -0,0 +1,90 @@
import { Button } from "@/components/ui/button"
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useFormContext } from "react-hook-form"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { useAtom } from "jotai"
import { linkLearningStateSelectorAtom } from "@/store/link"
import { useMemo } from "react"
import { LinkFormValues } from "../schema"
import { LEARNING_STATES } from "@/lib/constants"
export const LearningStateSelector: React.FC = () => {
const [islearningStateSelectorOpen, setIslearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
const form = useFormContext<LinkFormValues>()
const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === form.getValues("learningState")),
[form]
)
return (
<FormField
control={form.control}
name="learningState"
render={({ field }) => (
<FormItem className="space-y-0">
<FormLabel className="sr-only">Topic</FormLabel>
<Popover open={islearningStateSelectorOpen} onOpenChange={setIslearningStateSelectorOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button size="sm" type="button" role="combobox" variant="secondary" className="gap-x-2 text-sm">
{selectedLearningState?.icon && (
<LaIcon
name={selectedLearningState.icon}
className={cn("h-4 w-4", selectedLearningState.className)}
/>
)}
<span className={cn("truncate", selectedLearningState?.className || "")}>
{selectedLearningState?.label || "Select state"}
</span>
<LaIcon name="ChevronDown" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="end"
onCloseAutoFocus={e => e.preventDefault()}
>
<Command>
<CommandInput placeholder="Search state..." className="h-9" />
<CommandList>
<ScrollArea>
<CommandGroup>
{LEARNING_STATES.map(ls => (
<CommandItem
key={ls.value}
value={ls.value}
onSelect={value => {
field.onChange(value)
setIslearningStateSelectorOpen(false)
}}
>
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
<span className={ls.className}>{ls.label}</span>
<LaIcon
name="Check"
size={16}
className={cn(
"absolute right-3",
ls.value === field.value ? "text-primary" : "text-transparent"
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,36 @@
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { useFormContext } from "react-hook-form"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { LinkFormValues } from "../schema"
export const NotesSection: React.FC = () => {
const form = useFormContext<LinkFormValues>()
return (
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="relative space-y-0">
<FormLabel className="sr-only">Note</FormLabel>
<FormControl>
<>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<LaIcon name="Pencil" aria-hidden="true" className="text-muted-foreground/70 size-3" />
</div>
<Input
{...field}
autoComplete="off"
placeholder="Take a notes..."
className={cn("placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0")}
/>
</>
</FormControl>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { useFormContext } from "react-hook-form"
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LinkFormValues } from "../schema"
interface TitleInputProps {
urlFetched: string | null
}
export const TitleInput: React.FC<TitleInputProps> = ({ urlFetched }) => {
const form = useFormContext<LinkFormValues>()
return (
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Title</FormLabel>
<FormControl>
<Input
{...field}
type={urlFetched ? "text" : "hidden"}
autoComplete="off"
maxLength={100}
autoFocus
placeholder="Title"
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
/>
</FormControl>
</FormItem>
)}
/>
)
}

View File

@@ -1,74 +0,0 @@
import { Button } from "@/components/ui/button"
import { Command, CommandInput, CommandList, CommandItem } from "@/components/ui/command"
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { useState } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
import { useFormContext } from "react-hook-form"
import { LinkFormValues } from "../manage"
import { cn } from "@/lib/utils"
const TOPICS = [
{ id: "1", name: "Work" },
{ id: "2", name: "Personal" }
]
export const TopicSelector: React.FC = () => {
const form = useFormContext<LinkFormValues>()
const [open, setOpen] = useState(false)
const { setValue } = useFormContext()
return (
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">Topic</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button size="sm" type="button" role="combobox" variant="secondary" className="!mt-0 gap-x-2 text-sm">
<span className="truncate">
{field.value ? TOPICS.find(topic => topic.name === field.value)?.name : "Select topic"}
</span>
<ChevronDownIcon size={16} />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-52 rounded-lg p-0" side="right" align="start">
<Command>
<CommandInput placeholder="Search topic..." className="h-9" />
<CommandList>
<ScrollArea>
{TOPICS.map(topic => (
<CommandItem
className="cursor-pointer"
key={topic.id}
value={topic.name}
onSelect={value => {
setValue("topic", value)
setOpen(false)
}}
>
{topic.name}
<CheckIcon
size={16}
className={cn(
"absolute right-3",
topic.name === field.value ? "text-primary" : "text-transparent"
)}
/>
</CommandItem>
))}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,90 @@
import { Button } from "@/components/ui/button"
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { CheckIcon } from "lucide-react"
import { useFormContext } from "react-hook-form"
import { cn } from "@/lib/utils"
import { useAtom } from "jotai"
import { linkTopicSelectorAtom } from "@/store/link"
import { LinkFormValues } from "../schema"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
import { ID } from "jazz-tools"
import { LaIcon } from "@/components/custom/la-icon"
import { Topic } from "@/lib/schema"
interface TopicSelectorProps {
onSelect?: (value: Topic) => void
}
export const TopicSelector: React.FC<TopicSelectorProps> = ({ onSelect }) => {
const globalGroup = useCoState(
PublicGlobalGroup,
process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>,
{
root: {
topics: []
}
}
)
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
const form = useFormContext<LinkFormValues>()
const handleSelect = (value: string) => {
const topic = globalGroup?.root.topics.find(topic => topic?.name === value)
if (topic) {
onSelect?.(topic)
form?.setValue("topic", value)
}
setIsTopicSelectorOpen(false)
}
const selectedValue = form ? form.watch("topic") : null
return (
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
<PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="gap-x-2 text-sm">
<span className="truncate">
{selectedValue
? globalGroup?.root.topics.find(topic => topic?.id && topic.name === selectedValue)?.prettyName
: "Topic"}
</span>
<LaIcon name="ChevronDown" />
</Button>
</PopoverTrigger>
<PopoverContent
className="z-50 w-52 rounded-lg p-0"
side="bottom"
align="end"
onCloseAutoFocus={e => e.preventDefault()}
>
<Command>
<CommandInput placeholder="Search topic..." className="h-9" />
<CommandList>
<ScrollArea>
<CommandGroup>
{globalGroup?.root.topics.map(
topic =>
topic?.id && (
<CommandItem key={topic.id} value={topic.name} onSelect={handleSelect}>
{topic.prettyName}
<CheckIcon
size={16}
className={cn(
"absolute right-3",
topic.name === selectedValue ? "text-primary" : "text-transparent"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,35 @@
import * as React from "react"
import { useFormContext } from "react-hook-form"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"
import { LinkFormValues } from "../schema"
interface UrlBadgeProps {
urlFetched: string | null
handleResetUrl: () => void
}
export const UrlBadge: React.FC<UrlBadgeProps> = ({ urlFetched, handleResetUrl }) => {
const form = useFormContext<LinkFormValues>()
if (!urlFetched) return null
return (
<div className="flex items-center gap-1.5 py-1.5">
<div className="flex min-w-0 flex-row items-center gap-1.5">
<Badge variant="secondary" className="relative truncate py-1 text-xs">
{form.getValues("url")}
<Button
size="icon"
type="button"
onClick={handleResetUrl}
className="text-muted-foreground hover:text-foreground ml-2 size-4 rounded-full bg-transparent hover:bg-transparent"
>
<LaIcon name="X" className="size-3.5" />
</Button>
</Badge>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import * as React from "react"
import { useFormContext } from "react-hook-form"
import { FormField, FormItem, FormControl, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LinkFormValues } from "../schema"
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { TooltipArrow } from "@radix-ui/react-tooltip"
interface UrlInputProps {
urlFetched: string | null
fetchMetadata: (url: string) => Promise<void>
isFetchingUrlMetadata: boolean
}
export const UrlInput: React.FC<UrlInputProps> = ({ urlFetched, fetchMetadata, isFetchingUrlMetadata }) => {
const [isFocused, setIsFocused] = React.useState(false)
const form = useFormContext<LinkFormValues>()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && form.getValues("url")) {
e.preventDefault()
fetchMetadata(form.getValues("url"))
}
}
const shouldShowTooltip = isFocused && !form.formState.errors.url && !!form.getValues("url") && !urlFetched
return (
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem
className={cn("grow space-y-0", {
"hidden select-none": urlFetched
})}
>
<FormLabel className="sr-only">Url</FormLabel>
<FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip open={shouldShowTooltip && !isFetchingUrlMetadata}>
<TooltipTrigger asChild>
<Input
{...field}
type={urlFetched ? "hidden" : "text"}
autoComplete="off"
maxLength={100}
autoFocus
placeholder="Paste a link or write a link"
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
</TooltipTrigger>
<TooltipContent align="center" side="top">
<TooltipArrow className="text-primary fill-current" />
<span>
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormControl>
<FormMessage className="px-1.5" />
</FormItem>
)}
/>
)
}

View File

@@ -1,20 +1,15 @@
import { urlSchema } from "@/lib/utils/schema"
import { z } from "zod"
import { isUrl } from "@/lib/utils"
export const createLinkSchema = z.object({
url: urlSchema,
icon: z.string().optional(),
title: z.string().min(1, { message: "Title can't be empty" }),
originalUrl: z.string().refine(isUrl, { message: "Only links are allowed" }),
description: z.string().optional(),
topic: z.string().optional(),
isLink: z.boolean().default(true),
meta: z
.object({
url: z.string(),
title: z.string(),
favicon: z.string(),
description: z.string().optional().nullable()
})
.optional()
.nullable(),
completed: z.boolean().default(false)
completed: z.boolean().default(false),
notes: z.string().optional(),
learningState: z.enum(["wantToLearn", "learning", "learned"]),
topic: z.string().nullable().optional()
})
export type LinkFormValues = z.infer<typeof createLinkSchema>

View File

@@ -3,7 +3,6 @@
import * as React from "react"
import { ListFilterIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { useMedia } from "react-use"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
@@ -11,20 +10,23 @@ import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useAtom } from "jotai"
import { linkSortAtom } from "@/store/link"
import { atom } from "jotai"
import { LEARNING_STATES } from "@/lib/constants"
import { useQueryState, parseAsStringLiteral } from "nuqs"
import { FancySwitch } from "@omit/react-fancy-switch"
import { cn } from "@/lib/utils"
interface TabItemProps {
url: string
label: string
}
const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
const TABS = ["All", "Learning", "To Learn", "Learned"]
export const learningStateAtom = atom<string>("all")
export const LinkHeader = () => {
export const LinkHeader = React.memo(() => {
const isTablet = useMedia("(max-width: 1024px)")
return (
<>
<ContentHeader className="p-4">
<ContentHeader className="px-6 py-5 max-lg:px-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
@@ -32,7 +34,7 @@ export const LinkHeader = () => {
</div>
</div>
{!isTablet && <Tabs />}
{!isTablet && <LearningTab />}
<div className="flex flex-auto"></div>
@@ -41,66 +43,66 @@ export const LinkHeader = () => {
{isTablet && (
<div className="border-b-primary/5 flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
<Tabs />
<LearningTab />
</div>
)}
</>
)
}
})
const Tabs = () => {
const [activeTab, setActiveTab] = React.useState(TABS[0])
LinkHeader.displayName = "LinkHeader"
const LearningTab = React.memo(() => {
const [activeTab, setActiveTab] = useAtom(learningStateAtom)
const [activeState, setActiveState] = useQueryState(
"state",
parseAsStringLiteral(Object.values(ALL_STATES_STRING)).withDefault(ALL_STATES_STRING[0])
)
const handleTabChange = React.useCallback(
(value: string) => {
setActiveTab(value)
setActiveState(value)
},
[setActiveTab, setActiveState]
)
React.useEffect(() => {
setActiveTab(activeState)
}, [activeState, setActiveTab])
return (
<div className="bg-secondary/50 flex items-baseline overflow-x-hidden rounded-md">
{TABS.map(tab => (
<TabItem key={tab} url="#" label={tab} isActive={activeTab === tab} onClick={() => setActiveTab(tab)} />
))}
</div>
<FancySwitch
value={activeTab}
onChange={value => {
handleTabChange(value as string)
}}
options={ALL_STATES}
className="bg-secondary flex rounded-lg"
highlighterClassName="bg-secondary-foreground/10 rounded-lg"
radioClassName={cn(
"relative mx-2 flex h-8 cursor-pointer items-center justify-center rounded-full px-1 text-sm text-secondary-foreground/60 data-[checked]:text-secondary-foreground font-medium transition-colors focus:outline-none"
)}
highlighterIncludeMargin={true}
/>
)
}
})
interface TabItemProps {
url: string
label: string
isActive: boolean
onClick: () => void
}
const TabItem = ({ url, label, isActive, onClick }: TabItemProps) => {
return (
<div tabIndex={-1} className="rounded-md">
<div className="flex flex-row">
<div aria-label={label}>
<Link href={url}>
<Button
size="sm"
type="button"
variant="ghost"
className={`gap-x-2 truncate text-sm ${isActive ? "bg-accent text-accent-foreground" : ""}`}
onClick={onClick}
>
{label}
</Button>
</Link>
</div>
</div>
</div>
)
}
const FilterAndSort = () => {
const FilterAndSort = React.memo(() => {
const [sort, setSort] = useAtom(linkSortAtom)
const [sortOpen, setSortOpen] = React.useState(false)
const getFilterText = () => {
const getFilterText = React.useCallback(() => {
return sort.charAt(0).toUpperCase() + sort.slice(1)
}
}, [sort])
const handleSortChange = (value: string) => {
setSort(value)
setSortOpen(false)
}
const handleSortChange = React.useCallback(
(value: string) => {
setSort(value)
setSortOpen(false)
},
[setSort]
)
return (
<div className="flex w-auto items-center justify-end">
@@ -134,4 +136,6 @@ const FilterAndSort = () => {
</div>
</div>
)
}
})
FilterAndSort.displayName = "FilterAndSort"

View File

@@ -1,17 +1,22 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { LinkIcon, Trash2Icon } from "lucide-react"
import Link from "next/link"
import Image from "next/image"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { PersonalLink } from "@/lib/schema/personal-link"
import { cn } from "@/lib/utils"
import { LinkForm } from "./form/manage"
import { Button } from "@/components/ui/button"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { ConfirmOptions } from "@omit/react-confirm-dialog"
import { LinkIcon, Trash2Icon } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import * as React from "react"
import { LinkForm } from "./form/link-form"
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { LaIcon } from "@/components/custom/la-icon"
import { LEARNING_STATES } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
interface ListItemProps {
@@ -25,6 +30,8 @@ interface ListItemProps {
setFocusedId: (id: string | null) => void
registerRef: (id: string, ref: HTMLLIElement | null) => void
onDelete?: (personalLink: PersonalLink) => void
showDeleteIconForLinkId: string | null
setShowDeleteIconForLinkId: (id: string | null) => void
}
export const ListItem: React.FC<ListItemProps> = ({
@@ -37,11 +44,11 @@ export const ListItem: React.FC<ListItemProps> = ({
isFocused,
setFocusedId,
registerRef,
onDelete
onDelete,
showDeleteIconForLinkId,
setShowDeleteIconForLinkId
}) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const formRef = React.useRef<HTMLFormElement>(null)
const [showDeleteIcon, setShowDeleteIcon] = React.useState(false)
const style = {
transform: CSS.Transform.toString(transform),
@@ -49,12 +56,6 @@ export const ListItem: React.FC<ListItemProps> = ({
pointerEvents: isDragging ? "none" : "auto"
}
React.useEffect(() => {
if (isEditing) {
formRef.current?.focus()
}
}, [isEditing])
const refCallback = React.useCallback(
(node: HTMLLIElement | null) => {
setNodeRef(node)
@@ -74,19 +75,17 @@ export const ListItem: React.FC<ListItemProps> = ({
setEditId(null)
}
const handleCancel = () => {
const handleOnClose = () => {
setEditId(null)
}
// const handleRowClick = () => {
// console.log("Row clicked", personalLink.id)
// setEditId(personalLink.id)
// }
const handleRowClick = () => {
setShowDeleteIcon(!showDeleteIcon)
}
const handleOnFail = () => {}
const handleDoubleClick = () => {
// const handleRowClick = () => {
// setShowDeleteIconForLinkId(personalLink.id)
// }
const handleRowDoubleClick = () => {
setEditId(personalLink.id)
}
@@ -116,8 +115,12 @@ export const ListItem: React.FC<ListItemProps> = ({
}
}
const selectedLearningState = LEARNING_STATES.find(ls => ls.value === personalLink.learningState)
if (isEditing) {
return <LinkForm ref={formRef} personalLink={personalLink} onSuccess={handleSuccess} onCancel={handleCancel} />
return (
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={handleOnFail} />
)
}
return (
@@ -128,27 +131,74 @@ export const ListItem: React.FC<ListItemProps> = ({
{...listeners}
tabIndex={0}
onFocus={() => setFocusedId(personalLink.id)}
onBlur={() => setFocusedId(null)}
onBlur={() => {
setFocusedId(null)
}}
onKeyDown={handleKeyDown}
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11", {
"bg-muted/50": isFocused
})}
onClick={handleRowClick}
onDoubleClick={handleDoubleClick}
// onClick={handleRowClick}
onDoubleClick={handleRowDoubleClick}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Checkbox
{/* <Checkbox
checked={personalLink.completed}
onClick={e => e.stopPropagation()}
onCheckedChange={() => {
personalLink.completed = !personalLink.completed
}}
className="border-muted-foreground border"
/>
{personalLink.isLink && personalLink.meta && (
/> */}
<Popover>
<PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
{selectedLearningState?.icon && (
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<Command>
<CommandInput placeholder="Search state..." className="h-9" />
<CommandList>
<ScrollArea>
<CommandGroup>
{LEARNING_STATES.map(ls => (
<CommandItem
key={ls.value}
value={ls.value}
onSelect={value => {
personalLink.learningState = value as "wantToLearn" | "learning" | "learned" | undefined
}}
>
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
<span className={ls.className}>{ls.label}</span>
<LaIcon
name="Check"
size={16}
className={cn(
"absolute right-3",
ls.value === personalLink.learningState ? "text-primary" : "text-transparent"
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{personalLink.icon && (
<Image
src={personalLink.meta.favicon}
src={personalLink.icon}
alt={personalLink.title}
className="size-5 rounded-full"
width={16}
@@ -160,14 +210,14 @@ export const ListItem: React.FC<ListItemProps> = ({
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
{personalLink.title}
</p>
{personalLink.isLink && personalLink.meta && (
{personalLink.url && (
<div className="group flex items-center gap-x-1">
<LinkIcon
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
/>
<Link
href={personalLink.meta.url}
href={personalLink.url}
passHref
prefetch={false}
target="_blank"
@@ -176,7 +226,7 @@ export const ListItem: React.FC<ListItemProps> = ({
}}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="xl:truncate">{personalLink.meta.url}</span>
<span className="xl:truncate">{personalLink.url}</span>
</Link>
</div>
)}
@@ -185,12 +235,15 @@ export const ListItem: React.FC<ListItemProps> = ({
</div>
<div className="flex shrink-0 items-center gap-x-4">
<Badge variant="secondary">Topic Name</Badge>
{showDeleteIcon && (
{personalLink.topic && <Badge variant="secondary">{personalLink.topic.prettyName}</Badge>}
{showDeleteIconForLinkId === personalLink.id && (
<Button
size="icon"
className="text-destructive h-auto w-auto bg-transparent hover:bg-transparent hover:text-red-500"
onClick={e => handleDelete(e, personalLink)}
onClick={e => {
e.stopPropagation()
handleDelete(e, personalLink)
}}
>
<Trash2Icon size={16} />
</Button>

View File

@@ -19,8 +19,10 @@ import { useKey } from "react-use"
import { useConfirm } from "@omit/react-confirm-dialog"
import { ListItem } from "./list-item"
import { useRef, useState, useCallback, useEffect } from "react"
import { learningStateAtom } from "./header"
const LinkList = () => {
const [activeLearningState] = useAtom(learningStateAtom)
const confirm = useConfirm()
const { me } = useAccount({
root: { personalLinks: [] }
@@ -32,11 +34,17 @@ const LinkList = () => {
const [focusedId, setFocusedId] = useState<string | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({})
const [showDeleteIconForLinkId, setShowDeleteIconForLinkId] = useState<string | null>(null)
let filteredLinks = personalLinks.filter(link => {
if (activeLearningState === "all") return true
if (!link?.learningState) return false
return link.learningState === activeLearningState
})
let sortedLinks =
sort === "title" && personalLinks
? [...personalLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
: personalLinks
sort === "title" && filteredLinks
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
: filteredLinks
sortedLinks = sortedLinks || []
const sensors = useSensors(
@@ -50,10 +58,6 @@ const LinkList = () => {
})
)
const overlayClick = () => {
setEditId(null)
}
const registerRef = useCallback((id: string, ref: HTMLLIElement | null) => {
linkRefs.current[id] = ref
}, [])
@@ -190,40 +194,39 @@ const LinkList = () => {
}
return (
<>
{editId && <div className="fixed inset-0 z-10" onClick={overlayClick} />}
<div className="relative z-20">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
<ul role="list" className="divide-primary/5 divide-y">
{sortedLinks.map(
linkItem =>
linkItem && (
<ListItem
key={linkItem.id}
confirm={confirm}
isEditing={editId === linkItem.id}
setEditId={setEditId}
personalLink={linkItem}
disabled={sort !== "manual" || editId !== null}
registerRef={registerRef}
isDragging={draggingId === linkItem.id}
isFocused={focusedId === linkItem.id}
setFocusedId={setFocusedId}
onDelete={handleDelete}
/>
)
)}
</ul>
</SortableContext>
</DndContext>
</div>
</>
<div className="relative z-20">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
<ul role="list" className="divide-primary/5 divide-y">
{sortedLinks.map(
linkItem =>
linkItem && (
<ListItem
key={linkItem.id}
confirm={confirm}
isEditing={editId === linkItem.id}
setEditId={setEditId}
personalLink={linkItem}
disabled={sort !== "manual" || editId !== null}
registerRef={registerRef}
isDragging={draggingId === linkItem.id}
isFocused={focusedId === linkItem.id}
setFocusedId={setFocusedId}
onDelete={handleDelete}
showDeleteIconForLinkId={showDeleteIconForLinkId}
setShowDeleteIconForLinkId={setShowDeleteIconForLinkId}
/>
)
)}
</ul>
</SortableContext>
</DndContext>
</div>
)
}

View File

@@ -1,18 +1,26 @@
"use client"
import React, { useCallback, useRef, useEffect } from "react"
import { LAEditor, LAEditorRef } from "@/components/la-editor"
// import { DetailPageHeader } from "./header" //dont need. check figma
import * as React from "react"
import { useAtom } from "jotai"
import { ID } from "jazz-tools"
import { PersonalPage } from "@/lib/schema/personal-page"
import { PersonalPage, Topic } from "@/lib/schema"
import { useCallback, useRef, useEffect, useState } from "react"
import { LAEditor, LAEditorRef } from "@/components/la-editor"
import { Content, EditorContent, useEditor } from "@tiptap/react"
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
import { toast } from "sonner"
import { EditorView } from "prosemirror-view"
import { EditorView } from "@tiptap/pm/view"
import { Editor } from "@tiptap/core"
import { generateUniqueSlug } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"
import { pageTopicSelectorAtom } from "@/store/page"
import { TopicSelector } from "@/components/routes/link/form/partial/topic-selector"
import DeletePageModal from "@/components/custom/delete-modal"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
const TITLE_PLACEHOLDER = "Page title"
@@ -25,7 +33,6 @@ export function DetailPageWrapper({ pageId }: { pageId: string }) {
<div className="flex flex-row">
<div className="flex h-full w-full">
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
{/* <DetailPageHeader pageId={pageId as ID<PersonalPage>} /> */}
<DetailPageForm page={page} />
</div>
</div>
@@ -33,11 +40,13 @@ export function DetailPageWrapper({ pageId }: { pageId: string }) {
)
}
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const { me } = useAccount()
const titleEditorRef = useRef<Editor | null>(null)
const contentEditorRef = useRef<LAEditorRef>(null)
const [, setTopicSelectorOpen] = useAtom(pageTopicSelectorAtom)
const [selectedPageTopic, setSelectedPageTopic] = useState<Topic | null>(page.topic || null)
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const updatePageContent = (content: Content, model: PersonalPage) => {
model.content = content
@@ -59,11 +68,11 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const personalPages = me.root?.personalPages?.toJSON() || []
const slug = generateUniqueSlug(personalPages, page.slug)
const capitalizedTitle = newTitle.charAt(0).toUpperCase() + newTitle.slice(1)
page.title = capitalizedTitle
const trimmedTitle = editor.getText().trim()
page.title = trimmedTitle
page.slug = slug
editor.commands.setContent(capitalizedTitle)
editor.commands.setContent(trimmedTitle)
}
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
@@ -74,44 +83,29 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const { selection } = state
const { $anchor } = selection
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
if ($anchor.pos === state.doc.content.size - 1) {
event.preventDefault()
contentEditorRef.current?.editor?.commands.focus("start")
return true
}
break
case "Enter":
if (!event.shiftKey) {
event.preventDefault()
contentEditorRef.current?.editor?.commands.focus("start")
return true
}
break
}
return false
}, [])
const handleContentKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
const editor = contentEditorRef.current?.editor
if (!editor) return false
const { state } = editor
const { selection } = state
const { $anchor } = selection
if ((event.key === "ArrowLeft" || event.key === "ArrowUp") && $anchor.pos - 1 === 0) {
event.preventDefault()
titleEditorRef.current?.commands.focus("end")
return true
}
return false
}, [])
const handleContentKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
const editor = contentEditorRef.current?.editor
if (!editor) return false
const { state } = editor
const { selection } = state
const { $anchor } = selection
return false
}, [])
const confirmDelete = (page: PersonalPage) => {
console.log("Deleting page:", page.id)
setDeleteModalOpen(false)
//TODO: add delete logic
}
const titleEditor = useEditor({
immediatelyRender: false,
extensions: [
@@ -159,11 +153,28 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
<form className="flex shrink-0 flex-col">
<div className="mb-2 mt-8 py-1.5">
<div className="mb-2 mt-8 flex flex-row justify-between py-1.5">
<EditorContent
editor={titleEditor}
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
/>
<div className="items-center space-x-4">
<TopicSelector
onSelect={topic => {
page.topic = topic
setSelectedPageTopic(topic)
setTopicSelectorOpen(false)
}}
/>
<Button
type="button"
variant="secondary"
className="text-foreground bg-truncat"
onClick={() => setDeleteModalOpen(true)}
>
<LaIcon name="Trash" className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-auto flex-col">
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
@@ -183,6 +194,15 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
</div>
</form>
</div>
<DeletePageModal
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
onConfirm={() => {
confirmDelete(page)
}}
title={page.title.charAt(0).toUpperCase() + page.title.slice(1)}
/>
</div>
)
}

View File

@@ -10,9 +10,9 @@ interface ProfileTopicsProps {
const ProfileTopics: React.FC<ProfileTopicsProps> = ({ topic }) => {
return (
<div className="flex cursor-pointer flex-row items-center justify-between rounded-lg bg-[#121212] p-3">
<div className="bg-result flex cursor-pointer flex-row items-center justify-between rounded-lg p-3">
<p>{topic}</p>
<IoChevronForward className="text-white" size={20} />
<IoChevronForward className="text-black/50 dark:text-white" size={20} />
</div>
)
}
@@ -30,22 +30,24 @@ interface ProfileTitleProps {
const ProfileTitle: React.FC<ProfileTitleProps> = ({ topicTitle, spanNumber }) => {
return (
<p className="pb-3 pl-2 text-base font-light text-white/50">
{topicTitle} <span className="text-white">{spanNumber}</span>
<p className="pb-3 pl-2 text-base font-light text-black/50 dark:text-white/50">
{topicTitle} <span className="text-black dark:text-white">{spanNumber}</span>
</p>
)
}
const ProfileLinks: React.FC<ProfileLinksProps> = ({ linklabel, link, topic }) => {
return (
<div className="flex flex-row items-center justify-between rounded-lg bg-[#121212] p-3 text-white">
<div className="bg-result flex flex-row items-center justify-between rounded-lg p-3 text-black dark:text-white">
<div className="flex flex-row items-center space-x-3">
<p className="text-base text-white">{linklabel}</p>
<p className="text-base">{linklabel}</p>
<div className="flex cursor-pointer flex-row items-center gap-1">
<p className="text-md text-white/10 transition-colors duration-300 hover:text-white/30">{link}</p>
<p className="text-md opacity-50 transition-colors duration-300 hover:opacity-30">{link}</p>
</div>
</div>
<div className="cursor-default rounded-lg bg-[#1a1a1a] p-2 text-white/60">{topic}</div>
<div className="cursor-default rounded-lg bg-[#888888] p-2 text-white dark:bg-[#1a1a1a] dark:text-opacity-50">
{topic}
</div>
</div>
)
}
@@ -84,25 +86,33 @@ export const SearchWrapper = () => {
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<div className="flex h-full w-full justify-center overflow-hidden">
<div className="w-full max-w-3xl px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-[70%] sm:px-6 lg:px-8">
<div className="relative mb-2 mt-5 flex w-full flex-row items-center transition-colors duration-300 hover:text-white/60">
<IoSearch className="absolute left-3 text-white/30" size={20} />
<IoSearch className="absolute left-3 text-black/30 dark:text-white/30" size={20} />
<input
type="text"
autoFocus
value={searchText}
onChange={inputChange}
onKeyDown={handleKeyDown}
className="w-full rounded-[10px] bg-[#16181d] p-10 py-3 pl-10 pr-3 font-semibold tracking-wider text-white outline-none placeholder:font-light placeholder:text-white/30"
className="bg-input w-full rounded-[10px] p-10 py-3 pl-10 pr-3 font-semibold tracking-wider text-black/70 outline-none placeholder:font-light dark:text-white"
placeholder="Search..."
/>
{showAiPlaceholder && searchText && !showAiSearch && (
<div className="absolute right-10 text-sm text-white/30">press &quot;Enter&quot; for AI search</div>
<div className="absolute right-10 text-sm text-black/70 dark:text-white/30">
press &quot;Enter&quot; for AI search
</div>
)}
{searchText && (
<IoCloseOutline className="absolute right-3 cursor-pointer opacity-30" size={20} onClick={clearSearch} />
<IoCloseOutline
className="absolute right-3 cursor-pointer text-black/70 dark:text-white/30"
size={20}
onClick={clearSearch}
/>
)}
</div>
<div className="my-5 rounded-lg bg-blue-600 p-4 font-semibold text-white"> Ask AI</div>
{showAiSearch ? (
<div className="relative w-full">
<div className="absolute left-1/2 w-[110%] -translate-x-1/2">
@@ -115,7 +125,6 @@ export const SearchWrapper = () => {
<ProfileTitle topicTitle="Topics" spanNumber={1} />
<ProfileTopics topic="Figma" />
</div>
<div className="my-5 space-y-1">
<ProfileTitle topicTitle="Links" spanNumber={3} />
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { Topic } from "@/lib/schema"
interface TopicDetailHeaderProps {
topic: Topic
}
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
return (
<>
<ContentHeader className="px-6 py-5 max-lg:px-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left text-xl font-bold">{topic.prettyName}</span>
</div>
</div>
<div className="flex flex-auto"></div>
<Button variant="secondary" size="sm" className="gap-x-2 text-sm">
<span>Add to my profile</span>
</Button>
</ContentHeader>
</>
)
})
TopicDetailHeader.displayName = "TopicDetailHeader"

View File

@@ -0,0 +1,108 @@
"use client"
import React from "react"
import Link from "next/link"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
import { ID } from "jazz-tools"
import { TopicDetailHeader } from "./Header"
import { LaIcon } from "@/components/custom/la-icon"
import { cn, ensureUrlProtocol } from "@/lib/utils"
import { Section as SectionSchema, Link as LinkSchema } from "@/lib/schema"
interface TopicDetailRouteProps {
topicName: string
}
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
const topics = useCoState(PublicGlobalGroup, process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>, {
root: {
topics: []
}
})
const topic = topics?.root.topics.find(topic => topic?.name === topicName)
if (!topic) {
return null
}
return (
<div className="flex h-full flex-auto flex-col">
<TopicDetailHeader topic={topic} />
<div className="flex w-full flex-1 flex-col gap-4 focus-visible:outline-none">
<div tabIndex={-1} className="outline-none">
<div className="flex flex-1 flex-col gap-4" role="listbox" aria-label="Topic sections">
{topic.latestGlobalGuide?.sections?.map(
(section, index) => section?.id && <Section key={index} section={section} />
)}
</div>
</div>
</div>
</div>
)
}
interface SectionProps {
section: SectionSchema
}
function Section({ section }: SectionProps) {
return (
<div className="flex flex-col">
<div className="flex items-center gap-4 px-4 py-2">
<p className="text-foreground text-sm font-medium">{section.title}</p>
<div className="border-b-secondary flex-1 border-b"></div>
</div>
<div className="flex flex-col gap-px py-2">
{section.links?.map((link, index) => link?.url && <LinkItem key={index} link={link} />)}
</div>
</div>
)
}
interface LinkItemProps {
link: LinkSchema
}
function LinkItem({ link }: LinkItemProps) {
return (
<li
tabIndex={0}
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11")}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<LaIcon name="GraduationCap" className="size-5" />
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
{link.title}
</p>
<div className="group flex items-center gap-x-1">
<LaIcon
name="Link"
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
/>
<Link
href={ensureUrlProtocol(link.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="xl:truncate">{link.url}</span>
</Link>
</div>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-x-4"></div>
</div>
</li>
)
}

View File

@@ -0,0 +1,42 @@
import { ReactNode, useState } from "react"
import { motion } from "framer-motion"
export function Keybind({ keys, children }: { keys: string[]; children: ReactNode }) {
const [hovered, setHovered] = useState(false)
const variants = {
hidden: { opacity: 0, y: 6, x: "-50%" },
visible: { opacity: 1, y: 0, x: "-50%" }
}
return (
<div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} className="group relative h-full">
<motion.div
initial="hidden"
animate={hovered ? "visible" : "hidden"}
variants={variants}
transition={{ duration: 0.2, delay: 0.4 }}
className="absolute left-[50%] top-[-30px] flex h-fit w-fit items-center rounded-[7px] border border-slate-400/30 bg-gray-100 p-[3px] px-2 text-[10px] drop-shadow-sm dark:border-slate-400/10 dark:bg-[#191919]"
style={{
boxShadow: "inset 0px 0px 6px 2px var(--boxShadow)"
}}
>
{keys.map((key, index) => (
<span key={key}>
{index > 0 && <span className="mx-1">+</span>}
{(() => {
switch (key.toLowerCase()) {
case "cmd":
return "⌘"
case "shift":
return "⇪"
default:
return key
}
})()}
</span>
))}
</motion.div>
{children}
</div>
)
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -41,12 +41,12 @@ const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<div className="flex items-center px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
"placeholder:text-muted-foreground flex w-full bg-transparent text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
@@ -83,7 +83,7 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

15
web/lib/constants.ts Normal file
View File

@@ -0,0 +1,15 @@
import { icons } from "lucide-react"
export type LearningStateValue = "wantToLearn" | "learning" | "learned"
export type LearningState = {
label: string
value: string
icon: keyof typeof icons
className: string
}
export const LEARNING_STATES: LearningState[] = [
{ label: "To Learn", value: "wantToLearn", icon: "Bookmark", className: "text-foreground" },
{ label: "Learning", value: "learning", icon: "GraduationCap", className: "text-[#D29752]" },
{ label: "Learned", value: "learned", icon: "Check", className: "text-[#708F51]" }
] as const

View File

@@ -1,5 +1,5 @@
import { co, CoMap, Encoders } from "jazz-tools"
import { GlobalTopic } from "./global-topic"
import { GlobalTopic } from "./global-topic.old"
// GlobalLinkAiSummary is high quality title, description, summary of link (generated by AI)
export class GlobalLinkAiSummary extends CoMap {

View File

@@ -11,7 +11,8 @@
import { CoMap, co, Account, Profile } from "jazz-tools"
import { PersonalPageLists } from "./personal-page"
import { PersonalLinkLists } from "./personal-link"
import { GlobalTopicLists } from "./global-topic"
import { ListOfTopics } from "./master/topic"
export class UserRoot extends CoMap {
name = co.string
username = co.string
@@ -24,9 +25,9 @@ export class UserRoot extends CoMap {
personalPages = co.ref(PersonalPageLists)
// not implemented yet
topicsWantToLearn = co.ref(GlobalTopicLists)
topicsLearning = co.ref(GlobalTopicLists)
topicsLearned = co.ref(GlobalTopicLists)
topicsWantToLearn = co.ref(ListOfTopics)
topicsLearning = co.ref(ListOfTopics)
topicsLearned = co.ref(ListOfTopics)
}
export class LaAccount extends Account {
@@ -53,9 +54,9 @@ export class LaAccount extends Account {
personalPages: PersonalPageLists.create([], { owner: this }),
// not implemented yet
topicsWantToLearn: GlobalTopicLists.create([], { owner: this }),
topicsLearning: GlobalTopicLists.create([], { owner: this }),
topicsLearned: GlobalTopicLists.create([], { owner: this })
topicsWantToLearn: ListOfTopics.create([], { owner: this }),
topicsLearning: ListOfTopics.create([], { owner: this }),
topicsLearned: ListOfTopics.create([], { owner: this })
},
{ owner: this }
)
@@ -63,7 +64,6 @@ export class LaAccount extends Account {
}
}
export * from "./global-link"
export * from "./global-topic"
export * from "./master/topic"
export * from "./personal-link"
export * from "./personal-page"

View File

@@ -0,0 +1,15 @@
import { co, CoList, CoMap } from "jazz-tools"
export class Connection extends CoMap {
name = co.string
}
export class ListOfConnections extends CoList.Of(co.ref(Connection)) {}
export class ForceGraph extends CoMap {
name = co.string
prettyName = co.string
connections = co.optional.ref(ListOfConnections)
}
export class ListOfForceGraphs extends CoList.Of(co.ref(ForceGraph)) {}

View File

@@ -0,0 +1,12 @@
import { co, CoMap, Group } from "jazz-tools"
import { ListOfForceGraphs } from "./force-graph"
import { ListOfTopics } from "./topic"
export class PublicGlobalGroupRoot extends CoMap {
forceGraphs = co.ref(ListOfForceGraphs)
topics = co.ref(ListOfTopics)
}
export class PublicGlobalGroup extends Group {
root = co.ref(PublicGlobalGroupRoot)
}

View File

@@ -0,0 +1,33 @@
import { co, CoList, CoMap } from "jazz-tools"
export class Link extends CoMap {
title = co.string
url = co.string
}
export class ListOfLinks extends CoList.Of(co.ref(Link)) {}
export class Section extends CoMap {
title = co.string
links = co.ref(ListOfLinks)
}
export class ListOfSections extends CoList.Of(co.ref(Section)) {}
export class LatestGlobalGuide extends CoMap {
sections = co.ref(ListOfSections)
}
export class TopicConnection extends CoMap {
name = co.string
}
export class ListOfTopicConnections extends CoList.Of(co.ref(TopicConnection)) {}
export class Topic extends CoMap {
name = co.string
prettyName = co.string
latestGlobalGuide = co.ref(LatestGlobalGuide)
}
export class ListOfTopics extends CoList.Of(co.ref(Topic)) {}

View File

@@ -1,34 +1,68 @@
import { co, CoList, CoMap } from "jazz-tools"
import { nullable } from "../types"
import { GlobalLink } from "./global-link"
import { GlobalTopic } from "./global-topic"
import { co, CoList, CoMap, Encoders, ID } from "jazz-tools"
import { Topic } from "./master/topic"
export class LinkMetadata extends CoMap {
url = co.string
title = co.string
favicon = co.string
description = nullable(co.string)
class BaseModel extends CoMap {
createdAt = co.encoded(Encoders.Date)
updatedAt = co.encoded(Encoders.Date)
}
/*
* Link is link user added, it wraps over Link and lets user add notes and other things to it,
* (as well as set own title/description/summary if GlobalLink ones is not good enough or is lacking)
*/
export class PersonalLink extends CoMap {
export class PersonalLink extends BaseModel {
url = co.string
icon = co.optional.string // is an icon URL
title = co.string
slug = co.string
description = co.optional.string
completed = co.boolean
sequence = co.number
isLink = co.boolean
meta = co.optional.ref(LinkMetadata)
// not yet implemented
learningState = co.optional.literal("wantToLearn", "learning", "learned")
notes = co.optional.string
summary = co.optional.string
globalLink = co.optional.ref(GlobalLink)
topic = co.optional.ref(GlobalTopic)
topic = co.optional.ref(Topic)
}
export class PersonalLinkLists extends CoList.Of(co.ref(PersonalLink)) {}
export function updatePersonalLink(link: PersonalLink, data: Partial<PersonalLink>): void {
Object.assign(link, { ...data, updatedAt: new Date() })
}
export function createPersonalLinkList(owner: { group: any }): PersonalLinkLists {
return PersonalLinkLists.create([], { owner: owner.group })
}
export function addToPersonalLinkList(list: PersonalLinkLists, item: PersonalLink): void {
list.push(item)
}
export function removeFromPersonalLinkList(list: PersonalLinkLists, id: ID<PersonalLink>): void {
const index = list.findIndex(item => item?.id === id)
if (index !== -1) {
list.splice(index, 1)
}
}
export function updateInPersonalLinkList(
list: PersonalLinkLists,
id: ID<PersonalLink>,
data: Partial<PersonalLink>
): void {
const item = list.find(item => item?.id === id)
if (item) {
Object.assign(item, { ...data, updatedAt: new Date() })
}
}
export function getFromPersonalLinkList(
list: PersonalLinkLists,
id: ID<PersonalLink>
): PersonalLink | null | undefined {
return list.find(item => item?.id === id)
}
export function safelyAccessPersonalLink<T>(
link: PersonalLink | null | undefined,
accessor: (link: PersonalLink) => T,
defaultValue: T
): T {
return link ? accessor(link) : defaultValue
}

View File

@@ -1,5 +1,5 @@
import { co, CoList, CoMap } from "jazz-tools"
import { GlobalTopic } from "./global-topic"
import { Topic } from "./master/topic"
/*
* Page, content that user can write to. Similar to Notion/Reflect page. It holds ProseMirror editor content + metadata.
@@ -11,7 +11,7 @@ export class PersonalPage extends CoMap {
title = co.string
slug = co.string
content = co.optional.json()
topic = co.optional.ref(GlobalTopic)
topic = co.optional.ref(Topic)
// backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently
}

View File

@@ -0,0 +1,39 @@
import { urlSchema } from "./schema"
function validateUrl(url: string): boolean {
const result = urlSchema.safeParse(url)
return result.success
}
describe("URL Validation", () => {
test("valid full URLs", () => {
expect(validateUrl("https://www.example.com")).toBe(true)
expect(validateUrl("http://example.com")).toBe(true)
expect(validateUrl("https://subdomain.example.co.uk")).toBe(true)
})
test("valid domain names without protocol", () => {
expect(validateUrl("aslamh.com")).toBe(true)
expect(validateUrl("www.example.com")).toBe(true)
expect(validateUrl("subdomain.example.co.uk")).toBe(true)
})
test("invalid URLs", () => {
expect(validateUrl("https://aslamh")).toBe(false)
expect(validateUrl("ftp://example.com")).toBe(false)
expect(validateUrl("http://.com")).toBe(false)
expect(validateUrl("just-a-string")).toBe(false)
expect(validateUrl("aslamh")).toBe(false)
})
test("edge cases", () => {
expect(validateUrl("https://localhost")).toBe(false) // No TLD
expect(validateUrl("https://123.45.67.89")).toBe(false) // IP address
expect(validateUrl("https://example.com/path?query=value")).toBe(true) // With path and query
})
test("empty and whitespace inputs", () => {
expect(validateUrl("")).toBe(false)
expect(validateUrl(" ")).toBe(false)
})
})

42
web/lib/utils/schema.ts Normal file
View File

@@ -0,0 +1,42 @@
/*
* This file contains custom schema definitions for Zod.
*/
import { z } from "zod"
export const urlSchema = z
.string()
.min(1, { message: "URL can't be empty" })
.refine(
value => {
const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
const isValidDomain = (domain: string) => {
try {
const url = new URL(`http://${domain}`)
return domainRegex.test(url.hostname)
} catch {
return false
}
}
if (isValidDomain(value)) {
return true
}
try {
const url = new URL(value)
if (!url.protocol.match(/^https?:$/)) {
return false
}
return isValidDomain(url.hostname)
} catch {
return false
}
},
{
message: "Please enter a valid URL"
}
)

View File

@@ -12,10 +12,10 @@ export function isUrl(text: string): boolean {
return pattern.test(text)
}
export function ensureUrlProtocol(url: string): string {
if (url.startsWith("http://") || url.startsWith("https://")) {
export function ensureUrlProtocol(url: string, defaultProtocol: string = "https://"): string {
if (url.match(/^[a-zA-Z]+:\/\//)) {
return url
}
return `https://${url}`
return `${defaultProtocol}${url.startsWith("//") ? url.slice(2) : url}`
}

View File

@@ -1,14 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**"
}
]
}
reactStrictMode: false,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**"
}
]
}
}
export default nextConfig

View File

@@ -1,103 +1,108 @@
{
"name": "web",
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "^3.9.0",
"@omit/react-confirm-dialog": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dismissable-layer": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-focus-scope": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tiptap/core": "^2.5.9",
"@tiptap/extension-blockquote": "^2.5.9",
"@tiptap/extension-bold": "^2.5.9",
"@tiptap/extension-bullet-list": "^2.5.9",
"@tiptap/extension-code": "^2.5.9",
"@tiptap/extension-code-block-lowlight": "^2.5.9",
"@tiptap/extension-document": "^2.5.9",
"@tiptap/extension-dropcursor": "^2.5.9",
"@tiptap/extension-focus": "^2.5.9",
"@tiptap/extension-gapcursor": "^2.5.9",
"@tiptap/extension-hard-break": "^2.5.9",
"@tiptap/extension-heading": "^2.5.9",
"@tiptap/extension-history": "^2.5.9",
"@tiptap/extension-horizontal-rule": "^2.5.9",
"@tiptap/extension-italic": "^2.5.9",
"@tiptap/extension-link": "^2.5.9",
"@tiptap/extension-list-item": "^2.5.9",
"@tiptap/extension-ordered-list": "^2.5.9",
"@tiptap/extension-paragraph": "^2.5.9",
"@tiptap/extension-placeholder": "^2.5.9",
"@tiptap/extension-strike": "^2.5.9",
"@tiptap/extension-task-item": "^2.5.9",
"@tiptap/extension-task-list": "^2.5.9",
"@tiptap/extension-text": "^2.5.9",
"@tiptap/extension-typography": "^2.5.9",
"@tiptap/pm": "^2.5.9",
"@tiptap/react": "^2.5.9",
"@tiptap/suggestion": "^2.5.9",
"axios": "^1.7.3",
"cheerio": "1.0.0-rc.12",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"jazz-react": "^0.7.25",
"jazz-tools": "^0.7.25",
"jotai": "^2.9.2",
"lowlight": "^3.1.0",
"lucide-react": "^0.424.0",
"next": "14.2.5",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^9.0.8",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.2",
"react-use": "^17.5.1",
"slugify": "^1.6.6",
"sonner": "^1.5.0",
"streaming-markdown": "^0.0.14",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"zod": "^3.23.8",
"zsa": "^0.5.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.1.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"eslint": "^9.8.0",
"eslint-config-next": "14.2.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.41",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9",
"ts-jest": "^29.2.4",
"typescript": "^5.5.4"
}
"name": "web",
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "^3.9.0",
"@omit/react-confirm-dialog": "^1.1.3",
"@omit/react-fancy-switch": "^0.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dismissable-layer": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-focus-scope": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tiptap/core": "^2.6.6",
"@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bold": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6",
"@tiptap/extension-code": "^2.6.6",
"@tiptap/extension-code-block-lowlight": "^2.6.6",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-dropcursor": "^2.6.6",
"@tiptap/extension-focus": "^2.6.6",
"@tiptap/extension-gapcursor": "^2.6.6",
"@tiptap/extension-hard-break": "^2.6.6",
"@tiptap/extension-heading": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-horizontal-rule": "^2.6.6",
"@tiptap/extension-italic": "^2.6.6",
"@tiptap/extension-link": "^2.6.6",
"@tiptap/extension-list-item": "^2.6.6",
"@tiptap/extension-ordered-list": "^2.6.6",
"@tiptap/extension-paragraph": "^2.6.6",
"@tiptap/extension-placeholder": "^2.6.6",
"@tiptap/extension-strike": "^2.6.6",
"@tiptap/extension-task-item": "^2.6.6",
"@tiptap/extension-task-list": "^2.6.6",
"@tiptap/extension-text": "^2.6.6",
"@tiptap/extension-typography": "^2.6.6",
"@tiptap/pm": "^2.6.6",
"@tiptap/react": "^2.6.6",
"@tiptap/suggestion": "^2.6.6",
"axios": "^1.7.5",
"cheerio": "1.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.3.30",
"jazz-react": "^0.7.34",
"jazz-tools": "^0.7.34",
"jotai": "^2.9.3",
"lowlight": "^3.1.0",
"lucide-react": "^0.429.0",
"next": "14.2.5",
"next-themes": "^0.3.0",
"nuqs": "^1.17.8",
"react": "^18.3.1",
"react-day-picker": "^9.0.8",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-textarea-autosize": "^8.5.3",
"react-use": "^17.5.1",
"slugify": "^1.6.6",
"sonner": "^1.5.0",
"streaming-markdown": "^0.0.14",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"zod": "^3.23.8",
"zsa": "^0.6.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.5.0",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"eslint": "^9.9.1",
"eslint-config-next": "14.2.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.41",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.10",
"ts-jest": "^29.2.5",
"typescript": "^5.5.4"
}
}

View File

@@ -4,3 +4,5 @@ import { atomWithStorage } from "jotai/utils"
export const linkSortAtom = atomWithStorage("sort", "manual")
export const linkShowCreateAtom = atom(false)
export const linkEditIdAtom = atom<string | number | null>(null)
export const linkLearningStateSelectorAtom = atom(false)
export const linkTopicSelectorAtom = atom(false)

3
web/store/page.ts Normal file
View File

@@ -0,0 +1,3 @@
import { atom } from "jotai"
export const pageTopicSelectorAtom = atom(false)

View File

@@ -17,6 +17,7 @@ const config = {
extend: {
colors: {
border: "hsl(var(--border))",
result: "hsl(var(--result))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",