├── src ├── App.css ├── vite-env.d.ts ├── main.tsx ├── lib │ ├── schemas.ts │ └── contentId.ts ├── components │ ├── ui │ │ ├── Alert.tsx │ │ ├── Label.tsx │ │ ├── ContentBlur.tsx │ │ ├── Input.tsx │ │ ├── Heading.tsx │ │ ├── Button.tsx │ │ ├── FormContainer.tsx │ │ ├── SeachSuggestions.tsx │ │ └── Sidebar.tsx │ ├── Footer.tsx │ ├── hooks │ │ ├── useClickOutside.ts │ │ ├── useFetchContent.ts │ │ ├── useKeyBindings.ts │ │ └── useMediaQuery.ts │ ├── Landing.tsx │ ├── LostPage.tsx │ ├── recoil │ │ └── atoms.ts │ ├── NavBar.tsx │ ├── Dashboard.tsx │ ├── ShareModal.tsx │ ├── Hero.tsx │ ├── Content.tsx │ ├── SharedContent.tsx │ ├── Register.tsx │ ├── Login.tsx │ ├── Card.tsx │ ├── SearchBar.tsx │ └── ContentForm.tsx ├── index.css └── App.tsx ├── tsconfig.node.tsbuildinfo ├── vercel.json ├── postcss.config.js ├── .env.example ├── tsconfig.json ├── vite.config.ts ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── public ├── vite.svg └── lost.svg ├── eslint.config.js ├── tailwind.config.js ├── tsconfig.app.tsbuildinfo ├── index.html ├── package.json └── README.md /src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.node.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./vite.config.ts"],"version":"5.6.3"} -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { "source": "/(.*)", "destination": "/" } 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=http://localhost:3000/v1 2 | VITE_FRONTEND_URL=http://localhost:5173 3 | VITE_TEST_USERNAME=fresh 4 | VITE_TEST_PASSWORD=fresh123 -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import './index.css' 3 | import App from './App.tsx' 4 | import { RecoilRoot } from 'recoil' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env.prod 27 | .env 28 | -------------------------------------------------------------------------------- /src/lib/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | export const AuthSchema = z.object({ 3 | username: z.string().min(3, {message: "Username has to be minimum of 3 letters"}) 4 | .max(10, {message: "Username has to be maximum of 10 letters"}), 5 | password: z.string().min(8, {message: "Password has to be minimum of 8 letters"}) 6 | .max(20, {message: "Password has to be maximum of 20 letters"}) 7 | }) -------------------------------------------------------------------------------- /src/components/ui/Alert.tsx: -------------------------------------------------------------------------------- 1 | 2 | interface AlertProps { 3 | text: string 4 | } 5 | 6 | const Alert: React.FC = ({ 7 | text 8 | }) => { 9 | return ( 10 |
12 | {text} 13 |
14 | ) 15 | } 16 | 17 | export default Alert 18 | -------------------------------------------------------------------------------- /src/components/ui/Label.tsx: -------------------------------------------------------------------------------- 1 | 2 | interface LabelProps{ 3 | children: React.ReactNode; 4 | onClick?: () => void 5 | } 6 | const Label: React.FC = ({ 7 | children, 8 | onClick 9 | }) => { 10 | return ( 11 |
13 | {children} 14 |
15 | ) 16 | } 17 | 18 | export default Label 19 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | const Footer = () => { 2 | return ( 3 | 13 | ) 14 | } 15 | 16 | export default Footer -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "Bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ui/ContentBlur.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from "react" 2 | import { motion } from "framer-motion"; 3 | 4 | interface ContentBlur extends HTMLAttributes { 5 | sideOpen: boolean 6 | children: React.ReactNode 7 | } 8 | const ContentBlur:React.FC = ({ 9 | sideOpen, 10 | children 11 | }) => { 12 | return ( 13 | 18 | {children} 19 | 20 | ) 21 | } 22 | 23 | export default ContentBlur 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body{ 6 | @apply bg-darkBackground text-text font-font1; 7 | } 8 | 9 | input::-ms-reveal { 10 | filter: invert(100%); 11 | } 12 | 13 | .tooltip { 14 | @apply absolute w-24 p-1 bg-cardColor-2 text-white text-sm text-center rounded-md shadow-md opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-200 top-[-2.2rem] right-[-2rem] after:content-[''] after:absolute after:top-full after:left-1/2 after:-translate-x-1/2 after:border-[6px] after:border-solid after:border-cardColor-2 after:border-t-cardColor-2 after:border-x-transparent after:border-b-transparent 15 | } 16 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | type ClickOutsideHandler = (event: Event) => void; 4 | 5 | export const useClickOutside = ( 6 | ref: React.RefObject, 7 | handler: ClickOutsideHandler 8 | ) => { 9 | useEffect(() => { 10 | const listener = (event: Event) => { 11 | // Do nothing if clicking ref's element or descendant elements 12 | if (!ref.current || ref.current.contains(event.target as Node)) return; 13 | 14 | handler(event); 15 | }; 16 | 17 | document.addEventListener("mousedown", listener); 18 | document.addEventListener("touchstart", listener); 19 | 20 | return () => { 21 | document.removeEventListener("mousedown", listener); 22 | document.removeEventListener("touchstart", listener); 23 | }; 24 | }, [ref, handler]); 25 | }; -------------------------------------------------------------------------------- /src/lib/contentId.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | export interface Tags{ 4 | tagId: string, 5 | title: string 6 | } 7 | export interface ContentPayload{ 8 | link: string; 9 | type: string; 10 | title: string; 11 | tags: string[]; 12 | contentId?: string 13 | } 14 | 15 | const enrichContent = (newContent: ContentPayload) => { 16 | if(!newContent.contentId){ 17 | newContent.contentId = uuidv4() 18 | } 19 | const enrichedTags: Tags[] = newContent.tags.map((tag) => ({ 20 | tagId: uuidv4(), 21 | title: tag, 22 | })); 23 | return { 24 | ...newContent, 25 | createdAt: new Date().toISOString(), // Add today's date 26 | contentId: newContent.contentId, //contentId 27 | tags: enrichedTags 28 | }; 29 | }; 30 | 31 | export default enrichContent; 32 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | font1: 'Manrope', //for headers, body, etc 12 | font2: 'quintessential' //for design bits 13 | }, 14 | colors: { 15 | primary: { 16 | 1: "#FFFFFF", 17 | 2: "#efefef" 18 | }, 19 | secondary: { 20 | 1: "#1B1818", 21 | 2 : "#1c1d1b" 22 | }, 23 | cardColor: { 24 | 1: '#333333', 25 | 2: '#262626', 26 | 3: '#888888' 27 | }, 28 | border: "#404040", 29 | logout:{ 30 | 1: 'rgb(239 68 68)', 31 | 2: "rgb(185 28 28)" 32 | }, 33 | text: "#FFFFFF", 34 | darkBackground: "#1B1818" 35 | }, 36 | }, 37 | plugins: [], 38 | } 39 | } -------------------------------------------------------------------------------- /src/components/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | interface InputProps extends React.InputHTMLAttributes { 2 | label?: string | null; 3 | inputId?: string | null; 4 | } 5 | 6 | const Input: React.FC = ({ 7 | label = null, 8 | inputId = null, 9 | ...props 10 | }) => { 11 | if (label && !inputId) { 12 | console.error("If 'label' is provided, 'id' must also be provided."); 13 | } 14 | 15 | return ( 16 | <> 17 | {label && inputId && ( 18 | 21 | )} 22 | 27 | 28 | ); 29 | }; 30 | 31 | export default Input; 32 | -------------------------------------------------------------------------------- /src/components/Landing.tsx: -------------------------------------------------------------------------------- 1 | import Footer from './Footer'; 2 | import Hero from './Hero'; 3 | import { NavBar } from './NavBar'; 4 | import Login from './Login'; 5 | import Register from './Register'; 6 | import { useState } from 'react'; 7 | 8 | const Landing = () => { 9 | const [current, setCurrent ] = useState('') 10 | const renderContent = () => { 11 | switch (current) { 12 | case 'login': 13 | return ; 14 | case 'register': 15 | return ; 16 | default: 17 | return ; 18 | } 19 | }; 20 | 21 | return ( 22 |
23 | 24 |
25 | {renderContent()} 26 |
27 |
28 |
29 | ); 30 | }; 31 | 32 | export default Landing; 33 | -------------------------------------------------------------------------------- /src/components/LostPage.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router-dom" 2 | import Heading from "./ui/Heading" 3 | 4 | const LostPage = () => { 5 | const navigate = useNavigate() 6 | return ( 7 |
8 |
9 | 10 | Lost in thought? 11 | 12 | 13 | Looks like this page is too! 14 |
15 | Let's get you back on 16 | navigate('/')} 19 | > 20 | track 21 | 22 |
23 |
24 | Lost 25 |
26 | ) 27 | } 28 | 29 | export default LostPage 30 | -------------------------------------------------------------------------------- /tsconfig.app.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/Card.tsx","./src/components/Content.tsx","./src/components/ContentForm.tsx","./src/components/Dashboard.tsx","./src/components/Footer.tsx","./src/components/Hero.tsx","./src/components/Landing.tsx","./src/components/Login.tsx","./src/components/LostPage.tsx","./src/components/NavBar.tsx","./src/components/Register.tsx","./src/components/SearchBar.tsx","./src/components/ShareModal.tsx","./src/components/SharedContent.tsx","./src/components/hooks/useClickOutside.ts","./src/components/hooks/useFetchContent.ts","./src/components/hooks/useKeyBindings.ts","./src/components/hooks/useMediaQuery.ts","./src/components/recoil/atoms.ts","./src/components/ui/Alert.tsx","./src/components/ui/Button.tsx","./src/components/ui/ContentBlur.tsx","./src/components/ui/FormContainer.tsx","./src/components/ui/Heading.tsx","./src/components/ui/Input.tsx","./src/components/ui/Label.tsx","./src/components/ui/SeachSuggestions.tsx","./src/components/ui/Sidebar.tsx","./src/lib/contentId.ts","./src/lib/schemas.ts"],"version":"5.6.3"} -------------------------------------------------------------------------------- /src/components/ui/Heading.tsx: -------------------------------------------------------------------------------- 1 | 2 | interface HeadingProps { 3 | children: React.ReactNode 4 | variant: "primary" | "secondary" 5 | className?: string 6 | size?: "sm" | "md" | "lg" | "xs" | "jsm" 7 | } 8 | 9 | const sizeStyles = { 10 | "xs": "text-[0.8rem] md:text-[0.9rem] lg:text-[1.1rem]", 11 | "jsm": "text-[1.1rem] md:text-[1.3rem] lg:text-[1.5rem]", 12 | "sm": "text-[1.2rem] md:text-[1.5rem] lg:text-[2rem]", 13 | "md": "text-[2rem] md:text-[2.3rem] lg:text-[2.5rem]", 14 | "lg": "text-[2.5rem] md:text-[3.5rem] lg:text-[4rem]" 15 | } 16 | const variantStyles = { 17 | "primary" : "font-font1 font-semibold tracking-tight text-text ", 18 | "secondary": "font-semibold text-border", 19 | } 20 | 21 | const Heading: React.FC = ( 22 | { 23 | children, 24 | variant, 25 | className='', 26 | size='sm' 27 | } 28 | ) => { 29 | return( 30 |
31 | {children} 32 |
33 | ) 34 | } 35 | 36 | export default Heading; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BigBrain 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/recoil/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil' 2 | import { ContentType } from '../Card' 3 | 4 | export const isLoggedIn= atom({ 5 | key: 'isLoggedIn', 6 | default: localStorage.getItem('token') ? true : false 7 | }) 8 | 9 | export const heroTitleinput = atom({ 10 | key: 'inputTitle', 11 | default: null 12 | }) 13 | 14 | export const heroLinkinput = atom({ 15 | key: 'inputLink', 16 | default: null 17 | }) 18 | 19 | export const shareLink = atom({ 20 | key: 'shareLink', 21 | default: '' 22 | }) 23 | 24 | export const allContentAtom = atom({ 25 | key: 'allContentAtom', 26 | default: [] 27 | }) 28 | 29 | export const filteredContentAtom = atom({ 30 | key: 'filteredContentAtom', 31 | default: [] 32 | }) 33 | 34 | export const userId = atom({ 35 | key: 'userId', 36 | default: '' 37 | }) 38 | 39 | export const modalStatus = atom({ 40 | key: 'modalStatus', 41 | default: false 42 | }) 43 | 44 | export const shareModal = atom({ 45 | key: 'shareModal', 46 | default: false 47 | }) -------------------------------------------------------------------------------- /src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ButtonProps extends React.ButtonHTMLAttributes { 4 | variant: "primary" | "secondary"; 5 | children: React.ReactNode; 6 | font?: "manrope" | "quintessential"; 7 | className?: string; 8 | onClick?: () => void; 9 | } 10 | 11 | const variantStyles = { 12 | primary: "bg-primary-1 text-black hover:bg-primary-2", 13 | secondary: "bg-secondary-1 border-2 border-white hover:bg-cardColor-1 text-white", 14 | }; 15 | 16 | const fontStyles = { 17 | manrope: "text-font1", 18 | quintessential: "text-font2", 19 | }; 20 | 21 | const Button: React.FC = ({ 22 | variant, 23 | children, 24 | font = "manrope", 25 | className = "", 26 | onClick, 27 | ...props 28 | }) => { 29 | const baseStyle = "px-2 py-1 md:px-3 md:py-2 rounded antialiased font-semibold"; 30 | 31 | return ( 32 | 39 | ); 40 | }; 41 | 42 | export default Button; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build --mode prod", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@types/crypto-js": "^4.2.2", 14 | "@types/dotenv": "^8.2.3", 15 | "axios": "^1.7.7", 16 | "crypto-js": "^4.2.0", 17 | "dotenv": "^16.4.7", 18 | "framer-motion": "^11.15.0", 19 | "lucide-react": "^0.460.0", 20 | "react": "^18.3.1", 21 | "react-dom": "^18.3.1", 22 | "react-router-dom": "^6.28.0", 23 | "recoil": "^0.7.7", 24 | "uuid": "^11.0.3", 25 | "zod": "^3.23.8" 26 | }, 27 | "devDependencies": { 28 | "@eslint/js": "^9.13.0", 29 | "@types/react": "^18.3.12", 30 | "@types/react-dom": "^18.3.1", 31 | "@vitejs/plugin-react": "^4.3.3", 32 | "autoprefixer": "^10.4.20", 33 | "eslint": "^9.13.0", 34 | "eslint-plugin-react-hooks": "^5.0.0", 35 | "eslint-plugin-react-refresh": "^0.4.14", 36 | "globals": "^15.11.0", 37 | "postcss": "^8.4.49", 38 | "tailwindcss": "^3.4.15", 39 | "typescript": "~5.6.2", 40 | "typescript-eslint": "^8.11.0", 41 | "vite": "^5.4.10" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/hooks/useFetchContent.ts: -------------------------------------------------------------------------------- 1 | import { useSetRecoilState } from "recoil"; 2 | import axios from "axios"; 3 | import { allContentAtom, filteredContentAtom, isLoggedIn, userId } from "../recoil/atoms"; 4 | 5 | export const useFetchContent = () => { 6 | const setContentStore = useSetRecoilState(allContentAtom); 7 | const setDisplayedContent = useSetRecoilState(filteredContentAtom); 8 | const setUserLogin = useSetRecoilState(isLoggedIn) 9 | const setUserId = useSetRecoilState(userId) 10 | const BASE_URL = import.meta.env.VITE_BASE_URL; 11 | const token = localStorage.getItem("token") || ""; 12 | 13 | const fetchContent = async () => { 14 | if (!BASE_URL) { 15 | console.error("Base URL is not configured"); 16 | return; 17 | } 18 | 19 | try { 20 | const response = await axios.get(`${BASE_URL}/content/`, { 21 | headers: { Authorization: `Bearer ${token}` }, 22 | }); 23 | const fetchedContent = response.data.allContent; 24 | setContentStore(fetchedContent); 25 | setDisplayedContent(fetchedContent); 26 | setUserLogin(true) 27 | setUserId(response.data.userId) 28 | } catch (error) { 29 | console.error("Failed to fetch content:", error); 30 | } 31 | }; 32 | 33 | return fetchContent; 34 | }; 35 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' 2 | import { useRecoilValue } from "recoil" 3 | import Landing from "./components/Landing" 4 | import Dashboard from "./components/Dashboard" 5 | import LostPage from './components/LostPage' 6 | import SharedContent from "./components/SharedContent" 7 | import { isLoggedIn } from "./components/recoil/atoms" 8 | 9 | function App() { 10 | const UserLogin = useRecoilValue(isLoggedIn) 11 | const token = localStorage.getItem('token') 12 | 13 | return ( 14 | 15 |
16 | 17 | : } 20 | /> 21 | 22 | : } 25 | /> 26 | 27 | } 30 | /> 31 | } 34 | /> 35 | 36 |
37 |
38 | ) 39 | } 40 | 41 | export default App -------------------------------------------------------------------------------- /src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { Brain } from "lucide-react"; 2 | import Button from "./ui/Button"; 3 | import { Dispatch, SetStateAction } from 'react' 4 | 5 | export const NavBar = ({ setCurrent }: { setCurrent: Dispatch> }) => { 6 | 7 | return ( 8 |
9 |
10 | setCurrent("")} 13 | /> 14 |

setCurrent("")} 17 | > 18 | BigBrain 19 |

