diff --git a/.gitignore b/.gitignore index b946812b..c589c424 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# base .DS_Store .env .env*.local @@ -13,3 +12,4 @@ pnpm-lock.yaml # other private past-* +output diff --git a/bun.lockb b/bun.lockb index 7a133c97..569990d6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli/run.ts b/cli/run.ts index 9d597a10..efc864bc 100644 --- a/cli/run.ts +++ b/cli/run.ts @@ -1,12 +1,89 @@ import { getEnvOrThrow } from "@/lib/utils" +import { PublicGlobalGroup } from "@/web/lib/schema/master/public-group" +import { startWorker } from "jazz-nodejs" +import { ID } from "jazz-tools" + +const JAZZ_WORKER_SECRET = getEnvOrThrow("JAZZ_WORKER_SECRET") async function run() { try { - const OPENAI_API_KEY = getEnvOrThrow("OPENAI_API_KEY") - console.log(OPENAI_API_KEY) + await readJazz() } catch (err) { console.log(err, "err") } } +async function readJazz() { + const { worker } = await startWorker({ + accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", + accountSecret: JAZZ_WORKER_SECRET + }) + + const globalGroupId = process.env.JAZZ_PUBLIC_GLOBAL_GROUP as ID + 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() diff --git a/cli/seed.ts b/cli/seed.ts index c88d838d..0145fa48 100644 --- a/cli/seed.ts +++ b/cli/seed.ts @@ -1,11 +1,466 @@ import { getEnvOrThrow } from "@/lib/utils" -import { LaAccount } from "@/web/lib/schema" +import { Connection, ForceGraph, ListOfConnections, ListOfForceGraphs } from "@/web/lib/schema/master/force-graph" +import { PublicGlobalGroup, PublicGlobalGroupRoot } from "@/web/lib/schema/master/public-group" +import { + LatestGlobalGuide, + Link, + ListOfLinks, + ListOfSections, + ListOfTopics, + Section, + Topic +} from "@/web/lib/schema/master/topic" +import fs from "fs/promises" import { startWorker } from "jazz-nodejs" -import { Group, ID } from "jazz-tools" +import { ID } from "jazz-tools" import { appendFile } from "node:fs/promises" +import path from "path" +// Define interfaces for JSON data structures +interface LinkJson { + id?: ID + title: string + url: string +} + +interface SectionJson { + title: string + links: LinkJson[] +} + +interface TopicJson { + name: string + prettyName: string + latestGlobalGuide: { + sections: SectionJson[] + } | null +} + +// Get the Jazz worker secret from environment variables const JAZZ_WORKER_SECRET = getEnvOrThrow("JAZZ_WORKER_SECRET") +/** + * Manages links, handling deduplication and tracking duplicates. + */ +class LinkManager { + private links: Map = 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 + + 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 = new Map() + private duplicateCount: number = 0 + + /** + * Adds a connection to the manager, tracking duplicates. + * @param connection - The connection to add. + */ + addConnection(connection: string) { + if (this.connections.has(connection)) { + this.duplicateCount++ + } else { + this.connections.set(connection, connection) + } + } + + /** + * Gets all unique connections. + * @returns An array of unique connections. + */ + getAllConnections() { + return Array.from(this.connections.values()) + } + + /** + * Gets the count of duplicate connections. + * @returns The number of duplicate connections. + */ + getDuplicateCount() { + return this.duplicateCount + } +} + +/** + * Inserts connections in batch. + * @param connections - An array of string objects to insert. + * @returns A Promise that resolves to an array of created Connection models. + */ +async function insertConnectionsInBatch(connections: string[]) { + const globalGroup = await loadGlobalGroup() + const rows = [] + + for (const connection of connections) { + const connectionModel = Connection.create( + { + name: connection + }, + { owner: globalGroup } + ) + rows.push(connectionModel) + } + + return rows +} + +/** + * Saves force graph data to the global group. + * @param connectionLists - An array of Connection models. + * @param forceGraphs - An array of ForceGraphJson objects. + */ +async function saveForceGraph(connectionLists: Connection[], forceGraphs: ForceGraphJson[]) { + const globalGroup = await loadGlobalGroup() + + forceGraphs.map(forceGraph => { + const forceGraphModel = ForceGraph.create( + { + name: forceGraph.name, + prettyName: forceGraph.prettyName, + connections: ListOfConnections.create([], { owner: globalGroup }) + }, + { owner: globalGroup } + ) + + forceGraph.connections.map(connection => { + const connectionModel = connectionLists.find(c => c.name === connection) + if (connectionModel) { + forceGraphModel.connections?.push(connectionModel) + } + }) + + globalGroup.root.forceGraphs?.push(forceGraphModel) + }) +} + +async function forceGraphSeed() { + console.log("Starting to seed force graph data") + + const directory = path.join(__dirname, "..", "private", "data", "edgedb") + + const connectionManager = new ConnectionManager() + const processedData: ForceGraphJson[] = [] + + const files = await fs.readdir(directory) + const file = files.find(file => file === "force-graphs.json") + + if (!file) { + console.error("No force-graphs.json file found") + return + } + + const filePath = path.join(directory, file) + + try { + const forceGraphs = JSON.parse(await fs.readFile(filePath, "utf-8")) as ForceGraphJson[] + + for (const forceGraph of forceGraphs) { + if (forceGraph.connections.length) { + for (const connection of forceGraph.connections) { + connectionManager.addConnection(connection) + } + } + + processedData.push(forceGraph) + } + } catch (error) { + console.error(`Error processing file ${file}:`, error) + } + + console.log(`Collected ${connectionManager.getAllConnections().length} unique connections.`) + console.log(`Found ${connectionManager.getDuplicateCount()} duplicate connections.`) + + const insertedConnections = await insertConnectionsInBatch(connectionManager.getAllConnections()) + await saveForceGraph(insertedConnections, processedData) + + // wait 3 seconds before finishing + await new Promise(resolve => setTimeout(resolve, 3000)) + console.log("Finished seeding force graph data") +} + +/** + * Performs a full production rebuild. + */ +async function fullProdRebuild() { + await prodSeed() + await forceGraphSeed() +} + +/** + * Main seed function to handle different commands. + */ async function seed() { const args = Bun.argv const command = args[2] @@ -20,40 +475,19 @@ async function seed() { case "prod": await prodSeed() break + case "fullProdRebuild": + await fullProdRebuild() + break + case "forceGraph": + await forceGraphSeed() + break default: console.log("Unknown command") break } - console.log("done") } catch (err) { console.error("Error occurred:", err) } } -// sets up jazz global group and writes it to .env -async function setup() { - const { worker } = await startWorker({ - accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", - accountSecret: JAZZ_WORKER_SECRET - }) - const user = (await await LaAccount.createAs(worker, { - creationProps: { name: "nikiv" } - }))! - const publicGlobalGroup = Group.create({ owner: worker }) - publicGlobalGroup.addMember("everyone", "reader") - await appendFile("./.env", `\nJAZZ_PUBLIC_GLOBAL_GROUP=${JSON.stringify(publicGlobalGroup.id)}`) - const adminGlobalGroup = Group.create({ owner: worker }) - adminGlobalGroup.addMember(user, "admin") - await appendFile("./.env", `\nJAZZ_ADMIN_GLOBAL_GROUP=${JSON.stringify(adminGlobalGroup.id)}`) -} - -async function prodSeed() { - const { worker } = await startWorker({ - accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", - accountSecret: JAZZ_WORKER_SECRET - }) - const globalGroup = await Group.load(process.env.JAZZ_PUBLIC_GLOBAL_GROUP as ID, worker, {}) - if (!globalGroup) return // TODO: err - // TODO: complete full seed (connections, topics from old LA) -} await seed() diff --git a/package.json b/package.json index e1b4a655..70c25964 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,31 @@ { - "name": "learn-anything", - "scripts": { - "dev": "bun web", - "web": "cd web && bun dev", - "web:build": "bun run --filter '*' build", - "cli": "bun run --watch cli/run.ts", - "seed": "bun --watch cli/seed.ts" - }, - "workspaces": [ - "web" - ], - "dependencies": { - "jazz-nodejs": "^0.7.23", - "react-icons": "^5.2.1" - }, - "devDependencies": { - "bun-types": "^1.1.21" - }, - "prettier": { - "plugins": [ - "prettier-plugin-tailwindcss" - ], - "useTabs": true, - "semi": false, - "trailingComma": "none", - "printWidth": 120, - "arrowParens": "avoid" - }, - "license": "MIT" + "name": "learn-anything", + "scripts": { + "dev": "bun web", + "web": "cd web && bun dev", + "web:build": "bun run --filter '*' build", + "cli": "bun run --watch cli/run.ts", + "seed": "bun --watch cli/seed.ts" + }, + "workspaces": [ + "web" + ], + "dependencies": { + "jazz-nodejs": "^0.7.34", + "react-icons": "^5.3.0" + }, + "devDependencies": { + "bun-types": "^1.1.26" + }, + "prettier": { + "plugins": [ + "prettier-plugin-tailwindcss" + ], + "useTabs": true, + "semi": false, + "trailingComma": "none", + "printWidth": 120, + "arrowParens": "avoid" + }, + "license": "MIT" } diff --git a/web/.env.example b/web/.env.example index d0c69440..37112a6d 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,2 +1,4 @@ NEXT_PUBLIC_APP_NAME="Learn Anything" -NEXT_PUBLIC_APP_URL=http://localhost:3000 \ No newline at end of file +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +NEXT_PUBLIC_JAZZ_GLOBAL_GROUP="" \ No newline at end of file diff --git a/web/app/(pages)/(topics)/[name]/page.tsx b/web/app/(pages)/(topics)/[name]/page.tsx new file mode 100644 index 00000000..28e0f249 --- /dev/null +++ b/web/app/(pages)/(topics)/[name]/page.tsx @@ -0,0 +1,5 @@ +import { TopicDetailRoute } from "@/components/routes/topics/detail/TopicDetailRoute" + +export default function DetailTopicPage({ params }: { params: { name: string } }) { + return +} diff --git a/web/app/(pages)/edit-profile/page.tsx b/web/app/(pages)/edit-profile/page.tsx new file mode 100644 index 00000000..3e492911 --- /dev/null +++ b/web/app/(pages)/edit-profile/page.tsx @@ -0,0 +1,5 @@ +import EditProfileRoute from "@/components/routes/EditProfileRoute" + +export default function EditProfilePage() { + return +} diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 1c296d0d..5fecb9a3 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -1,15 +1,22 @@ import { Sidebar } from "@/components/custom/sidebar/sidebar" +import PublicHomeRoute from "@/components/routes/PublicHomeRoute" export default async function RootLayout({ children }: { children: React.ReactNode }) { - return ( -
- + // TODO: get it from jazz/clerk + const loggedIn = true -
-
- {children} -
+ if (loggedIn) { + return ( +
+ + +
+
+ {children} +
+
-
- ) + ) + } + return } diff --git a/web/app/(pages)/links/page.tsx b/web/app/(pages)/links/page.tsx deleted file mode 100644 index a8361cae..00000000 --- a/web/app/(pages)/links/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LinkWrapper } from "@/components/routes/link/wrapper" - -export default function LinkPage() { - return -} diff --git a/web/app/(pages)/page.tsx b/web/app/(pages)/page.tsx new file mode 100644 index 00000000..5b0fdb1d --- /dev/null +++ b/web/app/(pages)/page.tsx @@ -0,0 +1,5 @@ +import AuthHomeRoute from "@/components/routes/AuthHomeRoute" + +export default function HomePage() { + return +} diff --git a/web/app/(pages)/profile/_components/wrapper.tsx b/web/app/(pages)/profile/_components/wrapper.tsx index 8db87158..829d83bb 100644 --- a/web/app/(pages)/profile/_components/wrapper.tsx +++ b/web/app/(pages)/profile/_components/wrapper.tsx @@ -1,14 +1,137 @@ "use client" import { useAccount } from "@/lib/providers/jazz-provider" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { LaIcon } from "@/components/custom/la-icon" +import { Icon } from "@/components/la-editor/components/ui/icon" +import { Button } from "@/components/ui/button" -export const ProfileWrapper = () => { - const account = useAccount() +interface ProfileStatsProps { + number: number + label: string +} +interface ProfileLinksProps { + linklabel?: string + link?: string + topic?: string +} + +interface ProfilePagesProps { + topic?: string +} + +const ProfileStats: React.FC = ({ number, label }) => { return ( -
-

{account.me.profile?.name}

-

Profile Page

+
+

{number}

+

{label}

+
+ ) +} + +const ProfileLinks: React.FC = ({ linklabel, link, topic }) => { + return ( +
+
+

{linklabel || "Untitled"}

+
+ +

{link || "#"}

+
+
+
{topic || "Uncategorized"}
+
+ ) +} + +const ProfilePages: React.FC = ({ topic }) => { + return ( +
+
{topic || "Uncategorized"}
+
+ ) +} + +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 ( +
+
+

+ Oops! This account doesn't exist. +

+

Try searching for another.

+

+ The link you followed may be broken, or the page may have been removed. Go back to + + homepage + + . +

+
+
+ ) + } + + return ( +
+
+

Profile

+ +
+

{username}

+
+
+
+
+

{account.me.profile.name}

+
+ @

{account.me.root?.username}

+
+ + +

{account.me.root?.website}

+
+
+ +
+
+
+
+ + + +
+
+ + {/*
+

Public Pages

+ {account.me.root?.personalPages?.map((page, index) => )} +
+
+

Public Links

+ {account.me.root?.personalLinks?.map((link, index) => ( + + ))} +
*/}
) } diff --git a/web/app/(topics)/[topic]/layout.tsx b/web/app/(topics)/[topic]/layout.tsx deleted file mode 100644 index f1c3e520..00000000 --- a/web/app/(topics)/[topic]/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Sidebar } from "@/components/custom/sidebar/sidebar" - -export default function TopicsLayout({ children }: { children: React.ReactNode }) { - return ( -
- -
-
- {children} -
-
-
- ) -} diff --git a/web/app/(topics)/[topic]/page.tsx b/web/app/(topics)/[topic]/page.tsx deleted file mode 100644 index 3ead540f..00000000 --- a/web/app/(topics)/[topic]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import GlobalTopic from "@/components/routes/globalTopic/globalTopic" - -export default function GlobalTopicPage({ params }: { params: { topic: string } }) { - return -} diff --git a/web/app/api/metadata/route.test.ts b/web/app/api/metadata/route.test.ts index 83c96baa..2567b77e 100644 --- a/web/app/api/metadata/route.test.ts +++ b/web/app/api/metadata/route.test.ts @@ -3,7 +3,7 @@ */ import { NextRequest } from "next/server" import axios from "axios" -import { GET } from "./route" +import { DEFAULT_VALUES, GET } from "./route" jest.mock("axios") const mockedAxios = axios as jest.Mocked @@ -19,7 +19,7 @@ describe("Metadata Fetcher", () => { Test Title - + ` @@ -37,7 +37,7 @@ describe("Metadata Fetcher", () => { expect(data).toEqual({ title: "Test Title", description: "Test Description", - favicon: "https://example.com/favicon.ico", + icon: "https://example.com/icon.ico", url: "https://example.com" }) }) @@ -66,9 +66,9 @@ describe("Metadata Fetcher", () => { expect(response.status).toBe(200) expect(data).toEqual({ - title: "No title available", - description: "No description available", - favicon: null, + title: DEFAULT_VALUES.TITLE, + description: DEFAULT_VALUES.DESCRIPTION, + icon: null, url: "https://example.com" }) }) @@ -92,9 +92,9 @@ describe("Metadata Fetcher", () => { expect(response.status).toBe(200) expect(data).toEqual({ - title: "No title available", - description: "No description available", - favicon: null, + title: DEFAULT_VALUES.TITLE, + description: DEFAULT_VALUES.DESCRIPTION, + icon: null, url: "https://example.com" }) }) diff --git a/web/app/api/metadata/route.ts b/web/app/api/metadata/route.ts index b0b458d5..b9487a47 100644 --- a/web/app/api/metadata/route.ts +++ b/web/app/api/metadata/route.ts @@ -1,29 +1,39 @@ import { NextRequest, NextResponse } from "next/server" import axios from "axios" import * as cheerio from "cheerio" +import { ensureUrlProtocol } from "@/lib/utils" +import { urlSchema } from "@/lib/utils/schema" interface Metadata { title: string description: string - favicon: string | null + icon: string | null url: string } -const DEFAULT_VALUES = { - TITLE: "No title available", - DESCRIPTION: "No description available", - IMAGE: null, +export const DEFAULT_VALUES = { + TITLE: "", + DESCRIPTION: "", FAVICON: null } export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) - const url = searchParams.get("url") + let url = searchParams.get("url") + + await new Promise(resolve => setTimeout(resolve, 1000)) if (!url) { return NextResponse.json({ error: "URL is required" }, { status: 400 }) } + const result = urlSchema.safeParse(url) + if (!result.success) { + throw new Error(result.error.issues.map(issue => issue.message).join(", ")) + } + + url = ensureUrlProtocol(url) + try { const { data } = await axios.get(url, { timeout: 5000, @@ -41,13 +51,12 @@ export async function GET(request: NextRequest) { $('meta[name="description"]').attr("content") || $('meta[property="og:description"]').attr("content") || DEFAULT_VALUES.DESCRIPTION, - favicon: - $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON, + icon: $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON, url: url } - if (metadata.favicon && !metadata.favicon.startsWith("http")) { - metadata.favicon = new URL(metadata.favicon, url).toString() + if (metadata.icon && !metadata.icon.startsWith("http")) { + metadata.icon = new URL(metadata.icon, url).toString() } return NextResponse.json(metadata) @@ -55,7 +64,7 @@ export async function GET(request: NextRequest) { const defaultMetadata: Metadata = { title: DEFAULT_VALUES.TITLE, description: DEFAULT_VALUES.DESCRIPTION, - favicon: DEFAULT_VALUES.FAVICON, + icon: DEFAULT_VALUES.FAVICON, url: url } return NextResponse.json(defaultMetadata) diff --git a/web/app/globals.css b/web/app/globals.css index 0214df23..29d3758a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,26 +1,6 @@ @tailwind base; @tailwind components; @tailwind utilities; -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); - -body { - font-family: "Inter", sans-serif; -} - -@layer base { - body { - @apply bg-background text-foreground; - font-feature-settings: - "rlig" 1, - "calt" 1; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - overflow-x: hidden; - min-height: 100vh; - line-height: 1.5; - } -} @layer base { :root { @@ -42,17 +22,19 @@ body { --destructive-foreground: 0 0% 98%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; - --ring: 240 10% 3.9%; + --result: 240 5.9% 96%; + --ring: 240 5.9% 10%; --radius: 0.5rem; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --boxShadow: rgba(0, 0, 0, 0.05); } .dark { - --background: 240 10% 3.9%; + --background: 240 10% 4.5%; --foreground: 0 0% 98%; --card: 240 10% 3.9%; --card-foreground: 0 0% 98%; @@ -69,13 +51,15 @@ body { --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; + --input: 220 9% 10%; + --result: 0 0% 7%; --ring: 240 4.9% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --boxShadow: rgba(255, 255, 255, 0.04); } } diff --git a/web/app/page.tsx b/web/app/page.tsx deleted file mode 100644 index 70761f42..00000000 --- a/web/app/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Button } from "@/components/ui/button" -import Link from "next/link" - -export default function HomePage() { - return ( -
- - - -
- ) -} diff --git a/web/components/LinkOptions.tsx b/web/components/LinkOptions.tsx new file mode 100644 index 00000000..51f12518 --- /dev/null +++ b/web/components/LinkOptions.tsx @@ -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 ( +
+
+ + + +
+
+ ) +} diff --git a/web/components/custom/ai-search.tsx b/web/components/custom/ai-search.tsx index d0904d51..d670334b 100644 --- a/web/components/custom/ai-search.tsx +++ b/web/components/custom/ai-search.tsx @@ -71,14 +71,14 @@ const AiSearch: React.FC = (props: { searchQuery: string }) => { return (
-
-
+
+

✨ This is what I have found:

-
+

{error}

-
diff --git a/web/components/custom/content-header.tsx b/web/components/custom/content-header.tsx index 54870dbd..a1cffd7b 100644 --- a/web/components/custom/content-header.tsx +++ b/web/components/custom/content-header.tsx @@ -16,7 +16,7 @@ export const ContentHeader = React.forwardRef { size="icon" variant="ghost" aria-label="Menu" - className="text-primary/60 z-50" + className="text-primary/60" onClick={handleClick} > diff --git a/web/components/custom/delete-modal.tsx b/web/components/custom/delete-modal.tsx new file mode 100644 index 00000000..be8e0551 --- /dev/null +++ b/web/components/custom/delete-modal.tsx @@ -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 ( + + + + Delete "{title}"? + This action cannot be undone. + + + + + + + + ) +} diff --git a/web/components/custom/la-icon.tsx b/web/components/custom/la-icon.tsx new file mode 100644 index 00000000..c0686f96 --- /dev/null +++ b/web/components/custom/la-icon.tsx @@ -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 +}) + +LaIcon.displayName = "LaIcon" diff --git a/web/components/custom/page-loader.tsx b/web/components/custom/page-loader.tsx new file mode 100644 index 00000000..9fb2c087 --- /dev/null +++ b/web/components/custom/page-loader.tsx @@ -0,0 +1,14 @@ +export const PageLoader = () => { + return ( +
+
+
+
Preparing application
+
+
+
+
+
+
+ ) +} diff --git a/web/components/custom/sidebar/partial/page-section.tsx b/web/components/custom/sidebar/partial/page-section.tsx index e93517cb..e9762231 100644 --- a/web/components/custom/sidebar/partial/page-section.tsx +++ b/web/components/custom/sidebar/partial/page-section.tsx @@ -1,19 +1,23 @@ -import { SidebarItem } from "../sidebar" import { z } from "zod" +import { useAtom } from "jotai" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { usePathname, useRouter } from "next/navigation" import { useAccount } from "@/lib/providers/jazz-provider" -import { Input } from "@/components/ui/input" +import { cn, generateUniqueSlug } from "@/lib/utils" +import { atomWithStorage } from "jotai/utils" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form" -import { PlusIcon } from "lucide-react" -import { generateUniqueSlug } from "@/lib/utils" -import { PersonalPage } from "@/lib/schema/personal-page" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" -import { useForm } from "react-hook-form" +import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page" import { zodResolver } from "@hookform/resolvers/zod" -import { useState, useEffect, useCallback } from "react" -import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { LaIcon } from "../../la-icon" +import { toast } from "sonner" +import Link from "next/link" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +const pageSortAtom = atomWithStorage("pageSort", "title") const createPageSchema = z.object({ title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" }) }) @@ -21,47 +25,114 @@ const createPageSchema = z.object({ type PageFormValues = z.infer export const PageSection: React.FC = () => { - const { me } = useAccount() - const [personalPages, setPersonalPages] = useState([]) + const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom) - useEffect(() => { - if (me.root?.personalPages) { - setPersonalPages(prevPages => { - const newPages = Array.from(me.root?.personalPages ?? []).filter((page): page is PersonalPage => page !== null) - return [...prevPages, ...newPages.filter(newPage => !prevPages.some(prevPage => prevPage.id === newPage.id))] - }) - } - }, [me.root?.personalPages]) + const { me } = useAccount({ + root: { personalPages: [] } + }) - const onPageCreated = useCallback((newPage: PersonalPage) => { - setPersonalPages(prevPages => [...prevPages, newPage]) - }, []) + const pageCount = me?.root.personalPages?.length || 0 + + const sortedPages = (filter: string) => { + setPagesSorted(filter) + } return ( -
-
-
+
+ +
+ +
-
-
- {personalPages.map(page => ( - - ))} -
-
+ {me?.root.personalPages && }
) } -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 ( +
+ {sortedPages.map( + page => + page?.id && ( +
+
+ +
+ +

{page.title}

+
+ +
+
+ ) + )} +
+ ) +} + +interface ShowAllFormProps { + filteredPages: (filter: string) => void +} +const ShowAllForm: React.FC = ({ filteredPages }) => { + const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom) + + const handleSort = (newSort: string) => { + setPagesSorted(newSort.toLowerCase()) + filteredPages(newSort.toLowerCase()) + } + + return ( + + + + + + handleSort("title")}> + Title + {pagesSorted === "title" && } + + handleSort("manual")}> + Manual + {pagesSorted === "manual" && } + + + + ) +} + +const CreatePageForm: React.FC = () => { const [open, setOpen] = useState(false) const { me } = useAccount() const route = useRouter() @@ -88,7 +159,6 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }> ) me.root?.personalPages?.push(newPersonalPage) - onPageCreated(newPersonalPage) form.reset() setOpen(false) @@ -103,9 +173,16 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }> return ( - +
diff --git a/web/components/custom/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx new file mode 100644 index 00000000..c150ae75 --- /dev/null +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -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 ( +
+ + {href ? ( + + {text} + + ) : ( + + {text} + + )} +
+ ) +} +export const ProfileSection: React.FC = () => { + const { me, logOut } = useAccount({ + profile: true + }) + const [menuOpen, setMenuOpen] = useState(false) + + const closeMenu = () => setMenuOpen(false) + + return ( +
+
+
+ + + + + + + + + + + + + + + + + + +
+ {/*
+
+ + + + +
*/} +
+
+ ) +} diff --git a/web/components/custom/sidebar/partial/topic-section.tsx b/web/components/custom/sidebar/partial/topic-section.tsx index b713a50b..3e35de07 100644 --- a/web/components/custom/sidebar/partial/topic-section.tsx +++ b/web/components/custom/sidebar/partial/topic-section.tsx @@ -1,100 +1,66 @@ -import { useState, useEffect, useRef } from "react" -import { usePathname } from "next/navigation" -import Link from "next/link" +import { useState, useRef } from "react" import { Button } from "@/components/ui/button" -import { ChevronDown, BookOpen, Bookmark, GraduationCap, Check } from "lucide-react" +import { LaIcon } from "@/components/custom/la-icon" import { SidebarItem } from "../sidebar" -const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"] +// const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"] export const TopicSection = () => { - const [showOptions, setShowOptions] = useState(false) const [selectedStatus, setSelectedStatus] = useState(null) const sectionRef = useRef(null) const learningOptions = [ - { text: "To Learn", icon: , color: "text-white/70" }, { - text: "Learning", - icon: , - color: "text-[#D29752]" + text: "To Learn", + icon: , + color: "text-black dark:text-white" }, { - text: "Learned", - icon: , - color: "text-[#708F51]" - } + text: "Learning", + icon: , + color: "text-[#D29752]" + }, + { text: "Learned", icon: , color: "text-[#708F51]" } ] const statusSelect = (status: string) => { - setSelectedStatus(status === "Show All" ? null : status) - setShowOptions(false) + setSelectedStatus(prevStatus => (prevStatus === status ? null : status)) } - useEffect(() => { - const overlayClick = (event: MouseEvent) => { - if (sectionRef.current && !sectionRef.current.contains(event.target as Node)) { - setShowOptions(false) - } + const topicCounts = { + "To Learn": 2, + Learning: 5, + Learned: 3, + get total() { + return this["To Learn"] + this.Learning + this.Learned } - - document.addEventListener("mousedown", overlayClick) - return () => { - document.removeEventListener("mousedown", overlayClick) - } - }, []) - - const availableOptions = selectedStatus - ? [ - { - text: "Show All", - icon: , - color: "text-white" - }, - ...learningOptions.filter(option => option.text !== selectedStatus) - ] - : learningOptions - - // const topicClick = (topic: string) => { - // router.push(`/${topic.toLowerCase()}`) - // } + } return (
- - - {showOptions && ( -
- {availableOptions.map(option => ( - +
+
+ {learningOptions.map(option => ( + - ))} -
- )} -
- {TOPICS.map(topic => ( - +
+ {topicCounts[option.text as keyof typeof topicCounts]} + ))}
) } - -export default TopicSection diff --git a/web/components/custom/sidebar/sidebar.tsx b/web/components/custom/sidebar/sidebar.tsx index 1dd7ec13..22336d93 100644 --- a/web/components/custom/sidebar/sidebar.tsx +++ b/web/components/custom/sidebar/sidebar.tsx @@ -5,14 +5,14 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { useMedia } from "react-use" import { useAtom } from "jotai" -import { LinkIcon, SearchIcon } from "lucide-react" +import { SearchIcon } from "lucide-react" import { Logo } from "@/components/custom/logo" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { isCollapseAtom } from "@/store/sidebar" - import { PageSection } from "./partial/page-section" import { TopicSection } from "./partial/topic-section" +import { ProfileSection } from "./partial/profile-section" interface SidebarContextType { isCollapsed: boolean @@ -73,14 +73,14 @@ export const SidebarItem: React.FC = React.memo(({ label, url, const LogoAndSearch: React.FC = React.memo(() => { const pathname = usePathname() return ( -
-
- +
+
+
{pathname === "/search" ? ( - + @@ -104,21 +104,20 @@ const LogoAndSearch: React.FC = React.memo(() => { }) const SidebarContent: React.FC = React.memo(() => { - const { isCollapsed } = React.useContext(SidebarContext) - const isTablet = useMedia("(max-width: 1024px)") - return ( - + <> + + + ) }) @@ -132,7 +131,7 @@ export const Sidebar: React.FC = () => { ) const sidebarInnerClasses = cn( - "h-full w-auto min-w-56 transition-transform duration-300 ease-in-out", + "h-full w-56 min-w-56 transition-transform duration-300 ease-in-out", isCollapsed ? "-translate-x-full" : "translate-x-0" ) diff --git a/web/components/custom/textarea-autosize.tsx b/web/components/custom/textarea-autosize.tsx new file mode 100644 index 00000000..82bd33b3 --- /dev/null +++ b/web/components/custom/textarea-autosize.tsx @@ -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 {} + +const TextareaAutosize = React.forwardRef(({ className, style, ...props }, ref) => { + return ( + + ) +}) + +TextareaAutosize.displayName = "TextareaAutosize" + +export { TextareaAutosize } diff --git a/web/components/la-editor/components/bubble-menu/bubble-menu.tsx b/web/components/la-editor/components/bubble-menu/bubble-menu.tsx index e56e30c9..cec9a11d 100644 --- a/web/components/la-editor/components/bubble-menu/bubble-menu.tsx +++ b/web/components/la-editor/components/bubble-menu/bubble-menu.tsx @@ -5,6 +5,7 @@ import { BubbleMenu as TiptapBubbleMenu, Editor } from "@tiptap/react" import { ToolbarButton } from "../ui/toolbar-button" import { Icon } from "../ui/icon" import * as React from "react" +import { Keybind } from "@/components/ui/Keybind" export type BubbleMenuProps = { editor: Editor @@ -14,38 +15,93 @@ export const BubbleMenu = ({ editor }: BubbleMenuProps) => { const commands = useTextmenuCommands(editor) const states = useTextmenuStates(editor) + const toolbarButtonClassname = + "hover:opacity-100 transition-all dark:border-slate-500/10 border-gray-400 hover:border-b-2 active:translate-y-0 hover:translate-y-[-1.5px] hover:bg-zinc-300 dark:hover:bg-neutral-800 shadow-md rounded-[10px]" + return ( - -
- - - - - - - - - + +
+ + + + + + + + + + + + + + + + {/* */} - - - - - - - + + + + + + + + + + + {/* diff --git a/web/components/routes/AuthHomeRoute.tsx b/web/components/routes/AuthHomeRoute.tsx new file mode 100644 index 00000000..2a27c1b5 --- /dev/null +++ b/web/components/routes/AuthHomeRoute.tsx @@ -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 ( +
+ + + +
+ ) +} diff --git a/web/components/routes/EditProfileRoute.tsx b/web/components/routes/EditProfileRoute.tsx new file mode 100644 index 00000000..69e232d3 --- /dev/null +++ b/web/components/routes/EditProfileRoute.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useAccount } from "@/lib/providers/jazz-provider" + +export default function EditProfileRoute() { + const account = useAccount() + + return ( +
+

Profile

+
+
+ +
+ + +

learn-anything.xyz/@

+ +