diff --git a/web/.env.example b/web/.env.example
deleted file mode 100644
index bae601e6..00000000
--- a/web/.env.example
+++ /dev/null
@@ -1,20 +0,0 @@
-NEXT_PUBLIC_APP_NAME="Learn Anything"
-NEXT_PUBLIC_APP_URL=http://localhost:3000
-
-NEXT_PUBLIC_JAZZ_GLOBAL_GROUP=""
-
-NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
-CLERK_SECRET_KEY=
-
-NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
-NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
-
-NEXT_PUBLIC_JAZZ_PEER_URL="wss://"
-
-RONIN_TOKEN=
-
-NEXT_PUBLIC_SENTRY_DSN=
-NEXT_PUBLIC_SENTRY_ORG=
-NEXT_PUBLIC_SENTRY_PROJECT=
-
-# IGNORE_BUILD_ERRORS=true
\ No newline at end of file
diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs
new file mode 100644
index 00000000..6c60a854
--- /dev/null
+++ b/web/.eslintrc.cjs
@@ -0,0 +1,14 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react-hooks/recommended",
+ ],
+ ignorePatterns: ["dist", ".eslintrc.cjs"],
+ parser: "@typescript-eslint/parser",
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ },
+}
diff --git a/web/.eslintrc.json b/web/.eslintrc.json
deleted file mode 100644
index 72cc705c..00000000
--- a/web/.eslintrc.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "extends": "next/core-web-vitals"
-}
diff --git a/web/.gitignore b/web/.gitignore
index d1dc7456..6cac3e14 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -1,40 +1,24 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+node_modules
+package-lock.json
+yarn.lock
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
-.yarn/install-state.gz
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# misc
+.env
.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# local env files
-.env*.local
-
-# vercel
+.cache
.vercel
+.output
+.vinxi
-# typescript
-*.tsbuildinfo
-next-env.d.ts
-
+/build/
+/api/
+/server/build
+/public/build
+.vinxi
# Sentry Config File
.env.sentry-build-plugin
-.ronin
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+
+.ronin
\ No newline at end of file
diff --git a/web/.npmrc b/web/.npmrc
deleted file mode 100644
index 2806c9c2..00000000
--- a/web/.npmrc
+++ /dev/null
@@ -1,2 +0,0 @@
-[install.scopes]
-ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }
\ No newline at end of file
diff --git a/web/.prettierignore b/web/.prettierignore
new file mode 100644
index 00000000..2be5eaa6
--- /dev/null
+++ b/web/.prettierignore
@@ -0,0 +1,4 @@
+**/build
+**/public
+pnpm-lock.yaml
+routeTree.gen.ts
\ No newline at end of file
diff --git a/web/app.config.ts b/web/app.config.ts
new file mode 100644
index 00000000..6e0bb830
--- /dev/null
+++ b/web/app.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "@tanstack/start/config"
+import tsConfigPaths from "vite-tsconfig-paths"
+
+export default defineConfig({
+ vite: {
+ plugins: () => [
+ tsConfigPaths({
+ projects: ["./tsconfig.json"],
+ }),
+ ],
+ },
+})
diff --git a/web/app/(auth)/layout.tsx b/web/app/(auth)/layout.tsx
deleted file mode 100644
index e99c8df2..00000000
--- a/web/app/(auth)/layout.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function AuthLayout({
- children
-}: Readonly<{
- children: React.ReactNode
-}>) {
- return {children}
-}
diff --git a/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx
deleted file mode 100644
index 0b5feeb9..00000000
--- a/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { SignInClient } from "@/components/custom/clerk/sign-in-client"
-
-export default async function Page() {
- return (
-
-
-
- )
-}
diff --git a/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx
deleted file mode 100644
index 57c4d9e3..00000000
--- a/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { SignUpClient } from "@/components/custom/clerk/sign-up-client"
-
-export default async function Page() {
- return (
-
-
-
- )
-}
diff --git a/web/app/(pages)/(topics)/[name]/page.tsx b/web/app/(pages)/(topics)/[name]/page.tsx
deleted file mode 100644
index 28e0f249..00000000
--- a/web/app/(pages)/(topics)/[name]/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { TopicDetailRoute } from "@/components/routes/topics/detail/TopicDetailRoute"
-
-export default function DetailTopicPage({ params }: { params: { name: string } }) {
- return
-}
diff --git a/web/app/(pages)/community/[topicName]/page.tsx b/web/app/(pages)/community/[topicName]/page.tsx
deleted file mode 100644
index 62e69371..00000000
--- a/web/app/(pages)/community/[topicName]/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { CommunityTopicRoute } from "@/components/routes/community/CommunityTopicRoute"
-
-export default function CommunityTopicPage({ params }: { params: { topicName: string } }) {
- return
-}
diff --git a/web/app/(pages)/edit-profile/page.tsx b/web/app/(pages)/edit-profile/page.tsx
deleted file mode 100644
index 3e492911..00000000
--- a/web/app/(pages)/edit-profile/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import EditProfileRoute from "@/components/routes/EditProfileRoute"
-
-export default function EditProfilePage() {
- return
-}
diff --git a/web/app/(pages)/journal/page.tsx b/web/app/(pages)/journal/page.tsx
deleted file mode 100644
index 0c2cd038..00000000
--- a/web/app/(pages)/journal/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { JournalRoute } from "@/components/routes/journal/JournalRoute"
-import { currentUser } from "@clerk/nextjs/server"
-import { notFound } from "next/navigation"
-import { get } from "ronin"
-
-export default async function JournalPage() {
- const user = await currentUser()
- const flag = await get.featureFlag.with.name("JOURNAL")
-
- if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
- notFound()
- }
-
- return
-}
diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx
deleted file mode 100644
index 0014c84c..00000000
--- a/web/app/(pages)/layout.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { Viewport } from "next"
-import { Sidebar } from "@/components/custom/sidebar/sidebar"
-import { CommandPalette } from "@/components/custom/command-palette/command-palette"
-import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
-import { Shortcut } from "@/components/custom/shortcut/shortcut"
-import { GlobalKeyboardHandler } from "@/components/custom/global-keyboard-handler"
-
-export const viewport: Viewport = {
- width: "device-width, shrink-to-fit=no",
- maximumScale: 1,
- userScalable: false
-}
-
-export default function PageLayout({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
- )
-}
diff --git a/web/app/(pages)/links/page.tsx b/web/app/(pages)/links/page.tsx
deleted file mode 100644
index 682f9c0a..00000000
--- a/web/app/(pages)/links/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { LinkRoute } from "@/components/routes/link/LinkRoute"
-
-export default function LinksPage() {
- return
-}
diff --git a/web/app/(pages)/onboarding/page.tsx b/web/app/(pages)/onboarding/page.tsx
deleted file mode 100644
index b286035c..00000000
--- a/web/app/(pages)/onboarding/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import OnboardingRoute from "@/components/routes/OnboardingRoute"
-
-export default function EditProfilePage() {
- return
-}
diff --git a/web/app/(pages)/pages/[id]/page.tsx b/web/app/(pages)/pages/[id]/page.tsx
deleted file mode 100644
index 974b86c1..00000000
--- a/web/app/(pages)/pages/[id]/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { PageDetailRoute } from "@/components/routes/page/detail/PageDetailRoute"
-
-export default function DetailPage({ params }: { params: { id: string } }) {
- return
-}
diff --git a/web/app/(pages)/pages/page.tsx b/web/app/(pages)/pages/page.tsx
deleted file mode 100644
index edc1ae2c..00000000
--- a/web/app/(pages)/pages/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { PageRoute } from "@/components/routes/page/PageRoute"
-
-export default function Page() {
- return
-}
diff --git a/web/app/(pages)/profile/_components/wrapper.tsx b/web/app/(pages)/profile/_components/wrapper.tsx
deleted file mode 100644
index cc7257de..00000000
--- a/web/app/(pages)/profile/_components/wrapper.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-"use client"
-
-import { useAccount } from "@/lib/providers/jazz-provider"
-import { useUser } from "@clerk/nextjs"
-import { useState, useRef, useCallback } from "react"
-import { useParams } from "next/navigation"
-import { Input } from "@/components/ui/input"
-import { Button } from "@/components/ui/button"
-import Link from "next/link"
-import { Avatar, AvatarImage } from "@/components/ui/avatar"
-
-interface ProfileStatsProps {
- number: number
- label: string
-}
-
-const ProfileStats: React.FC = ({ number, label }) => {
- return (
-
- )
-}
-
-export const ProfileWrapper = () => {
- const account = useAccount()
- const params = useParams()
- const username = params.username as string
- const { user, isSignedIn } = useUser()
- const avatarInputRef = useRef(null)
-
- const editAvatar = (event: React.ChangeEvent) => {
- const file = event.target.files?.[0]
- if (file) {
- const imageUrl = URL.createObjectURL(file)
- if (account.me && account.me.profile) {
- account.me.profile.avatarUrl = imageUrl
- }
- }
- }
-
- const [isEditing, setIsEditing] = useState(false)
- const [newName, setNewName] = useState(account.me?.profile?.name || "")
- const [error, setError] = useState("")
-
- const editProfileClicked = () => {
- setIsEditing(true)
- setError("")
- }
-
- const changeName = (e: React.ChangeEvent) => {
- setNewName(e.target.value)
- setError("")
- }
-
- const validateName = useCallback((name: string) => {
- if (name.trim().length < 2) {
- return "Name must be at least 2 characters long"
- }
- if (name.trim().length > 40) {
- return "Name must not exceed 40 characters"
- }
- return ""
- }, [])
-
- const saveProfile = () => {
- const validationError = validateName(newName)
- if (validationError) {
- setError(validationError)
- return
- }
-
- if (account.me && account.me.profile) {
- account.me.profile.name = newName.trim()
- }
- setIsEditing(false)
- }
-
- const cancelEditing = () => {
- setNewName(account.me?.profile?.name || "")
- setIsEditing(false)
- setError("")
- }
-
- if (!account.me || !account.me.profile) {
- return (
-
-
-
- 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 (
-
-
-
{username}
-
-
-
avatarInputRef.current?.click()} variant="ghost" className="p-0 hover:bg-transparent">
-
-
-
-
-
-
- {isEditing ? (
- <>
-
- {error &&
{error}
}
- >
- ) : (
-
{account.me?.profile?.name}
- )}
-
- {isEditing ? (
-
-
- Save
-
-
- Cancel
-
-
- ) : (
-
- Edit profile
-
- )}
-
-
-
-
-
Public profiles are coming soon
-
-
- )
-}
diff --git a/web/app/(pages)/profile/page.tsx b/web/app/(pages)/profile/page.tsx
deleted file mode 100644
index 8b4cb3e8..00000000
--- a/web/app/(pages)/profile/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { ProfileWrapper } from "./_components/wrapper"
-
-export default function ProfilePage() {
- return
-}
diff --git a/web/app/(pages)/search/page.tsx b/web/app/(pages)/search/page.tsx
deleted file mode 100644
index c6797e3a..00000000
--- a/web/app/(pages)/search/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { SearchWrapper } from "@/components/routes/search/wrapper"
-
-export default function ProfilePage() {
- return
-}
diff --git a/web/app/(pages)/settings/page.tsx b/web/app/(pages)/settings/page.tsx
deleted file mode 100644
index a5070993..00000000
--- a/web/app/(pages)/settings/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { SettingsRoute } from "@/components/routes/SettingsRoute"
-
-export default function SettingsPage() {
- return
-}
diff --git a/web/app/(pages)/tasks/page.tsx b/web/app/(pages)/tasks/page.tsx
deleted file mode 100644
index cabad9b4..00000000
--- a/web/app/(pages)/tasks/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { TaskRoute } from "@/components/routes/task/TaskRoute"
-import { currentUser } from "@clerk/nextjs/server"
-import { notFound } from "next/navigation"
-import { get } from "ronin"
-
-export default async function TaskPage() {
- const user = await currentUser()
- const flag = await get.featureFlag.with.name("TASK")
-
- if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
- notFound()
- }
-
- return
-}
diff --git a/web/app/(pages)/tasks/today/page.tsx b/web/app/(pages)/tasks/today/page.tsx
deleted file mode 100644
index d2e4f8a0..00000000
--- a/web/app/(pages)/tasks/today/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { TaskRoute } from "@/components/routes/task/TaskRoute"
-import { currentUser } from "@clerk/nextjs/server"
-import { notFound } from "next/navigation"
-import { get } from "ronin"
-
-export default async function TodayTasksPage() {
- const user = await currentUser()
- const flag = await get.featureFlag.with.name("TASK")
-
- if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
- notFound()
- }
-
- return
-}
diff --git a/web/app/(pages)/tasks/upcoming/page.tsx b/web/app/(pages)/tasks/upcoming/page.tsx
deleted file mode 100644
index 2ab33656..00000000
--- a/web/app/(pages)/tasks/upcoming/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { TaskRoute } from "@/components/routes/task/TaskRoute"
-import { currentUser } from "@clerk/nextjs/server"
-import { notFound } from "next/navigation"
-import { get } from "ronin"
-
-export default async function UpcomingTasksPage() {
- const user = await currentUser()
- const flag = await get.featureFlag.with.name("TASK")
-
- if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) {
- notFound()
- }
-
- return
-}
diff --git a/web/app/(pages)/tauri/page.tsx b/web/app/(pages)/tauri/page.tsx
deleted file mode 100644
index 6f9b88d0..00000000
--- a/web/app/(pages)/tauri/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import TauriRoute from "@/components/routes/tauri/TauriRoute"
-
-export default function TauriPage() {
- return
-}
diff --git a/web/app/(pages)/topics/page.tsx b/web/app/(pages)/topics/page.tsx
deleted file mode 100644
index 6251415e..00000000
--- a/web/app/(pages)/topics/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { TopicRoute } from "@/components/routes/topics/TopicRoute"
-
-export default function Page() {
- return
-}
diff --git a/web/app/(public)/layout.tsx b/web/app/(public)/layout.tsx
deleted file mode 100644
index 95c7ee6d..00000000
--- a/web/app/(public)/layout.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function PublicLayout({
- children
-}: Readonly<{
- children: React.ReactNode
-}>) {
- return {children}
-}
diff --git a/web/app/actions.ts b/web/app/actions.ts
index 42f598f6..a4fb2a19 100644
--- a/web/app/actions.ts
+++ b/web/app/actions.ts
@@ -1,91 +1,140 @@
-"use server"
+import { clerkClient, getAuth } from "@clerk/tanstack-start/server"
+import { createServerFn } from "@tanstack/start"
+import { create, get } from "ronin"
+import * as cheerio from "cheerio"
+import { ensureUrlProtocol } from "@/lib/utils"
+import { urlSchema } from "@/lib/utils/schema"
-import { authedProcedure } from "@/lib/utils/auth-procedure"
-import { currentUser } from "@clerk/nextjs/server"
-import { get } from "ronin"
-import { create } from "ronin"
-import { z } from "zod"
-import { ZSAError } from "zsa"
-
-const MAX_FILE_SIZE = 1 * 1024 * 1024
-const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
-
-export const getFeatureFlag = authedProcedure
- .input(
- z.object({
- name: z.string()
- })
- )
- .handler(async ({ input }) => {
- const { name } = input
- const flag = await get.featureFlag.with.name(name)
-
- return { flag }
- })
-
-export const sendFeedback = authedProcedure
- .input(
- z.object({
- content: z.string()
- })
- )
- .handler(async ({ input, ctx }) => {
- const { clerkUser } = ctx
- const { content } = input
-
- try {
- await create.feedback.with({
- message: content,
- emailFrom: clerkUser?.emailAddresses[0].emailAddress
- })
- } catch (error) {
- console.error(error)
- throw new ZSAError("ERROR", "Failed to send feedback")
- }
- })
-
-export const storeImage = authedProcedure
- .input(
- z.object({
- file: z
- .any()
- .refine(file => file instanceof File, {
- message: "Not a file"
- })
- .refine(file => ALLOWED_FILE_TYPES.includes(file.type), {
- message: "Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed."
- })
- .refine(file => file.size <= MAX_FILE_SIZE, {
- message: "File size exceeds the maximum limit of 1 MB."
- })
- }),
- { type: "formData" }
- )
- .handler(async ({ ctx, input }) => {
- const { file } = input
- const { clerkUser } = ctx
-
- if (!clerkUser?.id) {
- throw new ZSAError("NOT_AUTHORIZED", "You are not authorized to upload files")
- }
-
- try {
- const fileModel = await create.image.with({
- content: file,
- name: file.name,
- type: file.type,
- size: file.size
- })
-
- return { fileModel }
- } catch (error) {
- console.error(error)
- throw new ZSAError("ERROR", "Failed to store image")
- }
- })
-
-export const isExistingUser = async () => {
- const clerkUser = await currentUser()
- const roninUser = await get.existingStripeSubscriber.with({ email: clerkUser?.emailAddresses[0].emailAddress })
- return clerkUser?.emailAddresses[0].emailAddress === roninUser?.email
+interface Metadata {
+ title: string
+ description: string
+ icon: string | null
+ url: string
}
+
+const DEFAULT_VALUES = {
+ TITLE: "",
+ DESCRIPTION: "",
+ FAVICON: null,
+}
+
+export const fetchClerkAuth = createServerFn("GET", async (_, ctx) => {
+ const auth = await getAuth(ctx.request)
+
+ return {
+ user: auth,
+ }
+})
+
+export const getFeatureFlag = createServerFn(
+ "GET",
+ async (data: { name: string }) => {
+ const response = await get.featureFlag.with({
+ name: data.name,
+ })
+
+ return response
+ },
+)
+
+export const sendFeedbackFn = createServerFn(
+ "POST",
+ async (data: { content: string }, { request }) => {
+ const auth = await getAuth(request)
+ if (!auth.userId) {
+ throw new Error("Unauthorized")
+ }
+ const user = await clerkClient({
+ telemetry: { disabled: true },
+ }).users.getUser(auth.userId)
+ await create.feedback.with({
+ message: data.content,
+ emailFrom: user.emailAddresses[0].emailAddress,
+ })
+ },
+)
+
+export const isExistingUserFn = createServerFn(
+ "GET",
+ async (_, { request }) => {
+ const auth = await getAuth(request)
+
+ if (!auth.userId) {
+ return false
+ }
+
+ const user = await clerkClient({
+ telemetry: { disabled: true },
+ }).users.getUser(auth.userId)
+
+ const roninUser = await get.existingStripeSubscriber.with({
+ email: user.emailAddresses[0].emailAddress,
+ })
+
+ return user.emailAddresses[0].emailAddress === roninUser?.email
+ },
+)
+
+export const getMetadata = createServerFn("GET", async (url: string) => {
+ if (!url) {
+ return new Response('Missing "url" query parameter', {
+ 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 response = await fetch(url, {
+ headers: {
+ "User-Agent":
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`)
+ }
+
+ const data = await response.text()
+ const $ = cheerio.load(data)
+
+ const metadata: Metadata = {
+ title:
+ $("title").text() ||
+ $('meta[property="og:title"]').attr("content") ||
+ DEFAULT_VALUES.TITLE,
+ description:
+ $('meta[name="description"]').attr("content") ||
+ $('meta[property="og:description"]').attr("content") ||
+ DEFAULT_VALUES.DESCRIPTION,
+ icon:
+ $('link[rel="icon"]').attr("href") ||
+ $('link[rel="shortcut icon"]').attr("href") ||
+ DEFAULT_VALUES.FAVICON,
+ url: url,
+ }
+
+ if (metadata.icon && !metadata.icon.startsWith("http")) {
+ metadata.icon = new URL(metadata.icon, url).toString()
+ }
+
+ return metadata
+ } catch (error) {
+ console.error("Error fetching metadata:", error)
+ const defaultMetadata: Metadata = {
+ title: DEFAULT_VALUES.TITLE,
+ description: DEFAULT_VALUES.DESCRIPTION,
+ icon: DEFAULT_VALUES.FAVICON,
+ url: url,
+ }
+ return defaultMetadata
+ }
+})
diff --git a/web/app/api/metadata/route.test.ts b/web/app/api/metadata/route.test.ts
deleted file mode 100644
index 9f4fc91d..00000000
--- a/web/app/api/metadata/route.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @jest-environment node
- */
-import { NextRequest } from "next/server"
-import axios from "axios"
-import { GET } from "./route"
-
-const DEFAULT_VALUES = {
- TITLE: "",
- DESCRIPTION: "",
- FAVICON: null
-}
-
-jest.mock("axios")
-const mockedAxios = axios as jest.Mocked
-
-describe("Metadata Fetcher", () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it("should return metadata when URL is valid", async () => {
- const mockHtml = `
-
-
- Test Title
-
-
-
-
- `
-
- mockedAxios.get.mockResolvedValue({ data: mockHtml })
-
- const req = {
- url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com"
- } as unknown as NextRequest
-
- const response = await GET(req)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data).toEqual({
- title: "Test Title",
- description: "Test Description",
- icon: "https://example.com/icon.ico",
- url: "https://example.com"
- })
- })
-
- it("should return an error when URL is missing", async () => {
- const req = {
- url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata"
- } as unknown as NextRequest
-
- const response = await GET(req)
- const data = await response.json()
-
- expect(response.status).toBe(400)
- expect(data).toEqual({ error: "URL is required" })
- })
-
- it("should return default values when fetching fails", async () => {
- mockedAxios.get.mockRejectedValue(new Error("Network error"))
-
- const req = {
- url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com"
- } as unknown as NextRequest
-
- const response = await GET(req)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data).toEqual({
- title: DEFAULT_VALUES.TITLE,
- description: DEFAULT_VALUES.DESCRIPTION,
- icon: null,
- url: "https://example.com"
- })
- })
-
- it("should handle missing metadata gracefully", async () => {
- const mockHtml = `
-
-
-
-
- `
-
- mockedAxios.get.mockResolvedValue({ data: mockHtml })
-
- const req = {
- url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com"
- } as unknown as NextRequest
-
- const response = await GET(req)
- const data = await response.json()
-
- expect(response.status).toBe(200)
- expect(data).toEqual({
- 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
deleted file mode 100644
index f6209940..00000000
--- a/web/app/api/metadata/route.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-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
- icon: string | null
- url: string
-}
-
-const DEFAULT_VALUES = {
- TITLE: "",
- DESCRIPTION: "",
- FAVICON: null
-}
-
-export async function GET(request: NextRequest) {
- const { searchParams } = new URL(request.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,
- headers: {
- "User-Agent":
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
- }
- })
-
- const $ = cheerio.load(data)
-
- const metadata: Metadata = {
- title: $("title").text() || $('meta[property="og:title"]').attr("content") || DEFAULT_VALUES.TITLE,
- description:
- $('meta[name="description"]').attr("content") ||
- $('meta[property="og:description"]').attr("content") ||
- DEFAULT_VALUES.DESCRIPTION,
- icon: $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON,
- url: url
- }
-
- if (metadata.icon && !metadata.icon.startsWith("http")) {
- metadata.icon = new URL(metadata.icon, url).toString()
- }
-
- return NextResponse.json(metadata)
- } catch (error) {
- const defaultMetadata: Metadata = {
- title: DEFAULT_VALUES.TITLE,
- description: DEFAULT_VALUES.DESCRIPTION,
- icon: DEFAULT_VALUES.FAVICON,
- url: url
- }
- return NextResponse.json(defaultMetadata)
- }
-}
diff --git a/web/app/api/search-stream/route.ts b/web/app/api/search-stream/route.ts
deleted file mode 100644
index 21ca9a50..00000000
--- a/web/app/api/search-stream/route.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { NextRequest, NextResponse } from "next/server"
-
-export async function POST(request: NextRequest) {
- let data: unknown
- try {
- data = (await request.json()) as unknown
- } catch (error) {
- return new NextResponse("Invalid JSON", { status: 400 })
- }
-
- if (typeof data !== "object" || !data) {
- return new NextResponse("Missing request data", { status: 400 })
- }
-
- if (!("question" in data) || typeof data.question !== "string") {
- return new NextResponse("Missing `question` data field.", { status: 400 })
- }
-
- const chunks: string[] = [
- "# Hello",
- " from th",
- "e server",
- "\n\n your question",
- " was:\n\n",
- "> ",
- data.question,
- "\n\n",
- "**good bye!**"
- ]
-
- const stream = new ReadableStream({
- async start(controller) {
- for (const chunk of chunks) {
- controller.enqueue(chunk)
- await new Promise(resolve => setTimeout(resolve, 1000))
- }
-
- controller.close()
- }
- })
-
- return new NextResponse(stream)
-}
diff --git a/web/app/client.tsx b/web/app/client.tsx
new file mode 100644
index 00000000..caafdbd5
--- /dev/null
+++ b/web/app/client.tsx
@@ -0,0 +1,8 @@
+///
+import { hydrateRoot } from "react-dom/client"
+import { StartClient } from "@tanstack/start"
+import { createRouter } from "./router"
+
+const router = createRouter()
+
+hydrateRoot(document.getElementById("root")!, )
diff --git a/web/app/command-palette.css b/web/app/command-palette.css
deleted file mode 100644
index f4e67bc7..00000000
--- a/web/app/command-palette.css
+++ /dev/null
@@ -1,127 +0,0 @@
-@keyframes scaleIn {
- 0% {
- transform: scale(0.97) translateX(-50%);
- opacity: 0;
- }
- to {
- transform: scale(1) translateX(-50%);
- opacity: 1;
- }
-}
-
-@keyframes scaleOut {
- 0% {
- transform: scale(1) translateX(-50%);
- opacity: 1;
- }
- to {
- transform: scale(0.97) translateX(-50%);
- opacity: 0;
- }
-}
-
-@keyframes fadeIn {
- 0% {
- opacity: 0;
- }
- to {
- opacity: 0.8;
- }
-}
-@keyframes fadeOut {
- 0% {
- opacity: 0.8;
- }
- to {
- opacity: 0;
- }
-}
-
-:root {
- --cmdk-shadow: rgba(0, 0, 0, 0.12) 0px 4px 30px, rgba(0, 0, 0, 0.04) 0px 3px 17px, rgba(0, 0, 0, 0.04) 0px 2px 8px,
- rgba(0, 0, 0, 0.04) 0px 1px 1px;
- --cmdk-bg: rgb(255, 255, 255);
- --cmdk-border-color: rgb(216, 216, 216);
-
- --cmdk-input-color: rgb(48, 48, 49);
- --cmdk-input-placeholder: hsl(0, 0%, 56.1%);
-
- --cmdk-accent: rgb(243, 243, 243);
-}
-
-.dark {
- --cmdk-shadow: rgba(0, 0, 0, 0.15) 0px 4px 40px, rgba(0, 0, 0, 0.184) 0px 3px 20px, rgba(0, 0, 0, 0.184) 0px 3px 12px,
- rgba(0, 0, 0, 0.184) 0px 2px 8px, rgba(0, 0, 0, 0.184) 0px 1px 1px;
- --cmdk-bg: rgb(27, 28, 31);
- --cmdk-border-color: rgb(56, 59, 65);
-
- --cmdk-input-color: rgb(228, 229, 233);
- --cmdk-input-placeholder: hsl(0, 0%, 43.9%);
-
- --cmdk-accent: rgb(44, 48, 57);
-}
-
-[la-overlay][cmdk-overlay] {
- animation: fadeIn 0.2s ease;
- @apply fixed inset-0 z-50 opacity-80;
-}
-
-[la-dialog][cmdk-dialog] {
- top: 15%;
- transform: translateX(-50%);
- max-width: 640px;
- background: var(--cmdk-bg);
- box-shadow: var(--cmdk-shadow);
- transform-origin: left;
- animation: scaleIn 0.2s ease;
- transition: transform 0.1s ease;
- border: 0.5px solid var(--cmdk-border-color);
- @apply fixed left-1/2 z-50 w-full overflow-hidden rounded-lg outline-none;
-}
-
-[la-dialog][cmdk-dialog][data-state="closed"] {
- animation: scaleOut 0.2s ease;
-}
-
-.la [cmdk-input-wrapper] {
- border-bottom: 1px solid var(--cmdk-border-color);
- height: 62px;
- font-size: 1.125rem;
- @apply relative;
-}
-
-.la [cmdk-input] {
- font-size: inherit;
- height: 62px;
- color: var(--cmdk-input-color);
- caret-color: rgb(110, 94, 210);
- @apply m-0 w-full appearance-none border-none bg-transparent p-5 outline-none;
-}
-
-.la [cmdk-input]::placeholder {
- color: var(--cmdk-input-placeholder);
-}
-
-.la [cmdk-list] {
- max-height: 400px;
- overflow: auto;
- overscroll-behavior: contain;
- transition: 100ms ease;
- transition-property: height;
- @apply p-2;
-}
-
-.la [cmdk-group-heading] {
- font-size: 13px;
- height: 30px;
- @apply text-muted-foreground flex items-center px-2;
-}
-
-.la [cmdk-empty] {
- @apply text-muted-foreground flex h-16 items-center justify-center whitespace-pre-wrap text-sm;
-}
-
-.la [cmdk-item] {
- scroll-margin: 8px 0;
- @apply flex min-h-10 cursor-pointer items-center gap-3 rounded-md px-2 text-sm aria-selected:bg-[var(--cmdk-accent)];
-}
diff --git a/web/app/components/DefaultCatchBoundary.tsx b/web/app/components/DefaultCatchBoundary.tsx
new file mode 100644
index 00000000..ebb1259f
--- /dev/null
+++ b/web/app/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,53 @@
+import {
+ ErrorComponent,
+ Link,
+ rootRouteId,
+ useMatch,
+ useRouter,
+} from "@tanstack/react-router"
+import type { ErrorComponentProps } from "@tanstack/react-router"
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+ const isRoot = useMatch({
+ strict: false,
+ select: (state) => state.id === rootRouteId,
+ })
+
+ console.error(error)
+
+ return (
+
+
+
+ {
+ router.invalidate()
+ }}
+ className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
+ >
+ Try Again
+
+ {isRoot ? (
+
+ Home
+
+ ) : (
+ {
+ e.preventDefault()
+ window.history.back()
+ }}
+ >
+ Go Back
+
+ )}
+
+
+ )
+}
diff --git a/web/app/components/GlobalKeyboardHandler.tsx b/web/app/components/GlobalKeyboardHandler.tsx
new file mode 100644
index 00000000..83861309
--- /dev/null
+++ b/web/app/components/GlobalKeyboardHandler.tsx
@@ -0,0 +1,141 @@
+import * as React from "react"
+import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down"
+import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
+import { isModKey, isServer } from "@/lib/utils"
+import { useAtom } from "jotai"
+import { usePageActions } from "~/hooks/actions/use-page-actions"
+import { useAuth } from "@clerk/tanstack-start"
+import { useNavigate } from "@tanstack/react-router"
+import queryString from "query-string"
+import { commandPaletteOpenAtom } from "~/store/any-store"
+
+type RegisterKeyDownProps = {
+ trigger: KeyFilter
+ handler: (event: KeyboardEvent) => void
+ options?: Options
+}
+
+function RegisterKeyDown({ trigger, handler, options }: RegisterKeyDownProps) {
+ useKeyDown(trigger, handler, options)
+ return null
+}
+
+type Sequence = {
+ [key: string]: string
+}
+
+const SEQUENCES: Sequence = {
+ GL: "/links",
+ GP: "/pages",
+ GT: "/topics",
+}
+
+const MAX_SEQUENCE_TIME = 1000
+
+export function GlobalKeyboardHandler() {
+ if (isServer()) {
+ return null
+ }
+
+ return
+}
+
+export function KeyboardHandlerContent() {
+ const [, setOpenCommandPalette] = useAtom(commandPaletteOpenAtom)
+ const [sequence, setSequence] = React.useState([])
+ const { signOut } = useAuth()
+ const navigate = useNavigate()
+ const { me } = useAccountOrGuest()
+ const { newPage } = usePageActions()
+
+ const resetSequence = React.useCallback(() => {
+ setSequence([])
+ }, [])
+
+ const checkSequence = React.useCallback(() => {
+ const sequenceStr = sequence.join("")
+ const route = SEQUENCES[sequenceStr]
+
+ if (route) {
+ navigate({
+ to: route,
+ })
+ resetSequence()
+ }
+ }, [sequence, navigate, resetSequence])
+
+ const goToNewLink = React.useCallback(
+ (event: KeyboardEvent) => {
+ if (event.metaKey || event.altKey) {
+ return
+ }
+
+ navigate({
+ to: `/links?${queryString.stringify({ create: true })}`,
+ })
+ },
+ [navigate],
+ )
+
+ const goToNewPage = React.useCallback(
+ (event: KeyboardEvent) => {
+ if (event.metaKey || event.altKey) {
+ return
+ }
+
+ if (!me || me._type === "Anonymous") {
+ return
+ }
+
+ const page = newPage(me)
+
+ navigate({
+ to: `/pages/${page.id}`,
+ })
+ },
+ [me, newPage, navigate],
+ )
+
+ useKeyDown(
+ (e) => e.altKey && e.shiftKey && e.code === "KeyQ",
+ () => {
+ signOut()
+ },
+ )
+
+ useKeyDown(
+ () => true,
+ (e) => {
+ const key = e.key.toUpperCase()
+ setSequence((prev) => [...prev, key])
+ },
+ )
+
+ useKeyDown(
+ (e) => isModKey(e) && e.code === "KeyK",
+ (e) => {
+ e.preventDefault()
+ setOpenCommandPalette((prev) => !prev)
+ },
+ )
+
+ React.useEffect(() => {
+ checkSequence()
+
+ const timeoutId = setTimeout(() => {
+ resetSequence()
+ }, MAX_SEQUENCE_TIME)
+
+ return () => clearTimeout(timeoutId)
+ }, [sequence, checkSequence, resetSequence])
+
+ return (
+ me &&
+ me._type !== "Anonymous" && (
+ <>
+
+
+ >
+ )
+ )
+}
diff --git a/web/app/components/NotFound.tsx b/web/app/components/NotFound.tsx
new file mode 100644
index 00000000..b29bf8dc
--- /dev/null
+++ b/web/app/components/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Link } from "@tanstack/react-router"
+
+export function NotFound({ children }: { children?: any }) {
+ return (
+
+
+ {children ||
The page you are looking for does not exist.
}
+
+
+ window.history.back()}
+ className="bg-emerald-500 text-white px-2 py-1 rounded uppercase font-black text-sm"
+ >
+ Go back
+
+
+ Start Over
+
+
+
+ )
+}
diff --git a/web/app/components/Onboarding.tsx b/web/app/components/Onboarding.tsx
new file mode 100644
index 00000000..4c9761c1
--- /dev/null
+++ b/web/app/components/Onboarding.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from "react"
+import { atom, useAtom } from "jotai"
+import { atomWithStorage } from "jotai/utils"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { isExistingUserFn } from "~/actions"
+import { useLocation } from "@tanstack/react-router"
+
+const hasVisitedAtom = atomWithStorage("hasVisitedLearnAnything", false)
+const isDialogOpenAtom = atom(true)
+
+export function Onboarding() {
+ const { pathname } = useLocation()
+ const [hasVisited, setHasVisited] = useAtom(hasVisitedAtom)
+ const [isOpen, setIsOpen] = useAtom(isDialogOpenAtom)
+ const [isFetching, setIsFetching] = useState(true)
+ const [isExisting, setIsExisting] = useState(false)
+
+ useEffect(() => {
+ const loadUser = async () => {
+ try {
+ const existingUser = await isExistingUserFn()
+ setIsExisting(existingUser)
+ setIsOpen(true)
+ } catch (error) {
+ console.error("Error loading user:", error)
+ } finally {
+ setIsFetching(false)
+ }
+ }
+
+ if (!hasVisited && pathname !== "/") {
+ loadUser()
+ }
+ }, [hasVisited, pathname, setIsOpen])
+
+ const handleClose = () => {
+ setIsOpen(false)
+ setHasVisited(true)
+ }
+
+ if (hasVisited || isFetching) return null
+
+ return (
+
+
+
+
+ Welcome to Learn Anything!
+
+
+
+
+ {isExisting && (
+ <>
+ Existing Customer Notice
+
+ We noticed you are an existing Learn Anything customer. We
+ sincerely apologize for any broken experience you may have
+ encountered on the old website. We've been working hard on
+ this new version, which addresses previous issues and offers
+ more features. As an early customer, you're locked in at
+ the $3 price for our upcoming pro version.
+ Thank you for your support!
+
+ >
+ )}
+
+ Learn Anything is a learning platform that organizes knowledge in a
+ social way. You can create pages, add links, track learning status
+ of any topic, and more things in the future.
+
+
+ Try do these quick onboarding steps to get a feel for the product:
+
+
+ Create your first page
+ Add a link to a resource
+ Update your learning status on a topic
+
+
+ If you have any questions, don't hesitate to reach out. Click
+ on question mark button in the bottom right corner and enter your
+ message.
+
+
+
+
+ Close
+
+ Get Started
+
+
+
+
+ )
+}
+
+export default Onboarding
diff --git a/web/app/components/command-palette/command-data.ts b/web/app/components/command-palette/command-data.ts
new file mode 100644
index 00000000..bf1e4344
--- /dev/null
+++ b/web/app/components/command-palette/command-data.ts
@@ -0,0 +1,142 @@
+import { icons } from "lucide-react"
+import { LaAccount } from "@/lib/schema"
+import { HTMLLikeElement } from "@/lib/utils"
+import { useCommandActions } from "~/hooks/use-command-actions"
+
+export type CommandAction = string | (() => void)
+
+export interface CommandItemType {
+ id?: string
+ icon?: keyof typeof icons
+ value: string
+ label: HTMLLikeElement | string
+ action: CommandAction
+ payload?: any
+ shortcut?: string
+}
+
+export type CommandGroupType = Array<{
+ heading?: string
+ items: CommandItemType[]
+}>
+
+const createNavigationItem = (
+ icon: keyof typeof icons,
+ value: string,
+ path: string,
+ actions: ReturnType,
+): CommandItemType => ({
+ icon,
+ value: `Go to ${value}`,
+ label: {
+ tag: "span",
+ children: [
+ "Go to ",
+ {
+ tag: "span",
+ attributes: { className: "font-semibold" },
+ children: [value],
+ },
+ ],
+ },
+ action: () => actions.navigateTo(path),
+})
+
+export const createCommandGroups = (
+ actions: ReturnType,
+ me: LaAccount,
+): Record => ({
+ home: [
+ {
+ heading: "General",
+ items: [
+ {
+ icon: "SunMoon",
+ value: "Change Theme...",
+ label: "Change Theme...",
+ action: "CHANGE_PAGE",
+ payload: "changeTheme",
+ },
+ {
+ icon: "Copy",
+ value: "Copy Current URL",
+ label: "Copy Current URL",
+ action: actions.copyCurrentURL,
+ },
+ ],
+ },
+ {
+ heading: "Personal Links",
+ items: [
+ {
+ icon: "TextSearch",
+ value: "Search Links...",
+ label: "Search Links...",
+ action: "CHANGE_PAGE",
+ payload: "searchLinks",
+ },
+ {
+ icon: "Plus",
+ value: "Create New Link...",
+ label: "Create New Link...",
+ action: () => actions.navigateTo("/links?create=true"),
+ },
+ ],
+ },
+ {
+ heading: "Personal Pages",
+ items: [
+ {
+ icon: "FileSearch",
+ value: "Search Pages...",
+ label: "Search Pages...",
+ action: "CHANGE_PAGE",
+ payload: "searchPages",
+ },
+ {
+ icon: "Plus",
+ value: "Create New Page...",
+ label: "Create New Page...",
+ action: () => actions.createNewPage(me),
+ },
+ ],
+ },
+ {
+ heading: "Navigation",
+ items: [
+ createNavigationItem("ArrowRight", "Links", "/links", actions),
+ createNavigationItem("ArrowRight", "Pages", "/pages", actions),
+ createNavigationItem("ArrowRight", "Search", "/search", actions),
+ createNavigationItem("ArrowRight", "Profile", "/profile", actions),
+ createNavigationItem("ArrowRight", "Settings", "/settings", actions),
+ ],
+ },
+ ],
+ searchLinks: [],
+ searchPages: [],
+ topics: [],
+ changeTheme: [
+ {
+ items: [
+ {
+ icon: "Moon",
+ value: "Change Theme to Dark",
+ label: "Change Theme to Dark",
+ action: () => actions.changeTheme("dark"),
+ },
+ {
+ icon: "Sun",
+ value: "Change Theme to Light",
+ label: "Change Theme to Light",
+ action: () => actions.changeTheme("light"),
+ },
+ {
+ icon: "Monitor",
+ value: "Change Theme to System",
+ label: "Change Theme to System",
+ action: () => actions.changeTheme("system"),
+ },
+ ],
+ },
+ ],
+})
diff --git a/web/app/components/command-palette/command-group.tsx b/web/app/components/command-palette/command-group.tsx
new file mode 100644
index 00000000..96823cbb
--- /dev/null
+++ b/web/app/components/command-palette/command-group.tsx
@@ -0,0 +1,73 @@
+import * as React from "react"
+import { Command } from "cmdk"
+import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
+import { LaIcon } from "@/components/custom/la-icon"
+import { CommandItemType, CommandAction } from "./command-data"
+import { HTMLLikeElement, renderHTMLLikeElement } from "@/lib/utils"
+
+export interface CommandItemProps extends Omit {
+ action: CommandAction
+ handleAction: (action: CommandAction, payload?: any) => void
+}
+
+const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> =
+ React.memo(({ content }) => {
+ return (
+ {renderHTMLLikeElement(content)}
+ )
+ })
+
+HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
+
+export const CommandItem: React.FC = React.memo(
+ ({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
+ handleAction(action, payload)}
+ >
+ {icon && }
+
+ {shortcut && {shortcut} }
+
+ ),
+)
+
+CommandItem.displayName = "CommandItem"
+
+export interface CommandGroupProps {
+ heading?: string
+ items: CommandItemType[]
+ handleAction: (action: CommandAction, payload?: any) => void
+ isLastGroup: boolean
+}
+
+export const CommandGroup: React.FC = React.memo(
+ ({ heading, items, handleAction, isLastGroup }) => {
+ return (
+ <>
+ {heading ? (
+
+ {items.map((item, index) => (
+
+ ))}
+
+ ) : (
+ items.map((item, index) => (
+
+ ))
+ )}
+ {!isLastGroup && }
+ >
+ )
+ },
+)
+
+CommandGroup.displayName = "CommandGroup"
diff --git a/web/app/components/command-palette/command-palette.tsx b/web/app/components/command-palette/command-palette.tsx
new file mode 100644
index 00000000..d9b064ea
--- /dev/null
+++ b/web/app/components/command-palette/command-palette.tsx
@@ -0,0 +1,214 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { Command } from "cmdk"
+import {
+ Dialog,
+ DialogPortal,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+import { CommandGroup } from "./command-group"
+import { CommandAction, createCommandGroups } from "./command-data"
+import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
+import { useAtom } from "jotai"
+import { useCommandActions } from "~/hooks/use-command-actions"
+import {
+ filterItems,
+ getTopics,
+ getPersonalLinks,
+ getPersonalPages,
+ handleAction,
+} from "./utils"
+import { searchSafeRegExp } from "~/lib/utils"
+import { commandPaletteOpenAtom } from "~/store/any-store"
+
+export function CommandPalette() {
+ const { me } = useAccountOrGuest()
+
+ if (me._type === "Anonymous") return null
+
+ return
+}
+
+export function RealCommandPalette() {
+ const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
+ const dialogRef = React.useRef(null)
+ const [inputValue, setInputValue] = React.useState("")
+ const [activePage, setActivePage] = React.useState("home")
+ const [open, setOpen] = useAtom(commandPaletteOpenAtom)
+
+ const actions = useCommandActions()
+ const commandGroups = React.useMemo(
+ () => me && createCommandGroups(actions, me),
+ [actions, me],
+ )
+
+ const bounce = React.useCallback(() => {
+ if (dialogRef.current) {
+ dialogRef.current.style.transform = "scale(0.99) translateX(-50%)"
+ setTimeout(() => {
+ if (dialogRef.current) {
+ dialogRef.current.style.transform = ""
+ }
+ }, 100)
+ }
+ }, [])
+
+ const handleKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ bounce()
+ }
+
+ if (activePage !== "home" && !inputValue && e.key === "Backspace") {
+ e.preventDefault()
+ setActivePage("home")
+ setInputValue("")
+ bounce()
+ }
+ },
+ [activePage, inputValue, bounce],
+ )
+
+ const topics = React.useMemo(() => getTopics(actions), [actions])
+ const personalLinks = React.useMemo(
+ () => getPersonalLinks(me?.root.personalLinks || [], actions),
+ [me?.root.personalLinks, actions],
+ )
+ const personalPages = React.useMemo(
+ () => getPersonalPages(me?.root.personalPages || [], actions),
+ [me?.root.personalPages, actions],
+ )
+
+ const getFilteredCommands = React.useCallback(() => {
+ if (!commandGroups) return []
+
+ const searchRegex = searchSafeRegExp(inputValue)
+
+ if (activePage === "home") {
+ if (!inputValue) {
+ return commandGroups.home
+ }
+
+ const allGroups = [
+ ...Object.values(commandGroups).flat(),
+ personalLinks,
+ personalPages,
+ topics,
+ ]
+
+ return allGroups
+ .map((group) => ({
+ heading: group.heading,
+ items: filterItems(group.items, searchRegex),
+ }))
+ .filter((group) => group.items.length > 0)
+ }
+
+ switch (activePage) {
+ case "searchLinks":
+ return [
+ ...commandGroups.searchLinks,
+ { items: filterItems(personalLinks.items, searchRegex) },
+ ]
+ case "searchPages":
+ return [
+ ...commandGroups.searchPages,
+ { items: filterItems(personalPages.items, searchRegex) },
+ ]
+ default: {
+ const pageCommands = commandGroups[activePage]
+ if (!inputValue) return pageCommands
+ return pageCommands
+ .map((group) => ({
+ heading: group.heading,
+ items: filterItems(group.items, searchRegex),
+ }))
+ .filter((group) => group.items.length > 0)
+ }
+ }
+ }, [
+ inputValue,
+ activePage,
+ commandGroups,
+ personalLinks,
+ personalPages,
+ topics,
+ ])
+
+ const handleActionWrapper = React.useCallback(
+ (action: CommandAction, payload?: any) => {
+ handleAction(action, payload, {
+ setActivePage,
+ setInputValue,
+ bounce,
+ closeDialog: () => setOpen(false),
+ })
+ },
+ [bounce, setOpen],
+ )
+
+ const filteredCommands = React.useMemo(
+ () => getFilteredCommands(),
+ [getFilteredCommands],
+ )
+
+ const commandKey = React.useMemo(() => {
+ return filteredCommands
+ .map((group) => {
+ const itemsKey = group.items
+ .map((item) => `${item.label}-${item.value}`)
+ .join("|")
+ return `${group.heading}:${itemsKey}`
+ })
+ .join("__")
+ }, [filteredCommands])
+
+ if (!me) return null
+
+ return (
+
+
+
+
+
+ Command Palette
+
+ Search for commands and actions
+
+
+
+
+
+
+
+
+
+ No results found.
+ {filteredCommands.map((group, index, array) => (
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/command-palette/utils.ts b/web/app/components/command-palette/utils.ts
new file mode 100644
index 00000000..68e6c433
--- /dev/null
+++ b/web/app/components/command-palette/utils.ts
@@ -0,0 +1,74 @@
+import { GraphData } from "~/lib/constants"
+import { CommandAction, CommandItemType } from "./command-data"
+
+export const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
+ items.filter((item) => searchRegex.test(item.value)).slice(0, 10)
+
+export const getTopics = (actions: { navigateTo: (path: string) => void }) => ({
+ heading: "Topics",
+ items: GraphData.map((topic) => ({
+ icon: "Circle" as const,
+ value: topic?.prettyName || "",
+ label: topic?.prettyName || "",
+ action: () => actions.navigateTo(`/${topic?.name}`),
+ })),
+})
+
+export const getPersonalLinks = (
+ personalLinks: any[],
+ actions: { openLinkInNewTab: (url: string) => void },
+) => ({
+ heading: "Personal Links",
+ items: personalLinks.map((link) => ({
+ id: link?.id,
+ icon: "Link" as const,
+ value: link?.title || "Untitled",
+ label: link?.title || "Untitled",
+ action: () => actions.openLinkInNewTab(link?.url || "#"),
+ })),
+})
+
+export const getPersonalPages = (
+ personalPages: any[],
+ actions: { navigateTo: (path: string) => void },
+) => ({
+ heading: "Personal Pages",
+ items: personalPages.map((page) => ({
+ id: page?.id,
+ icon: "FileText" as const,
+ value: page?.title || "Untitled",
+ label: page?.title || "Untitled",
+ action: () => actions.navigateTo(`/pages/${page?.id}`),
+ })),
+})
+
+export const handleAction = (
+ action: CommandAction,
+ payload: any,
+ callbacks: {
+ setActivePage: (page: string) => void
+ setInputValue: (value: string) => void
+ bounce: () => void
+ closeDialog: () => void
+ },
+) => {
+ const { setActivePage, setInputValue, bounce, closeDialog } = callbacks
+
+ if (typeof action === "function") {
+ action()
+ closeDialog()
+ return
+ }
+
+ switch (action) {
+ case "CHANGE_PAGE":
+ if (payload) {
+ setActivePage(payload)
+ setInputValue("")
+ bounce()
+ }
+ break
+ default:
+ closeDialog()
+ }
+}
diff --git a/web/app/components/custom/ai-search.tsx b/web/app/components/custom/ai-search.tsx
new file mode 100644
index 00000000..5d8e303d
--- /dev/null
+++ b/web/app/components/custom/ai-search.tsx
@@ -0,0 +1,91 @@
+import * as React from "react"
+import * as smd from "streaming-markdown"
+
+interface AiSearchProps {
+ searchQuery: string
+}
+
+const AiSearch: React.FC = (props: { searchQuery: string }) => {
+ const [error, setError] = React.useState("")
+
+ const root_el = React.useRef(null)
+
+ const [parser, md_el] = React.useMemo(() => {
+ const md_el = document.createElement("div")
+ const renderer = smd.default_renderer(md_el)
+ const parser = smd.parser(renderer)
+ return [parser, md_el]
+ }, [])
+
+ React.useEffect(() => {
+ if (root_el.current) {
+ root_el.current.appendChild(md_el)
+ }
+ }, [md_el])
+
+ React.useEffect(() => {
+ const question = props.searchQuery
+
+ fetchData()
+ async function fetchData() {
+ let response: Response
+ try {
+ response = await fetch("/api/search-stream", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ question: question }),
+ })
+ } catch (error) {
+ console.error("Error fetching data:", error)
+ setError("Error fetching data")
+ return
+ }
+
+ if (!response.body) {
+ console.error("Response has no body")
+ setError("Response has no body")
+ return
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+
+ let done = false
+ while (!done) {
+ const res = await reader.read()
+
+ if (res.value) {
+ const text = decoder.decode(res.value)
+ smd.parser_write(parser, text)
+ }
+
+ if (res.done) {
+ smd.parser_end(parser)
+ done = true
+ }
+ }
+ }
+ }, [props.searchQuery, parser])
+
+ return (
+
+
+
+
✨ This is what I have found:
+
+
+
+
{error}
+
+ Ask Community
+
+
+ )
+}
+
+export default AiSearch
diff --git a/web/app/components/custom/column.tsx b/web/app/components/custom/column.tsx
new file mode 100644
index 00000000..16ddd045
--- /dev/null
+++ b/web/app/components/custom/column.tsx
@@ -0,0 +1,44 @@
+import React from "react"
+import { cn } from "@/lib/utils"
+
+interface ColumnWrapperProps extends React.HTMLAttributes {
+ style?: { [key: string]: string }
+}
+
+interface ColumnTextProps extends React.HTMLAttributes {}
+
+const ColumnWrapper = React.forwardRef(
+ ({ children, className, style, ...props }, ref) => (
+
+ {children}
+
+ ),
+)
+
+ColumnWrapper.displayName = "ColumnWrapper"
+
+const ColumnText = React.forwardRef(
+ ({ children, className, ...props }, ref) => (
+
+ {children}
+
+ ),
+)
+
+ColumnText.displayName = "ColumnText"
+
+export const Column = {
+ Wrapper: ColumnWrapper,
+ Text: ColumnText,
+}
diff --git a/web/app/components/custom/content-header.tsx b/web/app/components/custom/content-header.tsx
new file mode 100644
index 00000000..d9e48dd0
--- /dev/null
+++ b/web/app/components/custom/content-header.tsx
@@ -0,0 +1,58 @@
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { useAtom } from "jotai"
+import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar"
+import { useMedia } from "@/hooks/use-media"
+import { cn } from "@/lib/utils"
+import { LaIcon } from "@/components/custom/la-icon"
+
+type ContentHeaderProps = Omit, "title">
+
+export const ContentHeader = React.forwardRef<
+ HTMLDivElement,
+ ContentHeaderProps
+>(({ children, className, ...props }, ref) => {
+ return (
+
+ )
+})
+
+ContentHeader.displayName = "ContentHeader"
+
+export const SidebarToggleButton: React.FC = () => {
+ const [isCollapse] = useAtom(isCollapseAtom)
+ const [, toggle] = useAtom(toggleCollapseAtom)
+ const isTablet = useMedia("(max-width: 1024px)")
+
+ if (!isCollapse && !isTablet) return null
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ toggle()
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/custom/date-picker.tsx b/web/app/components/custom/date-picker.tsx
new file mode 100644
index 00000000..0ab66425
--- /dev/null
+++ b/web/app/components/custom/date-picker.tsx
@@ -0,0 +1,57 @@
+import * as React from "react"
+import { format } from "date-fns"
+import { Calendar as CalendarIcon } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+interface DatePickerProps {
+ date: Date | undefined
+ onDateChange: (date: Date | undefined) => void
+ className?: string
+}
+
+export function DatePicker({ date, onDateChange, className }: DatePickerProps) {
+ const [open, setOpen] = React.useState(false)
+
+ const selectDate = (selectedDate: Date | undefined) => {
+ onDateChange(selectedDate)
+ setOpen(false)
+ }
+
+ return (
+
+
+ e.stopPropagation()}
+ >
+
+ {date ? format(date, "PPP") : Pick a date }
+
+
+ e.stopPropagation()}
+ >
+
+
+
+ )
+}
diff --git a/web/app/components/custom/la-icon.tsx b/web/app/components/custom/la-icon.tsx
new file mode 100644
index 00000000..54293c5b
--- /dev/null
+++ b/web/app/components/custom/la-icon.tsx
@@ -0,0 +1,30 @@
+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/app/components/custom/learning-state-selector.tsx b/web/app/components/custom/learning-state-selector.tsx
new file mode 100644
index 00000000..3f5286d9
--- /dev/null
+++ b/web/app/components/custom/learning-state-selector.tsx
@@ -0,0 +1,137 @@
+import * as React from "react"
+import { useAtom } from "jotai"
+import { Button } from "@/components/ui/button"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { LaIcon } from "@/components/custom/la-icon"
+import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
+import { linkLearningStateSelectorAtom } from "@/store/link"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandItem,
+ CommandGroup,
+} from "@/components/ui/command"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { icons } from "lucide-react"
+
+interface LearningStateSelectorProps {
+ showSearch?: boolean
+ defaultLabel?: string
+ searchPlaceholder?: string
+ value?: string
+ onChange: (value: LearningStateValue) => void
+ className?: string
+ defaultIcon?: keyof typeof icons
+}
+
+export const LearningStateSelector: React.FC = ({
+ showSearch = true,
+ defaultLabel = "State",
+ searchPlaceholder = "Search state...",
+ value,
+ onChange,
+ className,
+ defaultIcon,
+}) => {
+ const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(
+ linkLearningStateSelectorAtom,
+ )
+ const selectedLearningState = React.useMemo(
+ () => LEARNING_STATES.find((ls) => ls.value === value),
+ [value],
+ )
+
+ const handleSelect = (selectedValue: string) => {
+ onChange(selectedValue as LearningStateValue)
+ setIsLearningStateSelectorOpen(false)
+ }
+
+ const iconName = selectedLearningState?.icon || defaultIcon
+ const labelText = selectedLearningState?.label || defaultLabel
+
+ return (
+
+
+
+ {iconName && (
+
+ )}
+ {labelText && (
+
+ {labelText}
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+interface LearningStateSelectorContentProps {
+ showSearch: boolean
+ searchPlaceholder: string
+ value?: string
+ onSelect: (value: string) => void
+}
+
+export const LearningStateSelectorContent: React.FC<
+ LearningStateSelectorContentProps
+> = ({ showSearch, searchPlaceholder, value, onSelect }) => {
+ return (
+
+ {showSearch && (
+
+ )}
+
+
+
+ {LEARNING_STATES.map((ls) => (
+
+ {ls.icon && (
+
+ )}
+ {ls.label}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/web/app/components/custom/spinner.tsx b/web/app/components/custom/spinner.tsx
new file mode 100644
index 00000000..5eeddcf2
--- /dev/null
+++ b/web/app/components/custom/spinner.tsx
@@ -0,0 +1,32 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+interface SpinnerProps extends React.SVGAttributes {}
+
+export const Spinner = React.forwardRef(
+ ({ className, ...props }, ref) => (
+
+
+
+
+ ),
+)
+
+Spinner.displayName = "Spinner"
diff --git a/web/app/components/custom/textarea-autosize.tsx b/web/app/components/custom/textarea-autosize.tsx
new file mode 100644
index 00000000..e2eed8e3
--- /dev/null
+++ b/web/app/components/custom/textarea-autosize.tsx
@@ -0,0 +1,25 @@
+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, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+
+TextareaAutosize.displayName = "TextareaAutosize"
+
+export { TextareaAutosize }
diff --git a/web/app/components/custom/topic-selector.tsx b/web/app/components/custom/topic-selector.tsx
new file mode 100644
index 00000000..62691f85
--- /dev/null
+++ b/web/app/components/custom/topic-selector.tsx
@@ -0,0 +1,208 @@
+import * as React from "react"
+import { atom, useAtom } from "jotai"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import { Button, buttonVariants } from "@/components/ui/button"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { LaIcon } from "@/components/custom/la-icon"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandItem,
+ CommandGroup,
+} from "@/components/ui/command"
+import { useCoState } from "@/lib/providers/jazz-provider"
+import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
+import { ListOfTopics, Topic } from "@/lib/schema"
+import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
+import { VariantProps } from "class-variance-authority"
+
+interface TopicSelectorProps extends VariantProps {
+ showSearch?: boolean
+ defaultLabel?: string
+ searchPlaceholder?: string
+ value?: string | null
+ onChange?: (value: string) => void
+ onTopicChange?: (value: Topic) => void
+ className?: string
+ renderSelectedText?: (value?: string | null) => React.ReactNode
+ side?: "bottom" | "top" | "right" | "left"
+ align?: "center" | "end" | "start"
+}
+
+export const topicSelectorAtom = atom(false)
+
+export const TopicSelector = React.forwardRef<
+ HTMLButtonElement,
+ TopicSelectorProps
+>(
+ (
+ {
+ showSearch = true,
+ defaultLabel = "Select topic",
+ searchPlaceholder = "Search topic...",
+ value,
+ onChange,
+ onTopicChange,
+ className,
+ renderSelectedText,
+ side = "bottom",
+ align = "end",
+ ...props
+ },
+ ref,
+ ) => {
+ const [isTopicSelectorOpen, setIsTopicSelectorOpen] =
+ useAtom(topicSelectorAtom)
+ const group = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, {
+ root: { topics: [] },
+ })
+
+ const handleSelect = React.useCallback(
+ (selectedTopicName: string, topic: Topic) => {
+ onChange?.(selectedTopicName)
+ onTopicChange?.(topic)
+ setIsTopicSelectorOpen(false)
+ },
+ [onChange, setIsTopicSelectorOpen, onTopicChange],
+ )
+
+ const displaySelectedText = React.useMemo(() => {
+ if (renderSelectedText) {
+ return renderSelectedText(value)
+ }
+ return {value || defaultLabel}
+ }, [value, defaultLabel, renderSelectedText])
+
+ return (
+
+
+
+ {displaySelectedText}
+
+
+
+
+ {group?.root.topics && (
+
+ )}
+
+
+ )
+ },
+)
+
+TopicSelector.displayName = "TopicSelector"
+
+interface TopicSelectorContentProps
+ extends Omit {
+ onSelect: (value: string, topic: Topic) => void
+ topics: ListOfTopics
+}
+
+const TopicSelectorContent: React.FC = React.memo(
+ ({ showSearch, searchPlaceholder, value, onSelect, topics }) => {
+ const [search, setSearch] = React.useState("")
+ const filteredTopics = React.useMemo(
+ () =>
+ topics.filter((topic) =>
+ topic?.prettyName.toLowerCase().includes(search.toLowerCase()),
+ ),
+ [topics, search],
+ )
+
+ const parentRef = React.useRef(null)
+
+ const rowVirtualizer = useVirtualizer({
+ count: filteredTopics.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 35,
+ overscan: 5,
+ })
+
+ return (
+
+ {showSearch && (
+
+ )}
+
+
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const topic = filteredTopics[virtualRow.index]
+ return (
+ topic && (
+ onSelect(value, topic)}
+ style={{
+ position: "absolute",
+ top: 0,
+ left: 0,
+ width: "100%",
+ height: `${virtualRow.size}px`,
+ transform: `translateY(${virtualRow.start}px)`,
+ }}
+ >
+ {topic.prettyName}
+
+
+ )
+ )
+ })}
+
+
+
+
+
+ )
+ },
+)
+
+TopicSelectorContent.displayName = "TopicSelectorContent"
+
+export default TopicSelector
diff --git a/web/app/components/icons/discord-icon.tsx b/web/app/components/icons/discord-icon.tsx
new file mode 100644
index 00000000..927b14a3
--- /dev/null
+++ b/web/app/components/icons/discord-icon.tsx
@@ -0,0 +1,29 @@
+export const DiscordIcon = () => (
+
+
+
+
+
+)
diff --git a/web/app/components/icons/logo-icon.tsx b/web/app/components/icons/logo-icon.tsx
new file mode 100644
index 00000000..76f8865a
--- /dev/null
+++ b/web/app/components/icons/logo-icon.tsx
@@ -0,0 +1,64 @@
+import * as React from "react"
+
+interface LogoIconProps extends React.SVGProps {}
+
+export const LogoIcon = ({ className, ...props }: LogoIconProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/shortcut/shortcut.tsx b/web/app/components/shortcut/shortcut.tsx
new file mode 100644
index 00000000..f7b19e25
--- /dev/null
+++ b/web/app/components/shortcut/shortcut.tsx
@@ -0,0 +1,190 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { atom, useAtom } from "jotai"
+import {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTitle,
+ sheetVariants,
+ SheetDescription,
+} from "@/components/ui/sheet"
+import { LaIcon } from "@/components/custom/la-icon"
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
+
+export const showShortcutAtom = atom(false)
+
+type ShortcutItem = {
+ label: string
+ keys: string[]
+ then?: string[]
+}
+
+type ShortcutSection = {
+ title: string
+ shortcuts: ShortcutItem[]
+}
+
+const SHORTCUTS: ShortcutSection[] = [
+ {
+ title: "General",
+ shortcuts: [
+ { label: "Open command menu", keys: ["⌘", "k"] },
+ { label: "Log out", keys: ["⌥", "⇧", "q"] },
+ ],
+ },
+ {
+ title: "Navigation",
+ shortcuts: [
+ { label: "Go to link", keys: ["G"], then: ["L"] },
+ { label: "Go to page", keys: ["G"], then: ["P"] },
+ { label: "Go to topic", keys: ["G"], then: ["T"] },
+ ],
+ },
+ {
+ title: "Links",
+ shortcuts: [{ label: "Create new link", keys: ["c"] }],
+ },
+ {
+ title: "Pages",
+ shortcuts: [{ label: "Create new page", keys: ["p"] }],
+ },
+]
+
+const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => (
+
+ {keyChar}
+
+)
+
+const ShortcutItem: React.FC = ({ label, keys, then }) => (
+
+
+ {label}
+
+
+
+
+ {keys.map((key, index) => (
+
+ ))}
+ {then && (
+ <>
+ then
+ {then.map((key, index) => (
+
+ ))}
+ >
+ )}
+
+
+
+
+)
+
+const ShortcutSection: React.FC = ({ title, shortcuts }) => (
+
+ {title}
+
+ {shortcuts.map((shortcut, index) => (
+
+ ))}
+
+
+)
+
+export function Shortcut() {
+ const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom)
+ const [searchQuery, setSearchQuery] = React.useState("")
+
+ const { disableKeydown } = useKeyboardManager("shortcutSection")
+
+ React.useEffect(() => {
+ disableKeydown(showShortcut)
+ }, [showShortcut, disableKeydown])
+
+ const filteredShortcuts = React.useMemo(() => {
+ if (!searchQuery) return SHORTCUTS
+
+ return SHORTCUTS.map((section) => ({
+ ...section,
+ shortcuts: section.shortcuts.filter((shortcut) =>
+ shortcut.label.toLowerCase().includes(searchQuery.toLowerCase()),
+ ),
+ })).filter((section) => section.shortcuts.length > 0)
+ }, [searchQuery])
+
+ return (
+
+
+
+
+
+
+ Keyboard Shortcuts
+
+
+ Quickly navigate around the app
+
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+ {filteredShortcuts.map((section, index) => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/sidebar/partials/feedback.tsx b/web/app/components/sidebar/partials/feedback.tsx
new file mode 100644
index 00000000..ebdb093e
--- /dev/null
+++ b/web/app/components/sidebar/partials/feedback.tsx
@@ -0,0 +1,158 @@
+import * as React from "react"
+import { Button, buttonVariants } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ DialogPortal,
+ DialogOverlay,
+ DialogPrimitive,
+} from "@/components/ui/dialog"
+import { LaIcon } from "@/components/custom/la-icon"
+import { MinimalTiptapEditor } from "@shared/minimal-tiptap"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { useRef, useState } from "react"
+import { cn } from "@/lib/utils"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { toast } from "sonner"
+import { Spinner } from "@/components/custom/spinner"
+import { Editor } from "@tiptap/react"
+import { sendFeedbackFn } from "~/actions"
+
+const formSchema = z.object({
+ content: z.string().min(1, {
+ message: "Feedback cannot be empty",
+ }),
+})
+
+export function Feedback() {
+ const [open, setOpen] = useState(false)
+ const editorRef = useRef(null)
+ const [isPending, setIsPending] = useState(false)
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ content: "",
+ },
+ })
+
+ const handleCreate = React.useCallback(
+ ({ editor }: { editor: Editor }) => {
+ if (form.getValues("content") && editor.isEmpty) {
+ editor.commands.setContent(form.getValues("content"))
+ }
+ editorRef.current = editor
+ },
+ [form],
+ )
+
+ async function onSubmit(values: z.infer) {
+ try {
+ setIsPending(true)
+ await sendFeedbackFn(values)
+
+ form.reset({ content: "" })
+ editorRef.current?.commands.clearContent()
+
+ setOpen(false)
+ toast.success("Feedback sent")
+ } catch (error) {
+ toast.error("Failed to send feedback")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/sidebar/partials/journal-section.tsx b/web/app/components/sidebar/partials/journal-section.tsx
new file mode 100644
index 00000000..acedfe6b
--- /dev/null
+++ b/web/app/components/sidebar/partials/journal-section.tsx
@@ -0,0 +1,120 @@
+import { useAccount } from "@/lib/providers/jazz-provider"
+import { cn } from "@/lib/utils"
+import { useEffect, useState } from "react"
+import { useAuth, useUser } from "@clerk/tanstack-start"
+import { LaIcon } from "~/components/custom/la-icon"
+import { Link } from "@tanstack/react-router"
+import { getFeatureFlag } from "~/actions"
+
+export const JournalSection: React.FC = () => {
+ const { me } = useAccount()
+ const journalEntries = me?.root?.journalEntries
+
+ const [, setIsFetching] = useState(false)
+ const [isFeatureActive, setIsFeatureActive] = useState(false)
+ const { isLoaded, isSignedIn } = useAuth()
+ const { user } = useUser()
+
+ useEffect(() => {
+ async function checkFeatureFlag() {
+ setIsFetching(true)
+
+ if (isLoaded && isSignedIn) {
+ const response = await getFeatureFlag({ name: "JOURNAL" })
+
+ if (
+ user?.emailAddresses.some((email) =>
+ response?.emails.includes(email.emailAddress),
+ )
+ ) {
+ setIsFeatureActive(true)
+ }
+ setIsFetching(false)
+ }
+ }
+
+ checkFeatureFlag()
+ }, [isLoaded, isSignedIn, user])
+
+ if (!isLoaded || !isSignedIn) {
+ return Loading...
+ }
+
+ if (!me) return null
+
+ if (!isFeatureActive) {
+ return null
+ }
+
+ return (
+
+
+ {journalEntries && journalEntries.length > 0 && (
+
+ )}
+
+ )
+}
+
+interface JournalHeaderProps {
+ entriesCount: number
+}
+
+const JournalSectionHeader: React.FC = ({
+ entriesCount,
+}) => (
+
+
+ Journal
+ {entriesCount > 0 && (
+ ({entriesCount})
+ )}
+
+
+)
+
+interface JournalEntryListProps {
+ entries: any[]
+}
+
+const JournalEntryList: React.FC = ({ entries }) => {
+ return (
+
+ {entries.map((entry, index) => (
+
+ ))}
+
+ )
+}
+
+interface JournalEntryItemProps {
+ entry: any
+}
+
+const JournalEntryItem: React.FC = ({ entry }) => (
+
+
+
+)
diff --git a/web/app/components/sidebar/partials/link-section.tsx b/web/app/components/sidebar/partials/link-section.tsx
new file mode 100644
index 00000000..a100210a
--- /dev/null
+++ b/web/app/components/sidebar/partials/link-section.tsx
@@ -0,0 +1,115 @@
+import * as React from "react"
+import { Link } from "@tanstack/react-router"
+import { useAccount } from "@/lib/providers/jazz-provider"
+import { cn } from "@/lib/utils"
+import { PersonalLinkLists } from "@/lib/schema/personal-link"
+import { LearningStateValue } from "~/lib/constants"
+
+export const LinkSection: React.FC = () => {
+ const { me } = useAccount({ root: { personalLinks: [] } })
+
+ if (!me) return null
+
+ const linkCount = me.root.personalLinks?.length || 0
+
+ return (
+
+
+
+
+ )
+}
+
+interface LinkSectionHeaderProps {
+ linkCount: number
+}
+
+const LinkSectionHeader: React.FC = ({ linkCount }) => (
+
+ Links
+ {linkCount > 0 && (
+ {linkCount}
+ )}
+
+)
+
+interface LinkListProps {
+ personalLinks: PersonalLinkLists
+}
+
+const LinkList: React.FC = ({ personalLinks }) => {
+ const linkStates: LearningStateValue[] = [
+ "wantToLearn",
+ "learning",
+ "learned",
+ ]
+ const linkLabels: Record = {
+ wantToLearn: "To Learn",
+ learning: "Learning",
+ learned: "Learned",
+ }
+
+ const linkCounts = linkStates.reduce(
+ (acc, state) => ({
+ ...acc,
+ [state]: personalLinks.filter((link) => link?.learningState === state)
+ .length,
+ }),
+ {} as Record,
+ )
+
+ return (
+
+ {linkStates.map((state) => (
+
+ ))}
+
+ )
+}
+
+interface LinkListItemProps {
+ label: string
+ state: LearningStateValue
+ count: number
+}
+
+const LinkListItem: React.FC = ({ label, state, count }) => (
+
+
+
+
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+
+)
diff --git a/web/app/components/sidebar/partials/page-section.tsx b/web/app/components/sidebar/partials/page-section.tsx
new file mode 100644
index 00000000..3e92f2c3
--- /dev/null
+++ b/web/app/components/sidebar/partials/page-section.tsx
@@ -0,0 +1,284 @@
+import * as React from "react"
+import { useAtom } from "jotai"
+import { atomWithStorage } from "jotai/utils"
+import { Link, useNavigate } from "@tanstack/react-router"
+import { useAccount } from "@/lib/providers/jazz-provider"
+import { cn } from "@/lib/utils"
+import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
+import { Button } from "@/components/ui/button"
+import { LaIcon } from "@/components/custom/la-icon"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { usePageActions } from "~/hooks/actions/use-page-actions"
+import { icons } from "lucide-react"
+
+type SortOption = "title" | "recent"
+type ShowOption = 5 | 10 | 15 | 20 | 0
+
+interface Option {
+ label: string
+ value: T
+}
+
+const SORTS: Option[] = [
+ { label: "Title", value: "title" },
+ { label: "Last edited", value: "recent" },
+]
+
+const SHOWS: Option[] = [
+ { label: "5 items", value: 5 },
+ { label: "10 items", value: 10 },
+ { label: "15 items", value: 15 },
+ { label: "20 items", value: 20 },
+ { label: "All", value: 0 },
+]
+
+const pageSortAtom = atomWithStorage("pageSort", "title")
+const pageShowAtom = atomWithStorage("pageShow", 5)
+
+export const PageSection: React.FC = () => {
+ const { me } = useAccount({
+ root: {
+ personalPages: [],
+ },
+ })
+ const [sort] = useAtom(pageSortAtom)
+ const [show] = useAtom(pageShowAtom)
+
+ if (!me) return null
+
+ const pageCount = me.root.personalPages?.length || 0
+
+ return (
+
+ )
+}
+
+interface PageSectionHeaderProps {
+ pageCount: number
+}
+
+const PageSectionHeader: React.FC = ({ pageCount }) => (
+
+
+
+ Pages
+ {pageCount > 0 && (
+ {pageCount}
+ )}
+
+
+
+
+
+
+
+)
+
+const NewPageButton: React.FC = () => {
+ const { me } = useAccount()
+ const navigate = useNavigate()
+ const { newPage } = usePageActions()
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ const page = newPage(me)
+
+ if (page.id) {
+ navigate({
+ to: "/pages/$pageId",
+ params: { pageId: page.id },
+ replace: true,
+ })
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+interface PageListProps {
+ personalPages: PersonalPageLists
+ sort: SortOption
+ show: ShowOption
+}
+
+const PageList: React.FC = ({ personalPages, sort, show }) => {
+ const sortedPages = React.useMemo(() => {
+ return [...personalPages]
+ .sort((a, b) => {
+ if (sort === "title") {
+ return (a?.title ?? "").localeCompare(b?.title ?? "")
+ }
+ return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0)
+ })
+ .slice(0, show === 0 ? personalPages.length : show)
+ }, [personalPages, sort, show])
+
+ return (
+
+ {sortedPages.map(
+ (page) => page?.id &&
,
+ )}
+
+ )
+}
+
+interface PageListItemProps {
+ page: PersonalPage
+}
+
+const PageListItem: React.FC = ({ page }) => {
+ return (
+
+
+
+
+
+
+ {page.title || "Untitled"}
+
+
+
+
+
+ )
+}
+
+interface SubMenuProps {
+ icon: keyof typeof icons
+ label: string
+ options: Option[]
+ currentValue: T
+ onSelect: (value: T) => void
+}
+
+const SubMenu = ({
+ icon,
+ label,
+ options,
+ currentValue,
+ onSelect,
+}: SubMenuProps) => (
+
+
+
+
+ {label}
+
+
+
+ {options.find((option) => option.value === currentValue)?.label}
+
+
+
+
+
+
+ {options.map((option) => (
+ onSelect(option.value)}
+ >
+ {option.label}
+ {currentValue === option.value && (
+
+ )}
+
+ ))}
+
+
+
+)
+
+const ShowAllForm: React.FC = () => {
+ const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
+ const [pagesShow, setPagesShow] = useAtom(pageShowAtom)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/sidebar/partials/profile-section.tsx b/web/app/components/sidebar/partials/profile-section.tsx
new file mode 100644
index 00000000..269b3de7
--- /dev/null
+++ b/web/app/components/sidebar/partials/profile-section.tsx
@@ -0,0 +1,186 @@
+import * as React from "react"
+import { useAtom } from "jotai"
+import { icons } from "lucide-react"
+import { LaIcon } from "@/components/custom/la-icon"
+import { DiscordIcon } from "@/components/icons/discord-icon"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Avatar, AvatarImage } from "@/components/ui/avatar"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+import { showShortcutAtom } from "@/components/shortcut/shortcut"
+import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
+import { SignInButton, useAuth, useUser } from "@clerk/tanstack-start"
+import { Link, useLocation } from "@tanstack/react-router"
+import { ShortcutKey } from "@shared/minimal-tiptap/components/shortcut-key"
+import { Feedback } from "./feedback"
+
+export const ProfileSection: React.FC = () => {
+ const { user, isSignedIn } = useUser()
+ const { signOut } = useAuth()
+ const [menuOpen, setMenuOpen] = React.useState(false)
+ const { pathname } = useLocation()
+ const [, setShowShortcut] = useAtom(showShortcutAtom)
+
+ const { disableKeydown } = useKeyboardManager("profileSection")
+
+ React.useEffect(() => {
+ disableKeydown(menuOpen)
+ }, [menuOpen, disableKeydown])
+
+ if (!isSignedIn) {
+ return (
+
+
+
+
+ Sign in
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+interface ProfileDropdownProps {
+ user: any
+ menuOpen: boolean
+ setMenuOpen: (open: boolean) => void
+ signOut: () => void
+ setShowShortcut: (show: boolean) => void
+}
+
+const ProfileDropdown: React.FC = ({
+ user,
+ menuOpen,
+ setMenuOpen,
+ signOut,
+ setShowShortcut,
+}) => (
+
+
+
+
+
+
+
+
+ {user.fullName}
+
+
+
+
+
+
+
+
+
+)
+
+interface DropdownMenuItemsProps {
+ signOut: () => void
+ setShowShortcut: (show: boolean) => void
+}
+
+const DropdownMenuItems: React.FC = ({
+ signOut,
+ setShowShortcut,
+}) => (
+ <>
+
+ setShowShortcut(true)}>
+
+ Shortcut
+
+
+
+
+
+
+
+
+
+
+ >
+)
+
+interface MenuLinkProps {
+ href: string
+ icon: keyof typeof icons | React.FC
+ text: string
+ iconClass?: string
+}
+
+const MenuLink: React.FC = ({
+ href,
+ icon,
+ text,
+ iconClass = "",
+}) => {
+ const IconComponent = typeof icon === "string" ? icons[icon] : icon
+ return (
+
+
+
+
+ {text}
+
+
+
+ )
+}
+
+export default ProfileSection
diff --git a/web/app/components/sidebar/partials/task-section.tsx b/web/app/components/sidebar/partials/task-section.tsx
new file mode 100644
index 00000000..62266b92
--- /dev/null
+++ b/web/app/components/sidebar/partials/task-section.tsx
@@ -0,0 +1,110 @@
+import { cn } from "@/lib/utils"
+import { useEffect, useState } from "react"
+import { isToday, isFuture } from "date-fns"
+import { useAccount } from "@/lib/providers/jazz-provider"
+import { useAuth, useUser } from "@clerk/tanstack-start"
+import { getFeatureFlag } from "~/actions"
+import { LaIcon } from "~/components/custom/la-icon"
+import { Link } from "@tanstack/react-router"
+
+export const TaskSection: React.FC = () => {
+ const { me } = useAccount({ root: { tasks: [] } })
+
+ const taskCount = me?.root?.tasks?.length || 0
+ const todayTasks =
+ me?.root?.tasks?.filter(
+ (task) =>
+ task?.status !== "done" && task?.dueDate && isToday(task.dueDate),
+ ) || []
+ const upcomingTasks =
+ me?.root?.tasks?.filter(
+ (task) =>
+ task?.status !== "done" && task?.dueDate && isFuture(task.dueDate),
+ ) || []
+
+ const [, setIsFetching] = useState(false)
+ const [isFeatureActive, setIsFeatureActive] = useState(false)
+ const { isLoaded, isSignedIn } = useAuth()
+ const { user } = useUser()
+
+ useEffect(() => {
+ async function checkFeatureFlag() {
+ setIsFetching(true)
+
+ if (isLoaded && isSignedIn) {
+ const response = await getFeatureFlag({ name: "TASK" })
+
+ if (
+ user?.emailAddresses.some((email) =>
+ response?.emails.includes(email.emailAddress),
+ )
+ ) {
+ setIsFeatureActive(true)
+ }
+ setIsFetching(false)
+ }
+ }
+
+ checkFeatureFlag()
+ }, [isLoaded, isSignedIn, user])
+
+ if (!isLoaded || !isSignedIn) {
+ return Loading...
+ }
+
+ if (!me) return null
+
+ if (!isFeatureActive) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+interface TaskSectionHeaderProps {
+ title: string
+ filter?: "today" | "upcoming"
+ count: number
+ iconName?: "BookOpenCheck" | "History"
+}
+
+const TaskSectionHeader: React.FC = ({
+ title,
+ filter,
+ count,
+ iconName,
+}) => (
+
+ {iconName && }
+
+
+ {title}
+ {count > 0 && {count} }
+
+
+)
diff --git a/web/app/components/sidebar/partials/topic-section.tsx b/web/app/components/sidebar/partials/topic-section.tsx
new file mode 100644
index 00000000..4ccd3867
--- /dev/null
+++ b/web/app/components/sidebar/partials/topic-section.tsx
@@ -0,0 +1,142 @@
+import * as React from "react"
+import { useAccount } from "@/lib/providers/jazz-provider"
+import { cn } from "@/lib/utils"
+import { LaIcon } from "@/components/custom/la-icon"
+import { ListOfTopics } from "@/lib/schema"
+import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
+import { Link } from "@tanstack/react-router"
+
+export const TopicSection: React.FC = () => {
+ const { me } = useAccount({
+ root: {
+ topicsWantToLearn: [],
+ topicsLearning: [],
+ topicsLearned: [],
+ },
+ })
+
+ const topicCount =
+ (me?.root.topicsWantToLearn?.length || 0) +
+ (me?.root.topicsLearning?.length || 0) +
+ (me?.root.topicsLearned?.length || 0)
+
+ if (!me) return null
+
+ return (
+
+
+
+
+ )
+}
+
+interface TopicSectionHeaderProps {
+ topicCount: number
+}
+
+const TopicSectionHeader: React.FC = ({
+ topicCount,
+}) => (
+
+
+ Topics
+ {topicCount > 0 && (
+ {topicCount}
+ )}
+
+
+)
+
+interface ListProps {
+ topicsWantToLearn: ListOfTopics
+ topicsLearning: ListOfTopics
+ topicsLearned: ListOfTopics
+}
+
+const List: React.FC = ({
+ topicsWantToLearn,
+ topicsLearning,
+ topicsLearned,
+}) => {
+ return (
+
+
+
+
+
+ )
+}
+
+interface ListItemProps {
+ label: string
+ value: LearningStateValue
+ count: number
+}
+
+const ListItem: React.FC = ({ label, value, count }) => {
+ const le = LEARNING_STATES.find((l) => l.value === value)
+
+ if (!le) return null
+
+ return (
+
+
+
+
+
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+
+ )
+}
diff --git a/web/app/components/sidebar/sidebar.tsx b/web/app/components/sidebar/sidebar.tsx
new file mode 100644
index 00000000..fd40985e
--- /dev/null
+++ b/web/app/components/sidebar/sidebar.tsx
@@ -0,0 +1,211 @@
+import * as React from "react"
+import { useMedia } from "@/hooks/use-media"
+import { useAtom } from "jotai"
+import { LogoIcon } from "@/components/icons/logo-icon"
+import { buttonVariants } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+import { isCollapseAtom } from "@/store/sidebar"
+import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
+import { LaIcon } from "@/components/custom/la-icon"
+import { Link, useLocation } from "@tanstack/react-router"
+
+import { LinkSection } from "./partials/link-section"
+import { PageSection } from "./partials/page-section"
+import { TopicSection } from "./partials/topic-section"
+import { ProfileSection } from "./partials/profile-section"
+import { JournalSection } from "./partials/journal-section"
+import { TaskSection } from "./partials/task-section"
+
+interface SidebarContextType {
+ isCollapsed: boolean
+ setIsCollapsed: React.Dispatch>
+}
+
+const SidebarContext = React.createContext({
+ isCollapsed: false,
+ setIsCollapsed: () => {},
+})
+
+const useSidebarCollapse = (
+ isTablet: boolean,
+): [boolean, React.Dispatch>] => {
+ const [isCollapsed, setIsCollapsed] = useAtom(isCollapseAtom)
+ const location = useLocation()
+
+ React.useEffect(() => {
+ if (isTablet) setIsCollapsed(true)
+ }, [location.pathname, setIsCollapsed, isTablet])
+
+ React.useEffect(() => {
+ setIsCollapsed(isTablet)
+ }, [isTablet, setIsCollapsed])
+
+ return [isCollapsed, setIsCollapsed]
+}
+
+interface SidebarItemProps {
+ label: string
+ url: string
+ icon?: React.ReactNode
+ onClick?: () => void
+ children?: React.ReactNode
+}
+
+const SidebarItem: React.FC = React.memo(
+ ({ label, url, icon, onClick, children }) => {
+ const { pathname } = useLocation()
+ const isActive = pathname === url
+
+ return (
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {label}
+ {children}
+
+
+ )
+ },
+)
+
+SidebarItem.displayName = "SidebarItem"
+
+const LogoAndSearch: React.FC = React.memo(() => {
+ const { pathname } = useLocation()
+
+ return (
+
+
+
+
+
+
+
+ {pathname === "/search" ? (
+ "← Back"
+ ) : (
+
+ )}
+
+
+
+ )
+})
+
+LogoAndSearch.displayName = "LogoAndSearch"
+
+const SidebarContent: React.FC = React.memo(() => {
+ const { me } = useAccountOrGuest()
+
+ return (
+
+
+
+
+
+
+ {me._type === "Account" &&
}
+ {me._type === "Account" &&
}
+ {me._type === "Account" &&
}
+ {me._type === "Account" &&
}
+ {me._type === "Account" &&
}
+
+
+
+
+ )
+})
+
+SidebarContent.displayName = "SidebarContent"
+
+const Sidebar: React.FC = () => {
+ const isTablet = useMedia("(max-width: 1024px)")
+ const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet)
+
+ const sidebarClasses = cn(
+ "h-full overflow-hidden transition-all duration-300 ease-in-out",
+ isCollapsed ? "w-0" : "w-auto min-w-56",
+ )
+
+ const sidebarInnerClasses = cn(
+ "h-full w-56 min-w-56 transition-transform duration-300 ease-in-out",
+ isCollapsed ? "-translate-x-full" : "translate-x-0",
+ )
+
+ const contextValue = React.useMemo(
+ () => ({ isCollapsed, setIsCollapsed }),
+ [isCollapsed, setIsCollapsed],
+ )
+
+ if (isTablet) {
+ return (
+ <>
+ setIsCollapsed(true)}
+ />
+
+ >
+ )
+ }
+
+ return (
+
+ )
+}
+
+Sidebar.displayName = "Sidebar"
+
+export { Sidebar, SidebarItem, SidebarContext }
diff --git a/web/app/components/ui/alert-dialog.tsx b/web/app/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..bcc66f84
--- /dev/null
+++ b/web/app/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/web/components/ui/avatar.tsx b/web/app/components/ui/avatar.tsx
similarity index 96%
rename from web/components/ui/avatar.tsx
rename to web/app/components/ui/avatar.tsx
index 51e507ba..d6e683ca 100644
--- a/web/components/ui/avatar.tsx
+++ b/web/app/components/ui/avatar.tsx
@@ -1,5 +1,3 @@
-"use client"
-
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
@@ -13,7 +11,7 @@ const Avatar = React.forwardRef<
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
- className
+ className,
)}
{...props}
/>
@@ -40,7 +38,7 @@ const AvatarFallback = React.forwardRef<
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
- className
+ className,
)}
{...props}
/>
diff --git a/web/app/components/ui/badge.tsx b/web/app/components/ui/badge.tsx
new file mode 100644
index 00000000..64081126
--- /dev/null
+++ b/web/app/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/web/app/components/ui/button.tsx b/web/app/components/ui/button.tsx
new file mode 100644
index 00000000..8de34f43
--- /dev/null
+++ b/web/app/components/ui/button.tsx
@@ -0,0 +1,57 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ },
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/web/app/components/ui/calendar.tsx b/web/app/components/ui/calendar.tsx
new file mode 100644
index 00000000..5e0c825f
--- /dev/null
+++ b/web/app/components/ui/calendar.tsx
@@ -0,0 +1,70 @@
+import * as React from "react"
+import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+export type CalendarProps = React.ComponentProps
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md",
+ ),
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "h-8 w-8 p-0 font-normal aria-selected:opacity-100",
+ ),
+ day_range_start: "day-range-start",
+ day_range_end: "day-range-end",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: () => ,
+ IconRight: () => ,
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/web/app/components/ui/checkbox.tsx b/web/app/components/ui/checkbox.tsx
new file mode 100644
index 00000000..5bffe19c
--- /dev/null
+++ b/web/app/components/ui/checkbox.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "@radix-ui/react-icons"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/web/app/components/ui/command.tsx b/web/app/components/ui/command.tsx
new file mode 100644
index 00000000..568e4fd8
--- /dev/null
+++ b/web/app/components/ui/command.tsx
@@ -0,0 +1,153 @@
+import * as React from "react"
+import { type DialogProps } from "@radix-ui/react-dialog"
+import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
+import { Command as CommandPrimitive } from "cmdk"
+
+import { cn } from "@/lib/utils"
+import { Dialog, DialogContent } from "@/components/ui/dialog"
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Command.displayName = CommandPrimitive.displayName
+
+interface CommandDialogProps extends DialogProps {}
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+
+CommandInput.displayName = CommandPrimitive.Input.displayName
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandList.displayName = CommandPrimitive.List.displayName
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+))
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandItem.displayName = CommandPrimitive.Item.displayName
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+CommandShortcut.displayName = "CommandShortcut"
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/web/app/components/ui/dialog.tsx b/web/app/components/ui/dialog.tsx
new file mode 100644
index 00000000..ba019955
--- /dev/null
+++ b/web/app/components/ui/dialog.tsx
@@ -0,0 +1,121 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { Cross2Icon } from "@radix-ui/react-icons"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+ DialogPrimitive,
+}
diff --git a/web/app/components/ui/dropdown-menu.tsx b/web/app/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..96315959
--- /dev/null
+++ b/web/app/components/ui/dropdown-menu.tsx
@@ -0,0 +1,198 @@
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { CheckIcon, DotFilledIcon } from "@radix-ui/react-icons"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/web/app/components/ui/form.tsx b/web/app/components/ui/form.tsx
new file mode 100644
index 00000000..2aa31017
--- /dev/null
+++ b/web/app/components/ui/form.tsx
@@ -0,0 +1,176 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ ControllerProps,
+ FieldPath,
+ FieldValues,
+ FormProvider,
+ useFormContext,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue,
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue,
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message) : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/web/app/components/ui/input.tsx b/web/app/components/ui/input.tsx
new file mode 100644
index 00000000..315bdeea
--- /dev/null
+++ b/web/app/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/web/app/components/ui/label.tsx b/web/app/components/ui/label.tsx
new file mode 100644
index 00000000..f1e8dbe1
--- /dev/null
+++ b/web/app/components/ui/label.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/web/app/components/ui/popover.tsx b/web/app/components/ui/popover.tsx
new file mode 100644
index 00000000..fa0ec921
--- /dev/null
+++ b/web/app/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverAnchor = PopoverPrimitive.Anchor
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/web/app/components/ui/scroll-area.tsx b/web/app/components/ui/scroll-area.tsx
new file mode 100644
index 00000000..c9124426
--- /dev/null
+++ b/web/app/components/ui/scroll-area.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/web/app/components/ui/select.tsx b/web/app/components/ui/select.tsx
new file mode 100644
index 00000000..4a71e842
--- /dev/null
+++ b/web/app/components/ui/select.tsx
@@ -0,0 +1,162 @@
+import * as React from "react"
+import {
+ CaretSortIcon,
+ CheckIcon,
+ ChevronDownIcon,
+ ChevronUpIcon,
+} from "@radix-ui/react-icons"
+import * as SelectPrimitive from "@radix-ui/react-select"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/web/app/components/ui/separator.tsx b/web/app/components/ui/separator.tsx
new file mode 100644
index 00000000..ae26bc80
--- /dev/null
+++ b/web/app/components/ui/separator.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref,
+ ) => (
+
+ ),
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
diff --git a/web/app/components/ui/sheet.tsx b/web/app/components/ui/sheet.tsx
new file mode 100644
index 00000000..bd3197b6
--- /dev/null
+++ b/web/app/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { Cross2Icon } from "@radix-ui/react-icons"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+export const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ },
+)
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+
+
+ Close
+
+ {children}
+
+
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/web/components/ui/skeleton.tsx b/web/app/components/ui/skeleton.tsx
similarity index 100%
rename from web/components/ui/skeleton.tsx
rename to web/app/components/ui/skeleton.tsx
diff --git a/web/components/ui/switch.tsx b/web/app/components/ui/switch.tsx
similarity index 92%
rename from web/components/ui/switch.tsx
rename to web/app/components/ui/switch.tsx
index 5f4117f0..e41d022b 100644
--- a/web/components/ui/switch.tsx
+++ b/web/app/components/ui/switch.tsx
@@ -1,5 +1,3 @@
-"use client"
-
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
@@ -12,14 +10,14 @@ const Switch = React.forwardRef<
diff --git a/web/app/components/ui/textarea.tsx b/web/app/components/ui/textarea.tsx
new file mode 100644
index 00000000..fa669d1b
--- /dev/null
+++ b/web/app/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/web/components/ui/toggle-group.tsx b/web/app/components/ui/toggle-group.tsx
similarity index 98%
rename from web/components/ui/toggle-group.tsx
rename to web/app/components/ui/toggle-group.tsx
index 1c876bbe..def735ed 100644
--- a/web/components/ui/toggle-group.tsx
+++ b/web/app/components/ui/toggle-group.tsx
@@ -1,5 +1,3 @@
-"use client"
-
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
@@ -47,7 +45,7 @@ const ToggleGroupItem = React.forwardRef<
variant: context.variant || variant,
size: context.size || size,
}),
- className
+ className,
)}
{...props}
>
diff --git a/web/app/components/ui/toggle.tsx b/web/app/components/ui/toggle.tsx
new file mode 100644
index 00000000..6d847081
--- /dev/null
+++ b/web/app/components/ui/toggle.tsx
@@ -0,0 +1,43 @@
+import * as React from "react"
+import * as TogglePrimitive from "@radix-ui/react-toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 px-3",
+ sm: "h-8 px-2",
+ lg: "h-10 px-3",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+)
+
+const Toggle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, size, ...props }, ref) => (
+
+))
+
+Toggle.displayName = TogglePrimitive.Root.displayName
+
+export { Toggle, toggleVariants }
diff --git a/web/app/components/ui/tooltip.tsx b/web/app/components/ui/tooltip.tsx
new file mode 100644
index 00000000..be640f36
--- /dev/null
+++ b/web/app/components/ui/tooltip.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+const TooltipProvider = TooltipPrimitive.Provider
+
+const Tooltip = TooltipPrimitive.Root
+
+const TooltipTrigger = TooltipPrimitive.Trigger
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/web/app/custom.css b/web/app/custom.css
deleted file mode 100644
index 3d0b3660..00000000
--- a/web/app/custom.css
+++ /dev/null
@@ -1,11 +0,0 @@
-:root {
- --link-background-muted: hsl(0, 0%, 97.3%);
- --link-border-after: hsl(0, 0%, 91%);
- --link-shadow: hsl(240, 5.6%, 82.5%);
-}
-
-.dark {
- --link-background-muted: hsl(220, 6.7%, 8.8%);
- --link-border-after: hsl(230, 10%, 11.8%);
- --link-shadow: hsl(234.9, 27.1%, 25.3%);
-}
diff --git a/web/app/data/graph.json b/web/app/data/graph.json
new file mode 100644
index 00000000..afac9435
--- /dev/null
+++ b/web/app/data/graph.json
@@ -0,0 +1,3129 @@
+[
+ { "name": "habits", "prettyName": "Habits", "connectedTopics": ["focusing"] },
+ {
+ "name": "music-playlists",
+ "prettyName": "Music playlists",
+ "connectedTopics": ["music"]
+ },
+ {
+ "name": "zeromq",
+ "prettyName": "ZeroMQ",
+ "connectedTopics": ["networking"]
+ },
+ {
+ "name": "cockroachdb",
+ "prettyName": "CockroachDB",
+ "connectedTopics": ["databases"]
+ },
+ { "name": "tezos", "prettyName": "Tezos", "connectedTopics": ["blockchain"] },
+ {
+ "name": "mongodb",
+ "prettyName": "MongoDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "xstate",
+ "prettyName": "XState",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "elasticsearch",
+ "prettyName": "Elasticsearch",
+ "connectedTopics": ["search-engines"]
+ },
+ { "name": "gpu", "prettyName": "GPU", "connectedTopics": ["hardware"] },
+ {
+ "name": "github-actions",
+ "prettyName": "GitHub actions",
+ "connectedTopics": ["github"]
+ },
+ {
+ "name": "capacitor",
+ "prettyName": "Capacitor",
+ "connectedTopics": ["web"]
+ },
+ {
+ "name": "wundergraph",
+ "prettyName": "WunderGraph",
+ "connectedTopics": ["graphql"]
+ },
+ {
+ "name": "linear-algebra",
+ "prettyName": "Linear algebra",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "typescript-libraries",
+ "prettyName": "TypeScript libraries",
+ "connectedTopics": ["typescript"]
+ },
+ {
+ "name": "php",
+ "prettyName": "PHP",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "unison",
+ "prettyName": "Unison",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "ember",
+ "prettyName": "Ember.js",
+ "connectedTopics": ["js-libraries"]
+ },
+ { "name": "shell", "prettyName": "Unix Shell", "connectedTopics": ["unix"] },
+ {
+ "name": "programming",
+ "prettyName": "Programming",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "linters",
+ "prettyName": "Linters",
+ "connectedTopics": ["compilers"]
+ },
+ {
+ "name": "transfer-learning",
+ "prettyName": "Transfer learning",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "intellij",
+ "prettyName": "IntelliJ IDEA",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "brain-computer-interfaces",
+ "prettyName": "Brain Computer Interfaces",
+ "connectedTopics": ["neuroscience"]
+ },
+ {
+ "name": "adblocking",
+ "prettyName": "Ad blocking",
+ "connectedTopics": ["privacy"]
+ },
+ {
+ "name": "data-processing",
+ "prettyName": "Data Processing",
+ "connectedTopics": ["data-science"]
+ },
+ { "name": "makeup", "prettyName": "Makeup", "connectedTopics": [] },
+ { "name": "drawing", "prettyName": "Drawing", "connectedTopics": ["art"] },
+ {
+ "name": "tailscale",
+ "prettyName": "Tailscale",
+ "connectedTopics": ["wireguard"]
+ },
+ {
+ "name": "cooking",
+ "prettyName": "Cooking",
+ "connectedTopics": ["recipes"]
+ },
+ { "name": "psychology", "prettyName": "Psychology", "connectedTopics": [] },
+ {
+ "name": "product-management",
+ "prettyName": "Product Management",
+ "connectedTopics": ["management"]
+ },
+ { "name": "humans", "prettyName": "Humans", "connectedTopics": [] },
+ {
+ "name": "statistics",
+ "prettyName": "Statistics",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "python-libraries",
+ "prettyName": "Python libraries",
+ "connectedTopics": ["python"]
+ },
+ {
+ "name": "elm-libraries",
+ "prettyName": "Elm libraries",
+ "connectedTopics": ["elm"]
+ },
+ {
+ "name": "combine",
+ "prettyName": "Combine Framework",
+ "connectedTopics": ["swift-libraries"]
+ },
+ {
+ "name": "c-libraries",
+ "prettyName": "C libraries",
+ "connectedTopics": ["c"]
+ },
+ { "name": "languages", "prettyName": "Languages", "connectedTopics": [] },
+ {
+ "name": "reverse-engineering",
+ "prettyName": "Reverse engineering",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "stream-processing",
+ "prettyName": "Stream processing",
+ "connectedTopics": ["data-processing"]
+ },
+ {
+ "name": "gadts",
+ "prettyName": "Generalized algebraic data type",
+ "connectedTopics": ["data-structures"]
+ },
+ {
+ "name": "memory-management",
+ "prettyName": "Memory management",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "directors",
+ "prettyName": "Film directors",
+ "connectedTopics": ["movies"]
+ },
+ {
+ "name": "nuclear-energy",
+ "prettyName": "Nuclear energy",
+ "connectedTopics": ["renewable-energy"]
+ },
+ {
+ "name": "home-automation",
+ "prettyName": "Home automation",
+ "connectedTopics": ["automation"]
+ },
+ {
+ "name": "black-holes",
+ "prettyName": "Black holes",
+ "connectedTopics": ["space"]
+ },
+ {
+ "name": "nix-darwin",
+ "prettyName": "Nix on macOS",
+ "connectedTopics": ["nix"]
+ },
+ {
+ "name": "game-engines",
+ "prettyName": "Game engines",
+ "connectedTopics": ["gamedev"]
+ },
+ { "name": "finland", "prettyName": "Finland", "connectedTopics": [] },
+ {
+ "name": "r",
+ "prettyName": "R language",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "interior-design",
+ "prettyName": "Interior Design",
+ "connectedTopics": ["architecture"]
+ },
+ { "name": "3d-modeling", "prettyName": "3D modeling", "connectedTopics": [] },
+ {
+ "name": "spline",
+ "prettyName": "Spline",
+ "connectedTopics": ["3d-modeling"]
+ },
+ { "name": "design", "prettyName": "Design", "connectedTopics": [] },
+ {
+ "name": "blender",
+ "prettyName": "Blender",
+ "connectedTopics": ["3d-modeling"]
+ },
+ { "name": "icons", "prettyName": "Icons", "connectedTopics": ["logos"] },
+ {
+ "name": "industrial-design",
+ "prettyName": "Industrial Design",
+ "connectedTopics": []
+ },
+ {
+ "name": "rpcs",
+ "prettyName": "Remote Procedure Calls",
+ "connectedTopics": ["distributed-systems"]
+ },
+ { "name": "grpc", "prettyName": "gRPC", "connectedTopics": ["encoding"] },
+ {
+ "name": "asking-questions",
+ "prettyName": "Asking Questions",
+ "connectedTopics": []
+ },
+ {
+ "name": "solving-problems",
+ "prettyName": "Solving problems",
+ "connectedTopics": []
+ },
+ { "name": "research", "prettyName": "Research", "connectedTopics": [] },
+ { "name": "goals", "prettyName": "Goals", "connectedTopics": [] },
+ {
+ "name": "music-albums",
+ "prettyName": "Music albums",
+ "connectedTopics": ["music"]
+ },
+ { "name": "song-covers", "prettyName": "Song covers", "connectedTopics": [] },
+ { "name": "guitar", "prettyName": "Guitar", "connectedTopics": ["music"] },
+ {
+ "name": "synthesizers",
+ "prettyName": "Synthesizers",
+ "connectedTopics": ["music"]
+ },
+ {
+ "name": "music-production",
+ "prettyName": "Music production",
+ "connectedTopics": ["music"]
+ },
+ { "name": "piano", "prettyName": "Piano", "connectedTopics": ["music"] },
+ {
+ "name": "logic-pro",
+ "prettyName": "Logic Pro",
+ "connectedTopics": ["music"]
+ },
+ { "name": "ableton", "prettyName": "Ableton", "connectedTopics": ["music"] },
+ {
+ "name": "music-artists",
+ "prettyName": "Music artists",
+ "connectedTopics": ["music"]
+ },
+ { "name": "singing", "prettyName": "Singing", "connectedTopics": [] },
+ { "name": "ambient", "prettyName": "Ambient sounds", "connectedTopics": [] },
+ { "name": "framer", "prettyName": "Framer", "connectedTopics": ["design"] },
+ {
+ "name": "animation",
+ "prettyName": "Animation",
+ "connectedTopics": ["design"]
+ },
+ {
+ "name": "design-systems",
+ "prettyName": "Design systems",
+ "connectedTopics": ["design"]
+ },
+ {
+ "name": "design-inspiration",
+ "prettyName": "Design inspiration",
+ "connectedTopics": ["design"]
+ },
+ { "name": "logos", "prettyName": "Logos", "connectedTopics": ["icons"] },
+ { "name": "fonts", "prettyName": "Fonts", "connectedTopics": [] },
+ { "name": "rive", "prettyName": "Rive", "connectedTopics": [] },
+ { "name": "color", "prettyName": "Color", "connectedTopics": [] },
+ {
+ "name": "user-experience",
+ "prettyName": "User Experience",
+ "connectedTopics": ["design"]
+ },
+ { "name": "figma", "prettyName": "Figma", "connectedTopics": ["design"] },
+ {
+ "name": "figma-plugins",
+ "prettyName": "Figma plugins",
+ "connectedTopics": ["figma"]
+ },
+ {
+ "name": "distributed-systems",
+ "prettyName": "Distributed systems",
+ "connectedTopics": ["software-architecture"]
+ },
+ { "name": "e-commerce", "prettyName": "E-commerce", "connectedTopics": [] },
+ {
+ "name": "3d-printing",
+ "prettyName": "3D Printing",
+ "connectedTopics": ["hardware"]
+ },
+ { "name": "iran", "prettyName": "Iran", "connectedTopics": [] },
+ { "name": "thailand", "prettyName": "Thailand", "connectedTopics": [] },
+ {
+ "name": "video",
+ "prettyName": "Video",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "basic-income",
+ "prettyName": "Basic income",
+ "connectedTopics": ["economy"]
+ },
+ {
+ "name": "spicedb",
+ "prettyName": "SpiceDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "mariadb",
+ "prettyName": "MariaDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "edgedb",
+ "prettyName": "EdgeDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "planetscale",
+ "prettyName": "Planetscale",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "prisma",
+ "prettyName": "Prisma",
+ "connectedTopics": ["databases"]
+ },
+ { "name": "music", "prettyName": "Music", "connectedTopics": [] },
+ {
+ "name": "message-queue",
+ "prettyName": "Message queue",
+ "connectedTopics": []
+ },
+ { "name": "mqtt", "prettyName": "MQTT", "connectedTopics": [] },
+ {
+ "name": "load-balancing",
+ "prettyName": "Load balancing",
+ "connectedTopics": ["distributed-systems"]
+ },
+ {
+ "name": "crdt",
+ "prettyName": "Conflict-free replicated data type",
+ "connectedTopics": ["data-structures"]
+ },
+ {
+ "name": "cinematography",
+ "prettyName": "Cinematography",
+ "connectedTopics": ["movies"]
+ },
+ {
+ "name": "finance",
+ "prettyName": "Finance",
+ "connectedTopics": ["economy"]
+ },
+ {
+ "name": "high-frequency-trading",
+ "prettyName": "High frequency trading",
+ "connectedTopics": ["finance"]
+ },
+ {
+ "name": "investing",
+ "prettyName": "Investing",
+ "connectedTopics": ["finance"]
+ },
+ { "name": "kdb", "prettyName": "Kdb+", "connectedTopics": ["databases"] },
+ {
+ "name": "prometheus",
+ "prettyName": "Prometheus",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "rocksdb",
+ "prettyName": "RocksDB",
+ "connectedTopics": ["databases"]
+ },
+ { "name": "redis", "prettyName": "Redis", "connectedTopics": ["databases"] },
+ { "name": "mysql", "prettyName": "MySQL", "connectedTopics": ["databases"] },
+ {
+ "name": "duckdb",
+ "prettyName": "DuckDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "dynamodb",
+ "prettyName": "DynamoDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "greptimedb",
+ "prettyName": "GreptimeDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "blockchain",
+ "prettyName": "Blockchain",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "cardano",
+ "prettyName": "Cardano",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "arweave",
+ "prettyName": "Arweave",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "ethereum",
+ "prettyName": "Ethereum",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "polkadot",
+ "prettyName": "Polkadot",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "cosmos",
+ "prettyName": "Cosmos",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "near",
+ "prettyName": "NEAR Protocol",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "uniswap",
+ "prettyName": "Uniswap",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "solana",
+ "prettyName": "Solana",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "clickhouse",
+ "prettyName": "ClickHouse",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "foundationdb",
+ "prettyName": "FoundationDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "surrealdb",
+ "prettyName": "SurrealDB",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "postgresql",
+ "prettyName": "PostgreSQL",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "memcached",
+ "prettyName": "Memcached",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "cassandra",
+ "prettyName": "Cassandra",
+ "connectedTopics": ["databases"]
+ },
+ { "name": "databases", "prettyName": "Databases", "connectedTopics": [] },
+ {
+ "name": "timescaledb",
+ "prettyName": "TimescaleDB",
+ "connectedTopics": ["databases"]
+ },
+ { "name": "neo4j", "prettyName": "Neo4j", "connectedTopics": ["databases"] },
+ {
+ "name": "bonsaidb",
+ "prettyName": "BonsaiDb",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "sqlite",
+ "prettyName": "SQLite",
+ "connectedTopics": ["databases"]
+ },
+ {
+ "name": "dgraph",
+ "prettyName": "Dgraph",
+ "connectedTopics": ["databases"]
+ },
+ { "name": "fauna", "prettyName": "Fauna", "connectedTopics": ["databases"] },
+ { "name": "sql", "prettyName": "SQL", "connectedTopics": ["databases"] },
+ { "name": "vim", "prettyName": "Vim", "connectedTopics": ["text-editors"] },
+ {
+ "name": "vim-plugins",
+ "prettyName": "Vim plugins",
+ "connectedTopics": ["vim"]
+ },
+ {
+ "name": "tiptap",
+ "prettyName": "TipTap",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "sublime-text-plugins",
+ "prettyName": "Sublime Text plugins",
+ "connectedTopics": ["sublime-text"]
+ },
+ {
+ "name": "sublime-text",
+ "prettyName": "Sublime Text",
+ "connectedTopics": ["text-editors"]
+ },
+ { "name": "zed", "prettyName": "Zed", "connectedTopics": ["text-editors"] },
+ {
+ "name": "text-editors",
+ "prettyName": "Text editors",
+ "connectedTopics": []
+ },
+ {
+ "name": "emacs-packages",
+ "prettyName": "Emacs packages",
+ "connectedTopics": ["emacs"]
+ },
+ {
+ "name": "emacs",
+ "prettyName": "Emacs",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "manaco-editor",
+ "prettyName": "Monaco Editor",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "vs-code",
+ "prettyName": "VS Code",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "vs-code-extensions",
+ "prettyName": "VS Code extensions",
+ "connectedTopics": ["vs-code"]
+ },
+ {
+ "name": "codemirror",
+ "prettyName": "CodeMirror",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "prosemirror",
+ "prettyName": "ProseMirror",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "helix",
+ "prettyName": "Helix",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "inngest",
+ "prettyName": "Inngest",
+ "connectedTopics": ["message-queue"]
+ },
+ {
+ "name": "site-reliability-engineering",
+ "prettyName": "Site Reliability Engineering",
+ "connectedTopics": ["devops"]
+ },
+ {
+ "name": "terraform",
+ "prettyName": "Terraform",
+ "connectedTopics": ["devops"]
+ },
+ {
+ "name": "temporal",
+ "prettyName": "Temporal",
+ "connectedTopics": ["devops"]
+ },
+ {
+ "name": "observability",
+ "prettyName": "Observability",
+ "connectedTopics": ["devops"]
+ },
+ { "name": "devops", "prettyName": "DevOps", "connectedTopics": [] },
+ { "name": "email", "prettyName": "Email", "connectedTopics": [] },
+ {
+ "name": "logseq",
+ "prettyName": "Logseq",
+ "connectedTopics": ["text-editors"]
+ },
+ {
+ "name": "product-hunt",
+ "prettyName": "Product Hunt",
+ "connectedTopics": []
+ },
+ { "name": "firebase", "prettyName": "Firebase", "connectedTopics": [] },
+ { "name": "docusaurus", "prettyName": "Docusaurus", "connectedTopics": [] },
+ {
+ "name": "twitter",
+ "prettyName": "Twitter",
+ "connectedTopics": ["social-networks"]
+ },
+ { "name": "dropbox", "prettyName": "Dropbox", "connectedTopics": [] },
+ { "name": "slack", "prettyName": "Slack", "connectedTopics": [] },
+ {
+ "name": "roam-research",
+ "prettyName": "Roam Research",
+ "connectedTopics": []
+ },
+ { "name": "ifttt", "prettyName": "IFTTT", "connectedTopics": [] },
+ { "name": "blogs", "prettyName": "Blogs", "connectedTopics": ["writing"] },
+ { "name": "focusing", "prettyName": "Focusing", "connectedTopics": [] },
+ { "name": "irc", "prettyName": "IRC", "connectedTopics": [] },
+ { "name": "vitepress", "prettyName": "VitePress", "connectedTopics": [] },
+ { "name": "gitbook", "prettyName": "GitBook", "connectedTopics": [] },
+ { "name": "notion", "prettyName": "Notion", "connectedTopics": [] },
+ { "name": "remnote", "prettyName": "Remnote", "connectedTopics": [] },
+ {
+ "name": "obsidian",
+ "prettyName": "Obsidian\naliases: [Obsidan]",
+ "connectedTopics": []
+ },
+ { "name": "reddit", "prettyName": "Reddit", "connectedTopics": [] },
+ { "name": "wordpress", "prettyName": "Wordpress", "connectedTopics": [] },
+ { "name": "dendron", "prettyName": "Dendron", "connectedTopics": [] },
+ { "name": "dat", "prettyName": "Dat", "connectedTopics": [] },
+ { "name": "codesandbox", "prettyName": "CodeSandbox", "connectedTopics": [] },
+ { "name": "sanity", "prettyName": "Sanity", "connectedTopics": [] },
+ { "name": "ansible", "prettyName": "Ansible", "connectedTopics": [] },
+ { "name": "zulip", "prettyName": "Zulip", "connectedTopics": [] },
+ { "name": "pdf", "prettyName": "PDF", "connectedTopics": [] },
+ {
+ "name": "personal-setups",
+ "prettyName": "Personal setups",
+ "connectedTopics": []
+ },
+ {
+ "name": "telegram",
+ "prettyName": "Telegram",
+ "connectedTopics": ["communication"]
+ },
+ {
+ "name": "discord",
+ "prettyName": "Discord",
+ "connectedTopics": ["communication"]
+ },
+ { "name": "tools", "prettyName": "Tools", "connectedTopics": [] },
+ { "name": "turso", "prettyName": "Turso", "connectedTopics": ["databases"] },
+ {
+ "name": "meilisearch",
+ "prettyName": "Meilisearch",
+ "connectedTopics": ["search-engines"]
+ },
+ {
+ "name": "voice-assistants",
+ "prettyName": "Voice assistants",
+ "connectedTopics": []
+ },
+ {
+ "name": "neuroscience",
+ "prettyName": "Neuroscience",
+ "connectedTopics": ["brain-computer-interfaces"]
+ },
+ {
+ "name": "cognition",
+ "prettyName": "Cognition",
+ "connectedTopics": ["neuroscience"]
+ },
+ { "name": "hardware", "prettyName": "Hardware", "connectedTopics": ["cpu"] },
+ {
+ "name": "circuit-design",
+ "prettyName": "Circuit design",
+ "connectedTopics": ["electrical-engineering"]
+ },
+ { "name": "risc-v", "prettyName": "RISC-V", "connectedTopics": [] },
+ { "name": "amd", "prettyName": "AMD", "connectedTopics": ["cpu"] },
+ { "name": "cpu", "prettyName": "CPU", "connectedTopics": [] },
+ {
+ "name": "displays",
+ "prettyName": "Displays",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "arduino",
+ "prettyName": "Arduino",
+ "connectedTopics": ["embedded-systems"]
+ },
+ {
+ "name": "raspberry-pi",
+ "prettyName": "Raspberry Pi",
+ "connectedTopics": ["hardware"]
+ },
+ {
+ "name": "firmware",
+ "prettyName": "Firmware",
+ "connectedTopics": ["hardware"]
+ },
+ {
+ "name": "verilog",
+ "prettyName": "Verilog",
+ "connectedTopics": ["hardware"]
+ },
+ {
+ "name": "fpga",
+ "prettyName": "Field-programmable gate array",
+ "connectedTopics": ["hardware"]
+ },
+ {
+ "name": "neuromorphic-computing",
+ "prettyName": "Neuromorphic Computing",
+ "connectedTopics": ["hardware"]
+ },
+ {
+ "name": "jxa",
+ "prettyName": "JavaScript for Automation",
+ "connectedTopics": ["javascript"]
+ },
+ {
+ "name": "macOS",
+ "prettyName": "macOS",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "applescript",
+ "prettyName": "AppleScript",
+ "connectedTopics": ["macOS"]
+ },
+ { "name": "tweetbot", "prettyName": "Tweetbot", "connectedTopics": [] },
+ { "name": "xcode", "prettyName": "Xcode", "connectedTopics": ["macOS"] },
+ {
+ "name": "xcode-extensions",
+ "prettyName": "Xcode Extensions",
+ "connectedTopics": ["xcode"]
+ },
+ {
+ "name": "keychain",
+ "prettyName": "Keychain",
+ "connectedTopics": ["macOS"]
+ },
+ {
+ "name": "macOS-apps",
+ "prettyName": "macOS apps",
+ "connectedTopics": ["macOS"]
+ },
+ { "name": "typinator", "prettyName": "Typinator", "connectedTopics": [] },
+ { "name": "timing", "prettyName": "Timing", "connectedTopics": [] },
+ { "name": "fantastical", "prettyName": "Fantastical", "connectedTopics": [] },
+ { "name": "hazel", "prettyName": "Hazel", "connectedTopics": [] },
+ { "name": "iterm", "prettyName": "iTerm", "connectedTopics": [] },
+ { "name": "sketch", "prettyName": "Sketch", "connectedTopics": [] },
+ { "name": "hammerspoon", "prettyName": "Hammerspoon", "connectedTopics": [] },
+ { "name": "scriptkit", "prettyName": "ScriptKit", "connectedTopics": [] },
+ { "name": "trello", "prettyName": "Trello", "connectedTopics": [] },
+ { "name": "2do", "prettyName": "2Do", "connectedTopics": [] },
+ { "name": "mindnode", "prettyName": "MindNode", "connectedTopics": [] },
+ { "name": "contacts", "prettyName": "Contacts", "connectedTopics": [] },
+ { "name": "textual", "prettyName": "Textual", "connectedTopics": [] },
+ { "name": "pixave", "prettyName": "Pixave", "connectedTopics": [] },
+ {
+ "name": "bettertouchtool",
+ "prettyName": "BetterTouchTool",
+ "connectedTopics": []
+ },
+ {
+ "name": "little-snitch",
+ "prettyName": "Little Snitch",
+ "connectedTopics": []
+ },
+ {
+ "name": "keyboard-maestro",
+ "prettyName": "Keyboard Maestro",
+ "connectedTopics": []
+ },
+ {
+ "name": "affinity-designer",
+ "prettyName": "Affinity Designer",
+ "connectedTopics": []
+ },
+ {
+ "name": "making-workflows",
+ "prettyName": "Making workflows",
+ "connectedTopics": []
+ },
+ { "name": "awgo", "prettyName": "AwGo", "connectedTopics": [] },
+ { "name": "alfred", "prettyName": "Alfred", "connectedTopics": ["macOS"] },
+ { "name": "1password", "prettyName": "1Password", "connectedTopics": [] },
+ {
+ "name": "karabiner",
+ "prettyName": "Karabiner",
+ "connectedTopics": ["macOS"]
+ },
+ { "name": "tracking", "prettyName": "Tracking", "connectedTopics": [] },
+ {
+ "name": "tor",
+ "prettyName": "Tor Project",
+ "connectedTopics": ["browsers"]
+ },
+ {
+ "name": "self-hosting",
+ "prettyName": "Self hosting",
+ "connectedTopics": ["web"]
+ },
+ { "name": "privacy", "prettyName": "Privacy", "connectedTopics": [] },
+ { "name": "freedom", "prettyName": "Freedom", "connectedTopics": [] },
+ { "name": "raycast", "prettyName": "Raycast", "connectedTopics": ["macOS"] },
+ { "name": "znc", "prettyName": "ZNC", "connectedTopics": ["irc"] },
+ {
+ "name": "github-bots",
+ "prettyName": "GitHub bots",
+ "connectedTopics": ["github"]
+ },
+ { "name": "open-source", "prettyName": "Open Source", "connectedTopics": [] },
+ {
+ "name": "data-structures",
+ "prettyName": "Data structures",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "automata-theory",
+ "prettyName": "Automata theory",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "algorithms",
+ "prettyName": "Algorithms",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "compression",
+ "prettyName": "Compression",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "parsing",
+ "prettyName": "Parsing",
+ "connectedTopics": ["compilers"]
+ },
+ {
+ "name": "computer-science",
+ "prettyName": "Computer Science",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "computer-architecture",
+ "prettyName": "Computer architecture",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "formal-verification",
+ "prettyName": "Formal verification",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "tla",
+ "prettyName": "TLA+",
+ "connectedTopics": ["formal-verification"]
+ },
+ {
+ "name": "kafka",
+ "prettyName": "Apache Kafka",
+ "connectedTopics": ["stream-processing"]
+ },
+ {
+ "name": "data-visualization",
+ "prettyName": "Data Visualization",
+ "connectedTopics": ["data-science"]
+ },
+ {
+ "name": "data-science",
+ "prettyName": "Data Science",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "datasette",
+ "prettyName": "Datasette",
+ "connectedTopics": ["data-processing"]
+ },
+ {
+ "name": "cryptography",
+ "prettyName": "Cryptography",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "zero-knowledge-proofs",
+ "prettyName": "Zero knowledge proofs",
+ "connectedTopics": ["encryption"]
+ },
+ {
+ "name": "encryption",
+ "prettyName": "Encryption",
+ "connectedTopics": ["security"]
+ },
+ { "name": "security", "prettyName": "Security", "connectedTopics": [] },
+ {
+ "name": "research-chemicals",
+ "prettyName": "Research chemicals",
+ "connectedTopics": ["drugs"]
+ },
+ { "name": "mdma", "prettyName": "MDMA", "connectedTopics": ["drugs"] },
+ { "name": "drugs", "prettyName": "Drugs", "connectedTopics": [] },
+ {
+ "name": "nootropics",
+ "prettyName": "Nootropics",
+ "connectedTopics": ["supplements"]
+ },
+ { "name": "opiates", "prettyName": "Opiates", "connectedTopics": ["drugs"] },
+ {
+ "name": "cannabis",
+ "prettyName": "Cannabis",
+ "connectedTopics": ["drugs"]
+ },
+ {
+ "name": "dissociatives",
+ "prettyName": "Dissociatives",
+ "connectedTopics": ["drugs"]
+ },
+ {
+ "name": "microdosing",
+ "prettyName": "Microdosing",
+ "connectedTopics": ["drugs"]
+ },
+ {
+ "name": "phenethylamines",
+ "prettyName": "Phenethylamines",
+ "connectedTopics": ["psychedelics"]
+ },
+ {
+ "name": "salvia",
+ "prettyName": "Salvia",
+ "connectedTopics": ["psychedelics"]
+ },
+ {
+ "name": "ketamine",
+ "prettyName": "Ketamine",
+ "connectedTopics": ["drugs"]
+ },
+ { "name": "dmt", "prettyName": "DMT", "connectedTopics": ["psychedelics"] },
+ {
+ "name": "tryptamines",
+ "prettyName": "Tryptamines",
+ "connectedTopics": ["psychedelics"]
+ },
+ {
+ "name": "psychedelics",
+ "prettyName": "Psychedelics",
+ "connectedTopics": ["drugs"]
+ },
+ { "name": "trippy", "prettyName": "Trippy things", "connectedTopics": [] },
+ { "name": "lsd", "prettyName": "LSD", "connectedTopics": ["lysergamides"] },
+ {
+ "name": "lysergamides",
+ "prettyName": "Lysergamides",
+ "connectedTopics": ["lsd"]
+ },
+ { "name": "sleep", "prettyName": "Sleep", "connectedTopics": ["health"] },
+ {
+ "name": "dreaming",
+ "prettyName": "Dreaming",
+ "connectedTopics": ["sleep"]
+ },
+ {
+ "name": "sketching",
+ "prettyName": "Sketching",
+ "connectedTopics": ["art"]
+ },
+ { "name": "art", "prettyName": "Art", "connectedTopics": [] },
+ { "name": "photography", "prettyName": "Photography", "connectedTopics": [] },
+ {
+ "name": "architecture",
+ "prettyName": "Architecture",
+ "connectedTopics": []
+ },
+ { "name": "anime", "prettyName": "Anime", "connectedTopics": [] },
+ {
+ "name": "midjourney",
+ "prettyName": "Midjourney",
+ "connectedTopics": ["generative-machine-learning"]
+ },
+ {
+ "name": "generative-art",
+ "prettyName": "Generative art",
+ "connectedTopics": ["art"]
+ },
+ { "name": "dancing", "prettyName": "Dancing", "connectedTopics": [] },
+ { "name": "furniture", "prettyName": "Furniture", "connectedTopics": [] },
+ { "name": "comics", "prettyName": "Comics", "connectedTopics": [] },
+ {
+ "name": "pen-plotting",
+ "prettyName": "Pen plotting",
+ "connectedTopics": ["art"]
+ },
+ { "name": "tattoos", "prettyName": "Tattoos", "connectedTopics": [] },
+ { "name": "clothes", "prettyName": "Clothes", "connectedTopics": [] },
+ { "name": "nestjs", "prettyName": "NestJS", "connectedTopics": [] },
+ {
+ "name": "nodejs",
+ "prettyName": "Node.js",
+ "connectedTopics": ["javascript"]
+ },
+ {
+ "name": "fastify",
+ "prettyName": "Fastify",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "webassembly",
+ "prettyName": "WebAssembly",
+ "connectedTopics": ["web"]
+ },
+ { "name": "swc", "prettyName": "swc", "connectedTopics": ["javascript"] },
+ {
+ "name": "search-engines",
+ "prettyName": "Search engines",
+ "connectedTopics": ["databases"]
+ },
+ { "name": "vite", "prettyName": "Vite", "connectedTopics": ["web"] },
+ { "name": "deno", "prettyName": "Deno", "connectedTopics": ["javascript"] },
+ {
+ "name": "web-workers",
+ "prettyName": "Web workers",
+ "connectedTopics": ["browsers"]
+ },
+ {
+ "name": "electron",
+ "prettyName": "Electron",
+ "connectedTopics": ["browsers"]
+ },
+ {
+ "name": "web-performance",
+ "prettyName": "Web performance",
+ "connectedTopics": ["web"]
+ },
+ { "name": "spin", "prettyName": "Spin", "connectedTopics": ["webassembly"] },
+ {
+ "name": "bookmarklets",
+ "prettyName": "Bookmarklets",
+ "connectedTopics": []
+ },
+ {
+ "name": "google-chrome",
+ "prettyName": "Google Chrome",
+ "connectedTopics": ["browsers"]
+ },
+ {
+ "name": "chrome-dev-tools",
+ "prettyName": "Chrome DevTools",
+ "connectedTopics": ["google-chrome"]
+ },
+ { "name": "stylish-themes", "prettyName": "Stylish", "connectedTopics": [] },
+ { "name": "browsers", "prettyName": "Browsers", "connectedTopics": [] },
+ { "name": "safari", "prettyName": "Safari", "connectedTopics": ["browsers"] },
+ {
+ "name": "firefox",
+ "prettyName": "Firefox",
+ "connectedTopics": ["browsers"]
+ },
+ {
+ "name": "playwright",
+ "prettyName": "Playwright",
+ "connectedTopics": ["browsers"]
+ },
+ {
+ "name": "esbuild",
+ "prettyName": "esbuild",
+ "connectedTopics": ["javascript"]
+ },
+ { "name": "redwood", "prettyName": "Redwood", "connectedTopics": [] },
+ { "name": "jamstack", "prettyName": "JAMstack", "connectedTopics": [] },
+ {
+ "name": "seo",
+ "prettyName": "Search Engine Optimization",
+ "connectedTopics": ["search-engines"]
+ },
+ { "name": "rollup", "prettyName": "Rollup", "connectedTopics": ["web"] },
+ { "name": "web", "prettyName": "Web", "connectedTopics": [] },
+ { "name": "rss", "prettyName": "RSS", "connectedTopics": [] },
+ { "name": "gsoc", "prettyName": "GSOC", "connectedTopics": [] },
+ {
+ "name": "github",
+ "prettyName": "GitHub",
+ "connectedTopics": ["open-source"]
+ },
+ { "name": "webrtc", "prettyName": "WebRTC", "connectedTopics": ["video"] },
+ {
+ "name": "cms",
+ "prettyName": "Content management systems",
+ "connectedTopics": []
+ },
+ {
+ "name": "orama",
+ "prettyName": "Orama",
+ "connectedTopics": ["search-engines"]
+ },
+ {
+ "name": "progressive-web-apps",
+ "prettyName": "Progressive web apps (PWA)",
+ "connectedTopics": ["web"]
+ },
+ {
+ "name": "web-scraping",
+ "prettyName": "Web scraping",
+ "connectedTopics": ["web"]
+ },
+ { "name": "webpack", "prettyName": "Webpack", "connectedTopics": [] },
+ {
+ "name": "web-components",
+ "prettyName": "Web Components",
+ "connectedTopics": ["html"]
+ },
+ { "name": "webkit", "prettyName": "WebKit", "connectedTopics": ["browsers"] },
+ {
+ "name": "web-engines",
+ "prettyName": "Web engines",
+ "connectedTopics": ["browsers"]
+ },
+ {
+ "name": "eleventy",
+ "prettyName": "Eleventy",
+ "connectedTopics": ["static-sites"]
+ },
+ { "name": "jekyll", "prettyName": "Jekyll", "connectedTopics": [] },
+ { "name": "hugo", "prettyName": "Hugo", "connectedTopics": [] },
+ {
+ "name": "static-sites",
+ "prettyName": "Static sites",
+ "connectedTopics": []
+ },
+ { "name": "latex", "prettyName": "LaTeX", "connectedTopics": [] },
+ {
+ "name": "authentication",
+ "prettyName": "Authentication",
+ "connectedTopics": []
+ },
+ { "name": "http", "prettyName": "HTTP", "connectedTopics": ["networking"] },
+ { "name": "rabbitmq", "prettyName": "RabbitMQ", "connectedTopics": [] },
+ { "name": "tcp", "prettyName": "TCP", "connectedTopics": ["networking"] },
+ { "name": "tls", "prettyName": "TLS", "connectedTopics": ["networking"] },
+ { "name": "networking", "prettyName": "Networking", "connectedTopics": [] },
+ {
+ "name": "mesh-networking",
+ "prettyName": "Mesh networking",
+ "connectedTopics": ["networking"]
+ },
+ { "name": "nginx", "prettyName": "Nginx", "connectedTopics": ["networking"] },
+ { "name": "caddy", "prettyName": "Caddy", "connectedTopics": ["http"] },
+ { "name": "vpn", "prettyName": "VPN", "connectedTopics": ["networking"] },
+ {
+ "name": "wireguard",
+ "prettyName": "WireGuard",
+ "connectedTopics": ["networking"]
+ },
+ {
+ "name": "decentralization",
+ "prettyName": "Decentralization",
+ "connectedTopics": []
+ },
+ {
+ "name": "websocket",
+ "prettyName": "WebSocket",
+ "connectedTopics": ["web"]
+ },
+ { "name": "activitypub", "prettyName": "ActivityPub", "connectedTopics": [] },
+ { "name": "ssh", "prettyName": "SSH", "connectedTopics": [] },
+ { "name": "lorawan", "prettyName": "LoRaWAN", "connectedTopics": [] },
+ { "name": "iot", "prettyName": "Internet of things", "connectedTopics": [] },
+ { "name": "ipfs", "prettyName": "IPFS", "connectedTopics": [] },
+ {
+ "name": "bittorrent",
+ "prettyName": "BitTorrent",
+ "connectedTopics": ["networking"]
+ },
+ {
+ "name": "peer-to-peer",
+ "prettyName": "Peer to peer",
+ "connectedTopics": ["decentralization"]
+ },
+ { "name": "quic", "prettyName": "QUIC", "connectedTopics": ["networking"] },
+ { "name": "dns", "prettyName": "DNS", "connectedTopics": ["networking"] },
+ {
+ "name": "microservices",
+ "prettyName": "Microservices",
+ "connectedTopics": ["distributed-systems"]
+ },
+ {
+ "name": "postgraphile",
+ "prettyName": "PostGraphile",
+ "connectedTopics": []
+ },
+ {
+ "name": "apollo-graphql",
+ "prettyName": "Apollo GraphQL",
+ "connectedTopics": []
+ },
+ { "name": "hasura", "prettyName": "Hasura", "connectedTopics": [] },
+ { "name": "graphql", "prettyName": "GraphQL", "connectedTopics": ["http"] },
+ {
+ "name": "grafbase",
+ "prettyName": "Grafbase",
+ "connectedTopics": ["graphql"]
+ },
+ {
+ "name": "matrix",
+ "prettyName": "Matrix",
+ "connectedTopics": ["communication"]
+ },
+ { "name": "wifi", "prettyName": "Wi-Fi", "connectedTopics": [] },
+ {
+ "name": "file-sharing",
+ "prettyName": "File sharing",
+ "connectedTopics": []
+ },
+ { "name": "gemini", "prettyName": "Gemini", "connectedTopics": [] },
+ { "name": "domains", "prettyName": "Domains", "connectedTopics": [] },
+ { "name": "rest", "prettyName": "REST", "connectedTopics": [] },
+ { "name": "hair", "prettyName": "Hair", "connectedTopics": [] },
+ { "name": "depression", "prettyName": "Depression", "connectedTopics": [] },
+ { "name": "aging", "prettyName": "Aging", "connectedTopics": [] },
+ { "name": "teeth", "prettyName": "Teeth", "connectedTopics": [] },
+ { "name": "ergonomics", "prettyName": "Ergonomics", "connectedTopics": [] },
+ { "name": "skin-care", "prettyName": "Skin care", "connectedTopics": [] },
+ { "name": "nutrition", "prettyName": "Nutrition", "connectedTopics": [] },
+ { "name": "coffee", "prettyName": "Coffee", "connectedTopics": [] },
+ { "name": "drinks", "prettyName": "Drinks", "connectedTopics": [] },
+ { "name": "tea", "prettyName": "Tea", "connectedTopics": [] },
+ { "name": "wine", "prettyName": "Wine", "connectedTopics": [] },
+ { "name": "recipes", "prettyName": "Recipes", "connectedTopics": [] },
+ { "name": "foods", "prettyName": "Foods", "connectedTopics": [] },
+ { "name": "hydroponics", "prettyName": "Hydroponics", "connectedTopics": [] },
+ { "name": "supplements", "prettyName": "Supplements", "connectedTopics": [] },
+ { "name": "fasting", "prettyName": "Fasting", "connectedTopics": [] },
+ { "name": "health", "prettyName": "Health", "connectedTopics": [] },
+ { "name": "visionos", "prettyName": "VisionOS", "connectedTopics": [] },
+ {
+ "name": "virtual-reality",
+ "prettyName": "Virtual reality",
+ "connectedTopics": ["computer-graphics"]
+ },
+ { "name": "ego", "prettyName": "Ego", "connectedTopics": [] },
+ {
+ "name": "consciousness",
+ "prettyName": "Consciousness",
+ "connectedTopics": []
+ },
+ { "name": "biases", "prettyName": "Biases", "connectedTopics": [] },
+ {
+ "name": "decision-making",
+ "prettyName": "Decision making",
+ "connectedTopics": []
+ },
+ { "name": "marketing", "prettyName": "Marketing", "connectedTopics": [] },
+ { "name": "addiction", "prettyName": "Addiction", "connectedTopics": [] },
+ { "name": "negotiating", "prettyName": "Negotiating", "connectedTopics": [] },
+ { "name": "articles", "prettyName": "Articles", "connectedTopics": [] },
+ { "name": "learning", "prettyName": "Learning", "connectedTopics": [] },
+ { "name": "university", "prettyName": "University", "connectedTopics": [] },
+ { "name": "education", "prettyName": "Education", "connectedTopics": [] },
+ {
+ "name": "web-accessibility",
+ "prettyName": "Web accessibility",
+ "connectedTopics": []
+ },
+ {
+ "name": "service-workers",
+ "prettyName": "Service workers",
+ "connectedTopics": []
+ },
+ {
+ "name": "chemistry",
+ "prettyName": "Chemistry",
+ "connectedTopics": ["physics"]
+ },
+ { "name": "payroll", "prettyName": "Payroll", "connectedTopics": [] },
+ {
+ "name": "marketplaces",
+ "prettyName": "Marketplaces",
+ "connectedTopics": []
+ },
+ {
+ "name": "startups",
+ "prettyName": "Startups",
+ "connectedTopics": ["business"]
+ },
+ {
+ "name": "venture-capital",
+ "prettyName": "Venture Capital",
+ "connectedTopics": []
+ },
+ { "name": "values", "prettyName": "Values", "connectedTopics": [] },
+ { "name": "funding", "prettyName": "Funding", "connectedTopics": [] },
+ { "name": "onboarding", "prettyName": "Onboarding", "connectedTopics": [] },
+ { "name": "business", "prettyName": "Business", "connectedTopics": [] },
+ { "name": "dao", "prettyName": "DAOs", "connectedTopics": [] },
+ {
+ "name": "landing-pages",
+ "prettyName": "Landing pages",
+ "connectedTopics": []
+ },
+ { "name": "restaurants", "prettyName": "Restaurants", "connectedTopics": [] },
+ { "name": "products", "prettyName": "Products", "connectedTopics": [] },
+ { "name": "keyboards", "prettyName": "Keyboards", "connectedTopics": [] },
+ { "name": "anki", "prettyName": "Anki", "connectedTopics": [] },
+ { "name": "management", "prettyName": "Management", "connectedTopics": [] },
+ { "name": "leadership", "prettyName": "Leadership", "connectedTopics": [] },
+ {
+ "name": "presentations",
+ "prettyName": "Presentations",
+ "connectedTopics": []
+ },
+ { "name": "talks", "prettyName": "Talks", "connectedTopics": [] },
+ { "name": "ton", "prettyName": "TON", "connectedTopics": [] },
+ { "name": "terra", "prettyName": "Terra", "connectedTopics": [] },
+ { "name": "nano", "prettyName": "Nano", "connectedTopics": [] },
+ { "name": "stellar", "prettyName": "Stellar", "connectedTopics": [] },
+ { "name": "libra", "prettyName": "Libra", "connectedTopics": [] },
+ {
+ "name": "monero",
+ "prettyName": "Monero",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "bitcoin",
+ "prettyName": "Bitcoin",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "avalanche",
+ "prettyName": "Avalanche",
+ "connectedTopics": ["blockchain"]
+ },
+ {
+ "name": "cryptocurrencies",
+ "prettyName": "Cryptocurrencies",
+ "connectedTopics": ["blockchain"]
+ },
+ { "name": "compassion", "prettyName": "Compassion", "connectedTopics": [] },
+ { "name": "memories", "prettyName": "Memories", "connectedTopics": [] },
+ {
+ "name": "parenting",
+ "prettyName": "Parenting",
+ "connectedTopics": ["life"]
+ },
+ { "name": "life", "prettyName": "Life", "connectedTopics": [] },
+ { "name": "success", "prettyName": "Success", "connectedTopics": [] },
+ { "name": "happiness", "prettyName": "Happiness", "connectedTopics": [] },
+ { "name": "death", "prettyName": "Death", "connectedTopics": [] },
+ { "name": "time", "prettyName": "Time", "connectedTopics": [] },
+ { "name": "journaling", "prettyName": "Journaling", "connectedTopics": [] },
+ { "name": "alan-watts", "prettyName": "Alan Watts", "connectedTopics": [] },
+ { "name": "cli", "prettyName": "Command Line Tools", "connectedTopics": [] },
+ { "name": "ngrok", "prettyName": "Ngrok", "connectedTopics": [] },
+ { "name": "sed", "prettyName": "Sed", "connectedTopics": [] },
+ { "name": "tmux", "prettyName": "Tmux", "connectedTopics": [] },
+ {
+ "name": "philanthropy",
+ "prettyName": "Philanthropy",
+ "connectedTopics": []
+ },
+ {
+ "name": "research-papers",
+ "prettyName": "Research papers",
+ "connectedTopics": ["research"]
+ },
+ { "name": "minimalism", "prettyName": "Minimalism", "connectedTopics": [] },
+ {
+ "name": "markov-chains",
+ "prettyName": "Markov chains",
+ "connectedTopics": ["statistics"]
+ },
+ { "name": "geometry", "prettyName": "Geometry", "connectedTopics": ["math"] },
+ {
+ "name": "group-theory",
+ "prettyName": "Group theory",
+ "connectedTopics": ["math"]
+ },
+ { "name": "topology", "prettyName": "Topology", "connectedTopics": ["math"] },
+ {
+ "name": "lambda-calculus",
+ "prettyName": "Lambda calculus",
+ "connectedTopics": ["functional-programming"]
+ },
+ { "name": "fractals", "prettyName": "Fractals", "connectedTopics": ["math"] },
+ {
+ "name": "wolfram-alpha",
+ "prettyName": "Wolfram Alpha",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "fourier-transform",
+ "prettyName": "Fourier transform",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "lean",
+ "prettyName": "Lean",
+ "connectedTopics": ["formal-verification"]
+ },
+ {
+ "name": "automated-theorem-proving",
+ "prettyName": "Automated theorem proving",
+ "connectedTopics": ["formal-verification"]
+ },
+ {
+ "name": "satisfiability-modulo-theories",
+ "prettyName": "Satisfiability modulo theories",
+ "connectedTopics": ["computer-science"]
+ },
+ { "name": "logic", "prettyName": "Logic", "connectedTopics": ["math"] },
+ {
+ "name": "geometric-algebra",
+ "prettyName": "Geometric algebra",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "homotopy-theory",
+ "prettyName": "Homotopy theory",
+ "connectedTopics": ["math"]
+ },
+ { "name": "math", "prettyName": "Math", "connectedTopics": [] },
+ {
+ "name": "cubical-type-theory",
+ "prettyName": "Cubical type theory",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "computational-type-theory",
+ "prettyName": "Computational type theory",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "type-theory",
+ "prettyName": "Type Theory",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "dependent-types",
+ "prettyName": "Dependent types",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "automatic-differentiation",
+ "prettyName": "Automatic differentiation",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "real-analysis",
+ "prettyName": "Real analysis",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "queueing-theory",
+ "prettyName": "Queueing theory",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "number-theory",
+ "prettyName": "Number theory",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "category-theory",
+ "prettyName": "Category theory",
+ "connectedTopics": ["math"]
+ },
+ { "name": "calculus", "prettyName": "Calculus", "connectedTopics": ["math"] },
+ {
+ "name": "differential-equations",
+ "prettyName": "Differential equations",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "nearest-neighbor-search",
+ "prettyName": "Nearest neighbor search",
+ "connectedTopics": ["algorithms"]
+ },
+ {
+ "name": "mathematical-optimization",
+ "prettyName": "Mathematical optimization",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "combinatorial-optimization",
+ "prettyName": "Combinatorial optimization",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "gradient-descent",
+ "prettyName": "Gradient descent",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "game-theory",
+ "prettyName": "Game theory",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "algebraic-topology",
+ "prettyName": "Algebraic topology",
+ "connectedTopics": ["math"]
+ },
+ { "name": "buddhism", "prettyName": "Buddhism", "connectedTopics": [] },
+ { "name": "meditation", "prettyName": "Meditation", "connectedTopics": [] },
+ { "name": "mindfulness", "prettyName": "Mindfulness", "connectedTopics": [] },
+ { "name": "tao", "prettyName": "Tao", "connectedTopics": [] },
+ {
+ "name": "ocaml-libraries",
+ "prettyName": "OCaml libraries",
+ "connectedTopics": ["ocaml"]
+ },
+ { "name": "ocaml", "prettyName": "OCaml", "connectedTopics": [] },
+ {
+ "name": "gleam",
+ "prettyName": "Gleam",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "programming-languages",
+ "prettyName": "Programming languages",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "datalog",
+ "prettyName": "Datalog",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "prolog",
+ "prettyName": "Prolog",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "modal", "prettyName": "Modal", "connectedTopics": [] },
+ {
+ "name": "factor",
+ "prettyName": "Factor",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "zig",
+ "prettyName": "Zig",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "zig-libraries",
+ "prettyName": "Zig libraries",
+ "connectedTopics": ["zig"]
+ },
+ {
+ "name": "janet",
+ "prettyName": "Janet",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "scheme",
+ "prettyName": "Scheme",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "racket",
+ "prettyName": "Racket",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "lisp",
+ "prettyName": "Lisp",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "carp",
+ "prettyName": "Carp",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "common-lisp",
+ "prettyName": "Common Lisp",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "pascal",
+ "prettyName": "Pascal",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "forth",
+ "prettyName": "Forth",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "go",
+ "prettyName": "Go",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "ent", "prettyName": "Ent", "connectedTopics": [] },
+ {
+ "name": "go-libraries",
+ "prettyName": "Go libraries",
+ "connectedTopics": ["go"]
+ },
+ { "name": "wails", "prettyName": "Wails", "connectedTopics": ["go"] },
+ {
+ "name": "apl",
+ "prettyName": "APL",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "nim",
+ "prettyName": "Nim",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "nim-libraries",
+ "prettyName": "Nim libraries",
+ "connectedTopics": ["nim"]
+ },
+ {
+ "name": "self",
+ "prettyName": "Self",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "assembly",
+ "prettyName": "Assembly",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "csharp",
+ "prettyName": "C#",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "tcl",
+ "prettyName": "Tcl",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "python",
+ "prettyName": "Python",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "dask", "prettyName": "Dask", "connectedTopics": [] },
+ { "name": "django", "prettyName": "Django", "connectedTopics": [] },
+ {
+ "name": "numpy",
+ "prettyName": "NumPy",
+ "connectedTopics": ["python-libraries"]
+ },
+ { "name": "fastapi", "prettyName": "FastAPI", "connectedTopics": [] },
+ {
+ "name": "babashka",
+ "prettyName": "Babashka",
+ "connectedTopics": ["clojure"]
+ },
+ {
+ "name": "clojurescript",
+ "prettyName": "ClojureScript",
+ "connectedTopics": ["clojure"]
+ },
+ { "name": "clojure", "prettyName": "Clojure", "connectedTopics": [] },
+ {
+ "name": "clojure-libraries",
+ "prettyName": "Clojure libraries",
+ "connectedTopics": ["clojure"]
+ },
+ { "name": "vale", "prettyName": "Vale", "connectedTopics": [] },
+ {
+ "name": "elixir-libraries",
+ "prettyName": "Elixir libraries",
+ "connectedTopics": ["elixir"]
+ },
+ {
+ "name": "elixir",
+ "prettyName": "Elixir",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "phoenix",
+ "prettyName": "Phoenix framework",
+ "connectedTopics": ["elixir"]
+ },
+ {
+ "name": "processing",
+ "prettyName": "Processing",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "p5js", "prettyName": "p5.js", "connectedTopics": [] },
+ { "name": "flutter", "prettyName": "Flutter", "connectedTopics": [] },
+ { "name": "dart", "prettyName": "Dart", "connectedTopics": [] },
+ {
+ "name": "haxe",
+ "prettyName": "Haxe",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "agda",
+ "prettyName": "Agda",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "tinybase",
+ "prettyName": "TinyBase",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "effect",
+ "prettyName": "Effect",
+ "connectedTopics": ["js-libraries"]
+ },
+ { "name": "effector", "prettyName": "Effector", "connectedTopics": [] },
+ {
+ "name": "typescript",
+ "prettyName": "TypeScript",
+ "connectedTopics": ["javascript"]
+ },
+ {
+ "name": "rust",
+ "prettyName": "Rust",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "rust-libraries",
+ "prettyName": "Rust libraries",
+ "connectedTopics": ["rust"]
+ },
+ {
+ "name": "axum",
+ "prettyName": "Axum",
+ "connectedTopics": ["rust-libraries"]
+ },
+ {
+ "name": "tauri",
+ "prettyName": "Tauri",
+ "connectedTopics": ["rust-libraries"]
+ },
+ {
+ "name": "purescript",
+ "prettyName": "PureScript",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "erlang",
+ "prettyName": "Erlang",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "r-packages",
+ "prettyName": "R packages",
+ "connectedTopics": ["r"]
+ },
+ {
+ "name": "java",
+ "prettyName": "Java",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "java-libraries",
+ "prettyName": "Java libraries",
+ "connectedTopics": ["java"]
+ },
+ {
+ "name": "elm",
+ "prettyName": "Elm",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "kotlin",
+ "prettyName": "Kotlin",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "kotlin-libraries",
+ "prettyName": "Kotlin libraries",
+ "connectedTopics": ["kotlin"]
+ },
+ {
+ "name": "fortran",
+ "prettyName": "Fortran",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "haskell-libraries",
+ "prettyName": "Haskell libraries",
+ "connectedTopics": ["haskell"]
+ },
+ {
+ "name": "haskell",
+ "prettyName": "Haskell",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "graph-theory",
+ "prettyName": "Graph theory",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "linear-programming",
+ "prettyName": "Linear programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "scala-libraries",
+ "prettyName": "Scala libraries",
+ "connectedTopics": ["scala"]
+ },
+ {
+ "name": "scala",
+ "prettyName": "Scala",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "futhark",
+ "prettyName": "Futhark",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "cpp-libraries",
+ "prettyName": "C++ libraries",
+ "connectedTopics": ["cpp"]
+ },
+ {
+ "name": "cpp",
+ "prettyName": "C++",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "qt", "prettyName": "Qt", "connectedTopics": ["cpp"] },
+ {
+ "name": "swift",
+ "prettyName": "Swift",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "swiftui", "prettyName": "SwiftUI", "connectedTopics": ["swift"] },
+ {
+ "name": "swift-libraries",
+ "prettyName": "Swift libraries",
+ "connectedTopics": ["swift"]
+ },
+ {
+ "name": "ada",
+ "prettyName": "Ada",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "lua",
+ "prettyName": "Lua",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "crystal",
+ "prettyName": "Crystal",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "language-server-protocol",
+ "prettyName": "Language Server Protocol",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "ink", "prettyName": "Ink", "connectedTopics": [] },
+ {
+ "name": "julia",
+ "prettyName": "Julia",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "julia-libraries",
+ "prettyName": "Julia libraries",
+ "connectedTopics": ["julia"]
+ },
+ {
+ "name": "odin",
+ "prettyName": "Odin",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "standard-ml",
+ "prettyName": "Standard ML",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "pony",
+ "prettyName": "Pony",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "idris",
+ "prettyName": "Idris",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "dhall", "prettyName": "Dhall", "connectedTopics": [] },
+ {
+ "name": "fsharp",
+ "prettyName": "F#",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "bun", "prettyName": "Bun", "connectedTopics": ["javascript"] },
+ { "name": "babel", "prettyName": "Babel", "connectedTopics": ["javascript"] },
+ {
+ "name": "javascript",
+ "prettyName": "JavaScript",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "astro",
+ "prettyName": "Astro",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "svelte",
+ "prettyName": "Svelte",
+ "connectedTopics": ["js-libraries"]
+ },
+ { "name": "mobx", "prettyName": "MobX", "connectedTopics": ["js-libraries"] },
+ {
+ "name": "js-libraries",
+ "prettyName": "JS libraries",
+ "connectedTopics": ["javascript"]
+ },
+ {
+ "name": "threejs",
+ "prettyName": "Three.js",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "vue",
+ "prettyName": "Vue.js",
+ "connectedTopics": ["js-libraries"]
+ },
+ { "name": "rxjs", "prettyName": "RxJS", "connectedTopics": ["js-libraries"] },
+ {
+ "name": "d3js",
+ "prettyName": "Data-Driven Documents",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "angular",
+ "prettyName": "Angular",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "solid",
+ "prettyName": "SolidJS",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "redux",
+ "prettyName": "Redux",
+ "connectedTopics": ["js-libraries"]
+ },
+ { "name": "jest", "prettyName": "Jest", "connectedTopics": ["js-libraries"] },
+ { "name": "qwik", "prettyName": "Qwik", "connectedTopics": ["js-libraries"] },
+ {
+ "name": "react-native",
+ "prettyName": "React Native",
+ "connectedTopics": ["javascript"]
+ },
+ {
+ "name": "relay",
+ "prettyName": "Relay",
+ "connectedTopics": ["js-libraries"]
+ },
+ { "name": "gatsby", "prettyName": "Gatsby JS", "connectedTopics": [] },
+ {
+ "name": "nextjs",
+ "prettyName": "Next.js",
+ "connectedTopics": ["js-libraries"]
+ },
+ { "name": "expo", "prettyName": "Expo", "connectedTopics": ["js-libraries"] },
+ {
+ "name": "react-hooks",
+ "prettyName": "React Hooks",
+ "connectedTopics": ["react"]
+ },
+ { "name": "mdx", "prettyName": "MDX", "connectedTopics": [] },
+ { "name": "blitz", "prettyName": "Blitz.js", "connectedTopics": [] },
+ {
+ "name": "react",
+ "prettyName": "React",
+ "connectedTopics": ["js-libraries"]
+ },
+ {
+ "name": "react-ssr",
+ "prettyName": "React Server Side Rendering",
+ "connectedTopics": ["react"]
+ },
+ {
+ "name": "react-components",
+ "prettyName": "React components",
+ "connectedTopics": ["react"]
+ },
+ { "name": "remix", "prettyName": "Remix", "connectedTopics": ["react"] },
+ { "name": "enhance", "prettyName": "Enhance", "connectedTopics": ["html"] },
+ {
+ "name": "eslint",
+ "prettyName": "ESLint",
+ "connectedTopics": ["javascript"]
+ },
+ {
+ "name": "objc",
+ "prettyName": "Objective C",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "objc-libraries",
+ "prettyName": "ObjC libraries",
+ "connectedTopics": ["objc"]
+ },
+ {
+ "name": "austral",
+ "prettyName": "Austral",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "perl",
+ "prettyName": "Perl",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "coq",
+ "prettyName": "Coq",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "c",
+ "prettyName": "C",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "val",
+ "prettyName": "Val",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "d",
+ "prettyName": "D",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "smalltalk",
+ "prettyName": "Smalltalk",
+ "connectedTopics": ["programming-languages"]
+ },
+ { "name": "rails", "prettyName": "Rails", "connectedTopics": ["ruby"] },
+ {
+ "name": "ruby-libraries",
+ "prettyName": "Ruby libraries",
+ "connectedTopics": ["ruby"]
+ },
+ { "name": "ruby", "prettyName": "Ruby", "connectedTopics": [] },
+ {
+ "name": "roc",
+ "prettyName": "Roc",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "bash",
+ "prettyName": "Bash",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "reasonml",
+ "prettyName": "ReasonML",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "reasonml-libraries",
+ "prettyName": "ReasonML libraries",
+ "connectedTopics": ["reasonml"]
+ },
+ { "name": "podcasts", "prettyName": "Podcasts", "connectedTopics": [] },
+ {
+ "name": "podcast-recording",
+ "prettyName": "Podcast recording",
+ "connectedTopics": ["podcasts"]
+ },
+ { "name": "streaming", "prettyName": "Streaming", "connectedTopics": [] },
+ { "name": "inlang", "prettyName": "Inlang", "connectedTopics": [] },
+ {
+ "name": "chinese",
+ "prettyName": "Chinese language",
+ "connectedTopics": []
+ },
+ {
+ "name": "english",
+ "prettyName": "English Language",
+ "connectedTopics": []
+ },
+ {
+ "name": "internationalization",
+ "prettyName": "Internationalization",
+ "connectedTopics": []
+ },
+ {
+ "name": "russian",
+ "prettyName": "Russian language",
+ "connectedTopics": []
+ },
+ { "name": "dotfiles", "prettyName": "Dotfiles", "connectedTopics": [] },
+ { "name": "warp", "prettyName": "Warp", "connectedTopics": [] },
+ { "name": "fish", "prettyName": "Fish Shell", "connectedTopics": ["shell"] },
+ { "name": "nushell", "prettyName": "Nushell", "connectedTopics": [] },
+ { "name": "mosh", "prettyName": "Mosh", "connectedTopics": [] },
+ { "name": "zsh-plugins", "prettyName": "Zsh plugins", "connectedTopics": [] },
+ { "name": "zsh", "prettyName": "Zsh", "connectedTopics": [] },
+ { "name": "v", "prettyName": "V", "connectedTopics": [] },
+ {
+ "name": "config-management",
+ "prettyName": "Configuration management",
+ "connectedTopics": []
+ },
+ {
+ "name": "courses",
+ "prettyName": "Courses",
+ "connectedTopics": ["education"]
+ },
+ {
+ "name": "arkit",
+ "prettyName": "ARKit",
+ "connectedTopics": ["augmented-reality"]
+ },
+ {
+ "name": "augmented-reality",
+ "prettyName": "Augmented Reality",
+ "connectedTopics": []
+ },
+ {
+ "name": "procedural-generation",
+ "prettyName": "Procedural generation",
+ "connectedTopics": []
+ },
+ { "name": "cuda", "prettyName": "CUDA", "connectedTopics": ["gpu"] },
+ { "name": "svg", "prettyName": "SVG", "connectedTopics": [] },
+ {
+ "name": "opengl",
+ "prettyName": "OpenGL",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "shaders",
+ "prettyName": "Shaders",
+ "connectedTopics": ["computer-graphics"]
+ },
+ { "name": "metal", "prettyName": "Metal", "connectedTopics": ["swift"] },
+ {
+ "name": "bezier-curves",
+ "prettyName": "Bézier curves",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "image-processing",
+ "prettyName": "Image processing",
+ "connectedTopics": ["algorithms"]
+ },
+ {
+ "name": "webgl",
+ "prettyName": "WebGL",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "webgpu",
+ "prettyName": "WebGPU",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "rendering",
+ "prettyName": "Rendering",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "ocr",
+ "prettyName": "Optical character recognition",
+ "connectedTopics": ["computer-vision"]
+ },
+ {
+ "name": "computer-vision",
+ "prettyName": "Computer vision",
+ "connectedTopics": []
+ },
+ {
+ "name": "vulkan",
+ "prettyName": "Vulkan API",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "ray-tracing",
+ "prettyName": "Ray tracing",
+ "connectedTopics": ["computer-graphics"]
+ },
+ {
+ "name": "computer-graphics",
+ "prettyName": "Computer graphics",
+ "connectedTopics": []
+ },
+ {
+ "name": "fly-io",
+ "prettyName": "Fly.io",
+ "connectedTopics": ["cloud-computing"]
+ },
+ {
+ "name": "azure",
+ "prettyName": "Azure",
+ "connectedTopics": ["cloud-computing"]
+ },
+ {
+ "name": "gcp",
+ "prettyName": "Google Cloud",
+ "connectedTopics": ["cloud-computing"]
+ },
+ {
+ "name": "cloud-computing",
+ "prettyName": "Cloud computing",
+ "connectedTopics": []
+ },
+ {
+ "name": "aws",
+ "prettyName": "AWS",
+ "connectedTopics": ["cloud-computing"]
+ },
+ { "name": "aws-amplify", "prettyName": "AWS Amplify", "connectedTopics": [] },
+ {
+ "name": "aws-lambda",
+ "prettyName": "AWS Lambda",
+ "connectedTopics": ["aws"]
+ },
+ {
+ "name": "cloudflare-workers",
+ "prettyName": "Cloudflare workers",
+ "connectedTopics": ["cloud-computing"]
+ },
+ {
+ "name": "serverless-computing",
+ "prettyName": "Serverless computing",
+ "connectedTopics": ["cloud-computing"]
+ },
+ {
+ "name": "knowledge-graphs",
+ "prettyName": "Knowledge graphs",
+ "connectedTopics": ["knowledge"]
+ },
+ { "name": "knowledge", "prettyName": "Knowledge", "connectedTopics": [] },
+ {
+ "name": "knowledge-extraction",
+ "prettyName": "Knowledge extraction",
+ "connectedTopics": ["knowledge"]
+ },
+ {
+ "name": "mental-models",
+ "prettyName": "Mental models",
+ "connectedTopics": ["solving-problems"]
+ },
+ {
+ "name": "relational-programming",
+ "prettyName": "Relational programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "visual-programming",
+ "prettyName": "Visual programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "agile-development",
+ "prettyName": "Agile development",
+ "connectedTopics": []
+ },
+ {
+ "name": "program-synthesis",
+ "prettyName": "Program synthesis",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "software-testing",
+ "prettyName": "Software testing",
+ "connectedTopics": ["programming"]
+ },
+ { "name": "cypress", "prettyName": "Cypress", "connectedTopics": [] },
+ {
+ "name": "fuzzing",
+ "prettyName": "Fuzzing",
+ "connectedTopics": ["software-testing"]
+ },
+ {
+ "name": "serialization",
+ "prettyName": "Serialization",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "structured-programming",
+ "prettyName": "Structured programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "hashing",
+ "prettyName": "Hashing",
+ "connectedTopics": ["programming"]
+ },
+ { "name": "json", "prettyName": "JSON", "connectedTopics": ["javascript"] },
+ {
+ "name": "version-control",
+ "prettyName": "Version control",
+ "connectedTopics": []
+ },
+ {
+ "name": "git",
+ "prettyName": "Git",
+ "connectedTopics": ["version-control"]
+ },
+ {
+ "name": "semantic-versioning",
+ "prettyName": "Semantic versioning",
+ "connectedTopics": ["version-control"]
+ },
+ {
+ "name": "protocol-buffers",
+ "prettyName": "Protocol buffers",
+ "connectedTopics": ["encoding"]
+ },
+ {
+ "name": "reactive-programming",
+ "prettyName": "Reactive programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "system-design",
+ "prettyName": "System Design",
+ "connectedTopics": ["software-architecture"]
+ },
+ {
+ "name": "regex",
+ "prettyName": "Regex",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "logic-programming",
+ "prettyName": "Logic programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "program-analysis",
+ "prettyName": "Program analysis",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "recursion",
+ "prettyName": "Recursion",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "functional-programming",
+ "prettyName": "Functional programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "algebraic-effects",
+ "prettyName": "Algebraic effects",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "object-oriented-programming",
+ "prettyName": "Object-oriented programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "encoding",
+ "prettyName": "Encoding",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "embedded-systems",
+ "prettyName": "Embedded systems",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "interactive-computing",
+ "prettyName": "Interactive computing",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "google-colab",
+ "prettyName": "Google Colab",
+ "connectedTopics": []
+ },
+ {
+ "name": "mathematica",
+ "prettyName": "Wolfram Mathematica",
+ "connectedTopics": []
+ },
+ {
+ "name": "jupyter-notebooks",
+ "prettyName": "Jupyter Notebooks",
+ "connectedTopics": ["python"]
+ },
+ {
+ "name": "state-machines",
+ "prettyName": "State machines",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "logging",
+ "prettyName": "Logging",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "design-patterns",
+ "prettyName": "Design patterns",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "array-programming",
+ "prettyName": "Array programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "probabilistic-programming",
+ "prettyName": "Probabilistic programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "documentation",
+ "prettyName": "Documentation",
+ "connectedTopics": []
+ },
+ {
+ "name": "software-architecture",
+ "prettyName": "Software architecture",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "dynamic-programming",
+ "prettyName": "Dynamic programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "continuous-integration",
+ "prettyName": "Continuous Integration",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "unix",
+ "prettyName": "Unix",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "constraint-programming",
+ "prettyName": "Constraint programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "concurrency",
+ "prettyName": "Concurrency",
+ "connectedTopics": ["programming-languages"]
+ },
+ {
+ "name": "coding-practice",
+ "prettyName": "Coding practice",
+ "connectedTopics": ["programming"]
+ },
+ { "name": "ethics", "prettyName": "Ethics", "connectedTopics": [] },
+ {
+ "name": "simulated-reality",
+ "prettyName": "Simulated reality",
+ "connectedTopics": []
+ },
+ {
+ "name": "effective-altruism",
+ "prettyName": "Effective altruism",
+ "connectedTopics": []
+ },
+ { "name": "philosophy", "prettyName": "Philosophy", "connectedTopics": [] },
+ {
+ "name": "communication",
+ "prettyName": "Communication",
+ "connectedTopics": []
+ },
+ { "name": "work", "prettyName": "Work", "connectedTopics": [] },
+ { "name": "hiring", "prettyName": "Hiring", "connectedTopics": [] },
+ { "name": "cv", "prettyName": "CV", "connectedTopics": [] },
+ { "name": "interviews", "prettyName": "Interviews", "connectedTopics": [] },
+ { "name": "freelancing", "prettyName": "Freelancing", "connectedTopics": [] },
+ {
+ "name": "finding-work",
+ "prettyName": "Finding work",
+ "connectedTopics": []
+ },
+ { "name": "remote-work", "prettyName": "Remote work", "connectedTopics": [] },
+ {
+ "name": "consultancies",
+ "prettyName": "Consultancies",
+ "connectedTopics": []
+ },
+ { "name": "animals", "prettyName": "Animals", "connectedTopics": [] },
+ { "name": "birds", "prettyName": "Birds", "connectedTopics": [] },
+ {
+ "name": "medicine",
+ "prettyName": "Medicine",
+ "connectedTopics": ["diseases"]
+ },
+ { "name": "cancer", "prettyName": "Cancer", "connectedTopics": ["diseases"] },
+ { "name": "diseases", "prettyName": "Diseases", "connectedTopics": [] },
+ {
+ "name": "history",
+ "prettyName": "History",
+ "connectedTopics": ["writing"]
+ },
+ {
+ "name": "anthropology",
+ "prettyName": "Anthropology",
+ "connectedTopics": []
+ },
+ {
+ "name": "speech-recognition",
+ "prettyName": "Speech recognition",
+ "connectedTopics": ["nlp"]
+ },
+ {
+ "name": "virtual-assistant",
+ "prettyName": "Virtual assistant",
+ "connectedTopics": []
+ },
+ {
+ "name": "nlp",
+ "prettyName": "Natural language processing",
+ "connectedTopics": []
+ },
+ {
+ "name": "speech-synthesis",
+ "prettyName": "Speech synthesis",
+ "connectedTopics": ["nlp"]
+ },
+ {
+ "name": "sentiment-analysis",
+ "prettyName": "Sentiment analysis",
+ "connectedTopics": ["nlp"]
+ },
+ { "name": "bots", "prettyName": "Bots", "connectedTopics": ["nlp"] },
+ { "name": "acting", "prettyName": "Acting", "connectedTopics": ["movies"] },
+ { "name": "movies", "prettyName": "Movies", "connectedTopics": [] },
+ { "name": "governance", "prettyName": "Governance", "connectedTopics": [] },
+ { "name": "politics", "prettyName": "Politics", "connectedTopics": [] },
+ { "name": "law", "prettyName": "Law", "connectedTopics": [] },
+ { "name": "instagram", "prettyName": "Instagram", "connectedTopics": [] },
+ { "name": "scuttlebutt", "prettyName": "Scuttlebutt", "connectedTopics": [] },
+ {
+ "name": "bluesky",
+ "prettyName": "Bluesky",
+ "connectedTopics": ["social-networks"]
+ },
+ {
+ "name": "social-networks",
+ "prettyName": "Social networks",
+ "connectedTopics": []
+ },
+ {
+ "name": "farcaster",
+ "prettyName": "Farcaster",
+ "connectedTopics": ["social-networks"]
+ },
+ {
+ "name": "lemmy",
+ "prettyName": "Lemmy",
+ "connectedTopics": ["social-networks"]
+ },
+ { "name": "nostr", "prettyName": "Nostr", "connectedTopics": [] },
+ {
+ "name": "mastodon",
+ "prettyName": "Mastodon",
+ "connectedTopics": ["social-networks"]
+ },
+ { "name": "veganism", "prettyName": "Veganism", "connectedTopics": [] },
+ { "name": "environment", "prettyName": "Environment", "connectedTopics": [] },
+ { "name": "solar", "prettyName": "Solar", "connectedTopics": [] },
+ { "name": "batteries", "prettyName": "Batteries", "connectedTopics": [] },
+ {
+ "name": "renewable-energy",
+ "prettyName": "Renewable energy",
+ "connectedTopics": []
+ },
+ { "name": "zero-waste", "prettyName": "Zero waste", "connectedTopics": [] },
+ { "name": "api", "prettyName": "API", "connectedTopics": ["programming"] },
+ { "name": "trpc", "prettyName": "tRPC", "connectedTopics": ["js-libraries"] },
+ { "name": "fitness", "prettyName": "Fitness", "connectedTopics": ["health"] },
+ {
+ "name": "strength-training",
+ "prettyName": "Strength training",
+ "connectedTopics": ["fitness"]
+ },
+ {
+ "name": "running",
+ "prettyName": "Running",
+ "connectedTopics": ["fitness"]
+ },
+ {
+ "name": "exercises",
+ "prettyName": "Exercises",
+ "connectedTopics": ["fitness"]
+ },
+ { "name": "yoga", "prettyName": "Yoga", "connectedTopics": ["fitness"] },
+ {
+ "name": "documentaries",
+ "prettyName": "Documentaries",
+ "connectedTopics": []
+ },
+ { "name": "automation", "prettyName": "Automation", "connectedTopics": [] },
+ { "name": "physics", "prettyName": "Physics", "connectedTopics": [] },
+ {
+ "name": "dark-matter",
+ "prettyName": "Dark matter",
+ "connectedTopics": ["psychedelics"]
+ },
+ {
+ "name": "quantum-computing",
+ "prettyName": "Quantum computing",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "quantum-physics",
+ "prettyName": "Quantum physics",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "quantum-gravity",
+ "prettyName": "Quantum gravity",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "string-theory",
+ "prettyName": "String theory",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "antimatter",
+ "prettyName": "Antimatter",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "electrical-engineering",
+ "prettyName": "Electrical engineering",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "signal-processing",
+ "prettyName": "Signal processing",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "classical-mechanics",
+ "prettyName": "Classical mechanics",
+ "connectedTopics": ["physics"]
+ },
+ { "name": "drones", "prettyName": "Drones", "connectedTopics": ["hardware"] },
+ { "name": "robots", "prettyName": "Robots", "connectedTopics": ["hardware"] },
+ { "name": "ideas", "prettyName": "Ideas", "connectedTopics": [] },
+ { "name": "poems", "prettyName": "Poems", "connectedTopics": ["writing"] },
+ {
+ "name": "universe",
+ "prettyName": "Universe",
+ "connectedTopics": ["physics"]
+ },
+ { "name": "space", "prettyName": "Space", "connectedTopics": ["physics"] },
+ {
+ "name": "rockets",
+ "prettyName": "Rockets",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "compilers",
+ "prettyName": "Compilers",
+ "connectedTopics": ["parsing"]
+ },
+ { "name": "llvm", "prettyName": "LLVM", "connectedTopics": ["compilers"] },
+ { "name": "ipad", "prettyName": "iPad", "connectedTopics": [] },
+ {
+ "name": "competitive-programming",
+ "prettyName": "Competitive programming",
+ "connectedTopics": ["programming"]
+ },
+ {
+ "name": "build-systems",
+ "prettyName": "Build systems",
+ "connectedTopics": ["programming"]
+ },
+ { "name": "turbo", "prettyName": "turbo", "connectedTopics": ["devops"] },
+ { "name": "chatgpt", "prettyName": "ChatGPT", "connectedTopics": ["nlp"] },
+ {
+ "name": "reinforcement-learning",
+ "prettyName": "Reinforcement learning",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "recommendation-systems",
+ "prettyName": "Recommendation systems",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "generative-adversarial-networks",
+ "prettyName": "Generative adversarial networks",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "graph-neural-networks",
+ "prettyName": "Graph neural networks",
+ "connectedTopics": ["neural-networks"]
+ },
+ {
+ "name": "neural-networks",
+ "prettyName": "Neural networks",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "ml-libraries",
+ "prettyName": "ML Libraries",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "pytorch",
+ "prettyName": "PyTorch",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "keras",
+ "prettyName": "Keras",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "tensorflow",
+ "prettyName": "TensorFlow",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "jax",
+ "prettyName": "JAX",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "autonomous-driving",
+ "prettyName": "Autonomous driving",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "ml-models",
+ "prettyName": "ML Models",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "generative-machine-learning",
+ "prettyName": "Generative Machine Learning",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "unsupervised-learning",
+ "prettyName": "Unsupervised learning",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "datasets",
+ "prettyName": "Datasets",
+ "connectedTopics": ["data-science"]
+ },
+ {
+ "name": "machine-learning",
+ "prettyName": "Machine learning",
+ "connectedTopics": ["math"]
+ },
+ {
+ "name": "artificial-intelligence",
+ "prettyName": "Artificial intelligence",
+ "connectedTopics": ["machine-learning"]
+ },
+ {
+ "name": "mirageos",
+ "prettyName": "MirageOS",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "android",
+ "prettyName": "Android",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "emulators",
+ "prettyName": "Emulators",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "windows",
+ "prettyName": "Windows",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "bsd",
+ "prettyName": "BSD",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "fuchsia-os",
+ "prettyName": "Fuchsia OS",
+ "connectedTopics": ["operating-systems"]
+ },
+ { "name": "coreml", "prettyName": "CoreML", "connectedTopics": ["swift"] },
+ { "name": "watchos", "prettyName": "WatchOS", "connectedTopics": ["swift"] },
+ { "name": "tvos", "prettyName": "tvOS", "connectedTopics": ["swift"] },
+ { "name": "homekit", "prettyName": "HomeKit", "connectedTopics": ["swift"] },
+ { "name": "ios", "prettyName": "iOS", "connectedTopics": ["macOS"] },
+ {
+ "name": "ios-shortcuts",
+ "prettyName": "iOS Shortcuts",
+ "connectedTopics": ["ios"]
+ },
+ {
+ "name": "file-systems",
+ "prettyName": "File systems",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "nixos",
+ "prettyName": "NixOS",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "linux",
+ "prettyName": "Linux",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "operating-systems",
+ "prettyName": "Operating systems",
+ "connectedTopics": ["computer-science"]
+ },
+ {
+ "name": "docker",
+ "prettyName": "Docker",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "kubernetes",
+ "prettyName": "Kubernetes",
+ "connectedTopics": ["containers"]
+ },
+ {
+ "name": "kubernetes-plugins",
+ "prettyName": "Kubernetes plugins",
+ "connectedTopics": ["kubernetes"]
+ },
+ {
+ "name": "containers",
+ "prettyName": "Containers",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "nix",
+ "prettyName": "Nix",
+ "connectedTopics": ["package-managers"]
+ },
+ {
+ "name": "brew",
+ "prettyName": "Brew",
+ "connectedTopics": ["package-managers"]
+ },
+ {
+ "name": "package-managers",
+ "prettyName": "Package managers",
+ "connectedTopics": ["operating-systems"]
+ },
+ {
+ "name": "minecraft",
+ "prettyName": "Minecraft",
+ "connectedTopics": ["games"]
+ },
+ {
+ "name": "board-games",
+ "prettyName": "Board games",
+ "connectedTopics": ["games"]
+ },
+ { "name": "sudoku", "prettyName": "Sudoku", "connectedTopics": ["games"] },
+ {
+ "name": "gamedev",
+ "prettyName": "Game development",
+ "connectedTopics": ["games"]
+ },
+ {
+ "name": "unreal-engine",
+ "prettyName": "Unreal Engine",
+ "connectedTopics": ["game-engines"]
+ },
+ {
+ "name": "godot",
+ "prettyName": "Godot",
+ "connectedTopics": ["game-engines"]
+ },
+ {
+ "name": "unity",
+ "prettyName": "Unity",
+ "connectedTopics": ["game-engines"]
+ },
+ {
+ "name": "ebiten",
+ "prettyName": "Ebiten",
+ "connectedTopics": ["game-engines"]
+ },
+ {
+ "name": "bevy",
+ "prettyName": "Bevy Engine",
+ "connectedTopics": ["game-engines"]
+ },
+ { "name": "chess", "prettyName": "Chess", "connectedTopics": ["games"] },
+ { "name": "golf", "prettyName": "Golf", "connectedTopics": ["games"] },
+ { "name": "games", "prettyName": "Games", "connectedTopics": [] },
+ { "name": "poker", "prettyName": "Poker", "connectedTopics": ["games"] },
+ {
+ "name": "front-end",
+ "prettyName": "Front End",
+ "connectedTopics": ["web"]
+ },
+ { "name": "css", "prettyName": "CSS", "connectedTopics": ["browsers"] },
+ {
+ "name": "tailwind-css",
+ "prettyName": "Tailwind CSS",
+ "connectedTopics": ["css"]
+ },
+ {
+ "name": "css-flexbox",
+ "prettyName": "CSS Flexbox",
+ "connectedTopics": ["css"]
+ },
+ {
+ "name": "css-in-js",
+ "prettyName": "CSS in JS",
+ "connectedTopics": ["css"]
+ },
+ { "name": "css-grid", "prettyName": "CSS Grid", "connectedTopics": ["css"] },
+ { "name": "html", "prettyName": "HTML", "connectedTopics": ["browsers"] },
+ { "name": "backups", "prettyName": "Backups", "connectedTopics": [] },
+ { "name": "seduction", "prettyName": "Seduction", "connectedTopics": [] },
+ { "name": "gifts", "prettyName": "Gifts", "connectedTopics": [] },
+ {
+ "name": "relationships",
+ "prettyName": "Relationships",
+ "connectedTopics": []
+ },
+ { "name": "sex", "prettyName": "Sex", "connectedTopics": [] },
+ { "name": "hiking", "prettyName": "Hiking", "connectedTopics": [] },
+ { "name": "cities", "prettyName": "Cities", "connectedTopics": [] },
+ {
+ "name": "transportation",
+ "prettyName": "Transportation",
+ "connectedTopics": []
+ },
+ { "name": "planes", "prettyName": "Planes", "connectedTopics": [] },
+ { "name": "cycling", "prettyName": "Cycling", "connectedTopics": [] },
+ { "name": "boats", "prettyName": "Boats", "connectedTopics": [] },
+ { "name": "afghanistan", "prettyName": "Afghanistan", "connectedTopics": [] },
+ { "name": "china", "prettyName": "China", "connectedTopics": [] },
+ {
+ "name": "united-kingdom",
+ "prettyName": "United Kingdom",
+ "connectedTopics": []
+ },
+ {
+ "name": "bazel",
+ "prettyName": "Bazel",
+ "connectedTopics": ["build-systems"]
+ },
+ { "name": "norway", "prettyName": "Norway", "connectedTopics": [] },
+ { "name": "portugal", "prettyName": "Portugal", "connectedTopics": [] },
+ { "name": "switzerland", "prettyName": "Switzerland", "connectedTopics": [] },
+ { "name": "bulgaria", "prettyName": "Bulgaria", "connectedTopics": [] },
+ { "name": "ireland", "prettyName": "Ireland", "connectedTopics": [] },
+ { "name": "sri-lanka", "prettyName": "Sri Lanka", "connectedTopics": [] },
+ { "name": "poland", "prettyName": "Poland", "connectedTopics": [] },
+ { "name": "italy", "prettyName": "Italy", "connectedTopics": [] },
+ { "name": "russia", "prettyName": "Russia", "connectedTopics": [] },
+ { "name": "estonia", "prettyName": "Estonia", "connectedTopics": [] },
+ { "name": "austria", "prettyName": "Austria", "connectedTopics": [] },
+ { "name": "kazakhstan", "prettyName": "Kazakhstan", "connectedTopics": [] },
+ { "name": "cyprus", "prettyName": "Cyprus", "connectedTopics": [] },
+ { "name": "visited", "prettyName": "Visited", "connectedTopics": [] },
+ { "name": "denmark", "prettyName": "Denmark", "connectedTopics": [] },
+ { "name": "france", "prettyName": "France", "connectedTopics": [] },
+ { "name": "turkey", "prettyName": "Turkey", "connectedTopics": [] },
+ { "name": "spain", "prettyName": "Spain", "connectedTopics": [] },
+ { "name": "indonesia", "prettyName": "Indonesia", "connectedTopics": [] },
+ { "name": "greece", "prettyName": "Greece", "connectedTopics": [] },
+ { "name": "japan", "prettyName": "Japan", "connectedTopics": [] },
+ {
+ "name": "canary-islands",
+ "prettyName": "Canary Islands",
+ "connectedTopics": []
+ },
+ { "name": "canada", "prettyName": "Canada", "connectedTopics": [] },
+ { "name": "europe", "prettyName": "Europe", "connectedTopics": [] },
+ { "name": "germany", "prettyName": "Germany", "connectedTopics": [] },
+ {
+ "name": "finding-home",
+ "prettyName": "Finding homes",
+ "connectedTopics": []
+ },
+ { "name": "travel", "prettyName": "Travel", "connectedTopics": [] },
+ { "name": "nomad", "prettyName": "Nomad", "connectedTopics": [] },
+ { "name": "backpacks", "prettyName": "Backpacks", "connectedTopics": [] },
+ { "name": "events", "prettyName": "Events", "connectedTopics": [] },
+ { "name": "geography", "prettyName": "Geography", "connectedTopics": [] },
+ {
+ "name": "spatial-analysis",
+ "prettyName": "Spatial analysis",
+ "connectedTopics": ["data-processing"]
+ },
+ { "name": "tv-series", "prettyName": "TV series", "connectedTopics": [] },
+ {
+ "name": "analytics",
+ "prettyName": "Analytics",
+ "connectedTopics": ["observability"]
+ },
+ {
+ "name": "tinybird",
+ "prettyName": "Tinybird",
+ "connectedTopics": ["analytics"]
+ },
+ {
+ "name": "grafana",
+ "prettyName": "Grafana",
+ "connectedTopics": ["analytics"]
+ },
+ {
+ "name": "genomics",
+ "prettyName": "Genomics",
+ "connectedTopics": ["physics"]
+ },
+ { "name": "dna", "prettyName": "DNA", "connectedTopics": ["physics"] },
+ {
+ "name": "bionics",
+ "prettyName": "Bionics",
+ "connectedTopics": ["physics"]
+ },
+ {
+ "name": "viruses",
+ "prettyName": "Viruses",
+ "connectedTopics": ["physics"]
+ },
+ { "name": "biology", "prettyName": "Biology", "connectedTopics": [] },
+ {
+ "name": "immunotherapy",
+ "prettyName": "Immunotherapy",
+ "connectedTopics": ["biology"]
+ },
+ {
+ "name": "immunology",
+ "prettyName": "Immunology",
+ "connectedTopics": ["biology"]
+ },
+ {
+ "name": "computational-biology",
+ "prettyName": "Computational biology",
+ "connectedTopics": ["biology"]
+ },
+ {
+ "name": "regenerative-medicine",
+ "prettyName": "Regenerative medicine",
+ "connectedTopics": ["biology"]
+ },
+ {
+ "name": "bioinformatics",
+ "prettyName": "Bioinformatics",
+ "connectedTopics": ["biology"]
+ },
+ {
+ "name": "evolution",
+ "prettyName": "Evolution",
+ "connectedTopics": ["biology"]
+ },
+ { "name": "markdown", "prettyName": "Markdown", "connectedTopics": [] },
+ {
+ "name": "writing-prompts",
+ "prettyName": "Writing prompts",
+ "connectedTopics": []
+ },
+ { "name": "writing", "prettyName": "Writing", "connectedTopics": [] },
+ { "name": "economy", "prettyName": "Economy", "connectedTopics": [] },
+ { "name": "israel", "prettyName": "Israel", "connectedTopics": [] },
+ { "name": "korea", "prettyName": "Korea", "connectedTopics": [] },
+ { "name": "georgia", "prettyName": "Georgia", "connectedTopics": [] },
+ { "name": "india", "prettyName": "India", "connectedTopics": [] },
+ { "name": "ukraine", "prettyName": "Ukraine", "connectedTopics": [] },
+ {
+ "name": "united-arab-emirates",
+ "prettyName": "United Arab Emirates",
+ "connectedTopics": []
+ },
+ { "name": "taiwan", "prettyName": "Taiwan", "connectedTopics": [] },
+ { "name": "romania", "prettyName": "Romania", "connectedTopics": [] },
+ { "name": "sweden", "prettyName": "Sweden", "connectedTopics": [] },
+ {
+ "name": "united-states",
+ "prettyName": "United States",
+ "connectedTopics": []
+ },
+ { "name": "belarus", "prettyName": "Belarus", "connectedTopics": [] },
+ { "name": "argentina", "prettyName": "Argentina", "connectedTopics": [] },
+ { "name": "netherlands", "prettyName": "Netherlands", "connectedTopics": [] }
+]
diff --git a/web/app/favicon.ico b/web/app/favicon.ico
deleted file mode 100644
index 718d6fea..00000000
Binary files a/web/app/favicon.ico and /dev/null differ
diff --git a/web/app/fonts.ts b/web/app/fonts.ts
deleted file mode 100644
index bcdcb16f..00000000
--- a/web/app/fonts.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Raleway } from "next/font/google"
-export { GeistSans } from "geist/font/sans"
-export { GeistMono } from "geist/font/mono"
-// import { Inter } from "next/font/google"
-
-// export const inter = Inter({ subsets: ["latin"] })
-export const raleway = Raleway({ subsets: ["latin"] })
diff --git a/web/app/global-error.tsx b/web/app/global-error.tsx
deleted file mode 100644
index 1d4c5617..00000000
--- a/web/app/global-error.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-"use client"
-
-import * as Sentry from "@sentry/nextjs"
-import NextError from "next/error"
-import { useEffect } from "react"
-
-export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
- useEffect(() => {
- Sentry.captureException(error)
- }, [error])
-
- return (
-
-
- {/* `NextError` is the default Next.js error page component. Its type
- definition requires a `statusCode` prop. However, since the App Router
- does not expose status codes for errors, we simply pass 0 to render a
- generic error message. */}
-
-
-
- )
-}
diff --git a/web/app/globals.css b/web/app/globals.css
deleted file mode 100644
index 24faef84..00000000
--- a/web/app/globals.css
+++ /dev/null
@@ -1,85 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-@layer base {
- :root {
- --background: 0 0% 100%;
- --foreground: 240 10% 3.9%;
- --card: 0 0% 100%;
- --card-foreground: 240 10% 3.9%;
- --popover: 0 0% 100%;
- --popover-foreground: 240 10% 3.9%;
- --primary: 240 5.9% 10%;
- --primary-foreground: 0 0% 98%;
- --secondary: 240 4.8% 95.9%;
- --secondary-foreground: 240 5.9% 10%;
- --muted: 240 4.8% 95.9%;
- --muted-foreground: 240 3.8% 46.1%;
- --accent: 240 4.8% 95.9%;
- --accent-foreground: 240 5.9% 10%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 0 0% 98%;
- --border: 240 5.9% 90%;
- --input: 240 5.9% 96%;
- --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% 4.5%;
- --foreground: 0 0% 98%;
- --card: 240 10% 3.9%;
- --card-foreground: 0 0% 98%;
- --popover: 240 10% 3.9%;
- --popover-foreground: 0 0% 98%;
- --primary: 0 0% 98%;
- --primary-foreground: 240 5.9% 10%;
- --secondary: 240 3.7% 15.9%;
- --secondary-foreground: 0 0% 98%;
- --muted: 240 3.7% 15.9%;
- --muted-foreground: 240 5% 64.9%;
- --accent: 240 3.7% 15.9%;
- --accent-foreground: 0 0% 98%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 0 0% 98%;
- --border: 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);
- }
-}
-
-@layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- }
-}
-
-@import "./command-palette.css";
-@import "./custom.css";
-
-.scrollbar-hide {
- -ms-overflow-style: none;
- scrollbar-width: none;
-}
-
-.scrollbar-hide::-webkit-scrollbar {
- display: none;
-}
diff --git a/web/app/hooks/actions/use-link-actions.ts b/web/app/hooks/actions/use-link-actions.ts
new file mode 100644
index 00000000..5cd66e57
--- /dev/null
+++ b/web/app/hooks/actions/use-link-actions.ts
@@ -0,0 +1,35 @@
+import * as React from "react"
+import { toast } from "sonner"
+import { LaAccount, PersonalLink } from "@/lib/schema"
+
+export const useLinkActions = () => {
+ const deleteLink = React.useCallback((me: LaAccount, link: PersonalLink) => {
+ if (!me.root?.personalLinks) return
+
+ try {
+ const index = me.root.personalLinks.findIndex(
+ (item) => item?.id === link.id,
+ )
+ if (index === -1) {
+ throw new Error(`Link with id ${link.id} not found`)
+ }
+
+ me.root.personalLinks.splice(index, 1)
+
+ toast.success("Link deleted.", {
+ position: "bottom-right",
+ description: `${link.title} has been deleted.`,
+ })
+ } catch (error) {
+ console.error("Failed to delete link:", error)
+ toast.error("Failed to delete link", {
+ description:
+ error instanceof Error ? error.message : "An unknown error occurred",
+ })
+ }
+ }, [])
+
+ return {
+ deleteLink,
+ }
+}
diff --git a/web/app/hooks/actions/use-page-actions.ts b/web/app/hooks/actions/use-page-actions.ts
new file mode 100644
index 00000000..4d5ebcd9
--- /dev/null
+++ b/web/app/hooks/actions/use-page-actions.ts
@@ -0,0 +1,50 @@
+import * as React from "react"
+import { toast } from "sonner"
+import { LaAccount, PersonalPage } from "@/lib/schema"
+import { ID } from "jazz-tools"
+
+export const usePageActions = () => {
+ const newPage = React.useCallback((me: LaAccount): PersonalPage => {
+ const newPersonalPage = PersonalPage.create(
+ { public: false, createdAt: new Date(), updatedAt: new Date() },
+ { owner: me._owner },
+ )
+ me.root?.personalPages?.push(newPersonalPage)
+ return newPersonalPage
+ }, [])
+
+ const deletePage = React.useCallback(
+ (me: LaAccount, pageId: ID): void => {
+ if (!me.root?.personalPages) return
+
+ const index = me.root.personalPages.findIndex(
+ (item) => item?.id === pageId,
+ )
+ if (index === -1) {
+ toast.error("Page not found")
+ return
+ }
+
+ const page = me.root.personalPages[index]
+ if (!page) {
+ toast.error("Page data is invalid")
+ return
+ }
+
+ try {
+ me.root.personalPages.splice(index, 1)
+
+ toast.success("Page deleted", {
+ position: "bottom-right",
+ description: `${page.title} has been deleted.`,
+ })
+ } catch (error) {
+ console.error("Failed to delete page", error)
+ toast.error("Failed to delete page")
+ }
+ },
+ [],
+ )
+
+ return { newPage, deletePage }
+}
diff --git a/web/app/hooks/actions/use-task-actions.ts b/web/app/hooks/actions/use-task-actions.ts
new file mode 100644
index 00000000..4f6d4b25
--- /dev/null
+++ b/web/app/hooks/actions/use-task-actions.ts
@@ -0,0 +1,61 @@
+import { useCallback } from "react"
+import { toast } from "sonner"
+import { LaAccount } from "@/lib/schema"
+import { ID } from "jazz-tools"
+import { ListOfTasks, Task } from "~/lib/schema/task"
+
+export const useTaskActions = () => {
+ const newTask = useCallback((me: LaAccount): Task | null => {
+ if (!me.root) {
+ console.error("User root is not initialized")
+ return null
+ }
+
+ if (!me.root.tasks) {
+ me.root.tasks = ListOfTasks.create([], { owner: me })
+ }
+
+ const newTask = Task.create(
+ {
+ title: "",
+ description: "",
+ status: "todo",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ { owner: me._owner },
+ )
+
+ me.root.tasks.push(newTask)
+ return newTask
+ }, [])
+
+ const deleteTask = useCallback((me: LaAccount, taskId: ID): void => {
+ if (!me.root?.tasks) return
+
+ const index = me.root.tasks.findIndex((item) => item?.id === taskId)
+ if (index === -1) {
+ toast.error("Task not found")
+ return
+ }
+
+ const task = me.root.tasks[index]
+ if (!task) {
+ toast.error("Task data is invalid")
+ return
+ }
+
+ try {
+ me.root.tasks.splice(index, 1)
+
+ toast.success("Task completed", {
+ position: "bottom-right",
+ })
+ } catch (error) {
+ console.error("Failed to delete task", error)
+ toast.error("Failed to delete task")
+ }
+ }, [])
+
+ return { newTask, deleteTask }
+}
diff --git a/web/app/hooks/use-active-item-scroll.ts b/web/app/hooks/use-active-item-scroll.ts
new file mode 100644
index 00000000..ad15560f
--- /dev/null
+++ b/web/app/hooks/use-active-item-scroll.ts
@@ -0,0 +1,36 @@
+import * as React from "react"
+
+type ElementRef = T | null
+type ElementRefs = ElementRef[]
+
+interface ActiveItemScrollOptions {
+ activeIndex: number | null
+}
+
+export function useActiveItemScroll(
+ options: ActiveItemScrollOptions,
+) {
+ const { activeIndex } = options
+ const elementRefs = React.useRef>([])
+
+ const scrollActiveElementIntoView = React.useCallback((index: number) => {
+ const activeElement = elementRefs.current[index]
+ activeElement?.focus()
+ // activeElement?.scrollIntoView({ block: "nearest" })
+ }, [])
+
+ React.useEffect(() => {
+ if (activeIndex !== null) {
+ scrollActiveElementIntoView(activeIndex)
+ }
+ }, [activeIndex, scrollActiveElementIntoView])
+
+ const setElementRef = React.useCallback(
+ (element: ElementRef, index: number) => {
+ elementRefs.current[index] = element
+ },
+ [],
+ )
+
+ return { setElementRef, scrollActiveElementIntoView }
+}
diff --git a/web/app/hooks/use-awaitable-navigate.ts b/web/app/hooks/use-awaitable-navigate.ts
new file mode 100644
index 00000000..9bc7df87
--- /dev/null
+++ b/web/app/hooks/use-awaitable-navigate.ts
@@ -0,0 +1,29 @@
+import * as React from "react"
+import type { NavigateOptions } from "@tanstack/react-router"
+import { useLocation, useNavigate } from "@tanstack/react-router"
+
+type Resolve = (value?: unknown) => void
+
+export const useAwaitableNavigate = () => {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const resolveFunctionsRef = React.useRef([])
+ const resolveAll = () => {
+ resolveFunctionsRef.current.forEach((resolve) => resolve())
+ resolveFunctionsRef.current.splice(0, resolveFunctionsRef.current.length)
+ }
+ const [, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ resolveAll()
+ }, [location])
+
+ return (options: NavigateOptions) => {
+ return new Promise((res) => {
+ startTransition(() => {
+ resolveFunctionsRef.current.push(res)
+ res(navigate(options))
+ })
+ })
+ }
+}
diff --git a/web/app/hooks/use-command-actions.ts b/web/app/hooks/use-command-actions.ts
new file mode 100644
index 00000000..7ae72981
--- /dev/null
+++ b/web/app/hooks/use-command-actions.ts
@@ -0,0 +1,53 @@
+import * as React from "react"
+import { ensureUrlProtocol } from "@/lib/utils"
+import { useTheme } from "next-themes"
+import { toast } from "sonner"
+import { LaAccount } from "@/lib/schema"
+import { usePageActions } from "./actions/use-page-actions"
+import { useNavigate } from "@tanstack/react-router"
+
+export const useCommandActions = () => {
+ const { setTheme } = useTheme()
+ const navigate = useNavigate()
+ const { newPage } = usePageActions()
+
+ const changeTheme = React.useCallback(
+ (theme: string) => {
+ setTheme(theme)
+ toast.success(`Theme changed to ${theme}.`, { position: "bottom-right" })
+ },
+ [setTheme],
+ )
+
+ const navigateTo = React.useCallback(
+ (path: string) => {
+ navigate({ to: path })
+ },
+ [navigate],
+ )
+
+ const openLinkInNewTab = React.useCallback((url: string) => {
+ window.open(ensureUrlProtocol(url), "_blank")
+ }, [])
+
+ const copyCurrentURL = React.useCallback(() => {
+ navigator.clipboard.writeText(window.location.href)
+ toast.success("URL copied to clipboard.", { position: "bottom-right" })
+ }, [])
+
+ const createNewPage = React.useCallback(
+ (me: LaAccount) => {
+ const page = newPage(me)
+ navigate({ to: `/pages/${page.id}` })
+ },
+ [navigate, newPage],
+ )
+
+ return {
+ changeTheme,
+ navigateTo,
+ openLinkInNewTab,
+ copyCurrentURL,
+ createNewPage,
+ }
+}
diff --git a/web/app/hooks/use-event-listener.ts b/web/app/hooks/use-event-listener.ts
new file mode 100644
index 00000000..83a49192
--- /dev/null
+++ b/web/app/hooks/use-event-listener.ts
@@ -0,0 +1,37 @@
+import * as React from "react"
+
+type EventMap = WindowEventMap & HTMLElementEventMap & VisualViewportEventMap
+
+export function useEventListener<
+ K extends keyof EventMap,
+ T extends Window | HTMLElement | VisualViewport | null = Window,
+>(
+ eventName: K,
+ handler: (event: EventMap[K]) => void,
+ element: T = window as unknown as T, // Cast to `unknown` first, then `T`
+ options: AddEventListenerOptions = {},
+) {
+ const savedHandler = React.useRef<(event: EventMap[K]) => void>()
+ const { capture, passive, once } = options
+
+ React.useEffect(() => {
+ savedHandler.current = handler
+ }, [handler])
+
+ React.useEffect(() => {
+ const isSupported = element && element.addEventListener
+ if (!isSupported) return
+
+ const eventListener = (event: EventMap[K]) => savedHandler.current?.(event)
+
+ const opts = { capture, passive, once }
+ element.addEventListener(eventName, eventListener as EventListener, opts)
+ return () => {
+ element.removeEventListener(
+ eventName,
+ eventListener as EventListener,
+ opts,
+ )
+ }
+ }, [eventName, element, capture, passive, once])
+}
diff --git a/web/app/hooks/use-is-mounted.ts b/web/app/hooks/use-is-mounted.ts
new file mode 100644
index 00000000..c18d007d
--- /dev/null
+++ b/web/app/hooks/use-is-mounted.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+/**
+ * Hook to check if component is still mounted
+ *
+ * @returns {boolean} true if the component is mounted, false otherwise
+ */
+export function useIsMounted() {
+ const isMounted = React.useRef(false)
+
+ React.useEffect(() => {
+ isMounted.current = true
+ return () => {
+ isMounted.current = false
+ }
+ }, [])
+
+ return React.useCallback(() => isMounted.current, [])
+}
diff --git a/web/app/hooks/use-key-down.ts b/web/app/hooks/use-key-down.ts
new file mode 100644
index 00000000..1921e340
--- /dev/null
+++ b/web/app/hooks/use-key-down.ts
@@ -0,0 +1,77 @@
+import * as React from "react"
+import { isModKey, isServer, isTextInput } from "@/lib/utils"
+
+export type KeyFilter = ((event: KeyboardEvent) => boolean) | string
+export type Options = { allowInInput?: boolean }
+
+type RegisteredCallback = {
+ callback: (event: KeyboardEvent) => void
+ options?: Options
+}
+
+let callbacks: RegisteredCallback[] = []
+let isInitialized = false
+
+const initializeKeyboardListeners = () => {
+ if (isServer() || isInitialized) return
+
+ let imeOpen = false
+
+ window.addEventListener("keydown", (event) => {
+ if (imeOpen) return
+
+ for (const registered of [...callbacks].reverse()) {
+ if (event.defaultPrevented) break
+
+ if (
+ !isTextInput(event.target as HTMLElement) ||
+ registered.options?.allowInInput ||
+ isModKey(event)
+ ) {
+ registered.callback(event)
+ }
+ }
+ })
+
+ window.addEventListener("compositionstart", () => {
+ imeOpen = true
+ })
+ window.addEventListener("compositionend", () => {
+ imeOpen = false
+ })
+
+ isInitialized = true
+}
+
+const createKeyPredicate = (keyFilter: KeyFilter) =>
+ typeof keyFilter === "function"
+ ? keyFilter
+ : typeof keyFilter === "string"
+ ? (event: KeyboardEvent) => event.key === keyFilter
+ : keyFilter
+ ? () => true
+ : () => false
+
+export function useKeyDown(
+ key: KeyFilter,
+ fn: (event: KeyboardEvent) => void,
+ options?: Options,
+): void {
+ const predicate = React.useMemo(() => createKeyPredicate(key), [key])
+
+ React.useEffect(() => {
+ initializeKeyboardListeners()
+
+ const handler = (event: KeyboardEvent) => {
+ if (predicate(event)) {
+ fn(event)
+ }
+ }
+
+ callbacks.push({ callback: handler, options })
+
+ return () => {
+ callbacks = callbacks.filter((cb) => cb.callback !== handler)
+ }
+ }, [fn, predicate, options])
+}
diff --git a/web/app/hooks/use-keyboard-manager.ts b/web/app/hooks/use-keyboard-manager.ts
new file mode 100644
index 00000000..0a464d41
--- /dev/null
+++ b/web/app/hooks/use-keyboard-manager.ts
@@ -0,0 +1,62 @@
+import * as React from "react"
+import { useAtom } from "jotai"
+import { keyboardDisableSourcesAtom } from "@/store/keyboard-manager"
+
+const allowedKeys = [
+ "Escape",
+ "ArrowUp",
+ "ArrowDown",
+ "ArrowLeft",
+ "ArrowRight",
+ "Enter",
+ "Tab",
+ "Backspace",
+ "Home",
+ "End",
+]
+
+export function useKeyboardManager(sourceId: string) {
+ const [disableSources, setDisableSources] = useAtom(
+ keyboardDisableSourcesAtom,
+ )
+
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (disableSources.has(sourceId)) {
+ if (allowedKeys.includes(event.key)) {
+ if (event.key === "Escape") {
+ setDisableSources((prev) => {
+ const next = new Set(prev)
+ next.delete(sourceId)
+ return next
+ })
+ }
+ } else {
+ event.stopPropagation()
+ }
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown, true)
+ return () => window.removeEventListener("keydown", handleKeyDown, true)
+ }, [disableSources, sourceId, setDisableSources])
+
+ const disableKeydown = React.useCallback(
+ (disable: boolean) => {
+ setDisableSources((prev) => {
+ const next = new Set(prev)
+ if (disable) {
+ next.add(sourceId)
+ } else {
+ next.delete(sourceId)
+ }
+ return next
+ })
+ },
+ [setDisableSources, sourceId],
+ )
+
+ const isKeyboardDisabled = disableSources.has(sourceId)
+
+ return { disableKeydown, isKeyboardDisabled }
+}
diff --git a/web/app/hooks/use-media.ts b/web/app/hooks/use-media.ts
new file mode 100644
index 00000000..87221c2f
--- /dev/null
+++ b/web/app/hooks/use-media.ts
@@ -0,0 +1,23 @@
+import * as React from "react"
+
+export function useMedia(query: string): boolean {
+ const [matches, setMatches] = React.useState(false)
+
+ React.useEffect(() => {
+ if (window.matchMedia) {
+ const media = window.matchMedia(query)
+ if (media.matches !== matches) {
+ setMatches(media.matches)
+ }
+ const listener = () => {
+ setMatches(media.matches)
+ }
+ media.addListener(listener)
+ return () => media.removeListener(listener)
+ }
+
+ return undefined
+ }, [matches, query])
+
+ return matches
+}
diff --git a/web/app/hooks/use-on-click-outside.ts b/web/app/hooks/use-on-click-outside.ts
new file mode 100644
index 00000000..6c7989fb
--- /dev/null
+++ b/web/app/hooks/use-on-click-outside.ts
@@ -0,0 +1,28 @@
+import * as React from "react"
+import { useEventListener } from "./use-event-listener"
+
+/**
+ * Hook to detect clicks outside of a specified element.
+ *
+ * @param ref The React ref to the element.
+ * @param callback The handler to call when a click outside the element is detected.
+ */
+export function useOnClickOutside(
+ ref: React.RefObject,
+ callback?: (event: MouseEvent | TouchEvent) => void,
+ options: AddEventListenerOptions = {},
+) {
+ const listener = React.useCallback(
+ (event: MouseEvent | TouchEvent) => {
+ // Do nothing if clicking ref's element or descendent elements
+ if (!ref.current || ref.current.contains(event.target as Node)) {
+ return
+ }
+ callback?.(event)
+ },
+ [ref, callback],
+ )
+
+ useEventListener("mousedown", listener, window, options)
+ useEventListener("touchstart", listener, window, options)
+}
diff --git a/web/components/minimal-tiptap/hooks/use-theme.ts b/web/app/hooks/use-theme.ts
similarity index 61%
rename from web/components/minimal-tiptap/hooks/use-theme.ts
rename to web/app/hooks/use-theme.ts
index 9bb816b6..d9b70882 100644
--- a/web/components/minimal-tiptap/hooks/use-theme.ts
+++ b/web/app/hooks/use-theme.ts
@@ -1,10 +1,10 @@
-import * as React from 'react'
+import * as React from "react"
export const useTheme = () => {
const [isDarkMode, setIsDarkMode] = React.useState(false)
React.useEffect(() => {
- const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
setIsDarkMode(darkModeMediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => {
@@ -12,10 +12,10 @@ export const useTheme = () => {
setIsDarkMode(newDarkMode)
}
- darkModeMediaQuery.addEventListener('change', handleChange)
+ darkModeMediaQuery.addEventListener("change", handleChange)
return () => {
- darkModeMediaQuery.removeEventListener('change', handleChange)
+ darkModeMediaQuery.removeEventListener("change", handleChange)
}
}, [])
diff --git a/web/app/hooks/use-touch-sensor.ts b/web/app/hooks/use-touch-sensor.ts
new file mode 100644
index 00000000..8694404d
--- /dev/null
+++ b/web/app/hooks/use-touch-sensor.ts
@@ -0,0 +1,27 @@
+import * as React from "react"
+import { isClient } from "~/lib/utils"
+
+export function useTouchSensor() {
+ const [isTouchDevice, setIsTouchDevice] = React.useState(false)
+
+ React.useEffect(() => {
+ const detectTouch = () => {
+ setIsTouchDevice(
+ isClient() &&
+ (window.matchMedia?.("(hover: none) and (pointer: coarse)")
+ ?.matches ||
+ "ontouchstart" in window ||
+ navigator.maxTouchPoints > 0),
+ )
+ }
+
+ detectTouch()
+ window.addEventListener("touchstart", detectTouch, false)
+
+ return () => {
+ window.removeEventListener("touchstart", detectTouch)
+ }
+ }, [])
+
+ return isTouchDevice
+}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
deleted file mode 100644
index 871c2b75..00000000
--- a/web/app/layout.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import type { Metadata, Viewport } from "next"
-import { cn } from "@/lib/utils"
-import { ThemeProvider } from "@/lib/providers/theme-provider"
-import "./globals.css"
-import { ClerkProviderClient } from "@/components/custom/clerk/clerk-provider-client"
-import { JotaiProvider } from "@/lib/providers/jotai-provider"
-import { Toaster } from "@/components/ui/sonner"
-import { ConfirmProvider } from "@/lib/providers/confirm-provider"
-import { DeepLinkProvider } from "@/lib/providers/deep-link-provider"
-import { GeistMono, GeistSans } from "./fonts"
-import { JazzAndAuth } from "@/lib/providers/jazz-provider"
-import { TooltipProvider } from "@/components/ui/tooltip"
-
-export const viewport: Viewport = {
- width: "device-width",
- height: "device-height",
- initialScale: 1,
- viewportFit: "cover"
-}
-
-export const metadata: Metadata = {
- title: "Learn Anything",
- description: "Organize world's knowledge, explore connections and curate learning paths"
-}
-
-const Providers = ({ children }: { children: React.ReactNode }) => (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-)
-
-export default function RootLayout({
- children
-}: Readonly<{
- children: React.ReactNode
-}>) {
- return (
-
-
-
- {children}
-
-
-
-
-
- )
-}
diff --git a/web/app/lib/constants.ts b/web/app/lib/constants.ts
new file mode 100644
index 00000000..7bf429ef
--- /dev/null
+++ b/web/app/lib/constants.ts
@@ -0,0 +1,45 @@
+import { ID } from "jazz-tools"
+import { icons } from "lucide-react"
+import { PublicGlobalGroup } from "./schema/master/public-group"
+import { getEnvVariable } from "./utils"
+import Graph from "@/data/graph.json"
+
+export type LearningStateValue = "wantToLearn" | "learning" | "learned"
+export type LearningState = {
+ label: string
+ value: LearningStateValue
+ icon: keyof typeof icons
+ className: string
+}
+export interface GraphNode {
+ name: string
+ prettyName: string
+ connectedTopics: 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
+
+export const JAZZ_GLOBAL_GROUP_ID = getEnvVariable(
+ "VITE_JAZZ_GLOBAL_GROUP_ID",
+) as ID
+
+export const GraphData = Graph as GraphNode[]
diff --git a/web/app/lib/providers/clerk-provider.tsx b/web/app/lib/providers/clerk-provider.tsx
new file mode 100644
index 00000000..d5bc2b8b
--- /dev/null
+++ b/web/app/lib/providers/clerk-provider.tsx
@@ -0,0 +1,23 @@
+import { ClerkProvider as BaseClerkProvider } from "@clerk/tanstack-start"
+import { dark } from "@clerk/themes"
+import { useTheme } from "next-themes"
+
+interface ClerkProviderProps {
+ children: React.ReactNode
+}
+
+export const ClerkProvider: React.FC = ({ children }) => {
+ const { theme, systemTheme } = useTheme()
+
+ const isDarkTheme =
+ theme === "dark" || (theme === "system" && systemTheme === "dark")
+
+ const appearance = {
+ baseTheme: isDarkTheme ? dark : undefined,
+ variables: { colorPrimary: isDarkTheme ? "#dddddd" : "#2e2e2e" },
+ }
+
+ return (
+ {children}
+ )
+}
diff --git a/web/app/lib/providers/jazz-provider.tsx b/web/app/lib/providers/jazz-provider.tsx
new file mode 100644
index 00000000..d26c5dcd
--- /dev/null
+++ b/web/app/lib/providers/jazz-provider.tsx
@@ -0,0 +1,71 @@
+import { createJazzReactApp } from "jazz-react"
+import { LaAccount } from "@/lib/schema"
+import { useJazzClerkAuth } from "jazz-react-auth-clerk"
+import { useAuth, useClerk } from "@clerk/tanstack-start"
+import { useLocation } from "@tanstack/react-router"
+import { getEnvVariable } from "../utils"
+import { AuthMethod } from "jazz-tools"
+
+const Jazz = createJazzReactApp({
+ AccountSchema: LaAccount,
+})
+
+export const { useAccount, useAccountOrGuest, useCoState, useAcceptInvite } =
+ Jazz
+
+function assertPeerUrl(
+ url: string | undefined,
+): asserts url is `wss://${string}` | `ws://${string}` {
+ if (!url) {
+ throw new Error("JAZZ_PEER_URL is not defined")
+ }
+ if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
+ throw new Error("JAZZ_PEER_URL must start with wss:// or ws://")
+ }
+}
+
+const JAZZ_PEER_URL = (() => {
+ const rawUrl = getEnvVariable("VITE_JAZZ_PEER_URL")
+ assertPeerUrl(rawUrl)
+ return rawUrl
+})()
+
+interface ChildrenProps {
+ children: React.ReactNode
+}
+
+export function JazzAndAuth({ children }: ChildrenProps) {
+ const { pathname } = useLocation()
+ const Component = pathname === "/" ? JazzGuest : JazzAuth
+ return {children}
+}
+
+export function JazzAuth({ children }: ChildrenProps) {
+ const clerk = useClerk()
+ const { isLoaded, isSignedIn } = useAuth()
+ const [authMethod] = useJazzClerkAuth(clerk)
+
+ if (!isLoaded) return null
+ if (!isSignedIn) return {children}
+ if (!authMethod) return null
+
+ return {children}
+}
+
+export function JazzGuest({ children }: ChildrenProps) {
+ return {children}
+}
+
+function JazzProvider({
+ auth,
+ children,
+}: {
+ auth: AuthMethod | "guest"
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/app/lib/schema/index.ts b/web/app/lib/schema/index.ts
new file mode 100644
index 00000000..72075adb
--- /dev/null
+++ b/web/app/lib/schema/index.ts
@@ -0,0 +1,74 @@
+import { CoMap, co, Account, Profile } from "jazz-tools"
+import { PersonalPageLists } from "./personal-page"
+import { PersonalLinkLists } from "./personal-link"
+import { ListOfTopics } from "./master/topic"
+import { ListOfTasks } from "./task"
+import { JournalEntryLists } from "./journal"
+
+declare module "jazz-tools" {
+ interface Profile {
+ avatarUrl?: string
+ }
+}
+
+export class UserRoot extends CoMap {
+ name = co.string
+ username = co.string
+ avatar = co.optional.string
+ website = co.optional.string
+ bio = co.optional.string
+ is_public = co.optional.boolean
+
+ personalLinks = co.ref(PersonalLinkLists)
+ personalPages = co.ref(PersonalPageLists)
+
+ topicsWantToLearn = co.ref(ListOfTopics)
+ topicsLearning = co.ref(ListOfTopics)
+ topicsLearned = co.ref(ListOfTopics)
+
+ tasks = co.ref(ListOfTasks)
+ journalEntries = co.ref(JournalEntryLists)
+}
+
+export class LaAccount extends Account {
+ profile = co.ref(Profile)
+ root = co.ref(UserRoot)
+
+ migrate(
+ this: LaAccount,
+ creationProps?: { name: string; avatarUrl?: string },
+ ) {
+ // since we dont have a custom AuthProvider yet.
+ // and still using the DemoAuth. the creationProps will only accept name.
+ // so just do default profile create provided by jazz-tools
+ super.migrate(creationProps)
+
+ if (!this._refs.root && creationProps) {
+ this.root = UserRoot.create(
+ {
+ name: creationProps.name,
+ username: creationProps.name,
+ avatar: creationProps.avatarUrl || "",
+ website: "",
+ bio: "",
+ is_public: false,
+
+ personalLinks: PersonalLinkLists.create([], { owner: this }),
+ personalPages: PersonalPageLists.create([], { owner: this }),
+
+ topicsWantToLearn: ListOfTopics.create([], { owner: this }),
+ topicsLearning: ListOfTopics.create([], { owner: this }),
+ topicsLearned: ListOfTopics.create([], { owner: this }),
+
+ tasks: ListOfTasks.create([], { owner: this }),
+ journalEntries: JournalEntryLists.create([], { owner: this }),
+ },
+ { owner: this },
+ )
+ }
+ }
+}
+
+export * from "./master/topic"
+export * from "./personal-link"
+export * from "./personal-page"
diff --git a/web/lib/schema/journal.ts b/web/app/lib/schema/journal.ts
similarity index 52%
rename from web/lib/schema/journal.ts
rename to web/app/lib/schema/journal.ts
index 2ced0d6a..16ce90e8 100644
--- a/web/lib/schema/journal.ts
+++ b/web/app/lib/schema/journal.ts
@@ -1,11 +1,11 @@
import { co, CoList, CoMap, Encoders } from "jazz-tools"
export class JournalEntry extends CoMap {
- title = co.string
- content = co.json()
- date = co.encoded(Encoders.Date)
- createdAt = co.encoded(Encoders.Date)
- updatedAt = co.encoded(Encoders.Date)
+ title = co.string
+ content = co.json()
+ date = co.encoded(Encoders.Date)
+ createdAt = co.encoded(Encoders.Date)
+ updatedAt = co.encoded(Encoders.Date)
}
export class JournalEntryLists extends CoList.Of(co.ref(JournalEntry)) {}
diff --git a/web/lib/schema/master/force-graph.ts b/web/app/lib/schema/master/force-graph.ts
similarity index 70%
rename from web/lib/schema/master/force-graph.ts
rename to web/app/lib/schema/master/force-graph.ts
index d6587ac6..a53fc41f 100644
--- a/web/lib/schema/master/force-graph.ts
+++ b/web/app/lib/schema/master/force-graph.ts
@@ -1,15 +1,15 @@
import { co, CoList, CoMap } from "jazz-tools"
export class Connection extends CoMap {
- name = co.string
+ 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)
+ name = co.string
+ prettyName = co.string
+ connections = co.optional.ref(ListOfConnections)
}
export class ListOfForceGraphs extends CoList.Of(co.ref(ForceGraph)) {}
diff --git a/web/lib/schema/master/public-group.ts b/web/app/lib/schema/master/public-group.ts
similarity index 67%
rename from web/lib/schema/master/public-group.ts
rename to web/app/lib/schema/master/public-group.ts
index 9244b925..837f9280 100644
--- a/web/lib/schema/master/public-group.ts
+++ b/web/app/lib/schema/master/public-group.ts
@@ -3,10 +3,10 @@ import { ListOfForceGraphs } from "./force-graph"
import { ListOfTopics } from "./topic"
export class PublicGlobalGroupRoot extends CoMap {
- forceGraphs = co.ref(ListOfForceGraphs)
- topics = co.ref(ListOfTopics)
+ forceGraphs = co.ref(ListOfForceGraphs)
+ topics = co.ref(ListOfTopics)
}
export class PublicGlobalGroup extends Group {
- root = co.ref(PublicGlobalGroupRoot)
+ root = co.ref(PublicGlobalGroupRoot)
}
diff --git a/web/lib/schema/master/topic.ts b/web/app/lib/schema/master/topic.ts
similarity index 53%
rename from web/lib/schema/master/topic.ts
rename to web/app/lib/schema/master/topic.ts
index a39e351b..aa01d448 100644
--- a/web/lib/schema/master/topic.ts
+++ b/web/app/lib/schema/master/topic.ts
@@ -1,34 +1,35 @@
import { co, CoList, CoMap } from "jazz-tools"
-// TODO: this should be GlobalLink but it's not because lookup of 100k elements is slow
export class Link extends CoMap {
- title = co.string
- url = co.string
+ 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)
+ 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)
+ sections = co.ref(ListOfSections)
}
export class TopicConnection extends CoMap {
- name = co.string
+ name = co.string
}
-export class ListOfTopicConnections extends CoList.Of(co.ref(TopicConnection)) {}
+export class ListOfTopicConnections extends CoList.Of(
+ co.ref(TopicConnection),
+) {}
export class Topic extends CoMap {
- name = co.string
- prettyName = co.string
- latestGlobalGuide = co.ref(LatestGlobalGuide)
+ name = co.string
+ prettyName = co.string
+ latestGlobalGuide = co.ref(LatestGlobalGuide)
}
export class ListOfTopics extends CoList.Of(co.ref(Topic)) {}
diff --git a/web/app/lib/schema/personal-link.ts b/web/app/lib/schema/personal-link.ts
new file mode 100644
index 00000000..fc5a6dde
--- /dev/null
+++ b/web/app/lib/schema/personal-link.ts
@@ -0,0 +1,24 @@
+import { co, CoList, CoMap, Encoders } from "jazz-tools"
+import { Link, Topic } from "./master/topic"
+
+class BaseModel extends CoMap {
+ createdAt = co.encoded(Encoders.Date)
+ updatedAt = co.encoded(Encoders.Date)
+}
+
+export class PersonalLink extends BaseModel {
+ url = co.string
+ icon = co.optional.string // is an icon URL
+ link = co.optional.ref(Link)
+ title = co.string
+ slug = co.string
+ description = co.optional.string
+ completed = co.boolean
+ sequence = co.number
+ learningState = co.optional.literal("wantToLearn", "learning", "learned")
+ notes = co.optional.string
+ summary = co.optional.string
+ topic = co.optional.ref(Topic)
+}
+
+export class PersonalLinkLists extends CoList.Of(co.ref(PersonalLink)) {}
diff --git a/web/lib/schema/personal-page.ts b/web/app/lib/schema/personal-page.ts
similarity index 56%
rename from web/lib/schema/personal-page.ts
rename to web/app/lib/schema/personal-page.ts
index 6db76862..d9deec1c 100644
--- a/web/lib/schema/personal-page.ts
+++ b/web/app/lib/schema/personal-page.ts
@@ -8,14 +8,14 @@ import { Topic } from "./master/topic"
* - if public, certain members (can do read/write access accordingly), personal (end to end encrypted, only accessed by user)
*/
export class PersonalPage extends CoMap {
- title = co.optional.string
- slug = co.optional.string // is used only when `public: true` for sharing, `@user/page-slug`
- public = co.boolean
- content = co.optional.json()
- topic = co.optional.ref(Topic)
- createdAt = co.encoded(Encoders.Date)
- updatedAt = co.encoded(Encoders.Date)
- // backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently
+ title = co.optional.string
+ slug = co.optional.string // is used only when `public: true` for sharing, `@user/page-slug`
+ public = co.boolean
+ content = co.optional.json()
+ topic = co.optional.ref(Topic)
+ createdAt = co.encoded(Encoders.Date)
+ updatedAt = co.encoded(Encoders.Date)
+ // backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently
}
export class PersonalPageLists extends CoList.Of(co.ref(PersonalPage)) {}
diff --git a/web/app/lib/schema/task.ts b/web/app/lib/schema/task.ts
new file mode 100644
index 00000000..0608b2eb
--- /dev/null
+++ b/web/app/lib/schema/task.ts
@@ -0,0 +1,12 @@
+import { co, CoList, CoMap, Encoders } from "jazz-tools"
+
+export class Task extends CoMap {
+ title = co.string
+ description = co.optional.string
+ status = co.literal("todo", "in_progress", "done")
+ createdAt = co.encoded(Encoders.Date)
+ updatedAt = co.encoded(Encoders.Date)
+ dueDate = co.optional.encoded(Encoders.Date)
+}
+
+export class ListOfTasks extends CoList.Of(co.ref(Task)) {}
diff --git a/web/app/lib/utils/env.ts b/web/app/lib/utils/env.ts
new file mode 100644
index 00000000..2cfd44c1
--- /dev/null
+++ b/web/app/lib/utils/env.ts
@@ -0,0 +1,33 @@
+/**
+ *
+ * Utility function to get env variables.
+ *
+ * @param name env variable name
+ * @param defaultVaue default value to return if the env variable is not set
+ * @returns string
+ *
+ * @internal
+ */
+export const getEnvVariable = (
+ name: string,
+ defaultVaue: string = "",
+): string => {
+ // Node envs
+ if (
+ typeof process !== "undefined" &&
+ process.env &&
+ typeof process.env[name] === "string"
+ ) {
+ return (process.env[name] as string) || defaultVaue
+ }
+
+ if (
+ typeof import.meta !== "undefined" &&
+ import.meta.env &&
+ typeof import.meta.env[name] === "string"
+ ) {
+ return import.meta.env[name]
+ }
+
+ return defaultVaue
+}
diff --git a/web/app/lib/utils/force-graph/canvas.ts b/web/app/lib/utils/force-graph/canvas.ts
new file mode 100644
index 00000000..cb89dd4f
--- /dev/null
+++ b/web/app/lib/utils/force-graph/canvas.ts
@@ -0,0 +1,54 @@
+/**
+ * Resizes the canvas to match the size it is being displayed.
+ *
+ * @param canvas the canvas to resize
+ * @returns `true` if the canvas was resized
+ */
+export function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
+ // Get the size the browser is displaying the canvas in device pixels.
+ const dpr = window.devicePixelRatio
+ const { width, height } = canvas.getBoundingClientRect()
+ const display_width = Math.round(width * dpr)
+ const display_height = Math.round(height * dpr)
+
+ const need_resize =
+ canvas.width != display_width || canvas.height != display_height
+
+ if (need_resize) {
+ canvas.width = display_width
+ canvas.height = display_height
+ }
+
+ return need_resize
+}
+
+export interface CanvasResizeObserver {
+ /** Canvas was resized since last check. Set it to `false` to reset. */
+ resized: boolean
+ canvas: HTMLCanvasElement
+ observer: ResizeObserver
+}
+
+export function resize(observer: CanvasResizeObserver): boolean {
+ const resized = resizeCanvasToDisplaySize(observer.canvas)
+ observer.resized ||= resized
+ return resized
+}
+
+export function resizeObserver(
+ canvas: HTMLCanvasElement,
+): CanvasResizeObserver {
+ const cro: CanvasResizeObserver = {
+ resized: false,
+ canvas: canvas,
+ observer: null!,
+ }
+ cro.observer = new ResizeObserver(resize.bind(null, cro))
+ resize(cro)
+ cro.observer.observe(canvas)
+ return cro
+}
+
+export function clear(observer: CanvasResizeObserver): void {
+ observer.observer.disconnect()
+}
diff --git a/web/app/lib/utils/force-graph/index.ts b/web/app/lib/utils/force-graph/index.ts
new file mode 100644
index 00000000..112bf480
--- /dev/null
+++ b/web/app/lib/utils/force-graph/index.ts
@@ -0,0 +1,2 @@
+export * from "./canvas"
+export * from "./schedule"
diff --git a/web/app/lib/utils/force-graph/schedule.ts b/web/app/lib/utils/force-graph/schedule.ts
new file mode 100644
index 00000000..8ca6eb9f
--- /dev/null
+++ b/web/app/lib/utils/force-graph/schedule.ts
@@ -0,0 +1,151 @@
+export interface Scheduler {
+ trigger: (...args: Args) => void
+ clear: () => void
+}
+
+/**
+ * Creates a callback that is debounced and cancellable. The debounced callback is called on **trailing** edge.
+ *
+ * @param callback The callback to debounce
+ * @param wait The duration to debounce in milliseconds
+ *
+ * @example
+ * ```ts
+ * const debounce = schedule.debounce((message: string) => console.log(message), 250)
+ * debounce.trigger('Hello!')
+ * debounce.clear() // clears a timeout in progress
+ * ```
+ */
+export function debounce(
+ callback: (...args: Args) => void,
+ wait?: number,
+): Debounce {
+ return new Debounce(callback, wait)
+}
+
+export class Debounce implements Scheduler {
+ timeout_id: ReturnType | undefined
+
+ constructor(
+ public callback: (...args: Args) => void,
+ public wait?: number,
+ ) {}
+
+ trigger(...args: Args): void {
+ if (this.timeout_id !== undefined) {
+ this.clear()
+ }
+ this.timeout_id = setTimeout(() => {
+ this.callback(...args)
+ }, this.wait)
+ }
+
+ clear(): void {
+ clearTimeout(this.timeout_id)
+ }
+}
+
+/**
+ * Creates a callback that is throttled and cancellable. The throttled callback is called on **trailing** edge.
+ *
+ * @param callback The callback to throttle
+ * @param wait The duration to throttle
+ *
+ * @example
+ * ```ts
+ * const throttle = schedule.throttle((val: string) => console.log(val), 250)
+ * throttle.trigger('my-new-value')
+ * throttle.clear() // clears a timeout in progress
+ * ```
+ */
+export function throttle(
+ callback: (...args: Args) => void,
+ wait?: number,
+): Throttle {
+ return new Throttle(callback, wait)
+}
+
+export class Throttle implements Scheduler {
+ is_throttled = false
+ timeout_id: ReturnType | undefined
+ last_args: Args | undefined
+
+ constructor(
+ public callback: (...args: Args) => void,
+ public wait?: number,
+ ) {}
+
+ trigger(...args: Args): void {
+ this.last_args = args
+ if (this.is_throttled) {
+ return
+ }
+ this.is_throttled = true
+ this.timeout_id = setTimeout(() => {
+ this.callback(...(this.last_args as Args))
+ this.is_throttled = false
+ }, this.wait)
+ }
+
+ clear(): void {
+ clearTimeout(this.timeout_id)
+ this.is_throttled = false
+ }
+}
+
+/**
+ * Creates a callback throttled using `window.requestIdleCallback()`. ([MDN reference](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback))
+ *
+ * The throttled callback is called on **trailing** edge.
+ *
+ * @param callback The callback to throttle
+ * @param max_wait maximum wait time in milliseconds until the callback is called
+ *
+ * @example
+ * ```ts
+ * const idle = schedule.scheduleIdle((val: string) => console.log(val), 250)
+ * idle.trigger('my-new-value')
+ * idle.clear() // clears a timeout in progress
+ * ```
+ */
+export function scheduleIdle(
+ callback: (...args: Args) => void,
+ max_wait?: number,
+): ScheduleIdle | Throttle {
+ return typeof requestIdleCallback == "function"
+ ? new ScheduleIdle(callback, max_wait)
+ : new Throttle(callback)
+}
+
+export class ScheduleIdle implements Scheduler {
+ is_deferred = false
+ request_id: ReturnType | undefined
+ last_args: Args | undefined
+
+ constructor(
+ public callback: (...args: Args) => void,
+ public max_wait?: number,
+ ) {}
+
+ trigger(...args: Args): void {
+ this.last_args = args
+ if (this.is_deferred) {
+ return
+ }
+ this.is_deferred = true
+ this.request_id = requestIdleCallback(
+ () => {
+ this.callback(...(this.last_args as Args))
+ this.is_deferred = false
+ },
+ { timeout: this.max_wait },
+ )
+ }
+
+ clear(): void {
+ if (this.request_id != undefined) {
+ cancelIdleCallback(this.request_id)
+ }
+ this.is_deferred = false
+ }
+}
diff --git a/web/app/lib/utils/index.ts b/web/app/lib/utils/index.ts
new file mode 100644
index 00000000..673102bf
--- /dev/null
+++ b/web/app/lib/utils/index.ts
@@ -0,0 +1,81 @@
+import * as React from "react"
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+function escapeRegExp(string: string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
+export const searchSafeRegExp = (inputValue: string) => {
+ const escapedChars = inputValue.split("").map(escapeRegExp)
+ return new RegExp(escapedChars.join(".*"), "i")
+}
+
+export function shuffleArray(array: T[]): T[] {
+ const shuffled = [...array]
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
+ }
+ return shuffled
+}
+
+export const isClient = () => typeof window !== "undefined"
+
+export const isServer = () => !isClient()
+
+const inputs = ["input", "select", "button", "textarea"] // detect if node is a text input element
+
+export function isTextInput(element: Element): boolean {
+ return !!(
+ element &&
+ element.tagName &&
+ (inputs.indexOf(element.tagName.toLowerCase()) !== -1 ||
+ element.attributes.getNamedItem("role")?.value === "textbox" ||
+ element.attributes.getNamedItem("contenteditable")?.value === "true")
+ )
+}
+
+export type HTMLAttributes = React.HTMLAttributes & {
+ [key: string]: any
+}
+
+export type HTMLLikeElement = {
+ tag: keyof JSX.IntrinsicElements
+ attributes?: HTMLAttributes
+ children?: (HTMLLikeElement | string)[]
+}
+
+export const renderHTMLLikeElement = (
+ element: HTMLLikeElement | string,
+): React.ReactNode => {
+ if (typeof element === "string") {
+ return element
+ }
+
+ const { tag, attributes = {}, children = [] } = element
+
+ return React.createElement(
+ tag,
+ attributes,
+ ...children.map((child) => renderHTMLLikeElement(child)),
+ )
+}
+
+export function calendarFormatDate(date: Date): string {
+ return date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+}
+
+export * from "./force-graph"
+export * from "./keyboard"
+export * from "./env"
+export * from "./slug"
+export * from "./url"
diff --git a/web/app/lib/utils/keyboard.ts b/web/app/lib/utils/keyboard.ts
new file mode 100644
index 00000000..d5f99db6
--- /dev/null
+++ b/web/app/lib/utils/keyboard.ts
@@ -0,0 +1,67 @@
+import { isServer } from "."
+
+interface ShortcutKeyResult {
+ symbol: string
+ readable: string
+}
+
+export function getShortcutKey(key: string): ShortcutKeyResult {
+ const lowercaseKey = key.toLowerCase()
+ if (lowercaseKey === "mod") {
+ return isMac()
+ ? { symbol: "⌘", readable: "Command" }
+ : { symbol: "Ctrl", readable: "Control" }
+ } else if (lowercaseKey === "alt") {
+ return isMac()
+ ? { symbol: "⌥", readable: "Option" }
+ : { symbol: "Alt", readable: "Alt" }
+ } else if (lowercaseKey === "shift") {
+ return isMac()
+ ? { symbol: "⇧", readable: "Shift" }
+ : { symbol: "Shift", readable: "Shift" }
+ } else {
+ return { symbol: key.toUpperCase(), readable: key }
+ }
+}
+
+export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
+ return keys.map((key) => getShortcutKey(key))
+}
+
+export function isModKey(
+ event: KeyboardEvent | MouseEvent | React.KeyboardEvent,
+) {
+ return isMac() ? event.metaKey : event.ctrlKey
+}
+
+export function isMac(): boolean {
+ if (isServer()) {
+ return false
+ }
+ return window.navigator.platform === "MacIntel"
+}
+
+export function isWindows(): boolean {
+ if (isServer()) {
+ return false
+ }
+ return window.navigator.platform === "Win32"
+}
+
+let supportsPassive = false
+
+try {
+ const opts = Object.defineProperty({}, "passive", {
+ get() {
+ supportsPassive = true
+ },
+ })
+ // @ts-expect-error ts-migrate(2769) testPassive is not a real event
+ window.addEventListener("testPassive", null, opts)
+ // @ts-expect-error ts-migrate(2769) testPassive is not a real event
+ window.removeEventListener("testPassive", null, opts)
+} catch (e) {
+ // No-op
+}
+
+export const supportsPassiveListener = supportsPassive
diff --git a/web/app/lib/utils/schema.ts b/web/app/lib/utils/schema.ts
new file mode 100644
index 00000000..6ee23e0a
--- /dev/null
+++ b/web/app/lib/utils/schema.ts
@@ -0,0 +1,43 @@
+/*
+ * 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",
+ },
+ )
diff --git a/web/app/lib/utils/seo.ts b/web/app/lib/utils/seo.ts
new file mode 100644
index 00000000..1546310e
--- /dev/null
+++ b/web/app/lib/utils/seo.ts
@@ -0,0 +1,33 @@
+export const seo = ({
+ title,
+ description,
+ keywords,
+ image,
+}: {
+ title: string
+ description?: string
+ image?: string
+ keywords?: string
+}) => {
+ const tags = [
+ { title },
+ { name: "description", content: description },
+ { name: "keywords", content: keywords },
+ { name: "twitter:title", content: title },
+ { name: "twitter:description", content: description },
+ { name: "twitter:creator", content: "@tannerlinsley" },
+ { name: "twitter:site", content: "@tannerlinsley" },
+ { name: "og:type", content: "website" },
+ { name: "og:title", content: title },
+ { name: "og:description", content: description },
+ ...(image
+ ? [
+ { name: "twitter:image", content: image },
+ { name: "twitter:card", content: "summary_large_image" },
+ { name: "og:image", content: image },
+ ]
+ : []),
+ ]
+
+ return tags
+}
diff --git a/web/app/lib/utils/slug.ts b/web/app/lib/utils/slug.ts
new file mode 100644
index 00000000..b7b5b712
--- /dev/null
+++ b/web/app/lib/utils/slug.ts
@@ -0,0 +1,22 @@
+import slugify from "slugify"
+
+export function generateUniqueSlug(
+ title: string,
+ maxLength: number = 60,
+): string {
+ const baseSlug = slugify(title, {
+ lower: true,
+ strict: true,
+ })
+
+ // Web Crypto API
+ const randomValues = new Uint8Array(4)
+ crypto.getRandomValues(randomValues)
+ const randomSuffix = Array.from(randomValues)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("")
+
+ const truncatedSlug = baseSlug.slice(0, Math.min(maxLength, 75) - 9)
+
+ return `${truncatedSlug}-${randomSuffix}`
+}
diff --git a/web/app/lib/utils/url.ts b/web/app/lib/utils/url.ts
new file mode 100644
index 00000000..0eb44443
--- /dev/null
+++ b/web/app/lib/utils/url.ts
@@ -0,0 +1,25 @@
+export function isValidUrl(string: string): boolean {
+ try {
+ new URL(string)
+ return true
+ } catch (_) {
+ return false
+ }
+}
+
+export function isUrl(text: string): boolean {
+ const pattern: RegExp =
+ /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/
+ return pattern.test(text)
+}
+
+export function ensureUrlProtocol(
+ url: string,
+ defaultProtocol: string = "https://",
+): string {
+ if (url.match(/^[a-zA-Z]+:\/\//)) {
+ return url
+ }
+
+ return `${defaultProtocol}${url.startsWith("//") ? url.slice(2) : url}`
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
deleted file mode 100644
index 5cb37316..00000000
--- a/web/app/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute"
-
-export default function HomePage() {
- return
-}
diff --git a/web/app/routeTree.gen.ts b/web/app/routeTree.gen.ts
new file mode 100644
index 00000000..d1baf960
--- /dev/null
+++ b/web/app/routeTree.gen.ts
@@ -0,0 +1,641 @@
+/* prettier-ignore-start */
+
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file is auto-generated by TanStack Router
+
+import { createFileRoute } from '@tanstack/react-router'
+
+// Import Routes
+
+import { Route as rootRoute } from './routes/__root'
+import { Route as LayoutImport } from './routes/_layout'
+import { Route as LayoutPagesImport } from './routes/_layout/_pages'
+import { Route as LayoutlandingIndexImport } from './routes/_layout/(landing)/index'
+import { Route as LayoutPagesProtectedImport } from './routes/_layout/_pages/_protected'
+import { Route as LayoutauthAuthImport } from './routes/_layout/(auth)/_auth'
+import { Route as LayoutPagestopicSplatImport } from './routes/_layout/_pages/(topic)/$'
+import { Route as LayoutPagesProtectedTopicsIndexImport } from './routes/_layout/_pages/_protected/topics/index'
+import { Route as LayoutPagesProtectedTasksIndexImport } from './routes/_layout/_pages/_protected/tasks/index'
+import { Route as LayoutPagesProtectedSettingsIndexImport } from './routes/_layout/_pages/_protected/settings/index'
+import { Route as LayoutPagesProtectedSearchIndexImport } from './routes/_layout/_pages/_protected/search/index'
+import { Route as LayoutPagesProtectedProfileIndexImport } from './routes/_layout/_pages/_protected/profile/index'
+import { Route as LayoutPagesProtectedPagesIndexImport } from './routes/_layout/_pages/_protected/pages/index'
+import { Route as LayoutPagesProtectedOnboardingIndexImport } from './routes/_layout/_pages/_protected/onboarding/index'
+import { Route as LayoutPagesProtectedLinksIndexImport } from './routes/_layout/_pages/_protected/links/index'
+import { Route as LayoutPagesProtectedJournalsIndexImport } from './routes/_layout/_pages/_protected/journals/index'
+import { Route as LayoutauthAuthSignUpSplatImport } from './routes/_layout/(auth)/_auth.sign-up.$'
+import { Route as LayoutauthAuthSignInSplatImport } from './routes/_layout/(auth)/_auth.sign-in.$'
+import { Route as LayoutPagesProtectedPagesPageIdIndexImport } from './routes/_layout/_pages/_protected/pages/$pageId/index'
+import { Route as LayoutPagesProtectedCommunityTopicNameIndexImport } from './routes/_layout/_pages/_protected/community/$topicName/index'
+
+// Create Virtual Routes
+
+const LayoutauthImport = createFileRoute('/_layout/(auth)')()
+
+// Create/Update Routes
+
+const LayoutRoute = LayoutImport.update({
+ id: '/_layout',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const LayoutauthRoute = LayoutauthImport.update({
+ id: '/(auth)',
+ getParentRoute: () => LayoutRoute,
+} as any)
+
+const LayoutPagesRoute = LayoutPagesImport.update({
+ id: '/_pages',
+ getParentRoute: () => LayoutRoute,
+} as any)
+
+const LayoutlandingIndexRoute = LayoutlandingIndexImport.update({
+ path: '/',
+ getParentRoute: () => LayoutRoute,
+} as any)
+
+const LayoutPagesProtectedRoute = LayoutPagesProtectedImport.update({
+ id: '/_protected',
+ getParentRoute: () => LayoutPagesRoute,
+} as any)
+
+const LayoutauthAuthRoute = LayoutauthAuthImport.update({
+ id: '/_auth',
+ getParentRoute: () => LayoutauthRoute,
+} as any)
+
+const LayoutPagestopicSplatRoute = LayoutPagestopicSplatImport.update({
+ path: '/$',
+ getParentRoute: () => LayoutPagesRoute,
+} as any)
+
+const LayoutPagesProtectedTopicsIndexRoute =
+ LayoutPagesProtectedTopicsIndexImport.update({
+ path: '/topics/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedTasksIndexRoute =
+ LayoutPagesProtectedTasksIndexImport.update({
+ path: '/tasks/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedSettingsIndexRoute =
+ LayoutPagesProtectedSettingsIndexImport.update({
+ path: '/settings/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedSearchIndexRoute =
+ LayoutPagesProtectedSearchIndexImport.update({
+ path: '/search/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedProfileIndexRoute =
+ LayoutPagesProtectedProfileIndexImport.update({
+ path: '/profile/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedPagesIndexRoute =
+ LayoutPagesProtectedPagesIndexImport.update({
+ path: '/pages/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedOnboardingIndexRoute =
+ LayoutPagesProtectedOnboardingIndexImport.update({
+ path: '/onboarding/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedLinksIndexRoute =
+ LayoutPagesProtectedLinksIndexImport.update({
+ path: '/links/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedJournalsIndexRoute =
+ LayoutPagesProtectedJournalsIndexImport.update({
+ path: '/journals/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutauthAuthSignUpSplatRoute = LayoutauthAuthSignUpSplatImport.update({
+ path: '/sign-up/$',
+ getParentRoute: () => LayoutauthAuthRoute,
+} as any)
+
+const LayoutauthAuthSignInSplatRoute = LayoutauthAuthSignInSplatImport.update({
+ path: '/sign-in/$',
+ getParentRoute: () => LayoutauthAuthRoute,
+} as any)
+
+const LayoutPagesProtectedPagesPageIdIndexRoute =
+ LayoutPagesProtectedPagesPageIdIndexImport.update({
+ path: '/pages/$pageId/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+const LayoutPagesProtectedCommunityTopicNameIndexRoute =
+ LayoutPagesProtectedCommunityTopicNameIndexImport.update({
+ path: '/community/$topicName/',
+ getParentRoute: () => LayoutPagesProtectedRoute,
+ } as any)
+
+// Populate the FileRoutesByPath interface
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/_layout': {
+ id: '/_layout'
+ path: ''
+ fullPath: ''
+ preLoaderRoute: typeof LayoutImport
+ parentRoute: typeof rootRoute
+ }
+ '/_layout/_pages': {
+ id: '/_layout/_pages'
+ path: ''
+ fullPath: ''
+ preLoaderRoute: typeof LayoutPagesImport
+ parentRoute: typeof LayoutImport
+ }
+ '/_layout/(auth)': {
+ id: '/_layout/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof LayoutauthImport
+ parentRoute: typeof LayoutImport
+ }
+ '/_layout/(auth)/_auth': {
+ id: '/_layout/_auth'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof LayoutauthAuthImport
+ parentRoute: typeof LayoutauthRoute
+ }
+ '/_layout/_pages/_protected': {
+ id: '/_layout/_pages/_protected'
+ path: ''
+ fullPath: ''
+ preLoaderRoute: typeof LayoutPagesProtectedImport
+ parentRoute: typeof LayoutPagesImport
+ }
+ '/_layout/(landing)/': {
+ id: '/_layout/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof LayoutlandingIndexImport
+ parentRoute: typeof LayoutImport
+ }
+ '/_layout/_pages/(topic)/$': {
+ id: '/_layout/_pages/$'
+ path: '/$'
+ fullPath: '/$'
+ preLoaderRoute: typeof LayoutPagestopicSplatImport
+ parentRoute: typeof LayoutPagesImport
+ }
+ '/_layout/(auth)/_auth/sign-in/$': {
+ id: '/_layout/_auth/sign-in/$'
+ path: '/sign-in/$'
+ fullPath: '/sign-in/$'
+ preLoaderRoute: typeof LayoutauthAuthSignInSplatImport
+ parentRoute: typeof LayoutauthAuthImport
+ }
+ '/_layout/(auth)/_auth/sign-up/$': {
+ id: '/_layout/_auth/sign-up/$'
+ path: '/sign-up/$'
+ fullPath: '/sign-up/$'
+ preLoaderRoute: typeof LayoutauthAuthSignUpSplatImport
+ parentRoute: typeof LayoutauthAuthImport
+ }
+ '/_layout/_pages/_protected/journals/': {
+ id: '/_layout/_pages/_protected/journals/'
+ path: '/journals'
+ fullPath: '/journals'
+ preLoaderRoute: typeof LayoutPagesProtectedJournalsIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/links/': {
+ id: '/_layout/_pages/_protected/links/'
+ path: '/links'
+ fullPath: '/links'
+ preLoaderRoute: typeof LayoutPagesProtectedLinksIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/onboarding/': {
+ id: '/_layout/_pages/_protected/onboarding/'
+ path: '/onboarding'
+ fullPath: '/onboarding'
+ preLoaderRoute: typeof LayoutPagesProtectedOnboardingIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/pages/': {
+ id: '/_layout/_pages/_protected/pages/'
+ path: '/pages'
+ fullPath: '/pages'
+ preLoaderRoute: typeof LayoutPagesProtectedPagesIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/profile/': {
+ id: '/_layout/_pages/_protected/profile/'
+ path: '/profile'
+ fullPath: '/profile'
+ preLoaderRoute: typeof LayoutPagesProtectedProfileIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/search/': {
+ id: '/_layout/_pages/_protected/search/'
+ path: '/search'
+ fullPath: '/search'
+ preLoaderRoute: typeof LayoutPagesProtectedSearchIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/settings/': {
+ id: '/_layout/_pages/_protected/settings/'
+ path: '/settings'
+ fullPath: '/settings'
+ preLoaderRoute: typeof LayoutPagesProtectedSettingsIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/tasks/': {
+ id: '/_layout/_pages/_protected/tasks/'
+ path: '/tasks'
+ fullPath: '/tasks'
+ preLoaderRoute: typeof LayoutPagesProtectedTasksIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/topics/': {
+ id: '/_layout/_pages/_protected/topics/'
+ path: '/topics'
+ fullPath: '/topics'
+ preLoaderRoute: typeof LayoutPagesProtectedTopicsIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/community/$topicName/': {
+ id: '/_layout/_pages/_protected/community/$topicName/'
+ path: '/community/$topicName'
+ fullPath: '/community/$topicName'
+ preLoaderRoute: typeof LayoutPagesProtectedCommunityTopicNameIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ '/_layout/_pages/_protected/pages/$pageId/': {
+ id: '/_layout/_pages/_protected/pages/$pageId/'
+ path: '/pages/$pageId'
+ fullPath: '/pages/$pageId'
+ preLoaderRoute: typeof LayoutPagesProtectedPagesPageIdIndexImport
+ parentRoute: typeof LayoutPagesProtectedImport
+ }
+ }
+}
+
+// Create and export the route tree
+
+interface LayoutPagesProtectedRouteChildren {
+ LayoutPagesProtectedJournalsIndexRoute: typeof LayoutPagesProtectedJournalsIndexRoute
+ LayoutPagesProtectedLinksIndexRoute: typeof LayoutPagesProtectedLinksIndexRoute
+ LayoutPagesProtectedOnboardingIndexRoute: typeof LayoutPagesProtectedOnboardingIndexRoute
+ LayoutPagesProtectedPagesIndexRoute: typeof LayoutPagesProtectedPagesIndexRoute
+ LayoutPagesProtectedProfileIndexRoute: typeof LayoutPagesProtectedProfileIndexRoute
+ LayoutPagesProtectedSearchIndexRoute: typeof LayoutPagesProtectedSearchIndexRoute
+ LayoutPagesProtectedSettingsIndexRoute: typeof LayoutPagesProtectedSettingsIndexRoute
+ LayoutPagesProtectedTasksIndexRoute: typeof LayoutPagesProtectedTasksIndexRoute
+ LayoutPagesProtectedTopicsIndexRoute: typeof LayoutPagesProtectedTopicsIndexRoute
+ LayoutPagesProtectedCommunityTopicNameIndexRoute: typeof LayoutPagesProtectedCommunityTopicNameIndexRoute
+ LayoutPagesProtectedPagesPageIdIndexRoute: typeof LayoutPagesProtectedPagesPageIdIndexRoute
+}
+
+const LayoutPagesProtectedRouteChildren: LayoutPagesProtectedRouteChildren = {
+ LayoutPagesProtectedJournalsIndexRoute:
+ LayoutPagesProtectedJournalsIndexRoute,
+ LayoutPagesProtectedLinksIndexRoute: LayoutPagesProtectedLinksIndexRoute,
+ LayoutPagesProtectedOnboardingIndexRoute:
+ LayoutPagesProtectedOnboardingIndexRoute,
+ LayoutPagesProtectedPagesIndexRoute: LayoutPagesProtectedPagesIndexRoute,
+ LayoutPagesProtectedProfileIndexRoute: LayoutPagesProtectedProfileIndexRoute,
+ LayoutPagesProtectedSearchIndexRoute: LayoutPagesProtectedSearchIndexRoute,
+ LayoutPagesProtectedSettingsIndexRoute:
+ LayoutPagesProtectedSettingsIndexRoute,
+ LayoutPagesProtectedTasksIndexRoute: LayoutPagesProtectedTasksIndexRoute,
+ LayoutPagesProtectedTopicsIndexRoute: LayoutPagesProtectedTopicsIndexRoute,
+ LayoutPagesProtectedCommunityTopicNameIndexRoute:
+ LayoutPagesProtectedCommunityTopicNameIndexRoute,
+ LayoutPagesProtectedPagesPageIdIndexRoute:
+ LayoutPagesProtectedPagesPageIdIndexRoute,
+}
+
+const LayoutPagesProtectedRouteWithChildren =
+ LayoutPagesProtectedRoute._addFileChildren(LayoutPagesProtectedRouteChildren)
+
+interface LayoutPagesRouteChildren {
+ LayoutPagesProtectedRoute: typeof LayoutPagesProtectedRouteWithChildren
+ LayoutPagestopicSplatRoute: typeof LayoutPagestopicSplatRoute
+}
+
+const LayoutPagesRouteChildren: LayoutPagesRouteChildren = {
+ LayoutPagesProtectedRoute: LayoutPagesProtectedRouteWithChildren,
+ LayoutPagestopicSplatRoute: LayoutPagestopicSplatRoute,
+}
+
+const LayoutPagesRouteWithChildren = LayoutPagesRoute._addFileChildren(
+ LayoutPagesRouteChildren,
+)
+
+interface LayoutauthAuthRouteChildren {
+ LayoutauthAuthSignInSplatRoute: typeof LayoutauthAuthSignInSplatRoute
+ LayoutauthAuthSignUpSplatRoute: typeof LayoutauthAuthSignUpSplatRoute
+}
+
+const LayoutauthAuthRouteChildren: LayoutauthAuthRouteChildren = {
+ LayoutauthAuthSignInSplatRoute: LayoutauthAuthSignInSplatRoute,
+ LayoutauthAuthSignUpSplatRoute: LayoutauthAuthSignUpSplatRoute,
+}
+
+const LayoutauthAuthRouteWithChildren = LayoutauthAuthRoute._addFileChildren(
+ LayoutauthAuthRouteChildren,
+)
+
+interface LayoutauthRouteChildren {
+ LayoutauthAuthRoute: typeof LayoutauthAuthRouteWithChildren
+}
+
+const LayoutauthRouteChildren: LayoutauthRouteChildren = {
+ LayoutauthAuthRoute: LayoutauthAuthRouteWithChildren,
+}
+
+const LayoutauthRouteWithChildren = LayoutauthRoute._addFileChildren(
+ LayoutauthRouteChildren,
+)
+
+interface LayoutRouteChildren {
+ LayoutPagesRoute: typeof LayoutPagesRouteWithChildren
+ LayoutauthRoute: typeof LayoutauthRouteWithChildren
+ LayoutlandingIndexRoute: typeof LayoutlandingIndexRoute
+}
+
+const LayoutRouteChildren: LayoutRouteChildren = {
+ LayoutPagesRoute: LayoutPagesRouteWithChildren,
+ LayoutauthRoute: LayoutauthRouteWithChildren,
+ LayoutlandingIndexRoute: LayoutlandingIndexRoute,
+}
+
+const LayoutRouteWithChildren =
+ LayoutRoute._addFileChildren(LayoutRouteChildren)
+
+export interface FileRoutesByFullPath {
+ '': typeof LayoutPagesProtectedRouteWithChildren
+ '/': typeof LayoutlandingIndexRoute
+ '/$': typeof LayoutPagestopicSplatRoute
+ '/sign-in/$': typeof LayoutauthAuthSignInSplatRoute
+ '/sign-up/$': typeof LayoutauthAuthSignUpSplatRoute
+ '/journals': typeof LayoutPagesProtectedJournalsIndexRoute
+ '/links': typeof LayoutPagesProtectedLinksIndexRoute
+ '/onboarding': typeof LayoutPagesProtectedOnboardingIndexRoute
+ '/pages': typeof LayoutPagesProtectedPagesIndexRoute
+ '/profile': typeof LayoutPagesProtectedProfileIndexRoute
+ '/search': typeof LayoutPagesProtectedSearchIndexRoute
+ '/settings': typeof LayoutPagesProtectedSettingsIndexRoute
+ '/tasks': typeof LayoutPagesProtectedTasksIndexRoute
+ '/topics': typeof LayoutPagesProtectedTopicsIndexRoute
+ '/community/$topicName': typeof LayoutPagesProtectedCommunityTopicNameIndexRoute
+ '/pages/$pageId': typeof LayoutPagesProtectedPagesPageIdIndexRoute
+}
+
+export interface FileRoutesByTo {
+ '': typeof LayoutPagesProtectedRouteWithChildren
+ '/': typeof LayoutlandingIndexRoute
+ '/$': typeof LayoutPagestopicSplatRoute
+ '/sign-in/$': typeof LayoutauthAuthSignInSplatRoute
+ '/sign-up/$': typeof LayoutauthAuthSignUpSplatRoute
+ '/journals': typeof LayoutPagesProtectedJournalsIndexRoute
+ '/links': typeof LayoutPagesProtectedLinksIndexRoute
+ '/onboarding': typeof LayoutPagesProtectedOnboardingIndexRoute
+ '/pages': typeof LayoutPagesProtectedPagesIndexRoute
+ '/profile': typeof LayoutPagesProtectedProfileIndexRoute
+ '/search': typeof LayoutPagesProtectedSearchIndexRoute
+ '/settings': typeof LayoutPagesProtectedSettingsIndexRoute
+ '/tasks': typeof LayoutPagesProtectedTasksIndexRoute
+ '/topics': typeof LayoutPagesProtectedTopicsIndexRoute
+ '/community/$topicName': typeof LayoutPagesProtectedCommunityTopicNameIndexRoute
+ '/pages/$pageId': typeof LayoutPagesProtectedPagesPageIdIndexRoute
+}
+
+export interface FileRoutesById {
+ __root__: typeof rootRoute
+ '/_layout': typeof LayoutRouteWithChildren
+ '/_layout/_pages': typeof LayoutPagesRouteWithChildren
+ '/_layout/': typeof LayoutlandingIndexRoute
+ '/_layout/_auth': typeof LayoutauthAuthRouteWithChildren
+ '/_layout/_pages/_protected': typeof LayoutPagesProtectedRouteWithChildren
+ '/_layout/_pages/$': typeof LayoutPagestopicSplatRoute
+ '/_layout/_auth/sign-in/$': typeof LayoutauthAuthSignInSplatRoute
+ '/_layout/_auth/sign-up/$': typeof LayoutauthAuthSignUpSplatRoute
+ '/_layout/_pages/_protected/journals/': typeof LayoutPagesProtectedJournalsIndexRoute
+ '/_layout/_pages/_protected/links/': typeof LayoutPagesProtectedLinksIndexRoute
+ '/_layout/_pages/_protected/onboarding/': typeof LayoutPagesProtectedOnboardingIndexRoute
+ '/_layout/_pages/_protected/pages/': typeof LayoutPagesProtectedPagesIndexRoute
+ '/_layout/_pages/_protected/profile/': typeof LayoutPagesProtectedProfileIndexRoute
+ '/_layout/_pages/_protected/search/': typeof LayoutPagesProtectedSearchIndexRoute
+ '/_layout/_pages/_protected/settings/': typeof LayoutPagesProtectedSettingsIndexRoute
+ '/_layout/_pages/_protected/tasks/': typeof LayoutPagesProtectedTasksIndexRoute
+ '/_layout/_pages/_protected/topics/': typeof LayoutPagesProtectedTopicsIndexRoute
+ '/_layout/_pages/_protected/community/$topicName/': typeof LayoutPagesProtectedCommunityTopicNameIndexRoute
+ '/_layout/_pages/_protected/pages/$pageId/': typeof LayoutPagesProtectedPagesPageIdIndexRoute
+}
+
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | ''
+ | '/'
+ | '/$'
+ | '/sign-in/$'
+ | '/sign-up/$'
+ | '/journals'
+ | '/links'
+ | '/onboarding'
+ | '/pages'
+ | '/profile'
+ | '/search'
+ | '/settings'
+ | '/tasks'
+ | '/topics'
+ | '/community/$topicName'
+ | '/pages/$pageId'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | ''
+ | '/'
+ | '/$'
+ | '/sign-in/$'
+ | '/sign-up/$'
+ | '/journals'
+ | '/links'
+ | '/onboarding'
+ | '/pages'
+ | '/profile'
+ | '/search'
+ | '/settings'
+ | '/tasks'
+ | '/topics'
+ | '/community/$topicName'
+ | '/pages/$pageId'
+ id:
+ | '__root__'
+ | '/_layout'
+ | '/_layout/_pages'
+ | '/_layout/'
+ | '/_layout/_auth'
+ | '/_layout/_pages/_protected'
+ | '/_layout/_pages/$'
+ | '/_layout/_auth/sign-in/$'
+ | '/_layout/_auth/sign-up/$'
+ | '/_layout/_pages/_protected/journals/'
+ | '/_layout/_pages/_protected/links/'
+ | '/_layout/_pages/_protected/onboarding/'
+ | '/_layout/_pages/_protected/pages/'
+ | '/_layout/_pages/_protected/profile/'
+ | '/_layout/_pages/_protected/search/'
+ | '/_layout/_pages/_protected/settings/'
+ | '/_layout/_pages/_protected/tasks/'
+ | '/_layout/_pages/_protected/topics/'
+ | '/_layout/_pages/_protected/community/$topicName/'
+ | '/_layout/_pages/_protected/pages/$pageId/'
+ fileRoutesById: FileRoutesById
+}
+
+export interface RootRouteChildren {
+ LayoutRoute: typeof LayoutRouteWithChildren
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ LayoutRoute: LayoutRouteWithChildren,
+}
+
+export const routeTree = rootRoute
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+/* prettier-ignore-end */
+
+/* ROUTE_MANIFEST_START
+{
+ "routes": {
+ "__root__": {
+ "filePath": "__root.tsx",
+ "children": [
+ "/_layout"
+ ]
+ },
+ "/_layout": {
+ "filePath": "_layout.tsx",
+ "children": [
+ "/_layout/_pages",
+ "/_layout/",
+ "/_layout/"
+ ]
+ },
+ "/_layout/_pages": {
+ "filePath": "_layout/_pages.tsx",
+ "parent": "/_layout",
+ "children": [
+ "/_layout/_pages/_protected",
+ "/_layout/_pages/$"
+ ]
+ },
+ "/_layout/": {
+ "filePath": "_layout/(landing)/index.tsx",
+ "parent": "/_layout"
+ },
+ "/_layout/_auth": {
+ "filePath": "_layout/(auth)/_auth.tsx",
+ "parent": "/_layout/",
+ "children": [
+ "/_layout/_auth/sign-in/$",
+ "/_layout/_auth/sign-up/$"
+ ]
+ },
+ "/_layout/_pages/_protected": {
+ "filePath": "_layout/_pages/_protected.tsx",
+ "parent": "/_layout/_pages",
+ "children": [
+ "/_layout/_pages/_protected/journals/",
+ "/_layout/_pages/_protected/links/",
+ "/_layout/_pages/_protected/onboarding/",
+ "/_layout/_pages/_protected/pages/",
+ "/_layout/_pages/_protected/profile/",
+ "/_layout/_pages/_protected/search/",
+ "/_layout/_pages/_protected/settings/",
+ "/_layout/_pages/_protected/tasks/",
+ "/_layout/_pages/_protected/topics/",
+ "/_layout/_pages/_protected/community/$topicName/",
+ "/_layout/_pages/_protected/pages/$pageId/"
+ ]
+ },
+ "/_layout/_pages/$": {
+ "filePath": "_layout/_pages/(topic)/$.tsx",
+ "parent": "/_layout/_pages"
+ },
+ "/_layout/_auth/sign-in/$": {
+ "filePath": "_layout/(auth)/_auth.sign-in.$.tsx",
+ "parent": "/_layout/_auth"
+ },
+ "/_layout/_auth/sign-up/$": {
+ "filePath": "_layout/(auth)/_auth.sign-up.$.tsx",
+ "parent": "/_layout/_auth"
+ },
+ "/_layout/_pages/_protected/journals/": {
+ "filePath": "_layout/_pages/_protected/journals/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/links/": {
+ "filePath": "_layout/_pages/_protected/links/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/onboarding/": {
+ "filePath": "_layout/_pages/_protected/onboarding/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/pages/": {
+ "filePath": "_layout/_pages/_protected/pages/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/profile/": {
+ "filePath": "_layout/_pages/_protected/profile/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/search/": {
+ "filePath": "_layout/_pages/_protected/search/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/settings/": {
+ "filePath": "_layout/_pages/_protected/settings/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/tasks/": {
+ "filePath": "_layout/_pages/_protected/tasks/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/topics/": {
+ "filePath": "_layout/_pages/_protected/topics/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/community/$topicName/": {
+ "filePath": "_layout/_pages/_protected/community/$topicName/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ },
+ "/_layout/_pages/_protected/pages/$pageId/": {
+ "filePath": "_layout/_pages/_protected/pages/$pageId/index.tsx",
+ "parent": "/_layout/_pages/_protected"
+ }
+ }
+}
+ROUTE_MANIFEST_END */
diff --git a/web/app/router.tsx b/web/app/router.tsx
new file mode 100644
index 00000000..3b96be59
--- /dev/null
+++ b/web/app/router.tsx
@@ -0,0 +1,29 @@
+import { createRouter as createTanStackRouter } from "@tanstack/react-router"
+import { routeTree } from "./routeTree.gen"
+import { DefaultCatchBoundary } from "./components/DefaultCatchBoundary"
+import { NotFound } from "./components/NotFound"
+import { QueryClient } from "@tanstack/react-query"
+import { routerWithQueryClient } from "@tanstack/react-router-with-query"
+
+export function createRouter() {
+ const queryClient = new QueryClient()
+
+ const router = routerWithQueryClient(
+ createTanStackRouter({
+ routeTree,
+ defaultPreload: "intent",
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ context: { queryClient },
+ }),
+ queryClient,
+ )
+
+ return router
+}
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: ReturnType
+ }
+}
diff --git a/web/app/routes/__root.tsx b/web/app/routes/__root.tsx
new file mode 100644
index 00000000..83d098b0
--- /dev/null
+++ b/web/app/routes/__root.tsx
@@ -0,0 +1,117 @@
+///
+import type { QueryClient } from "@tanstack/react-query"
+import {
+ Outlet,
+ ScrollRestoration,
+ createRootRouteWithContext,
+} from "@tanstack/react-router"
+import { Body, Head, Html, Meta, Scripts } from "@tanstack/start"
+import * as React from "react"
+import { fetchClerkAuth } from "~/actions"
+import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary.js"
+import { NotFound } from "~/components/NotFound.js"
+import appCss from "~/styles/app.css?url"
+
+export const TanStackRouterDevtools =
+ process.env.NODE_ENV === "production"
+ ? () => null
+ : React.lazy(() =>
+ import("@tanstack/router-devtools").then((res) => ({
+ default: res.TanStackRouterDevtools,
+ })),
+ )
+
+export const ReactQueryDevtools =
+ process.env.NODE_ENV === "production"
+ ? () => null
+ : React.lazy(() =>
+ import("@tanstack/react-query-devtools/production").then((d) => ({
+ default: d.ReactQueryDevtools,
+ })),
+ )
+
+export const Route = createRootRouteWithContext<{
+ queryClient: QueryClient
+}>()({
+ meta: () => [
+ {
+ charSet: "utf-8",
+ },
+ {
+ name: "viewport",
+ content: "width=device-width, initial-scale=1",
+ },
+ ],
+ links: () => [
+ { rel: "stylesheet", href: appCss },
+ {
+ rel: "apple-touch-icon",
+ sizes: "180x180",
+ href: "/apple-touch-icon.png",
+ },
+ {
+ rel: "icon",
+ type: "image/png",
+ sizes: "32x32",
+ href: "/favicon-32x32.png",
+ },
+ {
+ rel: "icon",
+ type: "image/png",
+ sizes: "16x16",
+ href: "/favicon-16x16.png",
+ },
+ { rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
+ { rel: "icon", href: "/favicon.ico" },
+ ],
+ beforeLoad: async ({ cause }) => {
+ if (cause !== "stay") {
+ const { user } = await fetchClerkAuth()
+ return {
+ user,
+ }
+ }
+
+ return {
+ user: null,
+ }
+ },
+ errorComponent: (props) => {
+ return (
+
+
+
+ )
+ },
+ notFoundComponent: () => ,
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+ )
+}
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout.tsx b/web/app/routes/_layout.tsx
new file mode 100644
index 00000000..b674929e
--- /dev/null
+++ b/web/app/routes/_layout.tsx
@@ -0,0 +1,17 @@
+import { Outlet, createFileRoute } from "@tanstack/react-router"
+import { ThemeProvider } from "next-themes"
+import { ClerkProvider } from "~/lib/providers/clerk-provider"
+
+export const Route = createFileRoute("/_layout")({
+ component: LayoutComponent,
+})
+
+function LayoutComponent() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout/(auth)/_auth.sign-in.$.tsx b/web/app/routes/_layout/(auth)/_auth.sign-in.$.tsx
new file mode 100644
index 00000000..2b77eaef
--- /dev/null
+++ b/web/app/routes/_layout/(auth)/_auth.sign-in.$.tsx
@@ -0,0 +1,21 @@
+import { SignIn } from "@clerk/tanstack-start"
+import { createFileRoute } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/_layout/(auth)/_auth/sign-in/$")({
+ component: () => ,
+})
+
+function SignInComponent() {
+ return (
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout/(auth)/_auth.sign-up.$.tsx b/web/app/routes/_layout/(auth)/_auth.sign-up.$.tsx
new file mode 100644
index 00000000..e4d46dde
--- /dev/null
+++ b/web/app/routes/_layout/(auth)/_auth.sign-up.$.tsx
@@ -0,0 +1,14 @@
+import { SignUp } from "@clerk/tanstack-start"
+import { createFileRoute } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/_layout/(auth)/_auth/sign-up/$")({
+ component: () => ,
+})
+
+function SignUpComponent() {
+ return (
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout/(auth)/_auth.tsx b/web/app/routes/_layout/(auth)/_auth.tsx
new file mode 100644
index 00000000..0d09086b
--- /dev/null
+++ b/web/app/routes/_layout/(auth)/_auth.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute, Outlet } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/_layout/(auth)/_auth")({
+ component: () => (
+
+
+
+ ),
+})
diff --git a/web/app/routes/_layout/(landing)/-components/autocomplete.tsx b/web/app/routes/_layout/(landing)/-components/autocomplete.tsx
new file mode 100644
index 00000000..1442edc6
--- /dev/null
+++ b/web/app/routes/_layout/(landing)/-components/autocomplete.tsx
@@ -0,0 +1,169 @@
+import * as React from "react"
+import {
+ Command,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { Command as CommandPrimitive } from "cmdk"
+import { motion, AnimatePresence } from "framer-motion"
+import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils"
+import { useIsMounted } from "@/hooks/use-is-mounted"
+
+interface GraphNode {
+ name: string
+ prettyName: string
+ connectedTopics: string[]
+}
+
+interface AutocompleteProps {
+ topics: GraphNode[]
+ onSelect: (topic: string) => void
+ onInputChange: (value: string) => void
+}
+
+export function Autocomplete({
+ topics = [],
+ onSelect,
+ onInputChange,
+}: AutocompleteProps): JSX.Element {
+ const inputRef = React.useRef(null)
+ const [open, setOpen] = React.useState(false)
+ const isMounted = useIsMounted()
+ const [inputValue, setInputValue] = React.useState("")
+ const [hasInteracted, setHasInteracted] = React.useState(false)
+
+ const [initialTopics, setInitialTopics] = React.useState([])
+
+ React.useEffect(() => {
+ setInitialTopics(shuffleArray(topics).slice(0, 5))
+ }, [topics])
+
+ const filteredTopics = React.useMemo(() => {
+ if (!inputValue) {
+ return initialTopics
+ }
+
+ const regex = searchSafeRegExp(inputValue)
+ return topics
+ .filter(
+ (topic) =>
+ regex.test(topic.name) ||
+ regex.test(topic.prettyName) ||
+ topic.connectedTopics.some((connectedTopic) =>
+ regex.test(connectedTopic),
+ ),
+ )
+ .sort((a, b) => a.prettyName.localeCompare(b.prettyName))
+ .slice(0, 10)
+ }, [inputValue, topics, initialTopics])
+
+ const handleSelect = React.useCallback(
+ (topic: GraphNode) => {
+ setOpen(false)
+ onSelect(topic.name)
+ },
+ [onSelect],
+ )
+
+ const handleInputChange = React.useCallback(
+ (value: string) => {
+ setInputValue(value)
+ setOpen(true)
+ setHasInteracted(true)
+ onInputChange(value)
+ },
+ [onInputChange],
+ )
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !open) {
+ event.preventDefault()
+ setOpen(true)
+ setHasInteracted(true)
+ }
+ },
+ [open],
+ )
+
+ const commandKey = React.useMemo(() => {
+ return filteredTopics
+ .map(
+ (topic) =>
+ `${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`,
+ )
+ .join("__")
+ }, [filteredTopics])
+
+ React.useEffect(() => {
+ if (inputRef.current && isMounted() && hasInteracted) {
+ inputRef.current.focus()
+ }
+ }, [commandKey, isMounted, hasInteracted])
+
+ return (
+
+
+ {
+ setTimeout(() => setOpen(false), 100)
+ }}
+ onFocus={() => setHasInteracted(true)}
+ onClick={() => {
+ setOpen(true)
+ setHasInteracted(true)
+ }}
+ placeholder={filteredTopics[0]?.prettyName}
+ className={cn(
+ "placeholder:text-muted-foreground flex-1 bg-transparent px-2 outline-none",
+ )}
+ autoFocus
+ />
+
+
+
+ {open && hasInteracted && (
+
+
+
+ {filteredTopics.map((topic, index) => (
+ handleSelect(topic)}
+ className="min-h-10 rounded-none px-3 py-1.5"
+ >
+ {topic.prettyName}
+
+ {topic.connectedTopics.length > 0 &&
+ topic.connectedTopics.join(", ")}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ )
+}
+
+export default Autocomplete
diff --git a/web/app/routes/_layout/(landing)/-components/force-graph-client.tsx b/web/app/routes/_layout/(landing)/-components/force-graph-client.tsx
new file mode 100644
index 00000000..661cbcdf
--- /dev/null
+++ b/web/app/routes/_layout/(landing)/-components/force-graph-client.tsx
@@ -0,0 +1,381 @@
+import * as react from "react"
+import * as fg from "@nothing-but/force-graph"
+import * as schedule from "@/lib/utils"
+import * as canvas from "@/lib/utils"
+import { searchSafeRegExp } from "@/lib/utils"
+import { ease, trig, raf, color } from "@nothing-but/utils"
+
+export type RawGraphNode = {
+ name: string
+ prettyName: string
+ connectedTopics: string[]
+}
+
+const COLORS: readonly color.HSL[] = [
+ [3, 86, 64],
+ [15, 87, 66],
+ [31, 90, 69],
+ [15, 87, 66],
+ [31, 90, 69],
+ [344, 87, 70],
+]
+
+type ColorMap = Record
+
+function generateColorMap(g: fg.graph.Graph): ColorMap {
+ const hsl_map: ColorMap = {}
+
+ for (let i = 0; i < g.nodes.length; i++) {
+ hsl_map[g.nodes[i].key as string] = COLORS[i % COLORS.length]
+ }
+
+ for (const { a, b } of g.edges) {
+ const a_hsl = hsl_map[a.key as string]
+ const b_hsl = hsl_map[b.key as string]
+
+ const am = a.mass - 1
+ const bm = b.mass - 1
+
+ hsl_map[a.key as string] = color.mix(a_hsl, b_hsl, am * am * am, bm)
+ hsl_map[b.key as string] = color.mix(a_hsl, b_hsl, am, bm * bm * bm)
+ }
+
+ return hsl_map
+}
+
+function generateNodesFromRawData(
+ g: fg.graph.Graph,
+ raw_data: RawGraphNode[],
+): void {
+ const nodes_map = new Map()
+
+ /* create nodes */
+ for (const raw of raw_data) {
+ const node = fg.graph.make_node()
+ node.key = raw.name
+ node.label = raw.prettyName
+
+ fg.graph.add_node(g, node)
+ nodes_map.set(raw.name, node)
+ }
+
+ /* connections */
+ for (const raw of raw_data) {
+ const node_a = nodes_map.get(raw.name)!
+
+ for (const name_b of raw.connectedTopics) {
+ const node_b = nodes_map.get(name_b)!
+ fg.graph.connect(g, node_a, node_b)
+ }
+ }
+
+ /* calc mass from number of connections */
+ for (const node of g.nodes) {
+ const edges = fg.graph.get_node_edges(g, node)
+ node.mass = fg.graph.node_mass_from_edges(edges.length)
+ }
+}
+
+function filterNodes(s: State, filter: string): void {
+ fg.graph.clear_nodes(s.graph)
+
+ if (filter === "") {
+ fg.graph.add_nodes(s.graph, s.nodes)
+ fg.graph.add_edges(s.graph, s.edges)
+ } else {
+ // regex matching all letters of the filter (out of order)
+ const regex = searchSafeRegExp(filter)
+
+ fg.graph.add_nodes(
+ s.graph,
+ s.nodes.filter((node) => regex.test(node.label)),
+ )
+ fg.graph.add_edges(
+ s.graph,
+ s.edges.filter(
+ (edge) => regex.test(edge.a.label) && regex.test(edge.b.label),
+ ),
+ )
+ }
+}
+
+const GRAPH_OPTIONS: fg.graph.Options = {
+ min_move: 0.001,
+ inertia_strength: 0.3,
+ origin_strength: 0.01,
+ repel_distance: 40,
+ repel_strength: 2,
+ link_strength: 0.03,
+ grid_size: 500,
+}
+
+const TITLE_SIZE_PX = 400
+
+const simulateGraph = (
+ alpha: number,
+ gestures: fg.canvas.CanvasGestures,
+ vw: number,
+ vh: number,
+): void => {
+ const c = gestures.canvas
+ const g = c.graph
+
+ alpha = alpha / 10 // slow things down a bit
+
+ fg.graph.simulate(g, alpha)
+
+ /*
+ Push nodes away from the center (the title)
+ */
+ const grid_radius = g.options.grid_size / 2
+ const origin_x = grid_radius + c.translate.x
+ const origin_y = grid_radius + c.translate.y
+ const vmax = Math.max(vw, vh)
+ const push_radius =
+ (Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) *
+ (g.options.grid_size / c.scale) +
+ 80 /* additional margin for when scrolled in */
+
+ for (const node of g.nodes) {
+ //
+ const dist_x = node.pos.x - origin_x
+ const dist_y = (node.pos.y - origin_y) * 2
+ const dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y)
+ if (dist > push_radius) continue
+
+ const strength = ease.in_expo((push_radius - dist) / push_radius)
+
+ node.vel.x += strength * (node.pos.x - origin_x) * 10 * alpha
+ node.vel.y += strength * (node.pos.y - origin_y) * 10 * alpha
+ }
+
+ /*
+ When a node is being dragged
+ it will pull it's connections
+ */
+ if (gestures.mode.type === fg.canvas.Mode.DraggingNode) {
+ //
+ const node = gestures.mode.node
+
+ for (const edge of fg.graph.each_node_edge(g, node)) {
+ const b = edge.b === node ? edge.a : edge.b
+
+ const dx =
+ (b.pos.x - node.pos.x) *
+ g.options.link_strength *
+ edge.strength *
+ alpha *
+ 10
+ const dy =
+ (b.pos.y - node.pos.y) *
+ g.options.link_strength *
+ edge.strength *
+ alpha *
+ 10
+
+ b.vel.x -= dx / b.mass
+ b.vel.y -= dy / b.mass
+ }
+ }
+}
+
+const drawGraph = (c: fg.canvas.CanvasState, color_map: ColorMap): void => {
+ fg.canvas.resetFrame(c)
+ fg.canvas.drawEdges(c)
+
+ /*
+ Draw text nodes
+ */
+ const grid_size = c.graph.options.grid_size
+ const max_size = Math.max(c.ctx.canvas.width, c.ctx.canvas.height)
+
+ const clip_rect = fg.canvas.get_ctx_clip_rect(c.ctx, { x: 100, y: 20 })
+
+ c.ctx.textAlign = "center"
+ c.ctx.textBaseline = "middle"
+
+ for (const node of c.graph.nodes) {
+ const x = (node.pos.x / grid_size) * max_size
+ const y = (node.pos.y / grid_size) * max_size
+
+ if (fg.canvas.in_rect_xy(clip_rect, x, y)) {
+ const base_size = max_size / 220
+ const mass_boost_size = max_size / 140
+ const mass_boost = (node.mass - 1) / 8 / c.scale
+
+ c.ctx.font = `${base_size + mass_boost * mass_boost_size}px sans-serif`
+
+ const opacity = 0.6 + ((node.mass - 1) / 50) * 4
+
+ c.ctx.fillStyle =
+ node.anchor || c.hovered_node === node
+ ? `rgba(129, 140, 248, ${opacity})`
+ : color.hsl_to_hsla_string(color_map[node.key as string], opacity)
+
+ c.ctx.fillText(node.label, x, y)
+ }
+ }
+}
+
+class State {
+ ctx: CanvasRenderingContext2D | null = null
+
+ /* copy of all nodes to filter them */
+ nodes: fg.graph.Node[] = []
+ edges: fg.graph.Edge[] = []
+
+ graph: fg.graph.Graph = fg.graph.make_graph(GRAPH_OPTIONS)
+ gestures: fg.canvas.CanvasGestures | null = null
+
+ raf_id: number = 0
+ bump_end = 0
+ alpha = 0
+ frame_iter_limit = raf.frameIterationsLimit(60)
+ schedule_filter = schedule.scheduleIdle(filterNodes)
+ ro: ResizeObserver | undefined
+}
+
+function init(
+ s: State,
+ props: {
+ onNodeClick: (name: string) => void
+ raw_nodes: RawGraphNode[]
+ canvas_el: HTMLCanvasElement | null
+ },
+) {
+ const { canvas_el, raw_nodes } = props
+
+ if (canvas_el == null) return
+
+ s.ctx = canvas_el.getContext("2d")
+ if (s.ctx == null) return
+
+ generateNodesFromRawData(s.graph, raw_nodes)
+ fg.graph.set_positions_smart(s.graph)
+
+ s.nodes = s.graph.nodes.slice()
+ s.edges = s.graph.edges.slice()
+
+ const color_map = generateColorMap(s.graph)
+
+ const canvas_state = fg.canvas.canvasState({
+ ctx: s.ctx,
+ graph: s.graph,
+ max_scale: 3,
+ init_scale: 1.7,
+ init_grid_pos: trig.ZERO,
+ })
+
+ const gestures = (s.gestures = fg.canvas.canvasGestures({
+ canvas: canvas_state,
+ onGesture: (e) => {
+ switch (e.type) {
+ case fg.canvas.GestureEventType.Translate:
+ s.bump_end = raf.bump(s.bump_end)
+ break
+ case fg.canvas.GestureEventType.NodeClick:
+ props.onNodeClick(e.node.key as string)
+ break
+ case fg.canvas.GestureEventType.NodeDrag:
+ fg.graph.set_position(canvas_state.graph, e.node, e.pos)
+ break
+ }
+ },
+ }))
+
+ s.ro = new ResizeObserver(() => {
+ if (canvas.resizeCanvasToDisplaySize(canvas_el)) {
+ fg.canvas.updateTranslate(
+ canvas_state,
+ canvas_state.translate.x,
+ canvas_state.translate.y,
+ )
+ }
+ })
+ s.ro.observe(canvas_el)
+
+ // initial simulation is the most crazy
+ // so it's off-screen
+ simulateGraph(6, gestures, window.innerWidth, window.innerHeight)
+
+ function loop(time: number) {
+ const is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
+ const iterations = Math.min(2, raf.calcIterations(s.frame_iter_limit, time))
+
+ for (let i = iterations; i > 0; i--) {
+ s.alpha = raf.updateAlpha(s.alpha, is_active || time < s.bump_end)
+ simulateGraph(s.alpha, gestures, window.innerWidth, window.innerHeight)
+ }
+
+ if (iterations > 0) {
+ drawGraph(canvas_state, color_map)
+ }
+
+ s.raf_id = requestAnimationFrame(loop)
+ }
+ s.raf_id = requestAnimationFrame(loop)
+}
+
+function updateQuery(s: State, filter_query: string) {
+ s.schedule_filter.trigger(s, filter_query)
+ s.bump_end = raf.bump(s.bump_end)
+}
+
+function cleanup(s: State) {
+ cancelAnimationFrame(s.raf_id)
+ s.gestures && fg.canvas.cleanupCanvasGestures(s.gestures)
+ s.schedule_filter.clear()
+ s.ro?.disconnect()
+}
+
+export type ForceGraphProps = {
+ onNodeClick: (name: string) => void
+ /**
+ * Filter the displayed nodes by name.
+ *
+ * `""` means no filter
+ */
+ filter_query: string
+ raw_nodes: RawGraphNode[]
+}
+
+export function ForceGraphClient(props: ForceGraphProps): react.JSX.Element {
+ const [canvas_el, setCanvasEl] = react.useState(
+ null,
+ )
+
+ const state = react.useRef(new State())
+
+ react.useEffect(() => {
+ init(state.current, {
+ canvas_el: canvas_el,
+ onNodeClick: props.onNodeClick,
+ raw_nodes: props.raw_nodes,
+ })
+ }, [canvas_el])
+
+ react.useEffect(() => {
+ updateQuery(state.current, props.filter_query)
+ }, [props.filter_query])
+
+ react.useEffect(() => {
+ return () => cleanup(state.current)
+ }, [])
+
+ return (
+
+
+
+ )
+}
+
+export default ForceGraphClient
diff --git a/web/app/routes/_layout/(landing)/index.tsx b/web/app/routes/_layout/(landing)/index.tsx
new file mode 100644
index 00000000..1e932931
--- /dev/null
+++ b/web/app/routes/_layout/(landing)/index.tsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { createFileRoute, useNavigate } from "@tanstack/react-router"
+import { motion } from "framer-motion"
+import { cn } from "@/lib/utils"
+import { GraphData } from "~/lib/constants"
+import { ForceGraphClient } from "./-components/force-graph-client"
+import { Autocomplete } from "./-components/autocomplete"
+
+export const Route = createFileRoute("/_layout/(landing)/")({
+ component: LandingComponent,
+})
+
+function LandingComponent() {
+ const navigate = useNavigate()
+ const [filterQuery, setFilterQuery] = React.useState("")
+
+ const handleTopicSelect = (topic: string) => {
+ navigate({
+ to: topic,
+ })
+ }
+
+ const handleInputChange = (value: string) => {
+ setFilterQuery(value)
+ }
+
+ return (
+
+
+
+
+
+
+ I want to learn
+
+
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout/_pages.tsx b/web/app/routes/_layout/_pages.tsx
new file mode 100644
index 00000000..cbf64c37
--- /dev/null
+++ b/web/app/routes/_layout/_pages.tsx
@@ -0,0 +1,57 @@
+import { Outlet, createFileRoute } from "@tanstack/react-router"
+import { Provider as JotaiProvider } from "jotai"
+import { Toaster } from "sonner"
+import { ConfirmDialogProvider } from "@omit/react-confirm-dialog"
+
+import { Sidebar } from "~/components/sidebar/sidebar"
+import { TooltipProvider } from "~/components/ui/tooltip"
+import { JazzAndAuth } from "~/lib/providers/jazz-provider"
+import { Shortcut } from "~/components/shortcut/shortcut"
+import { Onboarding } from "~/components/Onboarding"
+import { GlobalKeyboardHandler } from "~/components/GlobalKeyboardHandler"
+import { CommandPalette } from "~/components/command-palette/command-palette"
+
+export const Route = createFileRoute("/_layout/_pages")({
+ component: PagesLayout,
+})
+
+function PagesLayout() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function LayoutContent() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+function MainContent() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout/_pages/(topic)/$.tsx b/web/app/routes/_layout/_pages/(topic)/$.tsx
new file mode 100644
index 00000000..d6e3d2d1
--- /dev/null
+++ b/web/app/routes/_layout/_pages/(topic)/$.tsx
@@ -0,0 +1,107 @@
+import * as React from "react"
+import { createFileRoute, useParams } from "@tanstack/react-router"
+import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
+import { GraphData, JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
+import { Topic } from "@/lib/schema"
+import { atom } from "jotai"
+import { Skeleton } from "@/components/ui/skeleton"
+import { LaIcon } from "@/components/custom/la-icon"
+import { TopicDetailHeader } from "./-header"
+import { TopicDetailList } from "./-list"
+
+export const Route = createFileRoute("/_layout/_pages/(topic)/$")({
+ component: TopicDetailComponent,
+})
+
+export const openPopoverForIdAtom = atom(null)
+
+export function TopicDetailComponent() {
+ console.log("TopicDetailComponent")
+ const params = useParams({ from: "/_layout/_pages/$" })
+ const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
+
+ const topicID = React.useMemo(
+ () =>
+ me &&
+ Topic.findUnique({ topicName: params._splat }, JAZZ_GLOBAL_GROUP_ID, me),
+ [params._splat, me],
+ )
+ const topic = useCoState(Topic, topicID, {
+ latestGlobalGuide: { sections: [] },
+ })
+ const [activeIndex, setActiveIndex] = React.useState(-1)
+
+ const topicExists = GraphData.find((node) => {
+ return node.name === params._splat
+ })
+
+ if (!topicExists) {
+ return
+ }
+
+ const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap(
+ (section) => [
+ { type: "section" as const, data: section },
+ ...(section?.links?.map((link) => ({
+ type: "link" as const,
+ data: link,
+ })) || []),
+ ],
+ )
+
+ if (!topic || !me || !flattenedItems) {
+ return
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function NotFoundPlaceholder() {
+ return (
+
+
+
+ Topic not found
+
+
+ There is no topic with the given identifier.
+
+
+ )
+}
+
+function TopicDetailSkeleton() {
+ return (
+ <>
+
+
+
+ {[...Array(10)].map((_, index) => (
+
+ ))}
+
+ >
+ )
+}
diff --git a/web/app/routes/_layout/_pages/(topic)/-header.tsx b/web/app/routes/_layout/_pages/(topic)/-header.tsx
new file mode 100644
index 00000000..8c43837a
--- /dev/null
+++ b/web/app/routes/_layout/_pages/(topic)/-header.tsx
@@ -0,0 +1,139 @@
+import * as React from "react"
+import {
+ ContentHeader,
+ SidebarToggleButton,
+} from "@/components/custom/content-header"
+import { ListOfTopics, Topic } from "@/lib/schema"
+import { LearningStateSelector } from "@/components/custom/learning-state-selector"
+import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
+import { LearningStateValue } from "@/lib/constants"
+import { useMedia } from "@/hooks/use-media"
+import { useClerk } from "@clerk/tanstack-start"
+import { useLocation } from "@tanstack/react-router"
+
+interface TopicDetailHeaderProps {
+ topic: Topic
+}
+
+export const TopicDetailHeader = React.memo(function TopicDetailHeader({
+ topic,
+}: TopicDetailHeaderProps) {
+ const clerk = useClerk()
+ const { pathname } = useLocation()
+ const isMobile = useMedia("(max-width: 770px)")
+ const { me } = useAccountOrGuest({
+ root: {
+ topicsWantToLearn: [],
+ topicsLearning: [],
+ topicsLearned: [],
+ },
+ })
+
+ let p: {
+ index: number
+ topic?: Topic | null
+ learningState: LearningStateValue
+ } | null = null
+
+ const wantToLearnIndex =
+ me?._type === "Anonymous"
+ ? -1
+ : (me?.root.topicsWantToLearn.findIndex((t) => t?.id === topic.id) ?? -1)
+ if (wantToLearnIndex !== -1) {
+ p = {
+ index: wantToLearnIndex,
+ topic:
+ me && me._type !== "Anonymous"
+ ? me.root.topicsWantToLearn[wantToLearnIndex]
+ : undefined,
+ learningState: "wantToLearn",
+ }
+ }
+
+ const learningIndex =
+ me?._type === "Anonymous"
+ ? -1
+ : (me?.root.topicsLearning.findIndex((t) => t?.id === topic.id) ?? -1)
+ if (learningIndex !== -1) {
+ p = {
+ index: learningIndex,
+ topic:
+ me && me._type !== "Anonymous"
+ ? me?.root.topicsLearning[learningIndex]
+ : undefined,
+ learningState: "learning",
+ }
+ }
+
+ const learnedIndex =
+ me?._type === "Anonymous"
+ ? -1
+ : (me?.root.topicsLearned.findIndex((t) => t?.id === topic.id) ?? -1)
+ if (learnedIndex !== -1) {
+ p = {
+ index: learnedIndex,
+ topic:
+ me && me._type !== "Anonymous"
+ ? me?.root.topicsLearned[learnedIndex]
+ : undefined,
+ learningState: "learned",
+ }
+ }
+
+ const handleAddToProfile = (learningState: LearningStateValue) => {
+ if (me?._type === "Anonymous") {
+ return clerk.redirectToSignIn({
+ redirectUrl: pathname,
+ })
+ }
+
+ const topicLists: Record<
+ LearningStateValue,
+ (ListOfTopics | null) | undefined
+ > = {
+ wantToLearn: me?.root.topicsWantToLearn,
+ learning: me?.root.topicsLearning,
+ learned: me?.root.topicsLearned,
+ }
+
+ const removeFromList = (state: LearningStateValue, index: number) => {
+ topicLists[state]?.splice(index, 1)
+ }
+
+ if (p) {
+ if (learningState === p.learningState) {
+ removeFromList(p.learningState, p.index)
+ return
+ }
+ removeFromList(p.learningState, p.index)
+ }
+
+ topicLists[learningState]?.push(topic)
+ }
+
+ return (
+
+
+
+
+
+ {topic.prettyName}
+
+
+
+
+
+ {/* */}
+
+
+
+ )
+})
+
+TopicDetailHeader.displayName = "TopicDetailHeader"
diff --git a/web/app/routes/_layout/_pages/(topic)/-item.tsx b/web/app/routes/_layout/_pages/(topic)/-item.tsx
new file mode 100644
index 00000000..b341326c
--- /dev/null
+++ b/web/app/routes/_layout/_pages/(topic)/-item.tsx
@@ -0,0 +1,251 @@
+import * as React from "react"
+import { useAtom } from "jotai"
+import { toast } from "sonner"
+
+import { LaIcon } from "@/components/custom/la-icon"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import { Button } from "@/components/ui/button"
+import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
+
+import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
+import {
+ Link as LinkSchema,
+ PersonalLink,
+ PersonalLinkLists,
+ Topic,
+} from "@/lib/schema"
+import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
+import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
+import { useClerk } from "@clerk/tanstack-start"
+import { Link, useLocation, useNavigate } from "@tanstack/react-router"
+import { openPopoverForIdAtom } from "./$"
+
+interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> {
+ topic: Topic
+ link: LinkSchema
+ isActive: boolean
+ index: number
+ setActiveIndex: (index: number) => void
+ personalLinks?: PersonalLinkLists
+}
+
+export const LinkItem = React.memo(
+ React.forwardRef(
+ (
+ {
+ topic,
+ link,
+ isActive,
+ index,
+ setActiveIndex,
+ className,
+ personalLinks,
+ ...props
+ },
+ ref,
+ ) => {
+ const clerk = useClerk()
+ const { pathname } = useLocation()
+ const navigate = useNavigate()
+ const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
+ const [isPopoverOpen, setIsPopoverOpen] = React.useState(false)
+ const { me } = useAccountOrGuest()
+
+ const personalLink = React.useMemo(() => {
+ return personalLinks?.find((pl) => pl?.link?.id === link.id)
+ }, [personalLinks, link.id])
+
+ const selectedLearningState = React.useMemo(() => {
+ return LEARNING_STATES.find(
+ (ls) => ls.value === personalLink?.learningState,
+ )
+ }, [personalLink?.learningState])
+
+ const handleClick = React.useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault()
+ setActiveIndex(index)
+ },
+ [index, setActiveIndex],
+ )
+
+ const handleSelectLearningState = React.useCallback(
+ (learningState: LearningStateValue) => {
+ if (!personalLinks || !me || me?._type === "Anonymous") {
+ return clerk.redirectToSignIn({
+ redirectUrl: pathname,
+ })
+ }
+
+ const defaultToast = {
+ duration: 5000,
+ position: "bottom-right" as const,
+ closeButton: true,
+ action: {
+ label: "Go to list",
+ onClick: () =>
+ navigate({
+ to: "/links",
+ }),
+ },
+ }
+
+ if (personalLink) {
+ if (personalLink.learningState === learningState) {
+ personalLink.learningState = undefined
+ toast.error("Link learning state removed", defaultToast)
+ } else {
+ personalLink.learningState = learningState
+ toast.success("Link learning state updated", defaultToast)
+ }
+ } else {
+ const slug = generateUniqueSlug(link.title)
+ const newPersonalLink = PersonalLink.create(
+ {
+ url: link.url,
+ title: link.title,
+ slug,
+ link,
+ learningState,
+ sequence: personalLinks.length + 1,
+ completed: false,
+ topic,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ { owner: me },
+ )
+
+ personalLinks.push(newPersonalLink)
+
+ toast.success("Link added.", {
+ ...defaultToast,
+ description: `${link.title} has been added to your personal link.`,
+ })
+ }
+
+ setOpenPopoverForId(null)
+ setIsPopoverOpen(false)
+ },
+ [
+ personalLink,
+ personalLinks,
+ me,
+ link,
+ navigate,
+ topic,
+ setOpenPopoverForId,
+ clerk,
+ pathname,
+ ],
+ )
+
+ const handlePopoverOpenChange = React.useCallback(
+ (open: boolean) => {
+ setIsPopoverOpen(open)
+ setOpenPopoverForId(open ? link.id : null)
+ },
+ [link.id, setOpenPopoverForId],
+ )
+
+ return (
+
+
+
+
+
+ e.stopPropagation()}
+ >
+ {selectedLearningState?.icon ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ handleSelectLearningState(value as LearningStateValue)
+ }
+ />
+
+
+
+
+
+
+ {link.title}
+
+
+
+
+
+ e.stopPropagation()}
+ className="text-muted-foreground hover:text-primary text-xs"
+ >
+ {link.url}
+
+
+
+
+
+
+
+ )
+ },
+ ),
+)
+
+LinkItem.displayName = "LinkItem"
diff --git a/web/app/routes/_layout/_pages/(topic)/-list.tsx b/web/app/routes/_layout/_pages/(topic)/-list.tsx
new file mode 100644
index 00000000..2a6fd8ff
--- /dev/null
+++ b/web/app/routes/_layout/_pages/(topic)/-list.tsx
@@ -0,0 +1,107 @@
+import * as React from "react"
+import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
+import {
+ Link as LinkSchema,
+ Section as SectionSchema,
+ Topic,
+} from "@/lib/schema"
+import { LinkItem } from "./-item"
+import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
+
+export type FlattenedItem =
+ | { type: "link"; data: LinkSchema | null }
+ | { type: "section"; data: SectionSchema | null }
+
+interface TopicDetailListProps {
+ items: FlattenedItem[]
+ topic: Topic
+ activeIndex: number
+ setActiveIndex: (index: number) => void
+}
+
+export function TopicDetailList({
+ items,
+ topic,
+ activeIndex,
+ setActiveIndex,
+}: TopicDetailListProps) {
+ const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
+ const personalLinks =
+ !me || me._type === "Anonymous" ? undefined : me.root.personalLinks
+
+ const parentRef = React.useRef(null)
+
+ const virtualizer = useVirtualizer({
+ count: items.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 44,
+ overscan: 5,
+ })
+
+ const renderItem = React.useCallback(
+ (virtualRow: VirtualItem) => {
+ const item = items[virtualRow.index]
+
+ if (item.type === "section") {
+ return (
+
+
+
+ {item.data?.title}
+
+
+
+
+ )
+ }
+
+ if (item.data?.id) {
+ return (
+
+ )
+ }
+
+ return null
+ },
+ [items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks],
+ )
+
+ return (
+
+
+
+ {virtualizer.getVirtualItems().map(renderItem)}
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout/_pages/_protected.tsx b/web/app/routes/_layout/_pages/_protected.tsx
new file mode 100644
index 00000000..6ffb58fa
--- /dev/null
+++ b/web/app/routes/_layout/_pages/_protected.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/_layout/_pages/_protected")({
+ beforeLoad: ({ context, location, cause }) => {
+ if (cause !== "stay") {
+ if (!context?.user?.userId) {
+ throw redirect({
+ to: "/sign-in/$",
+ search: { redirect_url: location.pathname },
+ })
+ }
+ }
+ },
+ component: () => ,
+})
diff --git a/web/app/routes/_layout/_pages/_protected/community/$topicName/-list.tsx b/web/app/routes/_layout/_pages/_protected/community/$topicName/-list.tsx
new file mode 100644
index 00000000..04da91dd
--- /dev/null
+++ b/web/app/routes/_layout/_pages/_protected/community/$topicName/-list.tsx
@@ -0,0 +1,72 @@
+import { useState, useEffect } from "react"
+import { cn } from "@/lib/utils"
+import { Input } from "~/components/ui/input"
+import { LaIcon } from "~/components/custom/la-icon"
+
+interface Question {
+ id: string
+ title: string
+ author: string
+ timestamp: string
+}
+
+interface QuestionListProps {
+ topicName: string
+ onSelectQuestion: (question: Question) => void
+ selectedQuestionId?: string
+}
+
+export function QuestionList({
+ topicName,
+ onSelectQuestion,
+ selectedQuestionId,
+}: QuestionListProps) {
+ const [questions, setQuestions] = useState([])
+
+ useEffect(() => {
+ const mockQuestions: Question[] = Array(10)
+ .fill(null)
+ .map((_, index) => ({
+ id: (index + 1).toString(),
+ title: "What can I do offline in Figma?",
+ author: "Ana",
+ timestamp: "13:35",
+ }))
+ setQuestions(mockQuestions)
+ }, [topicName])
+
+ return (
+
+
+ {questions.map((question) => (
+
onSelectQuestion(question)}
+ >
+
+
+
{question.timestamp}
+
+
{question.title}
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/routes/_layout/_pages/_protected/community/$topicName/-thread.tsx b/web/app/routes/_layout/_pages/_protected/community/$topicName/-thread.tsx
new file mode 100644
index 00000000..cc87b38c
--- /dev/null
+++ b/web/app/routes/_layout/_pages/_protected/community/$topicName/-thread.tsx
@@ -0,0 +1,189 @@
+import { useState, useEffect, useRef } from "react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { LaIcon } from "~/components/custom/la-icon"
+interface Answer {
+ id: string
+ author: string
+ content: string
+ timestamp: string
+ replies?: Answer[]
+}
+
+interface QuestionThreadProps {
+ question: {
+ id: string
+ title: string
+ author: string
+ timestamp: string
+ }
+ onClose: () => void
+}
+
+export function QuestionThread({ question, onClose }: QuestionThreadProps) {
+ const [answers, setAnswers] = useState([])
+ const [newAnswer, setNewAnswer] = useState("")
+ const [replyTo, setReplyTo] = useState(null)
+ const [replyToAuthor, setReplyToAuthor] = useState(null)
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ const mockAnswers: Answer[] = [
+ {
+ id: "1",
+ author: "Noone",
+ content:
+ "Just press Command + Just press Command + Just press Command + Just press Command + Just press Command +",
+ timestamp: "14:40",
+ },
+ ]
+ setAnswers(mockAnswers)
+ }, [question.id])
+
+ const sendReply = (answer: Answer) => {
+ setReplyTo(answer)
+ setReplyToAuthor(answer.author)
+ setNewAnswer(`@${answer.author} `)
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ const length = inputRef.current.value.length
+ inputRef.current.setSelectionRange(length, length)
+ }
+ }, 0)
+ }
+
+ const changeInput = (e: React.ChangeEvent) => {
+ const newValue = e.target.value
+ setNewAnswer(newValue)
+
+ if (replyToAuthor && !newValue.startsWith(`@${replyToAuthor}`)) {
+ setReplyTo(null)
+ setReplyToAuthor(null)
+ }
+ }
+
+ const sendAnswer = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (newAnswer.trim()) {
+ const newReply: Answer = {
+ id: Date.now().toString(),
+ author: "Me",
+ content: newAnswer,
+ timestamp: new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ }),
+ }
+
+ if (replyTo) {
+ setAnswers((prevAnswers) =>
+ prevAnswers.map((answer) =>
+ answer.id === replyTo.id
+ ? { ...answer, replies: [...(answer.replies || []), newReply] }
+ : answer,
+ ),
+ )
+ } else {
+ setAnswers((prevAnswers) => [...prevAnswers, newReply])
+ }
+ setNewAnswer("")
+ setReplyTo(null)
+ setReplyToAuthor(null)
+ }
+ }
+
+ const renderAnswers = (answers: Answer[], isReply = false) => (
+
+ {answers.map((answer) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ sendReply(answer)}>
+