20 |
21 | 22 |
23 | 31 | 37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/hooks/useKeyBindings.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | interface KeyBinding { 4 | keys: string[]; // A combination of keys to trigger the callback (e.g., ["Control", "k"]) 5 | callback: () => void; // The function to execute when the keys are pressed 6 | } 7 | 8 | export function useKeyBindings(bindings: KeyBinding[]) { 9 | useEffect(() => { 10 | const handleKeyDown = (event: KeyboardEvent) => { 11 | bindings.forEach(({ keys, callback }) => { 12 | const normalizedKeys = keys.map((key) => key.toLowerCase()); 13 | const pressedKeys = new Set(); 14 | 15 | // Track modifier keys explicitly 16 | if (event.ctrlKey) pressedKeys.add("control"); 17 | if (event.shiftKey) pressedKeys.add("shift"); 18 | if (event.altKey) pressedKeys.add("alt"); 19 | if (event.metaKey) pressedKeys.add("meta"); 20 | 21 | // Add the actual key pressed 22 | if (event.key) pressedKeys.add(event.key.toLowerCase()); 23 | 24 | // Match exactly: pressed keys must match the defined keys 25 | const isExactMatch = 26 | pressedKeys.size === normalizedKeys.length && 27 | normalizedKeys.every((key) => pressedKeys.has(key)); 28 | 29 | if (isExactMatch) { 30 | event.preventDefault(); // Prevent default behavior 31 | callback(); // Execute the callback 32 | } 33 | }); 34 | }; 35 | 36 | window.addEventListener("keydown", handleKeyDown); 37 | return () => { 38 | window.removeEventListener("keydown", handleKeyDown); 39 | }; 40 | }, [bindings]); 41 | } -------------------------------------------------------------------------------- /src/components/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useState } from 'react' 2 | 3 | const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect 4 | 5 | type UseMediaQueryOptions = { 6 | defaultValue?: boolean 7 | initializeWithValue?: boolean 8 | } 9 | 10 | const IS_SERVER = typeof window === 'undefined' 11 | export function useMediaQuery( 12 | query = '(max-width:768px)', 13 | { 14 | defaultValue = false, 15 | initializeWithValue = true, 16 | }: UseMediaQueryOptions = {}, 17 | ): boolean { 18 | const getMatches = (query: string): boolean => { 19 | if (IS_SERVER) { 20 | return defaultValue 21 | } 22 | return window.matchMedia(query).matches 23 | } 24 | 25 | const [matches, setMatches] = useState(() => { 26 | if (initializeWithValue) { 27 | return getMatches(query) 28 | } 29 | return defaultValue 30 | }) 31 | 32 | // Handles the change event of the media query. 33 | function handleChange() { 34 | setMatches(getMatches(query)) 35 | } 36 | 37 | useIsomorphicLayoutEffect(() => { 38 | const matchMedia = window.matchMedia(query) 39 | 40 | // Triggered at the first client-side load and if query changes 41 | handleChange() 42 | 43 | // Use deprecated `addListener` and `removeListener` to support Safari < 14 (#135) 44 | if (matchMedia.addListener) { 45 | matchMedia.addListener(handleChange) 46 | } else { 47 | matchMedia.addEventListener('change', handleChange) 48 | } 49 | 50 | return () => { 51 | if (matchMedia.removeListener) { 52 | matchMedia.removeListener(handleChange) 53 | } else { 54 | matchMedia.removeEventListener('change', handleChange) 55 | } 56 | } 57 | }, [query]) 58 | 59 | return matches 60 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SecondBrain Application 2 | 3 | ## SecondBrain 📚🧠 4 | A modern knowledge management tool that empowers users to save, organize, and efficiently search through their personal knowledge base. 5 | 6 | ### Key Features 7 | - **Vector Search Implementation**: 8 | - Enhanced search functionality using a vector database for semantic and contextual searches. 9 | - Enables users to retrieve information based on intent and meaning, not just keywords. 10 | 11 | - **Custom UI Library**: 12 | - Developed a UI library from scratch for a seamless and user-friendly experience. 13 | - Features modular and reusable components tailored for this application. 14 | 15 | - **Link Organization**: 16 | - Add links (videos, articles, etc.) with titles and tags for better categorization. 17 | - Supports tagging for flexible organization and retrieval. 18 | 19 | - **Authentication**: 20 | - Includes normal authentication to ensure user data privacy and security. 21 | 22 | - **MongoDB Integration**: 23 | - Persistent data storage for all links, tags, and user information. 24 | 25 | ### Technologies Used 26 | - **Frontend**: Custom UI components built with [your stack, e.g., React/Tailwind/Vanilla JS]. 27 | - **Backend**: Node.js with Express.js for API handling and authentication. 28 | - **Database**: MongoDB for structured data storage and a vector database for search indexing. 29 | - **Search**: Semantic search powered by vector embeddings. 30 | 31 | ### Getting Started 32 | 1. Clone the repository: 33 | ```bash 34 | git clone https://github.com/your-username/secondbrain.git 35 | cd secondbrain 36 | ``` 37 | 2. Install dependencies: 38 | ```bash 39 | npm install 40 | ``` 41 | 3. Start the application: 42 | ```bash 43 | npm run dev 44 | ``` 45 | 46 | ### Future Enhancements 47 | - Expand vector search capabilities to support multimedia (e.g., images and videos). 48 | - Add collaborative features for team-based knowledge management. 49 | - Optimize the UI library for broader use in other projects. 50 | -------------------------------------------------------------------------------- /src/components/ui/FormContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent } from 'react'; 2 | import Heading from './Heading'; 3 | import { EllipsisVertical } from 'lucide-react'; 4 | import { Dispatch, SetStateAction } from 'react' 5 | interface FormContainerProps { 6 | title?: string; 7 | subtitle?: string; 8 | children: React.ReactNode; 9 | onSubmit?: (e: FormEvent) => void | Promise; 10 | authLoading?: boolean; 11 | className?: string; 12 | } 13 | 14 | const FormContainer: React.FC>; 16 | setPassword?: Dispatch>; 17 | variant?: boolean 18 | }> = ({ 19 | title='', 20 | subtitle='', 21 | children, 22 | onSubmit, 23 | setUsername, 24 | setPassword, 25 | variant = true 26 | }) => { 27 | const handleTestCreds = () => { 28 | setUsername?.(import.meta.env.VITE_TEST_USERNAME) 29 | setPassword?.(import.meta.env.VITE_TEST_PASSWORD) 30 | } 31 | return ( 32 |
33 |
34 |
35 | 36 | {title} 37 | 38 | { 39 | variant && 40 |
41 | 47 |
48 | Test Creds 49 |
50 |
51 | } 52 | 53 |
54 | 55 | {subtitle} 56 | 57 |
58 | {children} 59 |
60 |
61 |
62 | ); 63 | }; 64 | 65 | export default FormContainer; -------------------------------------------------------------------------------- /src/components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; 2 | import axios from "axios"; 3 | import { useEffect, useState } from "react"; 4 | import Sidebar from "./ui/Sidebar"; 5 | import { useFetchContent } from "./hooks/useFetchContent"; 6 | import Content from "./Content"; 7 | import { allContentAtom, filteredContentAtom, isLoggedIn, shareLink, shareModal } from "./recoil/atoms"; 8 | 9 | const Dashboard = () => { 10 | const token = localStorage.getItem("token") || ""; 11 | const [userLogin, setUserLogin] = useRecoilState(isLoggedIn); 12 | const setShareLink = useSetRecoilState(shareLink) 13 | const BASE_URL = import.meta.env.VITE_BASE_URL; 14 | const setDisplayedContent = useSetRecoilState(filteredContentAtom) 15 | const contentstore = useRecoilValue(allContentAtom) 16 | const fetchContent = useFetchContent(); 17 | 18 | const setShareModalStatus = useSetRecoilState(shareModal) 19 | 20 | 21 | const [sideOpen, setSideOpen] = useState(false); 22 | 23 | useEffect(() => { 24 | if(userLogin && token){ 25 | fetchContent() 26 | } 27 | }, [userLogin]); 28 | 29 | const onLogout = () => { 30 | setUserLogin(false) 31 | localStorage.removeItem('token') 32 | } 33 | 34 | const handleShareLink = async () => { 35 | setShareModalStatus(true) 36 | try { 37 | const response = await axios.post( 38 | `${BASE_URL}/brain/share`, 39 | { share: true }, 40 | { headers: { Authorization: `Bearer ${token}` } } 41 | ); 42 | const hashedString = response.data.link; 43 | setShareLink(`${import.meta.env.VITE_FRONTEND_URL}/shared/${hashedString}`); 44 | } catch (error) { 45 | console.error("Failed to generate share link:", error); 46 | } 47 | }; 48 | 49 | return ( 50 | <> 51 | setSideOpen((prev) => !prev)} 54 | contentStore={contentstore} 55 | setDisplayedContent={setDisplayedContent} 56 | showLogout={true} 57 | onLogout={onLogout} 58 | /> 59 |
60 | 64 |
65 | 66 | ); 67 | }; 68 | 69 | export default Dashboard; 70 | -------------------------------------------------------------------------------- /src/components/ShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilValue, useSetRecoilState } from "recoil" 2 | import Button from "./ui/Button" 3 | import Heading from "./ui/Heading" 4 | import { shareLink, shareModal } from "./recoil/atoms" 5 | 6 | const ShareModal = ({ 7 | }) => { 8 | 9 | const share = useRecoilValue(shareLink).toString() 10 | const setShareModalStatus = useSetRecoilState(shareModal) 11 | const handleCopy = () => { 12 | navigator.clipboard.writeText(share) 13 | setShareModalStatus(false) 14 | } 15 | return ( 16 |
17 |
18 | 25 |
26 |
27 | 28 | 29 | Share List 30 | 31 | 32 | 33 | 34 | Share your list with the world 35 | 36 | 37 |
38 |
39 | 40 | 47 |
48 |
49 |
50 |
51 | 52 | 53 | 54 | ) 55 | } 56 | 57 | export default ShareModal 58 | -------------------------------------------------------------------------------- /src/components/ui/SeachSuggestions.tsx: -------------------------------------------------------------------------------- 1 | import { Loader, PlusIcon, Share2 } from "lucide-react" 2 | import { useSetRecoilState } from "recoil" 3 | import { modalStatus, shareModal } from "../recoil/atoms" 4 | 5 | interface SearchSuggestionsType{ 6 | isLoading: boolean, 7 | searchResults: { title: string, link: string}[] 8 | } 9 | 10 | const SeachSuggestions : React.FC = ({ 11 | isLoading, 12 | searchResults 13 | }) => { 14 | const setAddModalStatus = useSetRecoilState(modalStatus) 15 | const setShareModalStatus = useSetRecoilState(shareModal) 16 | const defaultOptions = [ 17 | { 18 | icon: , 19 | text: "Add new memory", 20 | onClick: () => handleAddMemory() 21 | }, 22 | { 23 | icon: , 24 | text: "Share your memories", 25 | onClick: () => handleShareMemory() 26 | } 27 | ] 28 | 29 | const handleAddMemory = () => { 30 | console.log("Adding memory clicked") 31 | setAddModalStatus(true) 32 | } 33 | 34 | const handleShareMemory = () => { 35 | console.log("Share memory clicked") 36 | setShareModalStatus(true) 37 | } 38 | return ( 39 |
40 | {isLoading ? ( 41 |
42 | 43 |
44 | ) 45 | : ( 46 | searchResults.length > 0 ? ( 47 | searchResults.map((result, index) => ( 48 | 49 |
53 | 54 | {result.title} 55 |
56 |
57 | )) 58 | ) : ( 59 | defaultOptions.map((option, index) => ( 60 |
65 | {option.icon} {option.text} 66 |
67 | ))) 68 | ) 69 | } 70 |
71 | ) 72 | } 73 | 74 | export default SeachSuggestions 75 | -------------------------------------------------------------------------------- /src/components/Hero.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useState } from 'react'; 2 | import Button from './ui/Button' 3 | import Input from './ui/Input' 4 | import Heading from './ui/Heading'; 5 | import { useSetRecoilState } from 'recoil'; 6 | import { heroTitleinput, heroLinkinput } from './recoil/atoms'; 7 | 8 | const Hero = ({ setCurrent }: { setCurrent: Dispatch> }) => { 9 | const setHeroTitle = useSetRecoilState(heroTitleinput); 10 | const setHeroLink = useSetRecoilState(heroLinkinput); 11 | 12 | const [tempTitle, setTempTitle] = useState(null); 13 | const [tempLink, setTempLink] = useState(null); 14 | 15 | const handleTitleChange = (event: React.ChangeEvent) => { 16 | const newValue = event.target.value === '' ? null : event.target.value; 17 | setTempTitle(newValue); 18 | } 19 | 20 | const handleLinkChange = (event: React.ChangeEvent) => { 21 | const newValue = event.target.value === '' ? null : event.target.value; 22 | setTempLink(newValue); 23 | 24 | } 25 | 26 | const handleGetStarted = () => { 27 | setHeroTitle(tempTitle); 28 | setHeroLink(tempLink); 29 | setCurrent('register') 30 | } 31 | return ( 32 |
33 | 37 | Your Digital Mind: 38 |
39 | Save, Share, Revisit 40 |
41 |
42 | 47 | Organize your thoughts, one link at a time! 48 | 49 | 54 | 59 |
60 | 63 |
64 |
65 |
66 | ) 67 | } 68 | 69 | export default Hero -------------------------------------------------------------------------------- /src/components/Content.tsx: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from './hooks/useMediaQuery' 2 | import Heading from './ui/Heading' 3 | import Button from './ui/Button' 4 | import { useRecoilState, useRecoilValue } from 'recoil' 5 | import { PlusIcon, Share2Icon } from 'lucide-react' 6 | import Card from './Card' 7 | import ContentBlur from './ui/ContentBlur' 8 | import { filteredContentAtom, modalStatus, shareModal } from './recoil/atoms' 9 | import ContentForm from './ContentForm' 10 | import ShareModal from './ShareModal' 11 | import SearchBar from './SearchBar' 12 | 13 | interface ContentProps { 14 | handleShareLink: () => Promise; 15 | sideOpen: boolean, 16 | } 17 | 18 | const Content: React.FC = ({ 19 | handleShareLink, 20 | sideOpen, 21 | }) => { 22 | const isMobile = useMediaQuery() 23 | const displayedContent = useRecoilValue(filteredContentAtom) 24 | const [AddModalStatus, setAddModalStatus] = useRecoilState(modalStatus) 25 | const ShareModalStatus = useRecoilValue(shareModal) 26 | 27 | return ( 28 | <> 29 | 30 |
31 | 32 |
33 |
34 | What I'm Learning 35 |
36 | 45 | 54 |
55 |
56 |
57 | {displayedContent.length > 0 ? 58 | ( 59 | displayedContent.map((item) => ( 60 | //Frontend updates automatically as displayedContent is a state variable 61 | )) 62 | ) : ( 63 |

No content available

64 | )} 65 |
66 |
67 |
68 |
69 | 70 | {AddModalStatus && ( 71 | setAddModalStatus(false)} /> 72 | )} 73 | {ShareModalStatus && ( 74 | 75 | )} 76 | 77 | ) 78 | } 79 | 80 | 81 | 82 | export default Content 83 | -------------------------------------------------------------------------------- /src/components/SharedContent.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import axios from 'axios' 4 | import Card, { ContentType } from './Card' 5 | import Sidebar from './ui/Sidebar' 6 | import Heading from './ui/Heading' 7 | import { useMediaQuery } from './hooks/useMediaQuery' 8 | import ContentBlur from './ui/ContentBlur' 9 | import SearchBar from './SearchBar' 10 | 11 | function SharedContent() { 12 | const { sharelink } = useParams() 13 | const BASE_URL = import.meta.env.VITE_BASE_URL 14 | const [contentStore, setContentStore] = useState([]) 15 | const [displayedContent, setDisplayedContent] = useState([]) 16 | const [username, setUsername] = useState(null) 17 | const [error, setError] = useState(null) 18 | const [sideOpen, setSideOpen] = useState(false) 19 | const isMobile = useMediaQuery() 20 | 21 | useEffect(() => { 22 | const fetchSharedContent = async () => { 23 | try { 24 | const response = await axios.get(`${BASE_URL}/brain/${sharelink}`) 25 | console.log(response.data) 26 | setContentStore(response.data.content) 27 | setDisplayedContent(response.data.content) 28 | setUsername(response.data.content[0].userId.username) 29 | } catch (err) { 30 | // @ts-ignore 31 | setError(err.response?.data?.message || 'Failed to fetch shared content') 32 | } 33 | } 34 | 35 | fetchSharedContent() 36 | }, [sharelink]) 37 | 38 | if (error) { 39 | return
Error: {error}
40 | } 41 | 42 | return ( 43 | <> 44 | setSideOpen(p => !p)} 47 | contentStore={contentStore} 48 | setDisplayedContent={setDisplayedContent} 49 | /> 50 |
51 |
52 | 53 |
54 | {/* @ts-ignore */} 55 | {`${username?.charAt(0).toUpperCase() + username?.slice(1)}'s Brain`} 56 | 57 |
58 | {displayedContent.length > 0 ? ( 59 | displayedContent.map((item) => ( 60 | 61 | )) 62 | ) : ( 63 |

No content available

64 | )} 65 |
66 |
67 |
68 |
69 |
70 | 71 | 72 | ) 73 | } 74 | 75 | export default SharedContent -------------------------------------------------------------------------------- /src/components/ui/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Heading from "./Heading"; 3 | import { Brain, X, SquarePlay, File, Image, AudioLines, Grid2X2, ChevronRight } from "lucide-react"; 4 | import { ContentType } from "../Card"; 5 | import { useNavigate } from "react-router-dom"; 6 | 7 | interface SidebarProps { 8 | isOpen: boolean; 9 | toggleSidebar: () => void; 10 | contentStore: ContentType[]; 11 | setDisplayedContent: (content: ContentType[]) => void; 12 | showLogout?: boolean; 13 | onLogout?: () => void; 14 | filterItems?: Array<{ icon: React.ReactNode; label: string; filter: string }>; 15 | } 16 | 17 | const Sidebar: React.FC = ({ 18 | isOpen, 19 | toggleSidebar, 20 | contentStore, 21 | setDisplayedContent, 22 | showLogout = false, 23 | onLogout, 24 | filterItems = [ 25 | { icon: , label: 'Image', filter: 'image' }, 26 | { icon: , label: 'Video', filter: 'video' }, 27 | { icon: , label: 'Article', filter: 'article' }, 28 | { icon: , label: 'Audio', filter: 'audio' }, 29 | { icon: , label: 'All', filter: 'all' } 30 | ] 31 | }) => { 32 | 33 | const navigate = useNavigate() 34 | 35 | const handleFilter = (type: string) => { 36 | if (!setDisplayedContent) return; 37 | 38 | if (type === 'all') { 39 | setDisplayedContent(contentStore); 40 | } else { 41 | const filteredContent = contentStore.filter(content => content.type === type); 42 | setDisplayedContent(filteredContent); 43 | } 44 | toggleSidebar(); 45 | } 46 | 47 | return ( 48 |
51 |
52 |
53 |
54 | {isOpen && 55 | 56 | navigate('/')}/> 57 | navigate('/')}> 58 | BigBrain 59 | 60 | 61 | } 62 | 65 |
66 |
67 | 68 | {isOpen && ( 69 |
70 |
71 | {filterItems.map((item) => ( 72 | 80 | ))} 81 |
82 |
83 | )} 84 | 85 | {isOpen && showLogout && onLogout && ( 86 |
87 | 93 |
94 | )} 95 |
96 |
97 | ); 98 | }; 99 | 100 | export default Sidebar; -------------------------------------------------------------------------------- /src/components/Register.tsx: -------------------------------------------------------------------------------- 1 | import Input from './ui/Input' 2 | import Button from './ui/Button' 3 | import { useState, FormEvent } from 'react' 4 | import { AuthSchema } from '../lib/schemas' 5 | import { ZodError } from 'zod' 6 | import axios from 'axios' 7 | import Alert from './ui/Alert' 8 | import FormContainer from './ui/FormContainer' 9 | import React, { Dispatch, SetStateAction } from 'react' 10 | 11 | interface FormErrors { 12 | username?: string 13 | password?: string 14 | } 15 | 16 | const Register = ({ setCurrent }: { setCurrent: Dispatch> }) => { 17 | const [username, setUsername] = useState('') 18 | const [password, setPassword] = useState('') 19 | const [authLoading, setauthLoading] = useState(false) 20 | const [errors, setErrors] = useState({}) 21 | const [showAlert, setShowAlert] = useState(false) 22 | const BASE_URL = import.meta.env.VITE_BASE_URL; 23 | 24 | 25 | const handleSubmit = async (e: FormEvent) => { 26 | e.preventDefault() 27 | setErrors({}) 28 | 29 | try { 30 | const validatedData = AuthSchema.parse({ username, password }) 31 | 32 | setauthLoading(true) 33 | const response = await axios.post( 34 | `${BASE_URL}/user/register`, 35 | validatedData 36 | ) 37 | if (response.status === 200) { 38 | setShowAlert(true) 39 | setTimeout(() => { 40 | setCurrent('login') 41 | setShowAlert(false) 42 | }, 1000) 43 | } 44 | 45 | setUsername('') 46 | setPassword('') 47 | } catch (error) { 48 | if (error instanceof ZodError) { 49 | const formErrors: FormErrors = {} 50 | error.errors.forEach((err) => { 51 | if (err.path[0]) { 52 | formErrors[err.path[0] as keyof FormErrors] = err.message 53 | } 54 | }) 55 | setErrors(formErrors) 56 | } else if (axios.isAxiosError(error)) { 57 | if (error.response) { 58 | const errorMessage = error.response.data.message || 'An error occurred'; 59 | switch (error.response.status) { 60 | case 403: 61 | setErrors({ username: errorMessage }); 62 | break; 63 | case 411: 64 | setErrors({ 65 | username: errorMessage.includes('username') ? errorMessage : undefined, 66 | password: errorMessage.includes('password') ? errorMessage : undefined 67 | }); 68 | break; 69 | case 500: 70 | setErrors({ password: 'Server error. Please try again later.' }); 71 | break; 72 | default: 73 | setErrors({ password: errorMessage }); 74 | } 75 | } else if (error.request) { 76 | setErrors({ password: 'No response from server. Please check your connection.' }); 77 | } else { 78 | setErrors({ password: 'Error setting up the request. Please try again.' }); 79 | } 80 | } else { 81 | // Handle other types of errors 82 | setErrors({ password: 'Registration failed. Please try again.' }); 83 | } 84 | } finally { 85 | setauthLoading(false) 86 | } 87 | } 88 | 89 | const handleUsernameChange = (e: React.ChangeEvent) => { 90 | setUsername(e.target.value) 91 | if (errors.username) { 92 | setErrors((prev) => ({ ...prev, username: undefined })) 93 | } 94 | } 95 | 96 | const handlePasswordChange = (e: React.ChangeEvent) => { 97 | setPassword(e.target.value) 98 | if (errors.password) { 99 | setErrors((prev) => ({ ...prev, password: undefined })) 100 | } 101 | } 102 | 103 | return ( 104 |
105 | 112 |
113 | 121 | {errors.username && ( 122 |

{errors.username}

123 | )} 124 |
125 | 126 |
127 | 136 | {errors.password && ( 137 |

{errors.password}

138 | )} 139 |
140 | 141 | 144 |
145 | {showAlert && ( 146 | 147 | )} 148 |
149 | ); 150 | }; 151 | 152 | export default Register -------------------------------------------------------------------------------- /src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import Input from './ui/Input' 2 | import Button from './ui/Button' 3 | import { useState, FormEvent } from 'react' 4 | import { AuthSchema } from '../lib/schemas' 5 | import { ZodError } from 'zod' 6 | import axios from 'axios' 7 | import Alert from './ui/Alert' 8 | import { useSetRecoilState } from 'recoil' 9 | import { isLoggedIn } from './recoil/atoms' 10 | import FormContainer from './ui/FormContainer' 11 | 12 | interface FormErrors { 13 | username?: string 14 | password?: string 15 | } 16 | 17 | const Login = () => { 18 | const [username, setUsername] = useState('') 19 | const [password, setPassword] = useState('') 20 | const [authLoading, setauthLoading] = useState(false) 21 | const [errors, setErrors] = useState({}) 22 | const [showAlert, setShowAlert] = useState(false) 23 | const setisLoggedIn = useSetRecoilState(isLoggedIn) 24 | const BASE_URL = import.meta.env.VITE_BASE_URL; 25 | 26 | const handleSubmit = async (e: FormEvent) => { 27 | e.preventDefault() 28 | setErrors({}) 29 | 30 | try { 31 | const validatedData = AuthSchema.parse({ username, password }) 32 | 33 | setauthLoading(true) 34 | const response = await axios.post( 35 | `${BASE_URL}/user/login`, 36 | validatedData 37 | ) 38 | if (response.status === 200) { 39 | setShowAlert(true) 40 | setTimeout(() => { 41 | setisLoggedIn(true) 42 | localStorage.setItem('token', response.data.token) 43 | setShowAlert(false) 44 | }, 1000) 45 | } 46 | setUsername('') 47 | setPassword('') 48 | } catch (error) { 49 | if (error instanceof ZodError) { 50 | const formErrors: FormErrors = {} 51 | error.errors.forEach((err) => { 52 | if (err.path[0]) { 53 | formErrors[err.path[0] as keyof FormErrors] = err.message 54 | } 55 | }) 56 | setErrors(formErrors) 57 | } else if (axios.isAxiosError(error)) { 58 | if (error.response) { 59 | const errorMessage = error.response.data.message || 'An error occurred'; 60 | switch (error.response.status) { 61 | case 403: 62 | setErrors({ password: errorMessage }); 63 | break; 64 | case 411: 65 | setErrors({ 66 | username: errorMessage.includes('username') ? errorMessage : undefined, 67 | password: errorMessage.includes('password') ? errorMessage : undefined 68 | }); 69 | break; 70 | case 404: 71 | setErrors({ username: errorMessage }); 72 | break; 73 | case 401: 74 | setErrors({ password: errorMessage }); 75 | break; 76 | case 500: 77 | setErrors({ password: 'Server error. Please try again later.' }); 78 | break; 79 | default: 80 | setErrors({ password: errorMessage }); 81 | } 82 | } else if (error.request) { 83 | setErrors({ password: 'No response from server. Please check your connection.' }); 84 | } else { 85 | setErrors({ password: 'Error setting up the request. Please try again.' }); 86 | } 87 | } else { 88 | setErrors({ password: 'Login failed. Please try again.' }); 89 | } 90 | } finally { 91 | setauthLoading(false) 92 | } 93 | } 94 | 95 | const handleUsernameChange = (e: React.ChangeEvent) => { 96 | setUsername(e.target.value) 97 | if (errors.username) { 98 | setErrors((prev) => ({ ...prev, username: undefined })) 99 | } 100 | } 101 | 102 | const handlePasswordChange = (e: React.ChangeEvent) => { 103 | setPassword(e.target.value) 104 | if (errors.password) { 105 | setErrors((prev) => ({ ...prev, password: undefined })) 106 | } 107 | } 108 | 109 | return ( 110 |
111 | 119 |
120 | 128 | {errors.username && ( 129 |

{errors.username}

130 | )} 131 |
132 | 133 |
134 | 143 | {errors.password && ( 144 |

{errors.password}

145 | )} 146 |
147 | 148 | 151 |
152 | {showAlert && ( 153 | 154 | )} 155 |
156 | ); 157 | }; 158 | 159 | export default Login -------------------------------------------------------------------------------- /src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import { AudioLines, Trash, File, Image, SquarePlay, FilePenLine } from "lucide-react"; 2 | import React, { useState } from "react"; 3 | import { useRecoilState, useSetRecoilState } from "recoil"; 4 | import axios from "axios"; 5 | import ContentForm from "./ContentForm"; 6 | import { allContentAtom, filteredContentAtom } from "./recoil/atoms"; 7 | import { Tags } from "../lib/contentId"; 8 | 9 | const TypeStyles: { [key: string]: JSX.Element } = { 10 | 'image': , 11 | 'article': , 12 | 'video': , 13 | 'audio': , 14 | }; 15 | 16 | export interface ContentType { 17 | title?: string; 18 | type?: string; 19 | tags?: Tags[]; 20 | link?: string; 21 | createdAt?: string; 22 | contentId?: string; 23 | } 24 | 25 | 26 | export interface CardType extends ContentType { 27 | sideOpen?: boolean 28 | variant?: boolean, 29 | updateModal?: boolean, 30 | } 31 | const Card: React.FC = ({ 32 | title, 33 | type, 34 | tags, 35 | link, 36 | createdAt, 37 | contentId = '', 38 | sideOpen, 39 | variant=false, 40 | }) => { 41 | const BASE_URL = import.meta.env.VITE_BASE_URL 42 | const token = localStorage.getItem('token') || '' 43 | const [contentstore, setContentStore] = useRecoilState(allContentAtom) 44 | const setDisplayedContent = useSetRecoilState(filteredContentAtom) 45 | 46 | const [updateModal, setUpdateModal] = useState(false) 47 | 48 | const deleteContent = async(contentId: string) => { 49 | try { 50 | const filteredContent = contentstore.filter(content => content.contentId !== contentId) 51 | // Frontend updating quicker than BE accomodate for the lag deleting content. 52 | setContentStore(filteredContent) 53 | setDisplayedContent(filteredContent) 54 | await axios.delete(` 55 | ${BASE_URL}/content/`,{ 56 | data: {contentId: contentId}, 57 | headers: {Authorization: `Bearer ${token}`} 58 | } 59 | ); 60 | } catch (error) { 61 | console.error("Failed to delete content", error); 62 | alert('Error deleting the content') 63 | setContentStore(contentstore) 64 | setDisplayedContent(contentstore) 65 | } 66 | } 67 | return ( 68 |
69 |
70 |
71 | {TypeStyles[type!]} 72 | {title} 73 |
74 | { 75 | !variant && 76 |
77 | 80 | 83 |
84 | } 85 |
86 |
87 |
    88 | {tags && tags.length > 0 && ( 89 | tags.map((tag) => ( 90 |
  • 94 | # {tag.title} 95 |
  • 96 | )) 97 | )} 98 |
99 |
100 | 101 | {link && ( 102 | 109 | View Content 110 | 111 | )} 112 | 113 | {createdAt && ( 114 |

115 | 116 | Created At: 117 | 118 | {new Date(createdAt).toLocaleDateString()} 119 | 120 | 121 |

122 | )} 123 | 124 | {updateModal && 125 | setUpdateModal?.(false)} 127 | mainTitle="Update Content" 128 | initialData={{ title, type, tags, link, contentId, createdAt }} 129 | updateModal={updateModal} 130 | /> 131 | } 132 |
133 | ); 134 | }; 135 | 136 | 137 | 138 | export default Card; 139 | -------------------------------------------------------------------------------- /src/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { Search } from "lucide-react"; 3 | import SeachSuggestions from "./ui/SeachSuggestions"; 4 | import axios from "axios"; 5 | import { ContentType } from "./Card"; 6 | 7 | interface SearchBarProps{ 8 | contentStore: ContentType[] 9 | } 10 | const SearchBar: React.FC = ({ 11 | contentStore 12 | }) => { 13 | const containerRef = useRef(null); 14 | const inputRef = useRef(null) 15 | const [searchedTerm, setSearchedTerm] = useState(""); 16 | const [isFocused, setIsFocused] = useState(false); 17 | const [isLoading, setIsLoading] = useState(false) 18 | const [searchResults, setSearchResults] = useState<{ title: string, link: string}[]>([]); 19 | const BASE_URL = import.meta.env.VITE_BASE_URL; 20 | const token = localStorage.getItem('token') 21 | // const contentStore = useRecoilValue(allContentAtom) 22 | 23 | // To handle outside click 24 | useEffect(() => { 25 | const handleClickOutside = (event: MouseEvent) => { 26 | if (containerRef.current && !containerRef.current.contains(event.target as Node)) { 27 | setIsFocused(false); 28 | } 29 | }; 30 | 31 | document.addEventListener("mousedown", handleClickOutside); 32 | return () => { 33 | document.removeEventListener("mousedown", handleClickOutside); 34 | }; 35 | }, []); 36 | 37 | // To handle keyboard shotcuts 38 | useEffect(() => { 39 | const handleKeyDown = (e: KeyboardEvent) => { 40 | if ((e.ctrlKey || e.metaKey) && e.key === "k") { 41 | e.preventDefault(); 42 | setIsFocused(true); 43 | inputRef.current?.focus() 44 | } 45 | 46 | if (e.key === "Escape") { 47 | e.preventDefault(); 48 | setIsFocused(false); 49 | setSearchedTerm(""); 50 | inputRef.current?.blur() 51 | } 52 | }; 53 | 54 | document.addEventListener("keydown", handleKeyDown); 55 | return () => document.removeEventListener("keydown", handleKeyDown); 56 | }, []); 57 | 58 | const vectorSearch = async () => { 59 | try{ 60 | if(searchedTerm.trim()){ 61 | const response = await axios.post( 62 | `${BASE_URL}/content/search`, 63 | { search: searchedTerm }, 64 | {headers: { Authorization: `Bearer ${token}` } } 65 | ); 66 | const results: (string)[] = await response.data.search 67 | const orderMap = new Map(results.map((id, index) => [id, index])); 68 | 69 | const sortedContent = [...contentStore].sort((a, b) => { 70 | const indexA = orderMap.get(a.contentId!) ?? Infinity; 71 | const indexB = orderMap.get(b.contentId!) ?? Infinity; 72 | return indexA - indexB; 73 | }); 74 | 75 | setSearchResults(sortedContent.slice(0,3).map(content => ({ 76 | link: content.link!, 77 | title: content.title! 78 | }))) 79 | } 80 | } catch (e) { 81 | console.error("Error during vector search: ",e) 82 | } finally{ 83 | setIsLoading(false) 84 | } 85 | } 86 | 87 | // Debouncer 88 | useEffect(() => { 89 | setSearchResults([]) 90 | const debounceTimeout = setTimeout(() => { 91 | vectorSearch() 92 | }, 1000) 93 | 94 | return () => clearTimeout(debounceTimeout) 95 | }, [searchedTerm]) 96 | 97 | 98 | const handleInputChange = (e: React.ChangeEvent) => { 99 | setSearchedTerm(e.target.value); 100 | setIsFocused(true) 101 | if(e.target.value === ''){ 102 | setIsLoading(false) 103 | }else{ 104 | setIsLoading(true) 105 | } 106 | }; 107 | 108 | 109 | return ( 110 |
111 | {/* 112 | What would you like to do? 113 | */} 114 |
115 |
116 |
117 | setIsFocused(true)} 124 | /> 125 |
126 |
127 | 128 |
129 |
130 | Ctrl + K 131 |
132 |
133 |
134 | {isFocused && } 135 | {} 136 |
137 |
138 |
139 | ); 140 | }; 141 | 142 | export default SearchBar; 143 | -------------------------------------------------------------------------------- /public/lost.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ContentForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import axios from "axios"; 3 | import Button from "./ui/Button"; 4 | import Heading from "./ui/Heading"; 5 | import { ContentType } from "./Card"; 6 | import enrichContent, { ContentPayload } from "../lib/contentId"; 7 | import { useRecoilState, useSetRecoilState } from "recoil"; 8 | import { allContentAtom, filteredContentAtom } from "./recoil/atoms"; 9 | 10 | 11 | interface ContentFormProps { 12 | onClose: () => void; 13 | mainTitle?: string 14 | initialData?: ContentType 15 | updateModal?:boolean 16 | } 17 | 18 | const ContentForm: React.FC = ({ 19 | onClose, 20 | mainTitle = 'Add New Content', 21 | initialData, 22 | updateModal 23 | }) => { 24 | const token = localStorage.getItem("token") || ""; 25 | const [link, setLink] = useState(initialData?.link || ''); 26 | const [type, setType] = useState(initialData?.type ||""); 27 | const [title, setTitle] = useState(initialData?.title || ""); 28 | const [tags, setTags] = useState(initialData?.tags?.map(tag => tag.title) || []); 29 | const [newTag, setNewTag] = useState(""); 30 | const BASE_URL = import.meta.env.VITE_BASE_URL; 31 | 32 | const setDisplayedContent = useSetRecoilState(filteredContentAtom) 33 | const [contentStore, setContentStore] = useRecoilState(allContentAtom) 34 | const contentTypes = ["image", "video", "article", "audio"]; 35 | 36 | const isValidUrl = (url: string) => 37 | /^(https?:\/\/)?(www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(\/.*)?$/.test(url); 38 | 39 | 40 | const handleAddTag = () => { 41 | if (newTag.trim() && !tags.includes(newTag.trim().toLowerCase())) { 42 | setTags((prev) => [...prev, newTag.trim().toLowerCase()]); 43 | setNewTag(""); 44 | } 45 | }; 46 | 47 | const handleRemoveTag = (tagToRemove: string) => 48 | setTags((prev) => prev.filter((tag) => tag !== tagToRemove)); 49 | 50 | const handleSubmit = async (e: React.FormEvent) => { 51 | e.preventDefault(); 52 | 53 | if (!link || !isValidUrl(link)) return alert("Invalid URL."); 54 | if (!type) return alert("Please select a content type."); 55 | if (!title) return alert("Please enter a title."); 56 | const payload : ContentPayload = { 57 | link, 58 | type, 59 | title, 60 | tags, 61 | contentId: initialData?.contentId 62 | }; 63 | 64 | const enrichedContent = enrichContent(payload) 65 | 66 | if (payload?.contentId && updateModal && initialData?.contentId) { 67 | // Update content 68 | const previousContentStore = [...contentStore]; 69 | const updatedContent = contentStore.map(content => content.contentId === enrichedContent.contentId ? enrichedContent : content) 70 | setContentStore(updatedContent); // Update the FE first, then send out an request to BE. If it fails, rollback to befoer the update 71 | setDisplayedContent(updatedContent) 72 | onClose(); 73 | try{ 74 | await axios.put( 75 | `${BASE_URL}/content/`, 76 | { 77 | ...enrichedContent, 78 | contentId: initialData.contentId 79 | }, 80 | { headers: { Authorization: `Bearer ${token}` } } 81 | ); 82 | } catch(error){ 83 | alert("Failed to update content."); 84 | console.error('failed to PUT content', error) 85 | setContentStore(previousContentStore); 86 | setDisplayedContent(previousContentStore) 87 | } 88 | } else { 89 | // Create new content 90 | setContentStore((prevContent) => [...prevContent, enrichedContent]); // here the frontend and the backend are getting added in sync. 91 | setDisplayedContent((prevContent) => [...prevContent, enrichedContent]) 92 | onClose(); 93 | try{ 94 | console.log(enrichedContent) 95 | await axios.post( 96 | `${BASE_URL}/content`, 97 | enrichedContent, 98 | { headers: { Authorization: `Bearer ${token}` } } 99 | ); 100 | } catch (error){ 101 | alert("Submission failed."); 102 | console.error('failed to POST content', error) 103 | const filteredContent = contentStore.filter(content => content.contentId != enrichedContent.contentId ) 104 | setContentStore(filteredContent); 105 | setDisplayedContent(filteredContent) 106 | } 107 | } 108 | }; 109 | 110 | return ( 111 |
112 |
114 | 121 | 122 | 123 | {mainTitle} 124 | 125 | 126 |
127 |
128 | 129 | setLink(e.target.value)} 134 | placeholder="Enter content URL" 135 | className="w-full p-2 border rounded" 136 | required 137 | /> 138 |
139 |
140 | 141 | 155 |
156 |
157 | 158 | setTitle(e.target.value)} 163 | placeholder="Enter content title" 164 | className="w-full p-2 border rounded" 165 | required 166 | /> 167 |
168 |
169 | 170 |
171 | setNewTag(e.target.value)} 176 | placeholder="Add a tag" 177 | className="flex-grow p-2 border rounded-l" 178 | /> 179 | 186 |
187 |
188 | {tags.map((tag) => ( 189 | 190 | {tag} 191 | 198 | 199 | ))} 200 |
201 |
202 | 205 |
206 |
207 |
208 | ); 209 | }; 210 | 211 | export default ContentForm; 212 | --------------------------------------------------------------------------------