mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
# base
|
||||
.DS_Store
|
||||
.env
|
||||
.env*.local
|
||||
@@ -13,3 +12,4 @@ pnpm-lock.yaml
|
||||
# other
|
||||
private
|
||||
past-*
|
||||
output
|
||||
|
||||
81
cli/run.ts
81
cli/run.ts
@@ -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()
|
||||
|
||||
492
cli/seed.ts
492
cli/seed.ts
@@ -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()
|
||||
|
||||
58
package.json
58
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
5
web/app/(pages)/(topics)/[name]/page.tsx
Normal file
5
web/app/(pages)/(topics)/[name]/page.tsx
Normal 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} />
|
||||
}
|
||||
5
web/app/(pages)/edit-profile/page.tsx
Normal file
5
web/app/(pages)/edit-profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import EditProfileRoute from "@/components/routes/EditProfileRoute"
|
||||
|
||||
export default function EditProfilePage() {
|
||||
return <EditProfileRoute />
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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
5
web/app/(pages)/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AuthHomeRoute from "@/components/routes/AuthHomeRoute"
|
||||
|
||||
export default function HomePage() {
|
||||
return <AuthHomeRoute />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import GlobalTopic from "@/components/routes/globalTopic/globalTopic"
|
||||
|
||||
export default function GlobalTopicPage({ params }: { params: { topic: string } }) {
|
||||
return <GlobalTopic topic={params.topic} />
|
||||
}
|
||||
@@ -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"
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
25
web/components/LinkOptions.tsx
Normal file
25
web/components/LinkOptions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
37
web/components/custom/delete-modal.tsx
Normal file
37
web/components/custom/delete-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
web/components/custom/la-icon.tsx
Normal file
22
web/components/custom/la-icon.tsx
Normal 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"
|
||||
14
web/components/custom/page-loader.tsx
Normal file
14
web/components/custom/page-loader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
108
web/components/custom/sidebar/partial/profile-section.tsx
Normal file
108
web/components/custom/sidebar/partial/profile-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
23
web/components/custom/textarea-autosize.tsx
Normal file
23
web/components/custom/textarea-autosize.tsx
Normal 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 }
|
||||
@@ -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">
|
||||
|
||||
19
web/components/routes/AuthHomeRoute.tsx
Normal file
19
web/components/routes/AuthHomeRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
web/components/routes/EditProfileRoute.tsx
Normal file
45
web/components/routes/EditProfileRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
web/components/routes/PublicHomeRoute.tsx
Normal file
31
web/components/routes/PublicHomeRoute.tsx
Normal 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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
26
web/components/routes/force-graph.tsx
Normal file
26
web/components/routes/force-graph.tsx
Normal 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)}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
1
web/components/routes/link/form/index.ts
Normal file
1
web/components/routes/link/form/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./manage"
|
||||
253
web/components/routes/link/form/link-form.tsx
Normal file
253
web/components/routes/link/form/link-form.tsx
Normal 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"
|
||||
@@ -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} /> */
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
28
web/components/routes/link/form/partial/floating-button.tsx
Normal file
28
web/components/routes/link/form/partial/floating-button.tsx
Normal 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
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
36
web/components/routes/link/form/partial/notes-section.tsx
Normal file
36
web/components/routes/link/form/partial/notes-section.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
36
web/components/routes/link/form/partial/title-input.tsx
Normal file
36
web/components/routes/link/form/partial/title-input.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
web/components/routes/link/form/partial/topic-selector.tsx
Normal file
90
web/components/routes/link/form/partial/topic-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
web/components/routes/link/form/partial/url-badge.tsx
Normal file
35
web/components/routes/link/form/partial/url-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
web/components/routes/link/form/partial/url-input.tsx
Normal file
71
web/components/routes/link/form/partial/url-input.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 "Enter" for AI search</div>
|
||||
<div className="absolute right-10 text-sm text-black/70 dark:text-white/30">
|
||||
press "Enter" 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" />
|
||||
|
||||
33
web/components/routes/topics/detail/Header.tsx
Normal file
33
web/components/routes/topics/detail/Header.tsx
Normal 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"
|
||||
108
web/components/routes/topics/detail/TopicDetailRoute.tsx
Normal file
108
web/components/routes/topics/detail/TopicDetailRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
web/components/ui/Keybind.tsx
Normal file
42
web/components/ui/Keybind.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
web/components/ui/avatar.tsx
Normal file
50
web/components/ui/avatar.tsx
Normal 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 }
|
||||
@@ -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}
|
||||
|
||||
15
web/components/ui/skeleton.tsx
Normal file
15
web/components/ui/skeleton.tsx
Normal 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
15
web/lib/constants.ts
Normal 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
|
||||
@@ -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 {
|
||||
@@ -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"
|
||||
|
||||
15
web/lib/schema/master/force-graph.ts
Normal file
15
web/lib/schema/master/force-graph.ts
Normal 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)) {}
|
||||
12
web/lib/schema/master/public-group.ts
Normal file
12
web/lib/schema/master/public-group.ts
Normal 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)
|
||||
}
|
||||
33
web/lib/schema/master/topic.ts
Normal file
33
web/lib/schema/master/topic.ts
Normal 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)) {}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
39
web/lib/utils/schema.test.ts
Normal file
39
web/lib/utils/schema.test.ts
Normal 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
42
web/lib/utils/schema.ts
Normal 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"
|
||||
}
|
||||
)
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
207
web/package.json
207
web/package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
3
web/store/page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai"
|
||||
|
||||
export const pageTopicSelectorAtom = atom(false)
|
||||
@@ -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))",
|
||||
|
||||
Reference in New Issue
Block a user