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
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
@@ -13,3 +12,4 @@ pnpm-lock.yaml
|
|||||||
# other
|
# other
|
||||||
private
|
private
|
||||||
past-*
|
past-*
|
||||||
|
output
|
||||||
|
|||||||
81
cli/run.ts
81
cli/run.ts
@@ -1,12 +1,89 @@
|
|||||||
import { getEnvOrThrow } from "@/lib/utils"
|
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() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
const OPENAI_API_KEY = getEnvOrThrow("OPENAI_API_KEY")
|
await readJazz()
|
||||||
console.log(OPENAI_API_KEY)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err, "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()
|
await run()
|
||||||
|
|||||||
492
cli/seed.ts
492
cli/seed.ts
@@ -1,11 +1,466 @@
|
|||||||
import { getEnvOrThrow } from "@/lib/utils"
|
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 { startWorker } from "jazz-nodejs"
|
||||||
import { Group, ID } from "jazz-tools"
|
import { ID } from "jazz-tools"
|
||||||
import { appendFile } from "node:fs/promises"
|
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")
|
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() {
|
async function seed() {
|
||||||
const args = Bun.argv
|
const args = Bun.argv
|
||||||
const command = args[2]
|
const command = args[2]
|
||||||
@@ -20,40 +475,19 @@ async function seed() {
|
|||||||
case "prod":
|
case "prod":
|
||||||
await prodSeed()
|
await prodSeed()
|
||||||
break
|
break
|
||||||
|
case "fullProdRebuild":
|
||||||
|
await fullProdRebuild()
|
||||||
|
break
|
||||||
|
case "forceGraph":
|
||||||
|
await forceGraphSeed()
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
console.log("Unknown command")
|
console.log("Unknown command")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
console.log("done")
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error occurred:", 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()
|
await seed()
|
||||||
|
|||||||
58
package.json
58
package.json
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "learn-anything",
|
"name": "learn-anything",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun web",
|
"dev": "bun web",
|
||||||
"web": "cd web && bun dev",
|
"web": "cd web && bun dev",
|
||||||
"web:build": "bun run --filter '*' build",
|
"web:build": "bun run --filter '*' build",
|
||||||
"cli": "bun run --watch cli/run.ts",
|
"cli": "bun run --watch cli/run.ts",
|
||||||
"seed": "bun --watch cli/seed.ts"
|
"seed": "bun --watch cli/seed.ts"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"web"
|
"web"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jazz-nodejs": "^0.7.23",
|
"jazz-nodejs": "^0.7.34",
|
||||||
"react-icons": "^5.2.1"
|
"react-icons": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "^1.1.21"
|
"bun-types": "^1.1.26"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"prettier-plugin-tailwindcss"
|
"prettier-plugin-tailwindcss"
|
||||||
],
|
],
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
"arrowParens": "avoid"
|
"arrowParens": "avoid"
|
||||||
},
|
},
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
NEXT_PUBLIC_APP_NAME="Learn Anything"
|
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 { Sidebar } from "@/components/custom/sidebar/sidebar"
|
||||||
|
import PublicHomeRoute from "@/components/routes/PublicHomeRoute"
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
// TODO: get it from jazz/clerk
|
||||||
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
const loggedIn = true
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
if (loggedIn) {
|
||||||
<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">
|
return (
|
||||||
{children}
|
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
||||||
</main>
|
<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>
|
||||||
</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"
|
"use client"
|
||||||
|
|
||||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
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 = () => {
|
interface ProfileStatsProps {
|
||||||
const account = useAccount()
|
number: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileLinksProps {
|
||||||
|
linklabel?: string
|
||||||
|
link?: string
|
||||||
|
topic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfilePagesProps {
|
||||||
|
topic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileStats: React.FC<ProfileStatsProps> = ({ number, label }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="text-center font-semibold text-black/60 dark:text-white">
|
||||||
<h2>{account.me.profile?.name}</h2>
|
<p className="text-4xl">{number}</p>
|
||||||
<p>Profile Page</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>
|
</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 { NextRequest } from "next/server"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { GET } from "./route"
|
import { DEFAULT_VALUES, GET } from "./route"
|
||||||
|
|
||||||
jest.mock("axios")
|
jest.mock("axios")
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
@@ -19,7 +19,7 @@ describe("Metadata Fetcher", () => {
|
|||||||
<head>
|
<head>
|
||||||
<title>Test Title</title>
|
<title>Test Title</title>
|
||||||
<meta name="description" content="Test Description">
|
<meta name="description" content="Test Description">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/icon.ico">
|
||||||
</head>
|
</head>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
@@ -37,7 +37,7 @@ describe("Metadata Fetcher", () => {
|
|||||||
expect(data).toEqual({
|
expect(data).toEqual({
|
||||||
title: "Test Title",
|
title: "Test Title",
|
||||||
description: "Test Description",
|
description: "Test Description",
|
||||||
favicon: "https://example.com/favicon.ico",
|
icon: "https://example.com/icon.ico",
|
||||||
url: "https://example.com"
|
url: "https://example.com"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -66,9 +66,9 @@ describe("Metadata Fetcher", () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(data).toEqual({
|
expect(data).toEqual({
|
||||||
title: "No title available",
|
title: DEFAULT_VALUES.TITLE,
|
||||||
description: "No description available",
|
description: DEFAULT_VALUES.DESCRIPTION,
|
||||||
favicon: null,
|
icon: null,
|
||||||
url: "https://example.com"
|
url: "https://example.com"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -92,9 +92,9 @@ describe("Metadata Fetcher", () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(data).toEqual({
|
expect(data).toEqual({
|
||||||
title: "No title available",
|
title: DEFAULT_VALUES.TITLE,
|
||||||
description: "No description available",
|
description: DEFAULT_VALUES.DESCRIPTION,
|
||||||
favicon: null,
|
icon: null,
|
||||||
url: "https://example.com"
|
url: "https://example.com"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,29 +1,39 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import * as cheerio from "cheerio"
|
import * as cheerio from "cheerio"
|
||||||
|
import { ensureUrlProtocol } from "@/lib/utils"
|
||||||
|
import { urlSchema } from "@/lib/utils/schema"
|
||||||
|
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
favicon: string | null
|
icon: string | null
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_VALUES = {
|
export const DEFAULT_VALUES = {
|
||||||
TITLE: "No title available",
|
TITLE: "",
|
||||||
DESCRIPTION: "No description available",
|
DESCRIPTION: "",
|
||||||
IMAGE: null,
|
|
||||||
FAVICON: null
|
FAVICON: null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url)
|
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) {
|
if (!url) {
|
||||||
return NextResponse.json({ error: "URL is required" }, { status: 400 })
|
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 {
|
try {
|
||||||
const { data } = await axios.get(url, {
|
const { data } = await axios.get(url, {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
@@ -41,13 +51,12 @@ export async function GET(request: NextRequest) {
|
|||||||
$('meta[name="description"]').attr("content") ||
|
$('meta[name="description"]').attr("content") ||
|
||||||
$('meta[property="og:description"]').attr("content") ||
|
$('meta[property="og:description"]').attr("content") ||
|
||||||
DEFAULT_VALUES.DESCRIPTION,
|
DEFAULT_VALUES.DESCRIPTION,
|
||||||
favicon:
|
icon: $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON,
|
||||||
$('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON,
|
|
||||||
url: url
|
url: url
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.favicon && !metadata.favicon.startsWith("http")) {
|
if (metadata.icon && !metadata.icon.startsWith("http")) {
|
||||||
metadata.favicon = new URL(metadata.favicon, url).toString()
|
metadata.icon = new URL(metadata.icon, url).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(metadata)
|
return NextResponse.json(metadata)
|
||||||
@@ -55,7 +64,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const defaultMetadata: Metadata = {
|
const defaultMetadata: Metadata = {
|
||||||
title: DEFAULT_VALUES.TITLE,
|
title: DEFAULT_VALUES.TITLE,
|
||||||
description: DEFAULT_VALUES.DESCRIPTION,
|
description: DEFAULT_VALUES.DESCRIPTION,
|
||||||
favicon: DEFAULT_VALUES.FAVICON,
|
icon: DEFAULT_VALUES.FAVICON,
|
||||||
url: url
|
url: url
|
||||||
}
|
}
|
||||||
return NextResponse.json(defaultMetadata)
|
return NextResponse.json(defaultMetadata)
|
||||||
|
|||||||
@@ -1,26 +1,6 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
@@ -42,17 +22,19 @@ body {
|
|||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 5.9% 90%;
|
--border: 240 5.9% 90%;
|
||||||
--input: 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;
|
--radius: 0.5rem;
|
||||||
--chart-1: 12 76% 61%;
|
--chart-1: 12 76% 61%;
|
||||||
--chart-2: 173 58% 39%;
|
--chart-2: 173 58% 39%;
|
||||||
--chart-3: 197 37% 24%;
|
--chart-3: 197 37% 24%;
|
||||||
--chart-4: 43 74% 66%;
|
--chart-4: 43 74% 66%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-5: 27 87% 67%;
|
||||||
|
--boxShadow: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 4.5%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 240 10% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
@@ -69,13 +51,15 @@ body {
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 3.7% 15.9%;
|
--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%;
|
--ring: 240 4.9% 83.9%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--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 (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col items-center">
|
<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="w-full rounded-lg bg-inherit p-6 text-black dark:text-white">
|
||||||
<div className="mb-6 rounded-lg bg-blue-700 p-4">
|
<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>
|
<h2 className="text-lg font-medium">✨ This is what I have found:</h2>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-md pb-5 font-semibold opacity-50">{error}</p>
|
<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
|
Ask Community
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -50,7 +50,7 @@ export const SidebarToggleButton: React.FC = () => {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
className="text-primary/60 z-50"
|
className="text-primary/60"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon size={16} />
|
<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 { 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 { 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
|
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
|
||||||
import { PlusIcon } from "lucide-react"
|
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||||
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 { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useRouter } from "next/navigation"
|
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({
|
const createPageSchema = z.object({
|
||||||
title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" })
|
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>
|
type PageFormValues = z.infer<typeof createPageSchema>
|
||||||
|
|
||||||
export const PageSection: React.FC = () => {
|
export const PageSection: React.FC = () => {
|
||||||
const { me } = useAccount()
|
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
|
||||||
const [personalPages, setPersonalPages] = useState<PersonalPage[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { me } = useAccount({
|
||||||
if (me.root?.personalPages) {
|
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 onPageCreated = useCallback((newPage: PersonalPage) => {
|
const pageCount = me?.root.personalPages?.length || 0
|
||||||
setPersonalPages(prevPages => [...prevPages, newPage])
|
|
||||||
}, [])
|
const sortedPages = (filter: string) => {
|
||||||
|
setPagesSorted(filter)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-ml-2">
|
<div className="flex flex-col gap-px py-2">
|
||||||
<div className="group mb-0.5 ml-2 mt-2 flex flex-row items-center justify-between rounded-md">
|
<div className="hover:bg-accent group/pages flex items-center gap-px rounded-md">
|
||||||
<div
|
<Button
|
||||||
role="button"
|
variant="ghost"
|
||||||
tabIndex={0}
|
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus:outline-0 focus:ring-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"
|
|
||||||
>
|
>
|
||||||
<span className="group-hover:text-muted-foreground">Pages</span>
|
<p className="flex items-center text-xs font-medium">
|
||||||
<CreatePageForm onPageCreated={onPageCreated} />
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="relative shrink-0">
|
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} sortBy={pagesSorted} />}
|
||||||
<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>
|
|
||||||
</div>
|
</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 [open, setOpen] = useState(false)
|
||||||
const { me } = useAccount()
|
const { me } = useAccount()
|
||||||
const route = useRouter()
|
const route = useRouter()
|
||||||
@@ -88,7 +159,6 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }>
|
|||||||
)
|
)
|
||||||
|
|
||||||
me.root?.personalPages?.push(newPersonalPage)
|
me.root?.personalPages?.push(newPersonalPage)
|
||||||
onPageCreated(newPersonalPage)
|
|
||||||
|
|
||||||
form.reset()
|
form.reset()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -103,9 +173,16 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }>
|
|||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button type="button" size="icon" variant="ghost" aria-label="New Page" className="size-6">
|
<button
|
||||||
<PlusIcon size={16} />
|
type="button"
|
||||||
</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>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start">
|
<PopoverContent align="start">
|
||||||
<Form {...form}>
|
<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 { useState, useRef } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { Button } from "@/components/ui/button"
|
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"
|
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 = () => {
|
export const TopicSection = () => {
|
||||||
const [showOptions, setShowOptions] = useState(false)
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
||||||
const sectionRef = useRef<HTMLDivElement>(null)
|
const sectionRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const learningOptions = [
|
const learningOptions = [
|
||||||
{ text: "To Learn", icon: <Bookmark size={16} />, color: "text-white/70" },
|
|
||||||
{
|
{
|
||||||
text: "Learning",
|
text: "To Learn",
|
||||||
icon: <GraduationCap size={16} />,
|
icon: <LaIcon name="NotebookPen" className="size-3 flex-shrink-0" />,
|
||||||
color: "text-[#D29752]"
|
color: "text-black dark:text-white"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Learned",
|
text: "Learning",
|
||||||
icon: <Check size={16} />,
|
icon: <LaIcon name="GraduationCap" className="size-4 flex-shrink-0" />,
|
||||||
color: "text-[#708F51]"
|
color: "text-[#D29752]"
|
||||||
}
|
},
|
||||||
|
{ text: "Learned", icon: <LaIcon name="Check" className="size-4 flex-shrink-0" />, color: "text-[#708F51]" }
|
||||||
]
|
]
|
||||||
|
|
||||||
const statusSelect = (status: string) => {
|
const statusSelect = (status: string) => {
|
||||||
setSelectedStatus(status === "Show All" ? null : status)
|
setSelectedStatus(prevStatus => (prevStatus === status ? null : status))
|
||||||
setShowOptions(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const topicCounts = {
|
||||||
const overlayClick = (event: MouseEvent) => {
|
"To Learn": 2,
|
||||||
if (sectionRef.current && !sectionRef.current.contains(event.target as Node)) {
|
Learning: 5,
|
||||||
setShowOptions(false)
|
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 (
|
return (
|
||||||
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
|
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
|
||||||
<Button
|
<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">
|
||||||
onClick={() => setShowOptions(!showOptions)}
|
<span className="text-black dark:text-white">Topics {topicCounts.total}</span>
|
||||||
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"
|
<button className="opacity-0 transition-opacity duration-200 group-hover/topics:opacity-100">
|
||||||
>
|
<LaIcon name="Ellipsis" className="size-4 flex-shrink-0" />
|
||||||
<span>{selectedStatus ? `Topics: ${selectedStatus}` : "Topics"}</span>
|
</button>
|
||||||
<ChevronDown
|
</div>
|
||||||
size={16}
|
<div>
|
||||||
className={`transform transition-transform duration-200 ease-in-out ${
|
{learningOptions.map(option => (
|
||||||
showOptions ? "rotate-0" : "rotate-[-90deg]"
|
<Button
|
||||||
}`}
|
key={option.text}
|
||||||
/>
|
onClick={() => statusSelect(option.text)}
|
||||||
</Button>
|
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"
|
||||||
{showOptions && (
|
} shadow-none`}
|
||||||
<div className="rounded-md bg-neutral-800">
|
>
|
||||||
{availableOptions.map(option => (
|
<div className="flex items-center gap-2">
|
||||||
<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`}
|
|
||||||
>
|
|
||||||
{option.icon && <span className={option.color}>{option.icon}</span>}
|
{option.icon && <span className={option.color}>{option.icon}</span>}
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
</Button>
|
</div>
|
||||||
))}
|
<span className={`${option.color} mr-2`}>{topicCounts[option.text as keyof typeof topicCounts]}</span>
|
||||||
</div>
|
</Button>
|
||||||
)}
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TopicSection
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import Link from "next/link"
|
|||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useMedia } from "react-use"
|
import { useMedia } from "react-use"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { LinkIcon, SearchIcon } from "lucide-react"
|
import { SearchIcon } from "lucide-react"
|
||||||
import { Logo } from "@/components/custom/logo"
|
import { Logo } from "@/components/custom/logo"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { isCollapseAtom } from "@/store/sidebar"
|
import { isCollapseAtom } from "@/store/sidebar"
|
||||||
|
|
||||||
import { PageSection } from "./partial/page-section"
|
import { PageSection } from "./partial/page-section"
|
||||||
import { TopicSection } from "./partial/topic-section"
|
import { TopicSection } from "./partial/topic-section"
|
||||||
|
import { ProfileSection } from "./partial/profile-section"
|
||||||
|
|
||||||
interface SidebarContextType {
|
interface SidebarContextType {
|
||||||
isCollapsed: boolean
|
isCollapsed: boolean
|
||||||
@@ -73,14 +73,14 @@ export const SidebarItem: React.FC<SidebarItemProps> = React.memo(({ label, url,
|
|||||||
const LogoAndSearch: React.FC = React.memo(() => {
|
const LogoAndSearch: React.FC = React.memo(() => {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
return (
|
return (
|
||||||
<div className="px-3.5">
|
<div className="px-3">
|
||||||
<div className="mb-1 mt-2 flex h-10 max-w-full items-center">
|
<div className="mt-2 flex h-10 max-w-full items-center">
|
||||||
<Link href="/links" className="px-2">
|
<Link href="/" className="px-2">
|
||||||
<Logo className="size-7" />
|
<Logo className="size-7" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex min-w-2 grow flex-row" />
|
<div className="flex min-w-2 grow flex-row" />
|
||||||
{pathname === "/search" ? (
|
{pathname === "/search" ? (
|
||||||
<Link href="/links">
|
<Link href="/">
|
||||||
<Button size="sm" variant="secondary" type="button" className="text-md text-primary/60 font-medium">
|
<Button size="sm" variant="secondary" type="button" className="text-md text-primary/60 font-medium">
|
||||||
← Back
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -104,21 +104,20 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const SidebarContent: React.FC = React.memo(() => {
|
const SidebarContent: React.FC = React.memo(() => {
|
||||||
const { isCollapsed } = React.useContext(SidebarContext)
|
|
||||||
const isTablet = useMedia("(max-width: 1024px)")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
<>
|
||||||
<div className={cn({ "pt-12": !isCollapsed && isTablet })}>
|
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||||
<LogoAndSearch />
|
<div>
|
||||||
</div>
|
<LogoAndSearch />
|
||||||
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3.5">
|
</div>
|
||||||
<SidebarItem url="/links" label="Links" icon={<LinkIcon size={16} />} />
|
<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" />
|
<div className="h-2 shrink-0" />
|
||||||
<PageSection />
|
<PageSection />
|
||||||
<TopicSection />
|
<TopicSection />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<ProfileSection />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -132,7 +131,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sidebarInnerClasses = cn(
|
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"
|
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 { ToolbarButton } from "../ui/toolbar-button"
|
||||||
import { Icon } from "../ui/icon"
|
import { Icon } from "../ui/icon"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { Keybind } from "@/components/ui/Keybind"
|
||||||
|
|
||||||
export type BubbleMenuProps = {
|
export type BubbleMenuProps = {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
@@ -14,38 +15,93 @@ export const BubbleMenu = ({ editor }: BubbleMenuProps) => {
|
|||||||
const commands = useTextmenuCommands(editor)
|
const commands = useTextmenuCommands(editor)
|
||||||
const states = useTextmenuStates(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 (
|
return (
|
||||||
<TiptapBubbleMenu
|
<TiptapBubbleMenu
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
// duration: [0, 999999],
|
// duration: [0, 999999],
|
||||||
popperOptions: { placement: "top-start" }
|
popperOptions: { placement: "top-start" }
|
||||||
}}
|
}}
|
||||||
|
className="flex h-[40px] min-h-[40px] items-center rounded-[14px] shadow-md"
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="textMenu"
|
pluginKey="textMenu"
|
||||||
shouldShow={states.shouldShow}
|
shouldShow={states.shouldShow}
|
||||||
updateDelay={100}
|
updateDelay={100}
|
||||||
>
|
>
|
||||||
<PopoverWrapper className="flex items-center overflow-x-auto p-1">
|
<PopoverWrapper
|
||||||
<div className="space-x-1">
|
className="flex items-center rounded-[14px] border border-slate-400/10 bg-gray-100 p-[4px] dark:bg-[#121212]"
|
||||||
<ToolbarButton value="bold" aria-label="Bold" onPressedChange={commands.onBold} isActive={states.isBold}>
|
style={{
|
||||||
<Icon name="Bold" strokeWidth={2.5} />
|
boxShadow: "inset 0px 0px 5px 3px var(--boxShadow)"
|
||||||
</ToolbarButton>
|
}}
|
||||||
<ToolbarButton value="italic" aria-label="Italic" onClick={commands.onItalic}>
|
>
|
||||||
<Icon name="Italic" strokeWidth={2.5} />
|
<div className="flex space-x-1">
|
||||||
</ToolbarButton>
|
<Keybind keys={["Ctrl", "I"]}>
|
||||||
<ToolbarButton value="strikethrough" aria-label="Strikethrough" onClick={commands.onStrike}>
|
<ToolbarButton
|
||||||
<Icon name="Strikethrough" strokeWidth={2.5} />
|
className={toolbarButtonClassname}
|
||||||
</ToolbarButton>
|
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">
|
{/* <ToolbarButton value="link" aria-label="Link">
|
||||||
<Icon name="Link" strokeWidth={2.5} />
|
<Icon name="Link" strokeWidth={2.5} />
|
||||||
</ToolbarButton> */}
|
</ToolbarButton> */}
|
||||||
<ToolbarButton value="quote" aria-label="Quote" onClick={commands.onCode}>
|
<Keybind keys={["cmd", "K"]}>
|
||||||
<Icon name="Quote" strokeWidth={2.5} />
|
<ToolbarButton
|
||||||
</ToolbarButton>
|
className={toolbarButtonClassname}
|
||||||
<ToolbarButton value="inline code" aria-label="Inline code" onClick={commands.onCode}>
|
value="quote"
|
||||||
<Icon name="Braces" strokeWidth={2.5} />
|
aria-label="Quote"
|
||||||
</ToolbarButton>
|
onClick={commands.onCode}
|
||||||
<ToolbarButton value="code block" aria-label="Code block" onClick={commands.onCodeBlock}>
|
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} />
|
<Icon name="Code" strokeWidth={2.5} />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
{/* <ToolbarButton value="list" aria-label="List">
|
{/* <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 { useAtom } from "jotai"
|
||||||
import { linkEditIdAtom } from "@/store/link"
|
import { linkEditIdAtom } from "@/store/link"
|
||||||
|
|
||||||
export function LinkWrapper() {
|
export function AuthHomeRoute() {
|
||||||
const [editId] = useAtom(linkEditIdAtom)
|
const [editId] = useAtom(linkEditIdAtom)
|
||||||
|
|
||||||
return (
|
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"
|
"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 { 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 { linkEditIdAtom, linkShowCreateAtom } from "@/store/link"
|
||||||
import {
|
import { useAtom } from "jotai"
|
||||||
DropdownMenu,
|
import React, { useEffect, useRef, useState } from "react"
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { useKey } from "react-use"
|
import { useKey } from "react-use"
|
||||||
|
import { globalLinkFormExceptionRefsAtom, LinkForm } from "./link-form"
|
||||||
export type LinkFormValues = z.infer<typeof createLinkSchema>
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
|
import LinkOptions from "@/components/LinkOptions"
|
||||||
const DEFAULT_FORM_VALUES: Partial<LinkFormValues> = {
|
// import { FloatingButton } from "./partial/floating-button"
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
topic: "",
|
|
||||||
isLink: false,
|
|
||||||
meta: null
|
|
||||||
}
|
|
||||||
|
|
||||||
const LinkManage: React.FC = () => {
|
const LinkManage: React.FC = () => {
|
||||||
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
|
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
|
||||||
const [, setEditId] = useAtom(linkEditIdAtom)
|
const [editId, setEditId] = useAtom(linkEditIdAtom)
|
||||||
const formRef = useRef<HTMLFormElement>(null)
|
const [, setGlobalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
|
||||||
|
|
||||||
|
const [showOptions, setShowOptions] = useState(false)
|
||||||
|
|
||||||
|
const optionsRef = useRef<HTMLDivElement>(null)
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const toggleForm = (event: React.MouseEvent) => {
|
const toggleForm = (event: React.MouseEvent) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
if (showCreate) return
|
||||||
setShowCreate(prev => !prev)
|
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) {
|
if (!showCreate) {
|
||||||
formRef.current?.reset()
|
|
||||||
setEditId(null)
|
setEditId(null)
|
||||||
}
|
}
|
||||||
}, [showCreate, setEditId])
|
}, [showCreate, setEditId])
|
||||||
|
|
||||||
|
useKey("Escape", handleFormClose)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOutsideClick = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (optionsRef.current && !optionsRef.current.contains(event.target as Node)) {
|
||||||
formRef.current &&
|
setShowOptions(false)
|
||||||
!formRef.current.contains(event.target as Node) &&
|
|
||||||
buttonRef.current &&
|
|
||||||
!buttonRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setShowCreate(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCreate) {
|
if (showOptions) {
|
||||||
document.addEventListener("mousedown", handleOutsideClick)
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showCreate && (
|
{showCreate && <LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />}
|
||||||
<div className="z-50">
|
<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">
|
||||||
<LinkForm ref={formRef} onSuccess={() => setShowCreate(false)} onCancel={() => setShowCreate(false)} />
|
<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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<CreateButton ref={buttonRef} onClick={toggleForm} isOpen={showCreate} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
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 { z } from "zod"
|
||||||
import { isUrl } from "@/lib/utils"
|
|
||||||
|
|
||||||
export const createLinkSchema = z.object({
|
export const createLinkSchema = z.object({
|
||||||
|
url: urlSchema,
|
||||||
|
icon: z.string().optional(),
|
||||||
title: z.string().min(1, { message: "Title can't be empty" }),
|
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(),
|
description: z.string().optional(),
|
||||||
topic: z.string().optional(),
|
completed: z.boolean().default(false),
|
||||||
isLink: z.boolean().default(true),
|
notes: z.string().optional(),
|
||||||
meta: z
|
learningState: z.enum(["wantToLearn", "learning", "learned"]),
|
||||||
.object({
|
topic: z.string().nullable().optional()
|
||||||
url: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
favicon: z.string(),
|
|
||||||
description: z.string().optional().nullable()
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
completed: z.boolean().default(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type LinkFormValues = z.infer<typeof createLinkSchema>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { ListFilterIcon } from "lucide-react"
|
import { ListFilterIcon } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import Link from "next/link"
|
|
||||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||||
import { useMedia } from "react-use"
|
import { useMedia } from "react-use"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { linkSortAtom } from "@/store/link"
|
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 {
|
const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
|
||||||
url: string
|
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
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)")
|
const isTablet = useMedia("(max-width: 1024px)")
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||||
<SidebarToggleButton />
|
<SidebarToggleButton />
|
||||||
<div className="flex min-h-0 items-center">
|
<div className="flex min-h-0 items-center">
|
||||||
@@ -32,7 +34,7 @@ export const LinkHeader = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isTablet && <Tabs />}
|
{!isTablet && <LearningTab />}
|
||||||
|
|
||||||
<div className="flex flex-auto"></div>
|
<div className="flex flex-auto"></div>
|
||||||
|
|
||||||
@@ -41,66 +43,66 @@ export const LinkHeader = () => {
|
|||||||
|
|
||||||
{isTablet && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
const Tabs = () => {
|
LinkHeader.displayName = "LinkHeader"
|
||||||
const [activeTab, setActiveTab] = React.useState(TABS[0])
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="bg-secondary/50 flex items-baseline overflow-x-hidden rounded-md">
|
<FancySwitch
|
||||||
{TABS.map(tab => (
|
value={activeTab}
|
||||||
<TabItem key={tab} url="#" label={tab} isActive={activeTab === tab} onClick={() => setActiveTab(tab)} />
|
onChange={value => {
|
||||||
))}
|
handleTabChange(value as string)
|
||||||
</div>
|
}}
|
||||||
|
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 {
|
const FilterAndSort = React.memo(() => {
|
||||||
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 [sort, setSort] = useAtom(linkSortAtom)
|
const [sort, setSort] = useAtom(linkSortAtom)
|
||||||
const [sortOpen, setSortOpen] = React.useState(false)
|
const [sortOpen, setSortOpen] = React.useState(false)
|
||||||
|
|
||||||
const getFilterText = () => {
|
const getFilterText = React.useCallback(() => {
|
||||||
return sort.charAt(0).toUpperCase() + sort.slice(1)
|
return sort.charAt(0).toUpperCase() + sort.slice(1)
|
||||||
}
|
}, [sort])
|
||||||
|
|
||||||
const handleSortChange = (value: string) => {
|
const handleSortChange = React.useCallback(
|
||||||
setSort(value)
|
(value: string) => {
|
||||||
setSortOpen(false)
|
setSort(value)
|
||||||
}
|
setSortOpen(false)
|
||||||
|
},
|
||||||
|
[setSort]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-auto items-center justify-end">
|
<div className="flex w-auto items-center justify-end">
|
||||||
@@ -134,4 +136,6 @@ const FilterAndSort = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
FilterAndSort.displayName = "FilterAndSort"
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
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 { PersonalLink } from "@/lib/schema/personal-link"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LinkForm } from "./form/manage"
|
import { useSortable } from "@dnd-kit/sortable"
|
||||||
import { Button } from "@/components/ui/button"
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
import { ConfirmOptions } from "@omit/react-confirm-dialog"
|
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"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
interface ListItemProps {
|
interface ListItemProps {
|
||||||
@@ -25,6 +30,8 @@ interface ListItemProps {
|
|||||||
setFocusedId: (id: string | null) => void
|
setFocusedId: (id: string | null) => void
|
||||||
registerRef: (id: string, ref: HTMLLIElement | null) => void
|
registerRef: (id: string, ref: HTMLLIElement | null) => void
|
||||||
onDelete?: (personalLink: PersonalLink) => void
|
onDelete?: (personalLink: PersonalLink) => void
|
||||||
|
showDeleteIconForLinkId: string | null
|
||||||
|
setShowDeleteIconForLinkId: (id: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListItem: React.FC<ListItemProps> = ({
|
export const ListItem: React.FC<ListItemProps> = ({
|
||||||
@@ -37,11 +44,11 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
isFocused,
|
isFocused,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
registerRef,
|
registerRef,
|
||||||
onDelete
|
onDelete,
|
||||||
|
showDeleteIconForLinkId,
|
||||||
|
setShowDeleteIconForLinkId
|
||||||
}) => {
|
}) => {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
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 = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -49,12 +56,6 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
pointerEvents: isDragging ? "none" : "auto"
|
pointerEvents: isDragging ? "none" : "auto"
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isEditing) {
|
|
||||||
formRef.current?.focus()
|
|
||||||
}
|
|
||||||
}, [isEditing])
|
|
||||||
|
|
||||||
const refCallback = React.useCallback(
|
const refCallback = React.useCallback(
|
||||||
(node: HTMLLIElement | null) => {
|
(node: HTMLLIElement | null) => {
|
||||||
setNodeRef(node)
|
setNodeRef(node)
|
||||||
@@ -74,19 +75,17 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
setEditId(null)
|
setEditId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleOnClose = () => {
|
||||||
setEditId(null)
|
setEditId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// const handleRowClick = () => {
|
const handleOnFail = () => {}
|
||||||
// console.log("Row clicked", personalLink.id)
|
|
||||||
// setEditId(personalLink.id)
|
|
||||||
// }
|
|
||||||
const handleRowClick = () => {
|
|
||||||
setShowDeleteIcon(!showDeleteIcon)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
// const handleRowClick = () => {
|
||||||
|
// setShowDeleteIconForLinkId(personalLink.id)
|
||||||
|
// }
|
||||||
|
|
||||||
|
const handleRowDoubleClick = () => {
|
||||||
setEditId(personalLink.id)
|
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) {
|
if (isEditing) {
|
||||||
return <LinkForm ref={formRef} personalLink={personalLink} onSuccess={handleSuccess} onCancel={handleCancel} />
|
return (
|
||||||
|
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={handleOnFail} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,27 +131,74 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
{...listeners}
|
{...listeners}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onFocus={() => setFocusedId(personalLink.id)}
|
onFocus={() => setFocusedId(personalLink.id)}
|
||||||
onBlur={() => setFocusedId(null)}
|
onBlur={() => {
|
||||||
|
setFocusedId(null)
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11", {
|
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11", {
|
||||||
"bg-muted/50": isFocused
|
"bg-muted/50": isFocused
|
||||||
})}
|
})}
|
||||||
onClick={handleRowClick}
|
// onClick={handleRowClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleRowDoubleClick}
|
||||||
>
|
>
|
||||||
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
<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">
|
<div className="flex min-w-0 items-center gap-x-4">
|
||||||
<Checkbox
|
{/* <Checkbox
|
||||||
checked={personalLink.completed}
|
checked={personalLink.completed}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
personalLink.completed = !personalLink.completed
|
personalLink.completed = !personalLink.completed
|
||||||
}}
|
}}
|
||||||
className="border-muted-foreground border"
|
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
|
<Image
|
||||||
src={personalLink.meta.favicon}
|
src={personalLink.icon}
|
||||||
alt={personalLink.title}
|
alt={personalLink.title}
|
||||||
className="size-5 rounded-full"
|
className="size-5 rounded-full"
|
||||||
width={16}
|
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">
|
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
|
||||||
{personalLink.title}
|
{personalLink.title}
|
||||||
</p>
|
</p>
|
||||||
{personalLink.isLink && personalLink.meta && (
|
{personalLink.url && (
|
||||||
<div className="group flex items-center gap-x-1">
|
<div className="group flex items-center gap-x-1">
|
||||||
<LinkIcon
|
<LinkIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
|
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={personalLink.meta.url}
|
href={personalLink.url}
|
||||||
passHref
|
passHref
|
||||||
prefetch={false}
|
prefetch={false}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -176,7 +226,7 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="text-muted-foreground hover:text-primary text-xs"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -185,12 +235,15 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-x-4">
|
<div className="flex shrink-0 items-center gap-x-4">
|
||||||
<Badge variant="secondary">Topic Name</Badge>
|
{personalLink.topic && <Badge variant="secondary">{personalLink.topic.prettyName}</Badge>}
|
||||||
{showDeleteIcon && (
|
{showDeleteIconForLinkId === personalLink.id && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive h-auto w-auto bg-transparent hover:bg-transparent hover:text-red-500"
|
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} />
|
<Trash2Icon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { useKey } from "react-use"
|
|||||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||||
import { ListItem } from "./list-item"
|
import { ListItem } from "./list-item"
|
||||||
import { useRef, useState, useCallback, useEffect } from "react"
|
import { useRef, useState, useCallback, useEffect } from "react"
|
||||||
|
import { learningStateAtom } from "./header"
|
||||||
|
|
||||||
const LinkList = () => {
|
const LinkList = () => {
|
||||||
|
const [activeLearningState] = useAtom(learningStateAtom)
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
const { me } = useAccount({
|
const { me } = useAccount({
|
||||||
root: { personalLinks: [] }
|
root: { personalLinks: [] }
|
||||||
@@ -32,11 +34,17 @@ const LinkList = () => {
|
|||||||
const [focusedId, setFocusedId] = useState<string | null>(null)
|
const [focusedId, setFocusedId] = useState<string | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||||
const linkRefs = useRef<{ [key: string]: HTMLLIElement | 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 =
|
let sortedLinks =
|
||||||
sort === "title" && personalLinks
|
sort === "title" && filteredLinks
|
||||||
? [...personalLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
||||||
: personalLinks
|
: filteredLinks
|
||||||
sortedLinks = sortedLinks || []
|
sortedLinks = sortedLinks || []
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -50,10 +58,6 @@ const LinkList = () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const overlayClick = () => {
|
|
||||||
setEditId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const registerRef = useCallback((id: string, ref: HTMLLIElement | null) => {
|
const registerRef = useCallback((id: string, ref: HTMLLIElement | null) => {
|
||||||
linkRefs.current[id] = ref
|
linkRefs.current[id] = ref
|
||||||
}, [])
|
}, [])
|
||||||
@@ -190,40 +194,39 @@ const LinkList = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative z-20">
|
||||||
{editId && <div className="fixed inset-0 z-10" onClick={overlayClick} />}
|
<DndContext
|
||||||
<div className="relative z-20">
|
sensors={sensors}
|
||||||
<DndContext
|
collisionDetection={closestCenter}
|
||||||
sensors={sensors}
|
onDragStart={handleDragStart}
|
||||||
collisionDetection={closestCenter}
|
onDragEnd={handleDragEnd}
|
||||||
onDragStart={handleDragStart}
|
>
|
||||||
onDragEnd={handleDragEnd}
|
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
||||||
>
|
<ul role="list" className="divide-primary/5 divide-y">
|
||||||
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
{sortedLinks.map(
|
||||||
<ul role="list" className="divide-primary/5 divide-y">
|
linkItem =>
|
||||||
{sortedLinks.map(
|
linkItem && (
|
||||||
linkItem =>
|
<ListItem
|
||||||
linkItem && (
|
key={linkItem.id}
|
||||||
<ListItem
|
confirm={confirm}
|
||||||
key={linkItem.id}
|
isEditing={editId === linkItem.id}
|
||||||
confirm={confirm}
|
setEditId={setEditId}
|
||||||
isEditing={editId === linkItem.id}
|
personalLink={linkItem}
|
||||||
setEditId={setEditId}
|
disabled={sort !== "manual" || editId !== null}
|
||||||
personalLink={linkItem}
|
registerRef={registerRef}
|
||||||
disabled={sort !== "manual" || editId !== null}
|
isDragging={draggingId === linkItem.id}
|
||||||
registerRef={registerRef}
|
isFocused={focusedId === linkItem.id}
|
||||||
isDragging={draggingId === linkItem.id}
|
setFocusedId={setFocusedId}
|
||||||
isFocused={focusedId === linkItem.id}
|
onDelete={handleDelete}
|
||||||
setFocusedId={setFocusedId}
|
showDeleteIconForLinkId={showDeleteIconForLinkId}
|
||||||
onDelete={handleDelete}
|
setShowDeleteIconForLinkId={setShowDeleteIconForLinkId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { useCallback, useRef, useEffect } from "react"
|
import * as React from "react"
|
||||||
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
import { useAtom } from "jotai"
|
||||||
// import { DetailPageHeader } from "./header" //dont need. check figma
|
|
||||||
import { ID } from "jazz-tools"
|
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 { Content, EditorContent, useEditor } from "@tiptap/react"
|
||||||
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
||||||
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
|
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
|
||||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { EditorView } from "prosemirror-view"
|
import { EditorView } from "@tiptap/pm/view"
|
||||||
import { Editor } from "@tiptap/core"
|
import { Editor } from "@tiptap/core"
|
||||||
import { generateUniqueSlug } from "@/lib/utils"
|
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"
|
const TITLE_PLACEHOLDER = "Page title"
|
||||||
|
|
||||||
@@ -25,7 +33,6 @@ export function DetailPageWrapper({ pageId }: { pageId: string }) {
|
|||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
||||||
{/* <DetailPageHeader pageId={pageId as ID<PersonalPage>} /> */}
|
|
||||||
<DetailPageForm page={page} />
|
<DetailPageForm page={page} />
|
||||||
</div>
|
</div>
|
||||||
</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 { me } = useAccount()
|
||||||
|
|
||||||
const titleEditorRef = useRef<Editor | null>(null)
|
const titleEditorRef = useRef<Editor | null>(null)
|
||||||
const contentEditorRef = useRef<LAEditorRef>(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) => {
|
const updatePageContent = (content: Content, model: PersonalPage) => {
|
||||||
model.content = content
|
model.content = content
|
||||||
@@ -59,11 +68,11 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
const personalPages = me.root?.personalPages?.toJSON() || []
|
const personalPages = me.root?.personalPages?.toJSON() || []
|
||||||
const slug = generateUniqueSlug(personalPages, page.slug)
|
const slug = generateUniqueSlug(personalPages, page.slug)
|
||||||
|
|
||||||
const capitalizedTitle = newTitle.charAt(0).toUpperCase() + newTitle.slice(1)
|
const trimmedTitle = editor.getText().trim()
|
||||||
page.title = capitalizedTitle
|
page.title = trimmedTitle
|
||||||
page.slug = slug
|
page.slug = slug
|
||||||
|
|
||||||
editor.commands.setContent(capitalizedTitle)
|
editor.commands.setContent(trimmedTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||||
@@ -74,44 +83,29 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
const { selection } = state
|
const { selection } = state
|
||||||
const { $anchor } = selection
|
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) {
|
if ((event.key === "ArrowLeft" || event.key === "ArrowUp") && $anchor.pos - 1 === 0) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
titleEditorRef.current?.commands.focus("end")
|
titleEditorRef.current?.commands.focus("end")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
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({
|
const titleEditor = useEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -159,11 +153,28 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
|
<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)]">
|
<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">
|
<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
|
<EditorContent
|
||||||
editor={titleEditor}
|
editor={titleEditor}
|
||||||
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
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>
|
||||||
<div className="flex flex-auto flex-col">
|
<div className="flex flex-auto flex-col">
|
||||||
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DeletePageModal
|
||||||
|
isOpen={deleteModalOpen}
|
||||||
|
onClose={() => setDeleteModalOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
confirmDelete(page)
|
||||||
|
}}
|
||||||
|
title={page.title.charAt(0).toUpperCase() + page.title.slice(1)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ interface ProfileTopicsProps {
|
|||||||
|
|
||||||
const ProfileTopics: React.FC<ProfileTopicsProps> = ({ topic }) => {
|
const ProfileTopics: React.FC<ProfileTopicsProps> = ({ topic }) => {
|
||||||
return (
|
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>
|
<p>{topic}</p>
|
||||||
<IoChevronForward className="text-white" size={20} />
|
<IoChevronForward className="text-black/50 dark:text-white" size={20} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -30,22 +30,24 @@ interface ProfileTitleProps {
|
|||||||
|
|
||||||
const ProfileTitle: React.FC<ProfileTitleProps> = ({ topicTitle, spanNumber }) => {
|
const ProfileTitle: React.FC<ProfileTitleProps> = ({ topicTitle, spanNumber }) => {
|
||||||
return (
|
return (
|
||||||
<p className="pb-3 pl-2 text-base font-light text-white/50">
|
<p className="pb-3 pl-2 text-base font-light text-black/50 dark:text-white/50">
|
||||||
{topicTitle} <span className="text-white">{spanNumber}</span>
|
{topicTitle} <span className="text-black dark:text-white">{spanNumber}</span>
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileLinks: React.FC<ProfileLinksProps> = ({ linklabel, link, topic }) => {
|
const ProfileLinks: React.FC<ProfileLinksProps> = ({ linklabel, link, topic }) => {
|
||||||
return (
|
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">
|
<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">
|
<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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -84,25 +86,33 @@ export const SearchWrapper = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||||
<div className="flex h-full w-full justify-center 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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={inputChange}
|
onChange={inputChange}
|
||||||
onKeyDown={handleKeyDown}
|
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..."
|
placeholder="Search..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showAiPlaceholder && searchText && !showAiSearch && (
|
{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 && (
|
{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>
|
||||||
|
<div className="my-5 rounded-lg bg-blue-600 p-4 font-semibold text-white">✨ Ask AI</div>
|
||||||
{showAiSearch ? (
|
{showAiSearch ? (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<div className="absolute left-1/2 w-[110%] -translate-x-1/2">
|
<div className="absolute left-1/2 w-[110%] -translate-x-1/2">
|
||||||
@@ -115,7 +125,6 @@ export const SearchWrapper = () => {
|
|||||||
<ProfileTitle topicTitle="Topics" spanNumber={1} />
|
<ProfileTitle topicTitle="Topics" spanNumber={1} />
|
||||||
<ProfileTopics topic="Figma" />
|
<ProfileTopics topic="Figma" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-5 space-y-1">
|
<div className="my-5 space-y-1">
|
||||||
<ProfileTitle topicTitle="Links" spanNumber={3} />
|
<ProfileTitle topicTitle="Links" spanNumber={3} />
|
||||||
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />
|
<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.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center px-3" cmdk-input-wrapper="">
|
||||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<MagnifyingGlassIcon className="mr-2 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -83,7 +83,7 @@ const CommandGroup = React.forwardRef<
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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 { 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)
|
// GlobalLinkAiSummary is high quality title, description, summary of link (generated by AI)
|
||||||
export class GlobalLinkAiSummary extends CoMap {
|
export class GlobalLinkAiSummary extends CoMap {
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
import { CoMap, co, Account, Profile } from "jazz-tools"
|
import { CoMap, co, Account, Profile } from "jazz-tools"
|
||||||
import { PersonalPageLists } from "./personal-page"
|
import { PersonalPageLists } from "./personal-page"
|
||||||
import { PersonalLinkLists } from "./personal-link"
|
import { PersonalLinkLists } from "./personal-link"
|
||||||
import { GlobalTopicLists } from "./global-topic"
|
import { ListOfTopics } from "./master/topic"
|
||||||
|
|
||||||
export class UserRoot extends CoMap {
|
export class UserRoot extends CoMap {
|
||||||
name = co.string
|
name = co.string
|
||||||
username = co.string
|
username = co.string
|
||||||
@@ -24,9 +25,9 @@ export class UserRoot extends CoMap {
|
|||||||
personalPages = co.ref(PersonalPageLists)
|
personalPages = co.ref(PersonalPageLists)
|
||||||
|
|
||||||
// not implemented yet
|
// not implemented yet
|
||||||
topicsWantToLearn = co.ref(GlobalTopicLists)
|
topicsWantToLearn = co.ref(ListOfTopics)
|
||||||
topicsLearning = co.ref(GlobalTopicLists)
|
topicsLearning = co.ref(ListOfTopics)
|
||||||
topicsLearned = co.ref(GlobalTopicLists)
|
topicsLearned = co.ref(ListOfTopics)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LaAccount extends Account {
|
export class LaAccount extends Account {
|
||||||
@@ -53,9 +54,9 @@ export class LaAccount extends Account {
|
|||||||
personalPages: PersonalPageLists.create([], { owner: this }),
|
personalPages: PersonalPageLists.create([], { owner: this }),
|
||||||
|
|
||||||
// not implemented yet
|
// not implemented yet
|
||||||
topicsWantToLearn: GlobalTopicLists.create([], { owner: this }),
|
topicsWantToLearn: ListOfTopics.create([], { owner: this }),
|
||||||
topicsLearning: GlobalTopicLists.create([], { owner: this }),
|
topicsLearning: ListOfTopics.create([], { owner: this }),
|
||||||
topicsLearned: GlobalTopicLists.create([], { owner: this })
|
topicsLearned: ListOfTopics.create([], { owner: this })
|
||||||
},
|
},
|
||||||
{ owner: this }
|
{ owner: this }
|
||||||
)
|
)
|
||||||
@@ -63,7 +64,6 @@ export class LaAccount extends Account {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from "./global-link"
|
export * from "./master/topic"
|
||||||
export * from "./global-topic"
|
|
||||||
export * from "./personal-link"
|
export * from "./personal-link"
|
||||||
export * from "./personal-page"
|
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 { co, CoList, CoMap, Encoders, ID } from "jazz-tools"
|
||||||
import { nullable } from "../types"
|
import { Topic } from "./master/topic"
|
||||||
import { GlobalLink } from "./global-link"
|
|
||||||
import { GlobalTopic } from "./global-topic"
|
|
||||||
|
|
||||||
export class LinkMetadata extends CoMap {
|
class BaseModel extends CoMap {
|
||||||
url = co.string
|
createdAt = co.encoded(Encoders.Date)
|
||||||
title = co.string
|
updatedAt = co.encoded(Encoders.Date)
|
||||||
favicon = co.string
|
|
||||||
description = nullable(co.string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
export class PersonalLink extends BaseModel {
|
||||||
* Link is link user added, it wraps over Link and lets user add notes and other things to it,
|
url = co.string
|
||||||
* (as well as set own title/description/summary if GlobalLink ones is not good enough or is lacking)
|
icon = co.optional.string // is an icon URL
|
||||||
*/
|
|
||||||
export class PersonalLink extends CoMap {
|
|
||||||
title = co.string
|
title = co.string
|
||||||
slug = co.string
|
slug = co.string
|
||||||
description = co.optional.string
|
description = co.optional.string
|
||||||
completed = co.boolean
|
completed = co.boolean
|
||||||
sequence = co.number
|
sequence = co.number
|
||||||
isLink = co.boolean
|
|
||||||
meta = co.optional.ref(LinkMetadata)
|
|
||||||
|
|
||||||
// not yet implemented
|
|
||||||
learningState = co.optional.literal("wantToLearn", "learning", "learned")
|
learningState = co.optional.literal("wantToLearn", "learning", "learned")
|
||||||
notes = co.optional.string
|
notes = co.optional.string
|
||||||
summary = co.optional.string
|
summary = co.optional.string
|
||||||
globalLink = co.optional.ref(GlobalLink)
|
topic = co.optional.ref(Topic)
|
||||||
topic = co.optional.ref(GlobalTopic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PersonalLinkLists extends CoList.Of(co.ref(PersonalLink)) {}
|
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 { 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.
|
* 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
|
title = co.string
|
||||||
slug = co.string
|
slug = co.string
|
||||||
content = co.optional.json()
|
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
|
// 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)
|
return pattern.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureUrlProtocol(url: string): string {
|
export function ensureUrlProtocol(url: string, defaultProtocol: string = "https://"): string {
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (url.match(/^[a-zA-Z]+:\/\//)) {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
return `https://${url}`
|
return `${defaultProtocol}${url.startsWith("//") ? url.slice(2) : url}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "**"
|
hostname: "**"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
207
web/package.json
207
web/package.json
@@ -1,103 +1,108 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@omit/react-confirm-dialog": "^1.1.0",
|
"@omit/react-confirm-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.1",
|
"@omit/react-fancy-switch": "^0.1.1",
|
||||||
"@radix-ui/react-context-menu": "^2.2.1",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
"@radix-ui/react-dismissable-layer": "^1.1.0",
|
"@radix-ui/react-context-menu": "^2.2.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-focus-scope": "^1.1.0",
|
"@radix-ui/react-dismissable-layer": "^1.1.0",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-focus-scope": "^1.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-toggle": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@tiptap/core": "^2.5.9",
|
"@radix-ui/react-toggle": "^1.1.0",
|
||||||
"@tiptap/extension-blockquote": "^2.5.9",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@tiptap/extension-bold": "^2.5.9",
|
"@tiptap/core": "^2.6.6",
|
||||||
"@tiptap/extension-bullet-list": "^2.5.9",
|
"@tiptap/extension-blockquote": "^2.6.6",
|
||||||
"@tiptap/extension-code": "^2.5.9",
|
"@tiptap/extension-bold": "^2.6.6",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.5.9",
|
"@tiptap/extension-bullet-list": "^2.6.6",
|
||||||
"@tiptap/extension-document": "^2.5.9",
|
"@tiptap/extension-code": "^2.6.6",
|
||||||
"@tiptap/extension-dropcursor": "^2.5.9",
|
"@tiptap/extension-code-block-lowlight": "^2.6.6",
|
||||||
"@tiptap/extension-focus": "^2.5.9",
|
"@tiptap/extension-document": "^2.6.6",
|
||||||
"@tiptap/extension-gapcursor": "^2.5.9",
|
"@tiptap/extension-dropcursor": "^2.6.6",
|
||||||
"@tiptap/extension-hard-break": "^2.5.9",
|
"@tiptap/extension-focus": "^2.6.6",
|
||||||
"@tiptap/extension-heading": "^2.5.9",
|
"@tiptap/extension-gapcursor": "^2.6.6",
|
||||||
"@tiptap/extension-history": "^2.5.9",
|
"@tiptap/extension-hard-break": "^2.6.6",
|
||||||
"@tiptap/extension-horizontal-rule": "^2.5.9",
|
"@tiptap/extension-heading": "^2.6.6",
|
||||||
"@tiptap/extension-italic": "^2.5.9",
|
"@tiptap/extension-history": "^2.6.6",
|
||||||
"@tiptap/extension-link": "^2.5.9",
|
"@tiptap/extension-horizontal-rule": "^2.6.6",
|
||||||
"@tiptap/extension-list-item": "^2.5.9",
|
"@tiptap/extension-italic": "^2.6.6",
|
||||||
"@tiptap/extension-ordered-list": "^2.5.9",
|
"@tiptap/extension-link": "^2.6.6",
|
||||||
"@tiptap/extension-paragraph": "^2.5.9",
|
"@tiptap/extension-list-item": "^2.6.6",
|
||||||
"@tiptap/extension-placeholder": "^2.5.9",
|
"@tiptap/extension-ordered-list": "^2.6.6",
|
||||||
"@tiptap/extension-strike": "^2.5.9",
|
"@tiptap/extension-paragraph": "^2.6.6",
|
||||||
"@tiptap/extension-task-item": "^2.5.9",
|
"@tiptap/extension-placeholder": "^2.6.6",
|
||||||
"@tiptap/extension-task-list": "^2.5.9",
|
"@tiptap/extension-strike": "^2.6.6",
|
||||||
"@tiptap/extension-text": "^2.5.9",
|
"@tiptap/extension-task-item": "^2.6.6",
|
||||||
"@tiptap/extension-typography": "^2.5.9",
|
"@tiptap/extension-task-list": "^2.6.6",
|
||||||
"@tiptap/pm": "^2.5.9",
|
"@tiptap/extension-text": "^2.6.6",
|
||||||
"@tiptap/react": "^2.5.9",
|
"@tiptap/extension-typography": "^2.6.6",
|
||||||
"@tiptap/suggestion": "^2.5.9",
|
"@tiptap/pm": "^2.6.6",
|
||||||
"axios": "^1.7.3",
|
"@tiptap/react": "^2.6.6",
|
||||||
"cheerio": "1.0.0-rc.12",
|
"@tiptap/suggestion": "^2.6.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"axios": "^1.7.5",
|
||||||
"clsx": "^2.1.1",
|
"cheerio": "1.0.0",
|
||||||
"cmdk": "^1.0.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"date-fns": "^3.6.0",
|
"clsx": "^2.1.1",
|
||||||
"jazz-react": "^0.7.25",
|
"cmdk": "^1.0.0",
|
||||||
"jazz-tools": "^0.7.25",
|
"date-fns": "^3.6.0",
|
||||||
"jotai": "^2.9.2",
|
"framer-motion": "^11.3.30",
|
||||||
"lowlight": "^3.1.0",
|
"jazz-react": "^0.7.34",
|
||||||
"lucide-react": "^0.424.0",
|
"jazz-tools": "^0.7.34",
|
||||||
"next": "14.2.5",
|
"jotai": "^2.9.3",
|
||||||
"next-themes": "^0.3.0",
|
"lowlight": "^3.1.0",
|
||||||
"react": "^18.3.1",
|
"lucide-react": "^0.429.0",
|
||||||
"react-day-picker": "^9.0.8",
|
"next": "14.2.5",
|
||||||
"react-dom": "^18.3.1",
|
"next-themes": "^0.3.0",
|
||||||
"react-hook-form": "^7.52.2",
|
"nuqs": "^1.17.8",
|
||||||
"react-use": "^17.5.1",
|
"react": "^18.3.1",
|
||||||
"slugify": "^1.6.6",
|
"react-day-picker": "^9.0.8",
|
||||||
"sonner": "^1.5.0",
|
"react-dom": "^18.3.1",
|
||||||
"streaming-markdown": "^0.0.14",
|
"react-hook-form": "^7.53.0",
|
||||||
"tailwind-merge": "^2.4.0",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"react-use": "^17.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"slugify": "^1.6.6",
|
||||||
"zod": "^3.23.8",
|
"sonner": "^1.5.0",
|
||||||
"zsa": "^0.5.1"
|
"streaming-markdown": "^0.0.14",
|
||||||
},
|
"tailwind-merge": "^2.5.2",
|
||||||
"devDependencies": {
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"@testing-library/jest-dom": "^6.4.8",
|
"ts-node": "^10.9.2",
|
||||||
"@testing-library/react": "^16.0.0",
|
"zod": "^3.23.8",
|
||||||
"@types/jest": "^29.5.12",
|
"zsa": "^0.6.0"
|
||||||
"@types/node": "^22.1.0",
|
},
|
||||||
"@types/react": "^18.3.3",
|
"devDependencies": {
|
||||||
"@types/react-dom": "^18.3.0",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"eslint": "^9.8.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"eslint-config-next": "14.2.5",
|
"@types/jest": "^29.5.12",
|
||||||
"jest": "^29.7.0",
|
"@types/node": "^22.5.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"@types/react": "^18.3.4",
|
||||||
"postcss": "^8.4.41",
|
"@types/react-dom": "^18.3.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"eslint": "^9.9.1",
|
||||||
"tailwindcss": "^3.4.9",
|
"eslint-config-next": "14.2.5",
|
||||||
"ts-jest": "^29.2.4",
|
"jest": "^29.7.0",
|
||||||
"typescript": "^5.5.4"
|
"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 linkSortAtom = atomWithStorage("sort", "manual")
|
||||||
export const linkShowCreateAtom = atom(false)
|
export const linkShowCreateAtom = atom(false)
|
||||||
export const linkEditIdAtom = atom<string | number | null>(null)
|
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: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
|
result: "hsl(var(--result))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring))",
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
|
|||||||
Reference in New Issue
Block a user